add dcos oauth support (#560)
This commit is contained in:
@@ -160,7 +160,6 @@ fi
|
|||||||
|
|
||||||
ENV_SETUP="$VIRTUAL_ENV_PATH/bin/env-setup"
|
ENV_SETUP="$VIRTUAL_ENV_PATH/bin/env-setup"
|
||||||
source "$ENV_SETUP"
|
source "$ENV_SETUP"
|
||||||
dcos config set core.email anonymous-optout
|
|
||||||
dcos config set core.reporting false
|
dcos config set core.reporting false
|
||||||
dcos config set core.dcos_url $DCOS_URL
|
dcos config set core.dcos_url $DCOS_URL
|
||||||
dcos config set core.ssl_verify false
|
dcos config set core.ssl_verify false
|
||||||
|
|||||||
@@ -45,30 +45,12 @@ def _segment_track(event, conf, properties):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
data = {'event': event,
|
data = {'event': event,
|
||||||
'properties': properties}
|
'properties': properties,
|
||||||
|
'anonymousId': session_id}
|
||||||
if 'core.email' in conf:
|
|
||||||
data['userId'] = conf['core.email']
|
|
||||||
else:
|
|
||||||
data['anonymousId'] = session_id
|
|
||||||
|
|
||||||
_segment_request('track', data)
|
_segment_request('track', data)
|
||||||
|
|
||||||
|
|
||||||
def segment_identify(conf):
|
|
||||||
"""
|
|
||||||
Send a segment.io 'identify' event
|
|
||||||
|
|
||||||
:param conf: dcos config file
|
|
||||||
:type conf: Toml
|
|
||||||
:rtype: None
|
|
||||||
"""
|
|
||||||
|
|
||||||
if 'core.email' in conf:
|
|
||||||
data = {'userId': conf.get('core.email')}
|
|
||||||
_segment_request('identify', data)
|
|
||||||
|
|
||||||
|
|
||||||
def _segment_request(path, data):
|
def _segment_request(path, data):
|
||||||
"""
|
"""
|
||||||
Send a segment.io HTTP request
|
Send a segment.io HTTP request
|
||||||
|
|||||||
0
cli/dcoscli/auth/__init__.py
Normal file
0
cli/dcoscli/auth/__init__.py
Normal file
108
cli/dcoscli/auth/main.py
Normal file
108
cli/dcoscli/auth/main.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import dcoscli
|
||||||
|
import docopt
|
||||||
|
from dcos import cmds, config, emitting, http, util
|
||||||
|
from dcos.errors import DCOSAuthorizationException, DCOSException
|
||||||
|
from dcoscli.subcommand import default_command_info, default_doc
|
||||||
|
from dcoscli.util import decorate_docopt_usage
|
||||||
|
|
||||||
|
from six.moves import urllib
|
||||||
|
|
||||||
|
emitter = emitting.FlatEmitter()
|
||||||
|
logger = util.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
try:
|
||||||
|
return _main(argv)
|
||||||
|
except DCOSException as e:
|
||||||
|
emitter.publish(e)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
@decorate_docopt_usage
|
||||||
|
def _main(argv):
|
||||||
|
args = docopt.docopt(
|
||||||
|
default_doc("auth"),
|
||||||
|
argv=argv,
|
||||||
|
version='dcos-auth version {}'.format(dcoscli.version))
|
||||||
|
|
||||||
|
http.silence_requests_warnings()
|
||||||
|
|
||||||
|
return cmds.execute(_cmds(), args)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmds():
|
||||||
|
"""
|
||||||
|
:returns: all the supported commands
|
||||||
|
:rtype: list of dcos.cmds.Command
|
||||||
|
"""
|
||||||
|
|
||||||
|
return [
|
||||||
|
cmds.Command(
|
||||||
|
hierarchy=['auth', 'login'],
|
||||||
|
arg_keys=[],
|
||||||
|
function=_login),
|
||||||
|
|
||||||
|
cmds.Command(
|
||||||
|
hierarchy=['auth', 'logout'],
|
||||||
|
arg_keys=[],
|
||||||
|
function=_logout),
|
||||||
|
|
||||||
|
cmds.Command(
|
||||||
|
hierarchy=['auth'],
|
||||||
|
arg_keys=['--info'],
|
||||||
|
function=_info),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _info(info):
|
||||||
|
"""
|
||||||
|
:param info: Whether to output a description of this subcommand
|
||||||
|
:type info: boolean
|
||||||
|
:returns: process status
|
||||||
|
:rtype: int
|
||||||
|
"""
|
||||||
|
|
||||||
|
emitter.publish(default_command_info("auth"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _login():
|
||||||
|
"""
|
||||||
|
:returns: process status
|
||||||
|
:rtype: int
|
||||||
|
"""
|
||||||
|
|
||||||
|
# every call to login will generate a new token if applicable
|
||||||
|
_logout()
|
||||||
|
conf = util.get_config()
|
||||||
|
dcos_url = conf.get("core.dcos_url")
|
||||||
|
if dcos_url is None:
|
||||||
|
msg = ("Please provide the url to your DCOS cluster: "
|
||||||
|
"`dcos config set core.dcos_url`")
|
||||||
|
raise DCOSException(msg)
|
||||||
|
|
||||||
|
# hit protected endpoint which will prompt for auth if cluster has auth
|
||||||
|
try:
|
||||||
|
url = urllib.parse.urljoin(dcos_url, 'exhibitor')
|
||||||
|
http.get(url)
|
||||||
|
# if the user is authenticated, they have effectively "logged in" even if
|
||||||
|
# they are not authorized for this endpoint
|
||||||
|
except DCOSAuthorizationException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
emitter.publish("Login successful!")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _logout():
|
||||||
|
"""
|
||||||
|
Logout the user from dcos acs auth or oauth
|
||||||
|
|
||||||
|
:returns: process status
|
||||||
|
:rtype: int
|
||||||
|
"""
|
||||||
|
|
||||||
|
if util.get_config().get("core.dcos_acs_token") is not None:
|
||||||
|
config.unset("core.dcos_acs_token")
|
||||||
|
return 0
|
||||||
@@ -4,7 +4,6 @@ import dcoscli
|
|||||||
import docopt
|
import docopt
|
||||||
from dcos import cmds, config, emitting, http, util
|
from dcos import cmds, config, emitting, http, util
|
||||||
from dcos.errors import DCOSException
|
from dcos.errors import DCOSException
|
||||||
from dcoscli import analytics
|
|
||||||
from dcoscli.subcommand import default_command_info, default_doc
|
from dcoscli.subcommand import default_command_info, default_doc
|
||||||
from dcoscli.util import decorate_docopt_usage
|
from dcoscli.util import decorate_docopt_usage
|
||||||
|
|
||||||
@@ -88,9 +87,11 @@ def _set(name, value):
|
|||||||
notice = ("This config property has been deprecated. "
|
notice = ("This config property has been deprecated. "
|
||||||
"Please add your repositories with `dcos package repo add`")
|
"Please add your repositories with `dcos package repo add`")
|
||||||
return DCOSException(notice)
|
return DCOSException(notice)
|
||||||
toml_config = config.set_val(name, value)
|
if name == "core.email":
|
||||||
if (name == 'core.reporting' is True) or (name == 'core.email'):
|
notice = "This config property has been deprecated."
|
||||||
analytics.segment_identify(toml_config)
|
return DCOSException(notice)
|
||||||
|
|
||||||
|
config.set_val(name, value)
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
21
cli/dcoscli/data/help/auth.txt
Normal file
21
cli/dcoscli/data/help/auth.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
Description:
|
||||||
|
Authenticate to DCOS cluster
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
dcos auth --info
|
||||||
|
dcos auth login
|
||||||
|
dcos auth logout
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
login
|
||||||
|
Login to your DCOS Cluster.
|
||||||
|
logout
|
||||||
|
Logout of your DCOS Cluster.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help
|
||||||
|
Print usage.
|
||||||
|
--info
|
||||||
|
Print a short description of this subcommand.
|
||||||
|
--version
|
||||||
|
Print version information.
|
||||||
@@ -5,8 +5,7 @@ from concurrent.futures import ThreadPoolExecutor
|
|||||||
|
|
||||||
import dcoscli
|
import dcoscli
|
||||||
import docopt
|
import docopt
|
||||||
from dcos import (auth, constants, emitting, errors, http, mesos, subcommand,
|
from dcos import constants, emitting, errors, http, mesos, subcommand, util
|
||||||
util)
|
|
||||||
from dcos.errors import DCOSAuthenticationException, DCOSException
|
from dcos.errors import DCOSAuthenticationException, DCOSException
|
||||||
from dcoscli import analytics
|
from dcoscli import analytics
|
||||||
from dcoscli.subcommand import SubcommandMain, default_doc
|
from dcoscli.subcommand import SubcommandMain, default_doc
|
||||||
@@ -40,10 +39,6 @@ def _main():
|
|||||||
|
|
||||||
util.configure_process_from_environ()
|
util.configure_process_from_environ()
|
||||||
|
|
||||||
if args['<command>'] != 'config' and \
|
|
||||||
not auth.check_if_user_authenticated():
|
|
||||||
auth.force_auth()
|
|
||||||
|
|
||||||
config = util.get_config()
|
config = util.get_config()
|
||||||
set_ssl_info_env_vars(config)
|
set_ssl_info_env_vars(config)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ def _default_modules():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# avoid circular imports
|
# avoid circular imports
|
||||||
|
from dcoscli.auth import main as auth_main
|
||||||
from dcoscli.config import main as config_main
|
from dcoscli.config import main as config_main
|
||||||
from dcoscli.help import main as help_main
|
from dcoscli.help import main as help_main
|
||||||
from dcoscli.marathon import main as marathon_main
|
from dcoscli.marathon import main as marathon_main
|
||||||
@@ -19,7 +20,8 @@ def _default_modules():
|
|||||||
from dcoscli.service import main as service_main
|
from dcoscli.service import main as service_main
|
||||||
from dcoscli.task import main as task_main
|
from dcoscli.task import main as task_main
|
||||||
|
|
||||||
return {'config': config_main,
|
return {'auth': auth_main,
|
||||||
|
'config': config_main,
|
||||||
'help': help_main,
|
'help': help_main,
|
||||||
'marathon': marathon_main,
|
'marathon': marathon_main,
|
||||||
'node': node_main,
|
'node': node_main,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
[core]
|
[core]
|
||||||
reporting = false
|
reporting = false
|
||||||
email = "test@mail.com"
|
|
||||||
dcos_url = "http://dcos.snakeoil.mesosphere.com"
|
dcos_url = "http://dcos.snakeoil.mesosphere.com"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
[core]
|
[core]
|
||||||
dcos_acs_token = "foobar"
|
dcos_acs_token = "foobar"
|
||||||
dcos_url = "https://dcos.snakeoil.mesosphere.com"
|
dcos_url = "https://dcos.snakeoil.mesosphere.com"
|
||||||
email = "test@mail.com"
|
|
||||||
reporting = true
|
reporting = true
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
[core]
|
[core]
|
||||||
email = "test@mail.com"
|
|
||||||
reporting = true
|
reporting = true
|
||||||
dcos_url = "http://dcos.snakeoil.mesosphere.com"
|
dcos_url = "http://dcos.snakeoil.mesosphere.com"
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
[core]
|
[core]
|
||||||
reporting = false
|
reporting = false
|
||||||
email = "test@mail.com"
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
[core]
|
[core]
|
||||||
reporting = false
|
reporting = false
|
||||||
email = "test@mail.com"
|
|
||||||
[package]
|
[package]
|
||||||
cosmos_url = "http://localhost:7070"
|
cosmos_url = "http://localhost:7070"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
[core]
|
[core]
|
||||||
reporting = false
|
reporting = false
|
||||||
email = "test@mail.com"
|
|
||||||
timeout = 5
|
timeout = 5
|
||||||
dcos_url = "http://dcos.snakeoil.mesosphere.com"
|
dcos_url = "http://dcos.snakeoil.mesosphere.com"
|
||||||
ssl_verify = "false"
|
ssl_verify = "false"
|
||||||
|
|||||||
21
cli/tests/data/help/auth.txt
Normal file
21
cli/tests/data/help/auth.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
Description:
|
||||||
|
Authenticate to DCOS cluster
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
dcos auth --info
|
||||||
|
dcos auth login
|
||||||
|
dcos auth logout
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
login
|
||||||
|
Login to your DCOS Cluster.
|
||||||
|
logout
|
||||||
|
Logout of your DCOS Cluster.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help
|
||||||
|
Print usage.
|
||||||
|
--info
|
||||||
|
Print a short description of this subcommand.
|
||||||
|
--version
|
||||||
|
Print version information.
|
||||||
@@ -5,6 +5,7 @@ for easy management of a DCOS installation.
|
|||||||
|
|
||||||
Available DCOS commands:
|
Available DCOS commands:
|
||||||
|
|
||||||
|
auth Authenticate to DCOS cluster
|
||||||
config Manage the DCOS configuration file
|
config Manage the DCOS configuration file
|
||||||
help Display help information about DCOS
|
help Display help information about DCOS
|
||||||
marathon Deploy and manage applications to DCOS
|
marathon Deploy and manage applications to DCOS
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
[core]
|
[core]
|
||||||
reporting = false
|
reporting = false
|
||||||
email = "test@mail.com"
|
|
||||||
[marathon]
|
[marathon]
|
||||||
[package]
|
[package]
|
||||||
cosmos_url = "http://localhost:7070"
|
cosmos_url = "http://localhost:7070"
|
||||||
|
|||||||
@@ -3,5 +3,4 @@ cosmos_url = "http://localhost:7070"
|
|||||||
[core]
|
[core]
|
||||||
timeout = 5
|
timeout = 5
|
||||||
dcos_url = "https://dcos.snakeoil.mesosphere.com"
|
dcos_url = "https://dcos.snakeoil.mesosphere.com"
|
||||||
email = "test@mail.com"
|
|
||||||
reporting = false
|
reporting = false
|
||||||
|
|||||||
@@ -510,4 +510,4 @@ def config_unset(key, env=None):
|
|||||||
returncode, stdout, stderr = exec_command(cmd, env=env)
|
returncode, stdout, stderr = exec_command(cmd, env=env)
|
||||||
|
|
||||||
assert returncode == 0
|
assert returncode == 0
|
||||||
assert stderr == b''
|
assert stdout == b''
|
||||||
|
|||||||
53
cli/tests/integrations/test_auth.py
Normal file
53
cli/tests/integrations/test_auth.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from dcos import constants
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .common import assert_command, config_set, exec_command
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def env():
|
||||||
|
r = os.environ.copy()
|
||||||
|
r.update({
|
||||||
|
constants.PATH_ENV: os.environ[constants.PATH_ENV],
|
||||||
|
constants.DCOS_CONFIG_ENV: os.path.join("tests", "data", "dcos.toml"),
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def test_info():
|
||||||
|
stdout = b'Authenticate to DCOS cluster\n'
|
||||||
|
assert_command(['dcos', 'auth', '--info'],
|
||||||
|
stdout=stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_version():
|
||||||
|
stdout = b'dcos-auth version SNAPSHOT\n'
|
||||||
|
assert_command(['dcos', 'auth', '--version'],
|
||||||
|
stdout=stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_no_token(env):
|
||||||
|
exec_command(['dcos', 'config', 'unset', 'core.dcos_acs_token'], env=env)
|
||||||
|
|
||||||
|
returncode, _, stderr = exec_command(
|
||||||
|
['dcos', 'config', 'show', 'core.dcos_acs_token'], env=env)
|
||||||
|
assert returncode == 1
|
||||||
|
assert stderr == b"Property 'core.dcos_acs_token' doesn't exist\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_with_token(env):
|
||||||
|
config_set('core.dcos_acs_token', "foobar", env=env)
|
||||||
|
stderr = b"[core.dcos_acs_token]: changed\n"
|
||||||
|
assert_command(
|
||||||
|
['dcos', 'config', 'set', 'core.dcos_acs_token', 'faketoken'],
|
||||||
|
stderr=stderr,
|
||||||
|
env=env)
|
||||||
|
|
||||||
|
stderr = b'Removed [core.dcos_acs_token]\n'
|
||||||
|
assert_command(['dcos', 'auth', 'logout'],
|
||||||
|
stderr=stderr,
|
||||||
|
env=env)
|
||||||
@@ -51,7 +51,6 @@ def test_version():
|
|||||||
|
|
||||||
def _test_list_property(env):
|
def _test_list_property(env):
|
||||||
stdout = b"""core.dcos_url=http://dcos.snakeoil.mesosphere.com
|
stdout = b"""core.dcos_url=http://dcos.snakeoil.mesosphere.com
|
||||||
core.email=test@mail.com
|
|
||||||
core.reporting=False
|
core.reporting=False
|
||||||
core.ssl_verify=false
|
core.ssl_verify=false
|
||||||
core.timeout=5
|
core.timeout=5
|
||||||
@@ -89,7 +88,6 @@ def test_get_top_property(env):
|
|||||||
b"Property 'core' doesn't fully specify a value - "
|
b"Property 'core' doesn't fully specify a value - "
|
||||||
b"possible properties are:\n"
|
b"possible properties are:\n"
|
||||||
b"core.dcos_url\n"
|
b"core.dcos_url\n"
|
||||||
b"core.email\n"
|
|
||||||
b"core.reporting\n"
|
b"core.reporting\n"
|
||||||
b"core.ssl_verify\n"
|
b"core.ssl_verify\n"
|
||||||
b"core.timeout\n"
|
b"core.timeout\n"
|
||||||
@@ -108,6 +106,13 @@ def test_set_package_sources_property(env):
|
|||||||
returncode=1)
|
returncode=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_core_email_property(env):
|
||||||
|
notice = (b"This config property has been deprecated.\n")
|
||||||
|
assert_command(['dcos', 'config', 'set', 'core.email', 'foo@bar.com'],
|
||||||
|
stderr=notice,
|
||||||
|
returncode=1)
|
||||||
|
|
||||||
|
|
||||||
def test_set_existing_string_property(env):
|
def test_set_existing_string_property(env):
|
||||||
config_set('core.dcos_url',
|
config_set('core.dcos_url',
|
||||||
'http://dcos.snakeoil.mesosphere.com:5081', env)
|
'http://dcos.snakeoil.mesosphere.com:5081', env)
|
||||||
@@ -175,7 +180,7 @@ def test_unset_missing_property(env):
|
|||||||
|
|
||||||
def test_unset_output(env):
|
def test_unset_output(env):
|
||||||
assert_command(['dcos', 'config', 'unset', 'core.reporting'],
|
assert_command(['dcos', 'config', 'unset', 'core.reporting'],
|
||||||
stdout=b'Removed [core.reporting]\n',
|
stderr=b'Removed [core.reporting]\n',
|
||||||
env=env)
|
env=env)
|
||||||
config_set('core.reporting', 'false', env)
|
config_set('core.reporting', 'false', env)
|
||||||
|
|
||||||
@@ -185,7 +190,6 @@ def test_unset_top_property(env):
|
|||||||
b"Property 'core' doesn't fully specify a value - "
|
b"Property 'core' doesn't fully specify a value - "
|
||||||
b"possible properties are:\n"
|
b"possible properties are:\n"
|
||||||
b"core.dcos_url\n"
|
b"core.dcos_url\n"
|
||||||
b"core.email\n"
|
|
||||||
b"core.reporting\n"
|
b"core.reporting\n"
|
||||||
b"core.ssl_verify\n"
|
b"core.ssl_verify\n"
|
||||||
b"core.timeout\n"
|
b"core.timeout\n"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ for easy management of a DCOS installation.
|
|||||||
|
|
||||||
Available DCOS commands:
|
Available DCOS commands:
|
||||||
|
|
||||||
|
\tauth \tAuthenticate to DCOS cluster
|
||||||
\tconfig \tManage the DCOS configuration file
|
\tconfig \tManage the DCOS configuration file
|
||||||
\thelp \tDisplay help information about DCOS
|
\thelp \tDisplay help information about DCOS
|
||||||
\tmarathon \tDeploy and manage applications to DCOS
|
\tmarathon \tDeploy and manage applications to DCOS
|
||||||
@@ -75,3 +76,9 @@ def test_help_task():
|
|||||||
with open('tests/data/help/task.txt') as content:
|
with open('tests/data/help/task.txt') as content:
|
||||||
assert_command(['dcos', 'help', 'task'],
|
assert_command(['dcos', 'help', 'task'],
|
||||||
stdout=content.read().encode('utf-8'))
|
stdout=content.read().encode('utf-8'))
|
||||||
|
|
||||||
|
|
||||||
|
def test_help_auth():
|
||||||
|
with open('tests/data/help/auth.txt') as content:
|
||||||
|
assert_command(['dcos', 'help', 'auth'],
|
||||||
|
stdout=content.read().encode('utf-8'))
|
||||||
|
|||||||
@@ -4,14 +4,11 @@ from functools import wraps
|
|||||||
|
|
||||||
import dcoscli.analytics
|
import dcoscli.analytics
|
||||||
import rollbar
|
import rollbar
|
||||||
from dcos import constants, http
|
from dcos import constants
|
||||||
from dcoscli.constants import SEGMENT_URL
|
|
||||||
from dcoscli.main import main
|
from dcoscli.main import main
|
||||||
from dcoscli.subcommand import SubcommandMain
|
|
||||||
|
|
||||||
from mock import patch
|
from mock import patch
|
||||||
|
|
||||||
from .common import mock_called_some_args
|
|
||||||
|
|
||||||
ANON_ID = 0
|
ANON_ID = 0
|
||||||
USER_ID = 'test@mail.com'
|
USER_ID = 'test@mail.com'
|
||||||
@@ -32,22 +29,6 @@ def _mock(fn):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
@_mock
|
|
||||||
def test_config_set():
|
|
||||||
argv = ['set', 'core.email', 'test@mail.com']
|
|
||||||
|
|
||||||
config_thread = SubcommandMain("config", argv)
|
|
||||||
exitcode, err = config_thread.run_and_capture()
|
|
||||||
assert exitcode == 0
|
|
||||||
assert err is None
|
|
||||||
|
|
||||||
# segment.io
|
|
||||||
assert mock_called_some_args(http.post,
|
|
||||||
'{}/identify'.format(SEGMENT_URL),
|
|
||||||
json={'userId': 'test@mail.com'},
|
|
||||||
timeout=(1, 1))
|
|
||||||
|
|
||||||
|
|
||||||
@_mock
|
@_mock
|
||||||
def test_cluster_id_not_sent_on_config_call():
|
def test_cluster_id_not_sent_on_config_call():
|
||||||
"""Tests that cluster_id is not sent to segment.io on call to config
|
"""Tests that cluster_id is not sent to segment.io on call to config
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import os
|
|
||||||
import webbrowser
|
|
||||||
|
|
||||||
from dcos import auth, constants, util
|
|
||||||
from dcoscli.main import main
|
|
||||||
|
|
||||||
from mock import Mock, patch
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_browser_auth():
|
|
||||||
webbrowser.get = Mock(side_effect=webbrowser.Error())
|
|
||||||
with patch('webbrowser.open') as op:
|
|
||||||
_mock_dcos_run([util.which('dcos')], False)
|
|
||||||
assert op.call_count == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_anonymous_login():
|
|
||||||
with patch('sys.stdin.readline', return_value='\n'), \
|
|
||||||
patch('uuid.uuid1', return_value='anonymous@email'):
|
|
||||||
|
|
||||||
assert _mock_dcos_run(['dcos', 'help'], False) == 0
|
|
||||||
assert _mock_dcos_run(['dcos',
|
|
||||||
'config', 'show', 'core.email'], False) == 0
|
|
||||||
assert _mock_dcos_run(['dcos',
|
|
||||||
'config', 'unset', 'core.email'], False) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_dcos_run(args, authenticated=True):
|
|
||||||
if authenticated:
|
|
||||||
env = _config_with_credentials()
|
|
||||||
else:
|
|
||||||
env = _config_without_credentials()
|
|
||||||
|
|
||||||
with patch('sys.argv', args), patch.dict(os.environ, env):
|
|
||||||
return main()
|
|
||||||
|
|
||||||
|
|
||||||
def test_when_authenticated():
|
|
||||||
with patch('dcos.auth.force_auth'):
|
|
||||||
|
|
||||||
_mock_dcos_run(['dcos'], True)
|
|
||||||
assert auth.force_auth.call_count == 0
|
|
||||||
|
|
||||||
|
|
||||||
def _config_with_credentials():
|
|
||||||
return {
|
|
||||||
constants.DCOS_CONFIG_ENV: os.path.join(
|
|
||||||
'tests', 'data', 'auth', 'dcos_with_credentials.toml')
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _config_without_credentials():
|
|
||||||
return {
|
|
||||||
constants.DCOS_CONFIG_ENV: os.path.join(
|
|
||||||
'tests', 'data', 'auth', 'dcos_without_credentials.toml')
|
|
||||||
}
|
|
||||||
@@ -25,6 +25,14 @@ def test_get_auth_scheme_acs():
|
|||||||
assert realm == "acsjwt"
|
assert realm == "acsjwt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_auth_scheme_oauth():
|
||||||
|
with patch('requests.Response') as mock:
|
||||||
|
mock.headers = {'www-authenticate': 'oauthjwt'}
|
||||||
|
auth_scheme, realm = http.get_auth_scheme(mock)
|
||||||
|
assert auth_scheme == "oauthjwt"
|
||||||
|
assert realm == "oauthjwt"
|
||||||
|
|
||||||
|
|
||||||
def test_get_auth_scheme_bad_request():
|
def test_get_auth_scheme_bad_request():
|
||||||
with patch('requests.Response') as mock:
|
with patch('requests.Response') as mock:
|
||||||
mock.headers = {'www-authenticate': ''}
|
mock.headers = {'www-authenticate': ''}
|
||||||
@@ -116,6 +124,23 @@ def test_request_with_bad_auth_acl(mock, req_mock, auth_mock):
|
|||||||
assert e.exconly().split(':')[1].strip() == "Authentication failed"
|
assert e.exconly().split(':')[1].strip() == "Authentication failed"
|
||||||
|
|
||||||
|
|
||||||
|
@patch('requests.Response')
|
||||||
|
@patch('dcos.http._request')
|
||||||
|
@patch('dcos.http._get_http_auth')
|
||||||
|
def test_request_with_bad_oauth(mock, req_mock, auth_mock):
|
||||||
|
mock.url = 'http://domain.com'
|
||||||
|
mock.headers = {'www-authenticate': 'oauthjwt'}
|
||||||
|
mock.status_code = 401
|
||||||
|
|
||||||
|
auth_mock.return_value = http.DCOSAcsAuth("token")
|
||||||
|
|
||||||
|
req_mock.return_value = mock
|
||||||
|
|
||||||
|
with pytest.raises(DCOSException) as e:
|
||||||
|
http._request_with_auth(mock, "method", mock.url)
|
||||||
|
assert e.exconly().split(':')[1].strip() == "Authentication failed"
|
||||||
|
|
||||||
|
|
||||||
@patch('requests.Response')
|
@patch('requests.Response')
|
||||||
@patch('dcos.http._request')
|
@patch('dcos.http._request')
|
||||||
@patch('dcos.http._get_http_auth')
|
@patch('dcos.http._get_http_auth')
|
||||||
@@ -152,3 +177,22 @@ def test_request_with_auth_acl(mock, req_mock, auth_mock):
|
|||||||
|
|
||||||
response = http._request_with_auth(mock, "method", mock.url)
|
response = http._request_with_auth(mock, "method", mock.url)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@patch('requests.Response')
|
||||||
|
@patch('dcos.http._request')
|
||||||
|
@patch('dcos.http._get_http_auth')
|
||||||
|
def test_request_with_auth_oauth(mock, req_mock, auth_mock):
|
||||||
|
mock.url = 'http://domain.com'
|
||||||
|
mock.headers = {'www-authenticate': 'oauthjwt'}
|
||||||
|
mock.status_code = 401
|
||||||
|
|
||||||
|
auth = http.DCOSAcsAuth("token")
|
||||||
|
auth_mock.return_value = auth
|
||||||
|
|
||||||
|
mock2 = copy.deepcopy(mock)
|
||||||
|
mock2.status_code = 200
|
||||||
|
req_mock.return_value = mock2
|
||||||
|
|
||||||
|
response = http._request_with_auth(mock, "method", mock.url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|||||||
138
dcos/auth.py
138
dcos/auth.py
@@ -1,138 +0,0 @@
|
|||||||
import json
|
|
||||||
import sys
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import pkg_resources
|
|
||||||
from dcos import config, emitting, errors, http, jsonitem, util
|
|
||||||
from dcos.errors import DCOSException
|
|
||||||
from six import iteritems
|
|
||||||
|
|
||||||
from oauth2client import client
|
|
||||||
|
|
||||||
CLIENT_ID = '6a552732-ab9b-410d-9b7d-d8c6523b09a1'
|
|
||||||
CLIENT_SECRET = 'f56c1e2b-8599-40ca-b6a0-3aba3e702eae'
|
|
||||||
AUTH_URL = 'https://accounts.mesosphere.com/oauth/authorize'
|
|
||||||
TOKEN_URL = 'https://accounts.mesosphere.com/oauth/token'
|
|
||||||
USER_INFO_URL = 'https://accounts.mesosphere.com/api/v1/user.json'
|
|
||||||
CORE_TOKEN_KEY = 'token'
|
|
||||||
CORE_EMAIL_KEY = 'email'
|
|
||||||
emitter = emitting.FlatEmitter()
|
|
||||||
logger = util.get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _authorize():
|
|
||||||
"""Create OAuth flow and authorize user
|
|
||||||
|
|
||||||
:return: credentials dict
|
|
||||||
:rtype: dict
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
flow = client.OAuth2WebServerFlow(
|
|
||||||
client_id=CLIENT_ID,
|
|
||||||
client_secret=CLIENT_SECRET,
|
|
||||||
scope='',
|
|
||||||
auth_uri=AUTH_URL,
|
|
||||||
token_uri=TOKEN_URL,
|
|
||||||
redirect_uri=client.OOB_CALLBACK_URN,
|
|
||||||
response_type='code'
|
|
||||||
)
|
|
||||||
return _run(flow)
|
|
||||||
except:
|
|
||||||
logger.exception('Error during OAuth web flow')
|
|
||||||
raise DCOSException('There was a problem with '
|
|
||||||
'web authentication.')
|
|
||||||
|
|
||||||
|
|
||||||
def make_oauth_request(code, flow):
|
|
||||||
"""Make request to auth server using auth_code.
|
|
||||||
|
|
||||||
:param: code: auth_code read from cli
|
|
||||||
:param: flow: OAuth2 web server flow
|
|
||||||
:return: dict with the keys token and email
|
|
||||||
:rtype: dict
|
|
||||||
"""
|
|
||||||
credential = flow.step2_exchange(code)
|
|
||||||
token = credential.access_token
|
|
||||||
headers = {'Authorization': str('Bearer ' + token)}
|
|
||||||
data = http.requests.get(USER_INFO_URL, headers=headers).json()
|
|
||||||
mail = data['email']
|
|
||||||
credentials = {CORE_TOKEN_KEY: credential.access_token,
|
|
||||||
CORE_EMAIL_KEY: mail}
|
|
||||||
return credentials
|
|
||||||
|
|
||||||
|
|
||||||
def _run(flow):
|
|
||||||
"""Make authorization and retrieve access token and user email.
|
|
||||||
|
|
||||||
:param flow: OAuth2 web server flow
|
|
||||||
:param launch_browser: if possible to run browser
|
|
||||||
:return: dict with the keys token and email
|
|
||||||
:rtype: dict
|
|
||||||
"""
|
|
||||||
|
|
||||||
emitter.publish(
|
|
||||||
errors.DefaultError(
|
|
||||||
'\n\n\n{}\n\n {}\n\n'.format(
|
|
||||||
'Go to the following link in your browser:',
|
|
||||||
flow.step1_get_authorize_url())))
|
|
||||||
|
|
||||||
sys.stderr.write('Enter verification code: ')
|
|
||||||
code = sys.stdin.readline().strip()
|
|
||||||
if not code:
|
|
||||||
sys.stderr.write('Skipping authentication.\nEnter email address: ')
|
|
||||||
|
|
||||||
email = sys.stdin.readline().strip()
|
|
||||||
if not email:
|
|
||||||
emitter.publish(
|
|
||||||
errors.DefaultError(
|
|
||||||
'Skipping email input.'))
|
|
||||||
email = str(uuid.uuid1())
|
|
||||||
|
|
||||||
return {CORE_EMAIL_KEY: email}
|
|
||||||
|
|
||||||
return make_oauth_request(code, flow)
|
|
||||||
|
|
||||||
|
|
||||||
def check_if_user_authenticated():
|
|
||||||
"""Check if user is authenticated already
|
|
||||||
|
|
||||||
:returns user auth status
|
|
||||||
:rtype: boolean
|
|
||||||
"""
|
|
||||||
|
|
||||||
dcos_config = util.get_config()
|
|
||||||
return dcos_config.get('core.email', '') != ''
|
|
||||||
|
|
||||||
|
|
||||||
def force_auth():
|
|
||||||
"""Make user authentication process
|
|
||||||
|
|
||||||
:returns authentication process status
|
|
||||||
:rtype: boolean
|
|
||||||
"""
|
|
||||||
|
|
||||||
credentials = _authorize()
|
|
||||||
_save_auth_keys(credentials)
|
|
||||||
|
|
||||||
|
|
||||||
def _save_auth_keys(key_dict):
|
|
||||||
"""
|
|
||||||
:param key_dict: auth parameters dict
|
|
||||||
:type key_dict: dict
|
|
||||||
:rtype: None
|
|
||||||
"""
|
|
||||||
|
|
||||||
toml_config = util.get_config(True)
|
|
||||||
|
|
||||||
section = 'core'
|
|
||||||
config_schema = json.loads(
|
|
||||||
pkg_resources.resource_string(
|
|
||||||
'dcos',
|
|
||||||
'data/config-schema/core.json').decode('utf-8'))
|
|
||||||
for k, v in iteritems(key_dict):
|
|
||||||
python_value = jsonitem.parse_json_value(k, v, config_schema)
|
|
||||||
name = '{}.{}'.format(section, k)
|
|
||||||
toml_config[name] = python_value
|
|
||||||
|
|
||||||
config.save(toml_config)
|
|
||||||
return None
|
|
||||||
@@ -130,7 +130,7 @@ def unset(name):
|
|||||||
elif isinstance(value, collections.Mapping):
|
elif isinstance(value, collections.Mapping):
|
||||||
raise DCOSException(_generate_choice_msg(name, value))
|
raise DCOSException(_generate_choice_msg(name, value))
|
||||||
else:
|
else:
|
||||||
emitter.publish("Removed [{}]".format(name))
|
emitter.publish(DefaultError("Removed [{}]".format(name)))
|
||||||
save(toml_config)
|
save(toml_config)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -13,22 +13,12 @@
|
|||||||
"title": "DCOS ACS token",
|
"title": "DCOS ACS token",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"email": {
|
|
||||||
"description": "Your email address",
|
|
||||||
"title": "Your email address",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"mesos_master_url": {
|
"mesos_master_url": {
|
||||||
"description": "Mesos master URL. Must be set in format: \"http://host:port\"",
|
"description": "Mesos master URL. Must be set in format: \"http://host:port\"",
|
||||||
"format": "uri",
|
"format": "uri",
|
||||||
"title": "Mesos Master URL",
|
"title": "Mesos Master URL",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"refresh_token": {
|
|
||||||
"description": "Your OAuth refresh token",
|
|
||||||
"title": "The OAuth refresh token",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"reporting": {
|
"reporting": {
|
||||||
"default": true,
|
"default": true,
|
||||||
"description": "Whether to report usage events to Mesosphere",
|
"description": "Whether to report usage events to Mesosphere",
|
||||||
@@ -42,11 +32,6 @@
|
|||||||
"title": "Request timeout in seconds",
|
"title": "Request timeout in seconds",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"token": {
|
|
||||||
"description": "Your OAuth access token",
|
|
||||||
"title": "Your OAuth access token",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"ssl_verify": {
|
"ssl_verify": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "false",
|
"default": "false",
|
||||||
|
|||||||
72
dcos/http.py
72
dcos/http.py
@@ -161,9 +161,13 @@ def _request_with_auth(response,
|
|||||||
if creds not in AUTH_CREDS and response.status_code == 200:
|
if creds not in AUTH_CREDS and response.status_code == 200:
|
||||||
AUTH_CREDS[creds] = auth
|
AUTH_CREDS[creds] = auth
|
||||||
# acs invalid token
|
# acs invalid token
|
||||||
elif response.status_code == 401 and auth_scheme == "acsjwt":
|
elif response.status_code == 401 and \
|
||||||
|
auth_scheme in ["acsjwt", "oauthjwt"]:
|
||||||
|
|
||||||
if util.get_config().get("core.dcos_acs_token") is not None:
|
if util.get_config().get("core.dcos_acs_token") is not None:
|
||||||
config.unset("core.dcos_acs_token")
|
msg = ("Your core.dcos_acs_token is invalid. "
|
||||||
|
"Please run: `dcos auth login`")
|
||||||
|
raise DCOSException(msg)
|
||||||
|
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
@@ -342,7 +346,7 @@ def _get_auth_credentials(username, hostname):
|
|||||||
|
|
||||||
def get_auth_scheme(response):
|
def get_auth_scheme(response):
|
||||||
"""Return authentication scheme and realm requested by server for 'Basic'
|
"""Return authentication scheme and realm requested by server for 'Basic'
|
||||||
or 'acsjwt' (DCOS acs auth) type or None
|
or 'acsjwt' (DCOS acs auth) or 'oauthjwt' (DCOS acs oauth) type or None
|
||||||
|
|
||||||
:param response: requests.response
|
:param response: requests.response
|
||||||
:type response: requests.Response
|
:type response: requests.Response
|
||||||
@@ -354,7 +358,8 @@ def get_auth_scheme(response):
|
|||||||
auths = response.headers['www-authenticate'].split(',')
|
auths = response.headers['www-authenticate'].split(',')
|
||||||
scheme = next((auth_type.rstrip().lower() for auth_type in auths
|
scheme = next((auth_type.rstrip().lower() for auth_type in auths
|
||||||
if auth_type.rstrip().lower().startswith("basic") or
|
if auth_type.rstrip().lower().startswith("basic") or
|
||||||
auth_type.rstrip().lower().startswith("acsjwt")),
|
auth_type.rstrip().lower().startswith("acsjwt") or
|
||||||
|
auth_type.rstrip().lower().startswith("oauthjwt")),
|
||||||
None)
|
None)
|
||||||
if scheme:
|
if scheme:
|
||||||
scheme_info = scheme.split("=")
|
scheme_info = scheme.split("=")
|
||||||
@@ -385,7 +390,7 @@ def _get_http_auth(response, url, auth_scheme):
|
|||||||
password = url.password
|
password = url.password
|
||||||
|
|
||||||
if 'www-authenticate' in response.headers:
|
if 'www-authenticate' in response.headers:
|
||||||
if auth_scheme not in ['basic', 'acsjwt']:
|
if auth_scheme not in ['basic', 'acsjwt', 'oauthjwt']:
|
||||||
msg = ("Server responded with an HTTP 'www-authenticate' field of "
|
msg = ("Server responded with an HTTP 'www-authenticate' field of "
|
||||||
"'{}', DCOS only supports 'Basic'".format(
|
"'{}', DCOS only supports 'Basic'".format(
|
||||||
response.headers['www-authenticate']))
|
response.headers['www-authenticate']))
|
||||||
@@ -396,17 +401,59 @@ def _get_http_auth(response, url, auth_scheme):
|
|||||||
# we'd already be authed by python requests module
|
# we'd already be authed by python requests module
|
||||||
username, password = _get_auth_credentials(username, hostname)
|
username, password = _get_auth_credentials(username, hostname)
|
||||||
return HTTPBasicAuth(username, password)
|
return HTTPBasicAuth(username, password)
|
||||||
|
# dcos auth (acs or oauth)
|
||||||
else:
|
else:
|
||||||
return _get_dcos_acs_auth(username, password, hostname)
|
return _get_dcos_auth(auth_scheme, username, password, hostname)
|
||||||
else:
|
else:
|
||||||
msg = ("Invalid HTTP response: server returned an HTTP 401 response "
|
msg = ("Invalid HTTP response: server returned an HTTP 401 response "
|
||||||
"with no 'www-authenticate' field")
|
"with no 'www-authenticate' field")
|
||||||
raise DCOSException(msg)
|
raise DCOSException(msg)
|
||||||
|
|
||||||
|
|
||||||
def _get_dcos_acs_auth(username, password, hostname):
|
def _get_dcos_oauth_creds(dcos_url):
|
||||||
"""Get authentication flow for dcos acs auth
|
"""Get token credential for dcos oath
|
||||||
|
|
||||||
|
:param dcos_url: dcos cluster url
|
||||||
|
:type dcos_url: str
|
||||||
|
:returns: token from browser for oauth flow
|
||||||
|
:rtype: dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
oauth_login = 'login?redirect_uri=urn:ietf:wg:oauth:2.0:oob'
|
||||||
|
url = urllib.parse.urljoin(dcos_url, oauth_login)
|
||||||
|
msg = "\n{}\n\n {}\n\n{} ".format(
|
||||||
|
"Please go to the following link in your browser:",
|
||||||
|
url,
|
||||||
|
"Enter authentication token:")
|
||||||
|
sys.stderr.write(msg)
|
||||||
|
sys.stderr.flush()
|
||||||
|
token = sys.stdin.readline().strip()
|
||||||
|
return {"token": token}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dcos_acs_auth_creds(username, password, hostname):
|
||||||
|
"""Get credentials for dcos acs auth
|
||||||
|
|
||||||
|
:param username: username user for authentication
|
||||||
|
:type username: str
|
||||||
|
:param password: password for authentication
|
||||||
|
:type password: str
|
||||||
|
:param hostname: hostname for credentials
|
||||||
|
:type hostname: str
|
||||||
|
:returns: username/password credentials
|
||||||
|
:rtype: dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
if password is None:
|
||||||
|
username, password = _get_auth_credentials(username, hostname)
|
||||||
|
return {"uid": username, "password": password}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dcos_auth(auth_scheme, username, password, hostname):
|
||||||
|
"""Get authentication flow for dcos acs auth and dcos oauth
|
||||||
|
|
||||||
|
:param auth_scheme: authentication_scheme
|
||||||
|
:type auth_scheme: str
|
||||||
:param username: username user for authentication
|
:param username: username user for authentication
|
||||||
:type username: str
|
:type username: str
|
||||||
:param password: password for authentication
|
:param password: password for authentication
|
||||||
@@ -421,16 +468,17 @@ def _get_dcos_acs_auth(username, password, hostname):
|
|||||||
token = toml_config.get("core.dcos_acs_token")
|
token = toml_config.get("core.dcos_acs_token")
|
||||||
if token is None:
|
if token is None:
|
||||||
dcos_url = toml_config.get("core.dcos_url")
|
dcos_url = toml_config.get("core.dcos_url")
|
||||||
url = urllib.parse.urljoin(dcos_url, 'acs/api/v1/auth/login')
|
if auth_scheme == "acsjwt":
|
||||||
if password is None:
|
creds = _get_dcos_acs_auth_creds(username, password, hostname)
|
||||||
username, password = _get_auth_credentials(username, hostname)
|
else:
|
||||||
creds = {"uid": username, "password": password}
|
creds = _get_dcos_oauth_creds(dcos_url)
|
||||||
|
|
||||||
verify = _verify_ssl()
|
verify = _verify_ssl()
|
||||||
# Silence 'Unverified HTTPS request' and 'SecurityWarning' for bad cert
|
# Silence 'Unverified HTTPS request' and 'SecurityWarning' for bad cert
|
||||||
if verify is not None:
|
if verify is not None:
|
||||||
silence_requests_warnings()
|
silence_requests_warnings()
|
||||||
|
|
||||||
|
url = urllib.parse.urljoin(dcos_url, 'acs/api/v1/auth/login')
|
||||||
# using private method here, so we don't retry on this request
|
# using private method here, so we don't retry on this request
|
||||||
# error here will be bubbled up to _request_with_auth
|
# error here will be bubbled up to _request_with_auth
|
||||||
response = _request('post', url, json=creds, verify=verify)
|
response = _request('post', url, json=creds, verify=verify)
|
||||||
|
|||||||
@@ -134,8 +134,8 @@ def default_subcommands():
|
|||||||
:returns: list of all the default dcos cli subcommands
|
:returns: list of all the default dcos cli subcommands
|
||||||
:rtype: [str]
|
:rtype: [str]
|
||||||
"""
|
"""
|
||||||
|
return ["auth", "config", "help", "marathon",
|
||||||
return ["config", "help", "marathon", "node", "package", "service", "task"]
|
"node", "package", "service", "task"]
|
||||||
|
|
||||||
|
|
||||||
def documentation(executable_path):
|
def documentation(executable_path):
|
||||||
|
|||||||
Reference in New Issue
Block a user