From ac41782ef16cf09e08a637230116fee17992425c Mon Sep 17 00:00:00 2001 From: Michael Gummelt Date: Wed, 1 Jul 2015 10:46:36 -0700 Subject: [PATCH] dcos node ssh --- cli/dcoscli/node/main.py | 94 +++++++++++++++++++--- cli/dcoscli/service/main.py | 2 +- cli/dcoscli/task/main.py | 2 +- cli/tests/data/node/ssh_config | 2 + cli/tests/integrations/test_node.py | 119 +++++++++++++++++++++++++--- cli/tests/integrations/test_task.py | 8 +- dcos/mesos.py | 40 +++++++--- dcos/package.py | 12 +-- dcos/util.py | 23 ++++-- 9 files changed, 253 insertions(+), 49 deletions(-) create mode 100644 cli/tests/data/node/ssh_config diff --git a/cli/dcoscli/node/main.py b/cli/dcoscli/node/main.py index e4269c4..ce1ef35 100644 --- a/cli/dcoscli/node/main.py +++ b/cli/dcoscli/node/main.py @@ -4,18 +4,28 @@ Usage: dcos node --info dcos node [--json] dcos node log [--follow --lines=N --master --slave=] + dcos node ssh [--option SSHOPT=VAL ...] + [--config-file=] + [--user=] + (--master | --slave=) Options: - -h, --help Show this screen - --info Show a short description of this subcommand - --json Print json-formatted nodes - --follow Output data as the file grows - --lines=N Output the last N lines [default: 10] - --master Output the leading master's Mesos log - --slave= Output this slave's Mesos log - --version Show version + -h, --help Show this screen + --info Show a short description of this subcommand + --json Print json-formatted nodes + --follow Output data as the file grows + --lines=N Output the last N lines [default: 10] + --master Access the leading master + --slave= Access the slave with the provided ID + --option SSHOPT=VAL SSH option (see `man ssh_config`) + --config-file= Path to ssh config file + --user= SSH user [default: core] + --version Show version """ +import os +import subprocess + import dcoscli import docopt from dcos import cmds, emitting, errors, mesos, util @@ -61,6 +71,12 @@ def _cmds(): arg_keys=['--follow', '--lines', '--master', '--slave'], function=_log), + cmds.Command( + hierarchy=['node', 'ssh'], + arg_keys=['--master', '--slave', '--option', '--config-file', + '--user'], + function=_ssh), + cmds.Command( hierarchy=['node'], arg_keys=['--json'], @@ -80,7 +96,7 @@ def _info(): def _list(json_): - """List dcos nodes + """List DCOS nodes :param json_: If true, output json. Otherwise, output a human readable table. @@ -89,7 +105,7 @@ def _list(json_): :rtype: int """ - client = mesos.MesosClient() + client = mesos.DCOSClient() slaves = client.get_state_summary()['slaves'] if json_: emitter.publish(slaves) @@ -148,3 +164,61 @@ def _mesos_files(master, slave_id): slave = mesos.get_master().slave(slave_id) files.append(mesos.MesosFile('/slave/log', slave=slave)) return files + + +def _ssh(master, slave, option, config_file, user): + """SSH into a DCOS node. Since only the masters are definitely + publicly available, we first ssh into an arbitrary master, then + hop to the desired node. + + :param master: True if the user has opted to SSH into the leading + master + :type master: bool | None + :param slave: The slave ID if the user has opted to SSH into a slave + :type slave: str | None + :param option: SSH option + :type option: [str] + :param config_file: SSH config file + :type config_file: str | None + :param user: SSH user + :type user: str | None + :rtype: int + :returns: process return code + + """ + if not os.environ.get('SSH_AUTH_SOCK'): + raise DCOSException( + "There is no SSH_AUTH_SOCK env variable, which likely means you " + + "aren't running `ssh-agent`. `dcos node ssh` depends on " + + "`ssh-agent` so we can safely use your private key to hop " + + "between nodes in your cluster. Please run `ssh-agent`, " + + "then add your private key with `ssh-add`.") + + master_public_ip = mesos.DCOSClient().metadata()['PUBLIC_IPV4'] + ssh_options = ' '.join('-o {}'.format(opt) for opt in option) + + if config_file: + ssh_config = '-F {}'.format(config_file) + else: + ssh_config = '' + + if master: + host = 'leader.mesos' + else: + summary = mesos.DCOSClient().get_state_summary() + slave_obj = next((slave_ for slave_ in summary['slaves'] + if slave_['id'] == slave), + None) + if slave_obj: + host = mesos.parse_pid(slave_obj['pid'])[1] + else: + raise DCOSException('No slave found with ID [{}]'.format(slave)) + + cmd = "ssh -A -t {0} {1} {2}@{3} ssh -A -t {2}@{4}".format( + ssh_options, + ssh_config, + user, + master_public_ip, + host) + + return subprocess.call(cmd, shell=True) diff --git a/cli/dcoscli/service/main.py b/cli/dcoscli/service/main.py index 77c8605..15aca1d 100644 --- a/cli/dcoscli/service/main.py +++ b/cli/dcoscli/service/main.py @@ -121,5 +121,5 @@ def _shutdown(service_id): :rtype: int """ - mesos.MesosClient().shutdown_framework(service_id) + mesos.DCOSClient().shutdown_framework(service_id) return 0 diff --git a/cli/dcoscli/task/main.py b/cli/dcoscli/task/main.py index c241d1d..a0a54c7 100644 --- a/cli/dcoscli/task/main.py +++ b/cli/dcoscli/task/main.py @@ -170,7 +170,7 @@ def _mesos_files(completed, fltr, path): """ # get tasks - client = mesos.MesosClient() + client = mesos.DCOSClient() master = mesos.Master(client.get_master_state()) tasks = master.tasks(completed=completed, fltr=fltr) diff --git a/cli/tests/data/node/ssh_config b/cli/tests/data/node/ssh_config new file mode 100644 index 0000000..5338a9c --- /dev/null +++ b/cli/tests/data/node/ssh_config @@ -0,0 +1,2 @@ +Host * + Protocol 0 diff --git a/cli/tests/integrations/test_node.py b/cli/tests/integrations/test_node.py index 9df532d..6f15852 100644 --- a/cli/tests/integrations/test_node.py +++ b/cli/tests/integrations/test_node.py @@ -1,7 +1,12 @@ import json +import os +import pty import re +import subprocess +import time import dcos.util as util +from dcos import mesos from dcos.util import create_schema from ..fixtures.node import slave_fixture @@ -15,16 +20,23 @@ Usage: dcos node --info dcos node [--json] dcos node log [--follow --lines=N --master --slave=] + dcos node ssh [--option SSHOPT=VAL ...] + [--config-file=] + [--user=] + (--master | --slave=) Options: - -h, --help Show this screen - --info Show a short description of this subcommand - --json Print json-formatted nodes - --follow Output data as the file grows - --lines=N Output the last N lines [default: 10] - --master Output the leading master's Mesos log - --slave= Output this slave's Mesos log - --version Show version + -h, --help Show this screen + --info Show a short description of this subcommand + --json Print json-formatted nodes + --follow Output data as the file grows + --lines=N Output the last N lines [default: 10] + --master Access the leading master + --slave= Access the slave with the provided ID + --option SSHOPT=VAL SSH option (see `man ssh_config`) + --config-file= Path to ssh config file + --user= SSH user [default: core] + --version Show version """ assert_command(['dcos', 'node', '--help'], stdout=stdout) @@ -103,13 +115,85 @@ def test_node_log_invalid_lines(): returncode=1) -def _node(): - returncode, stdout, stderr = exec_command(['dcos', 'node', '--json']) +def test_node_ssh_master(): + _node_ssh(['--master']) - assert returncode == 0 - assert stderr == b'' - return json.loads(stdout.decode('utf-8')) +def test_node_ssh_slave(): + slave_id = mesos.DCOSClient().get_state_summary()['slaves'][0]['id'] + _node_ssh(['--slave={}'.format(slave_id)]) + + +def test_node_ssh_option(): + stdout, stderr = _node_ssh_output( + ['--master', '--option', 'Protocol=0']) + assert stdout == b'' + assert stderr.startswith(b'ignoring bad proto spec') + + +def test_node_ssh_config_file(): + stdout, stderr = _node_ssh_output( + ['--master', '--config-file', 'tests/data/node/ssh_config']) + assert stdout == b'' + assert stderr.startswith(b'ignoring bad proto spec') + + +def test_node_ssh_user(): + stdout, stderr = _node_ssh_output( + ['--master', '--user=bogus', '--option', 'PasswordAuthentication=no']) + assert stdout == b'' + assert stderr.startswith(b'Permission denied') + + +def test_node_ssh_no_agent(): + stderr = (b"There is no SSH_AUTH_SOCK env variable, which likely means " + b"you aren't running `ssh-agent`. `dcos node ssh` depends on" + b" `ssh-agent` so we can safely use your private key to hop " + b"between nodes in your cluster. Please run `ssh-agent`, then " + b"add your private key with `ssh-add`.\n") + assert_command(['dcos', 'node', 'ssh', '--master'], + stdout=b'', + stderr=stderr, + returncode=1) + + +def _node_ssh_output(args): + # ssh must run with stdin attached to a tty + master, slave = pty.openpty() + + cmd = ('ssh-agent /bin/bash -c ' + + '"ssh-add /host-home/.vagrant.d/insecure_private_key ' + + '2> /dev/null && dcos node ssh --option StrictHostKeyChecking=no' + + ' {}"').format(' '.join(args)) + proc = subprocess.Popen(cmd, + stdin=slave, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + preexec_fn=os.setsid, + close_fds=True, + shell=True) + os.close(slave) + + # wait for the ssh connection + time.sleep(8) + + # kill the whole process group + os.killpg(os.getpgid(proc.pid), 15) + + os.close(master) + return proc.communicate() + + +def _node_ssh(args): + stdout, stderr = _node_ssh_output(args) + + print('SSH STDOUT: {}'.format(stdout.decode('utf-8'))) + print('SSH STDERR: {}'.format(stderr.decode('utf-8'))) + + assert stdout + assert ((stderr == b'') or + (len(stderr.split('\n')) == 2 and + stderr.startswith('Warning: Permanently added'))) def _get_schema(slave): @@ -120,3 +204,12 @@ def _get_schema(slave): schema['properties']['attributes']['additionalProperties'] = True return schema + + +def _node(): + returncode, stdout, stderr = exec_command(['dcos', 'node', '--json']) + + assert returncode == 0 + assert stderr == b'' + + return json.loads(stdout.decode('utf-8')) diff --git a/cli/tests/integrations/test_task.py b/cli/tests/integrations/test_task.py index 9c103f9..9d55fba 100644 --- a/cli/tests/integrations/test_task.py +++ b/cli/tests/integrations/test_task.py @@ -252,10 +252,10 @@ def test_log_completed(): def test_log_master_unavailable(): """ Test master's state.json being unavailable """ - client = mesos.MesosClient() + client = mesos.DCOSClient() client.get_master_state = _mock_exception() - with patch('dcos.mesos.MesosClient', return_value=client): + with patch('dcos.mesos.DCOSClient', return_value=client): args = ['task', 'log', '_'] assert_mock(main, args, returncode=1, stderr=(b"exception\n")) @@ -263,10 +263,10 @@ def test_log_master_unavailable(): def test_log_slave_unavailable(): """ Test slave's state.json being unavailable """ with app(SLEEP1, 'test-app', True): - client = mesos.MesosClient() + client = mesos.DCOSClient() client.get_slave_state = _mock_exception() - with patch('dcos.mesos.MesosClient', return_value=client): + with patch('dcos.mesos.DCOSClient', return_value=client): args = ['task', 'log', 'test-app'] stderr = (b"""Error accessing slave: exception\n""" b"""No matching tasks. Exiting.\n""") diff --git a/dcos/mesos.py b/dcos/mesos.py index a907af2..58a7ddf 100644 --- a/dcos/mesos.py +++ b/dcos/mesos.py @@ -23,11 +23,11 @@ def get_master(): :rtype: Master """ - return Master(MesosClient().get_master_state()) + return Master(DCOSClient().get_master_state()) -class MesosClient(object): - """Client for communicating with the Mesos master""" +class DCOSClient(object): + """Client for communicating with DCOS""" def __init__(self): config = util.get_config() @@ -40,8 +40,21 @@ class MesosClient(object): else: self._mesos_master_url = mesos_master_url + def get_dcos_url(self, path): + """ Create a DCOS URL + + :param path: the path suffix of the URL + :type path: str + :returns: DCOS URL + :rtype: str + """ + if self._dcos_url: + return urllib.parse.urljoin(self._dcos_url, path) + else: + raise util.missing_config_exception('core.dcos_url') + def master_url(self, path): - """ Create a URL that hits the master + """ Create a master URL :param path: the path suffix of the desired URL :type path: str @@ -55,7 +68,7 @@ class MesosClient(object): # TODO (mgummelt): this doesn't work with self._mesos_master_url def slave_url(self, slave_id, path): - """ Create a URL that hits the slave + """ Create a slave URL :param slave_id: slave ID :type slave_id: str @@ -180,11 +193,20 @@ class MesosClient(object): url = self.master_url('master/shutdown') http.post(url, data=data) + def metadata(self): + """ Get /metadata + + :returns: /metadata content + :rtype: dict + """ + url = self.get_dcos_url('metadata') + return http.get(url).json() + class Master(object): """Mesos Master Model - :param state: Mesos master state json + :param state: Mesos master's state.json :type state: dict """ @@ -417,7 +439,7 @@ class Slave(object): """ if not self._state: - self._state = MesosClient().get_slave_state(self['id']) + self._state = DCOSClient().get_slave_state(self['id']) return self._state def _framework_dicts(self): @@ -611,7 +633,7 @@ class MesosFile(object): :param slave: slave where the file lives :type slave: Slave | None :param mesos_client: client to use for network requests - :type mesos_client: MesosClient | None + :type mesos_client: DCOSClient | None """ @@ -630,7 +652,7 @@ class MesosFile(object): self._task = task self._path = path - self._mesos_client = mesos_client or MesosClient() + self._mesos_client = mesos_client or DCOSClient() self._cursor = 0 def size(self): diff --git a/dcos/package.py b/dcos/package.py index b3d1634..5c2e3b0 100644 --- a/dcos/package.py +++ b/dcos/package.py @@ -165,7 +165,7 @@ def uninstall(package_name, remove_all, app_id, cli, app): remove_all, app_id, marathon.create_client(), - mesos.MesosClient()) + mesos.DCOSClient()) if num_apps > 0: uninstalled = True @@ -192,7 +192,7 @@ def uninstall_subcommand(distribution_name): return subcommand.uninstall(distribution_name) -def uninstall_app(app_name, remove_all, app_id, init_client, mesos_client): +def uninstall_app(app_name, remove_all, app_id, init_client, dcos_client): """Uninstalls an app. :param app_name: The app to uninstall @@ -203,8 +203,8 @@ def uninstall_app(app_name, remove_all, app_id, init_client, mesos_client): :type app_id: str :param init_client: The program to use to run the app :type init_client: object - :param mesos_client: the mesos client - :type mesos_client: dcos.mesos.MesosClient + :param dcos_client: the DCOS client + :type dcos_client: dcos.mesos.DCOSClient :returns: number of apps uninstalled :rtype: int """ @@ -245,7 +245,7 @@ def uninstall_app(app_name, remove_all, app_id, init_client, mesos_client): if framework_name is not None: logger.info( 'Trying to shutdown framework {}'.format(framework_name)) - frameworks = mesos.Master(mesos_client.get_master_state()) \ + frameworks = mesos.Master(dcos_client.get_master_state()) \ .frameworks(inactive=True) # Look up all the framework names @@ -259,7 +259,7 @@ def uninstall_app(app_name, remove_all, app_id, init_client, mesos_client): 'Found the following frameworks: {}'.format(framework_ids)) if len(framework_ids) == 1: - mesos_client.shutdown_framework(framework_ids[0]) + dcos_client.shutdown_framework(framework_ids[0]) elif len(framework_ids) > 1: raise DCOSException( "Unable to shutdown the framework for [{}] because there " diff --git a/dcos/util.py b/dcos/util.py index 62ff92c..cecdac1 100644 --- a/dcos/util.py +++ b/dcos/util.py @@ -108,6 +108,7 @@ def get_config(): :rtype: Toml """ + # avoid circular import from dcos import config return config.load_from_path( @@ -128,15 +129,27 @@ def get_config_vals(config, keys): missing = [key for key in keys if key not in config] if missing: - msg = '\n'.join( - 'Missing required config parameter: "{0}".'.format(key) + - ' Please run `dcos config set {0} `.'.format(key) - for key in keys) - raise DCOSException(msg) + raise missing_config_exception(keys) return [config[key] for key in keys] +def missing_config_exception(keys): + """ DCOSException for a missing config value + + :param keys: keys in the config dict + :type keys: [str] + :returns: DCOSException + :rtype: DCOSException + """ + + msg = '\n'.join( + 'Missing required config parameter: "{0}".'.format(key) + + ' Please run `dcos config set {0} `.'.format(key) + for key in keys) + return DCOSException(msg) + + def which(program): """Returns the path to the named executable program.