dcos node ssh

This commit is contained in:
Michael Gummelt
2015-07-01 10:46:36 -07:00
parent 0b88bc6830
commit ac41782ef1
9 changed files with 253 additions and 49 deletions

View File

@@ -4,6 +4,10 @@ Usage:
dcos node --info dcos node --info
dcos node [--json] dcos node [--json]
dcos node log [--follow --lines=N --master --slave=<slave-id>] dcos node log [--follow --lines=N --master --slave=<slave-id>]
dcos node ssh [--option SSHOPT=VAL ...]
[--config-file=<path>]
[--user=<user>]
(--master | --slave=<slave-id>)
Options: Options:
-h, --help Show this screen -h, --help Show this screen
@@ -11,11 +15,17 @@ Options:
--json Print json-formatted nodes --json Print json-formatted nodes
--follow Output data as the file grows --follow Output data as the file grows
--lines=N Output the last N lines [default: 10] --lines=N Output the last N lines [default: 10]
--master Output the leading master's Mesos log --master Access the leading master
--slave=<slave-id> Output this slave's Mesos log --slave=<slave-id> Access the slave with the provided ID
--option SSHOPT=VAL SSH option (see `man ssh_config`)
--config-file=<path> Path to ssh config file
--user=<user> SSH user [default: core]
--version Show version --version Show version
""" """
import os
import subprocess
import dcoscli import dcoscli
import docopt import docopt
from dcos import cmds, emitting, errors, mesos, util from dcos import cmds, emitting, errors, mesos, util
@@ -61,6 +71,12 @@ def _cmds():
arg_keys=['--follow', '--lines', '--master', '--slave'], arg_keys=['--follow', '--lines', '--master', '--slave'],
function=_log), function=_log),
cmds.Command(
hierarchy=['node', 'ssh'],
arg_keys=['--master', '--slave', '--option', '--config-file',
'--user'],
function=_ssh),
cmds.Command( cmds.Command(
hierarchy=['node'], hierarchy=['node'],
arg_keys=['--json'], arg_keys=['--json'],
@@ -80,7 +96,7 @@ def _info():
def _list(json_): def _list(json_):
"""List dcos nodes """List DCOS nodes
:param json_: If true, output json. :param json_: If true, output json.
Otherwise, output a human readable table. Otherwise, output a human readable table.
@@ -89,7 +105,7 @@ def _list(json_):
:rtype: int :rtype: int
""" """
client = mesos.MesosClient() client = mesos.DCOSClient()
slaves = client.get_state_summary()['slaves'] slaves = client.get_state_summary()['slaves']
if json_: if json_:
emitter.publish(slaves) emitter.publish(slaves)
@@ -148,3 +164,61 @@ def _mesos_files(master, slave_id):
slave = mesos.get_master().slave(slave_id) slave = mesos.get_master().slave(slave_id)
files.append(mesos.MesosFile('/slave/log', slave=slave)) files.append(mesos.MesosFile('/slave/log', slave=slave))
return files 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)

View File

@@ -121,5 +121,5 @@ def _shutdown(service_id):
:rtype: int :rtype: int
""" """
mesos.MesosClient().shutdown_framework(service_id) mesos.DCOSClient().shutdown_framework(service_id)
return 0 return 0

View File

@@ -170,7 +170,7 @@ def _mesos_files(completed, fltr, path):
""" """
# get tasks # get tasks
client = mesos.MesosClient() client = mesos.DCOSClient()
master = mesos.Master(client.get_master_state()) master = mesos.Master(client.get_master_state())
tasks = master.tasks(completed=completed, fltr=fltr) tasks = master.tasks(completed=completed, fltr=fltr)

View File

@@ -0,0 +1,2 @@
Host *
Protocol 0

View File

@@ -1,7 +1,12 @@
import json import json
import os
import pty
import re import re
import subprocess
import time
import dcos.util as util import dcos.util as util
from dcos import mesos
from dcos.util import create_schema from dcos.util import create_schema
from ..fixtures.node import slave_fixture from ..fixtures.node import slave_fixture
@@ -15,6 +20,10 @@ Usage:
dcos node --info dcos node --info
dcos node [--json] dcos node [--json]
dcos node log [--follow --lines=N --master --slave=<slave-id>] dcos node log [--follow --lines=N --master --slave=<slave-id>]
dcos node ssh [--option SSHOPT=VAL ...]
[--config-file=<path>]
[--user=<user>]
(--master | --slave=<slave-id>)
Options: Options:
-h, --help Show this screen -h, --help Show this screen
@@ -22,8 +31,11 @@ Options:
--json Print json-formatted nodes --json Print json-formatted nodes
--follow Output data as the file grows --follow Output data as the file grows
--lines=N Output the last N lines [default: 10] --lines=N Output the last N lines [default: 10]
--master Output the leading master's Mesos log --master Access the leading master
--slave=<slave-id> Output this slave's Mesos log --slave=<slave-id> Access the slave with the provided ID
--option SSHOPT=VAL SSH option (see `man ssh_config`)
--config-file=<path> Path to ssh config file
--user=<user> SSH user [default: core]
--version Show version --version Show version
""" """
assert_command(['dcos', 'node', '--help'], stdout=stdout) assert_command(['dcos', 'node', '--help'], stdout=stdout)
@@ -103,13 +115,85 @@ def test_node_log_invalid_lines():
returncode=1) returncode=1)
def _node(): def test_node_ssh_master():
returncode, stdout, stderr = exec_command(['dcos', 'node', '--json']) _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): def _get_schema(slave):
@@ -120,3 +204,12 @@ def _get_schema(slave):
schema['properties']['attributes']['additionalProperties'] = True schema['properties']['attributes']['additionalProperties'] = True
return schema 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'))

