dcos node ssh
This commit is contained in:
@@ -4,18 +4,28 @@ Usage:
|
||||
dcos node --info
|
||||
dcos node [--json]
|
||||
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:
|
||||
-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=<slave-id> 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=<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
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
@@ -121,5 +121,5 @@ def _shutdown(service_id):
|
||||
:rtype: int
|
||||
"""
|
||||
|
||||
mesos.MesosClient().shutdown_framework(service_id)
|
||||
mesos.DCOSClient().shutdown_framework(service_id)
|
||||
return 0
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
2
cli/tests/data/node/ssh_config
Normal file
2
cli/tests/data/node/ssh_config
Normal file
@@ -0,0 +1,2 @@
|
||||
Host *
|
||||
Protocol 0
|
||||
@@ -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=<slave-id>]
|
||||
dcos node ssh [--option SSHOPT=VAL ...]
|
||||
[--config-file=<path>]
|
||||
[--user=<user>]
|
||||
(--master | --slave=<slave-id>)
|
||||
|
||||
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=<slave-id> 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=<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
|
||||
"""
|
||||
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'))
|
||||
|
||||
@@ -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""")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 "
|
||||
|
||||
23
dcos/util.py
23
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} <value>`.'.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} <value>`.'.format(key)
|
||||
for key in keys)
|
||||
return DCOSException(msg)
|
||||
|
||||
|
||||
def which(program):
|
||||
"""Returns the path to the named executable program.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user