View File

@@ -252,10 +252,10 @@ def test_log_completed():
def test_log_master_unavailable(): def test_log_master_unavailable():
""" Test master's state.json being unavailable """ """ Test master's state.json being unavailable """
client = mesos.MesosClient() client = mesos.DCOSClient()
client.get_master_state = _mock_exception() 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', '_'] args = ['task', 'log', '_']
assert_mock(main, args, returncode=1, stderr=(b"exception\n")) assert_mock(main, args, returncode=1, stderr=(b"exception\n"))
@@ -263,10 +263,10 @@ def test_log_master_unavailable():
def test_log_slave_unavailable(): def test_log_slave_unavailable():
""" Test slave's state.json being unavailable """ """ Test slave's state.json being unavailable """
with app(SLEEP1, 'test-app', True): with app(SLEEP1, 'test-app', True):
client = mesos.MesosClient() client = mesos.DCOSClient()
client.get_slave_state = _mock_exception() 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'] args = ['task', 'log', 'test-app']
stderr = (b"""Error accessing slave: exception\n""" stderr = (b"""Error accessing slave: exception\n"""
b"""No matching tasks. Exiting.\n""") b"""No matching tasks. Exiting.\n""")

View File

@@ -23,11 +23,11 @@ def get_master():
:rtype: Master :rtype: Master
""" """
return Master(MesosClient().get_master_state()) return Master(DCOSClient().get_master_state())
class MesosClient(object): class DCOSClient(object):
"""Client for communicating with the Mesos master""" """Client for communicating with DCOS"""
def __init__(self): def __init__(self):
config = util.get_config() config = util.get_config()
@@ -40,8 +40,21 @@ class MesosClient(object):
else: else:
self._mesos_master_url = mesos_master_url 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): 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 :param path: the path suffix of the desired URL
:type path: str :type path: str
@@ -55,7 +68,7 @@ class MesosClient(object):
# TODO (mgummelt): this doesn't work with self._mesos_master_url # TODO (mgummelt): this doesn't work with self._mesos_master_url
def slave_url(self, slave_id, path): def slave_url(self, slave_id, path):
""" Create a URL that hits the slave """ Create a slave URL
:param slave_id: slave ID :param slave_id: slave ID
:type slave_id: str :type slave_id: str
@@ -180,11 +193,20 @@ class MesosClient(object):
url = self.master_url('master/shutdown') url = self.master_url('master/shutdown')
http.post(url, data=data) 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): class Master(object):
"""Mesos Master Model """Mesos Master Model
:param state: Mesos master state json :param state: Mesos master's state.json
:type state: dict :type state: dict
""" """
@@ -417,7 +439,7 @@ class Slave(object):
""" """
if not self._state: if not self._state:
self._state = MesosClient().get_slave_state(self['id']) self._state = DCOSClient().get_slave_state(self['id'])
return self._state return self._state
def _framework_dicts(self): def _framework_dicts(self):
@@ -611,7 +633,7 @@ class MesosFile(object):
:param slave: slave where the file lives :param slave: slave where the file lives
:type slave: Slave | None :type slave: Slave | None
:param mesos_client: client to use for network requests :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._task = task
self._path = path self._path = path
self._mesos_client = mesos_client or MesosClient() self._mesos_client = mesos_client or DCOSClient()
self._cursor = 0 self._cursor = 0
def size(self): def size(self):

View File

@@ -165,7 +165,7 @@ def uninstall(package_name, remove_all, app_id, cli, app):
remove_all, remove_all,
app_id, app_id,
marathon.create_client(), marathon.create_client(),
mesos.MesosClient()) mesos.DCOSClient())
if num_apps > 0: if num_apps > 0:
uninstalled = True uninstalled = True
@@ -192,7 +192,7 @@ def uninstall_subcommand(distribution_name):
return subcommand.uninstall(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. """Uninstalls an app.
:param app_name: The app to uninstall :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 :type app_id: str
:param init_client: The program to use to run the app :param init_client: The program to use to run the app
:type init_client: object :type init_client: object
:param mesos_client: the mesos client :param dcos_client: the DCOS client
:type mesos_client: dcos.mesos.MesosClient :type dcos_client: dcos.mesos.DCOSClient
:returns: number of apps uninstalled :returns: number of apps uninstalled
:rtype: int :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: if framework_name is not None:
logger.info( logger.info(
'Trying to shutdown framework {}'.format(framework_name)) '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) .frameworks(inactive=True)
# Look up all the framework names # 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)) 'Found the following frameworks: {}'.format(framework_ids))
if len(framework_ids) == 1: if len(framework_ids) == 1:
mesos_client.shutdown_framework(framework_ids[0]) dcos_client.shutdown_framework(framework_ids[0])
elif len(framework_ids) > 1: elif len(framework_ids) > 1:
raise DCOSException( raise DCOSException(
"Unable to shutdown the framework for [{}] because there " "Unable to shutdown the framework for [{}] because there "

View File

@@ -108,6 +108,7 @@ def get_config():
:rtype: Toml :rtype: Toml
""" """
# avoid circular import
from dcos import config from dcos import config
return config.load_from_path( return config.load_from_path(
@@ -128,13 +129,25 @@ def get_config_vals(config, keys):
missing = [key for key in keys if key not in config] missing = [key for key in keys if key not in config]
if missing: if missing:
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( msg = '\n'.join(
'Missing required config parameter: "{0}".'.format(key) + 'Missing required config parameter: "{0}".'.format(key) +
' Please run `dcos config set {0} <value>`.'.format(key) ' Please run `dcos config set {0} <value>`.'.format(key)
for key in keys) for key in keys)
raise DCOSException(msg) return DCOSException(msg)
return [config[key] for key in keys]
def which(program): def which(program):