diff --git a/dcos/api/marathon.py b/dcos/api/marathon.py index 6113084..30871c6 100644 --- a/dcos/api/marathon.py +++ b/dcos/api/marathon.py @@ -1,6 +1,8 @@ -import requests +import json +import urllib -from . import errors +import requests +from dcos.api import errors class Client(object): @@ -19,22 +21,87 @@ class Client(object): self._host = host self._port = port - def _create_url(self, path): + def _create_url(self, path, query_params=None): """Creates the url from the provided path :param path: Url path :type path: str - :return: Constructed url + :param query_params: Query string parameters + :type query_params: dict + :returns: Constructed url :rtype: str """ - return self._url_pattern.format( + url = self._url_pattern.format( host=self._host, port=self._port, path=path) + if query_params is not None: + query_string = urllib.urlencode(query_params) + url = (url + '?{}').format(query_string) + + return url + + def _sanitize_app_id(self, app_id): + """ + :param app_id: Raw application ID + :type app_id: str + :returns: Sanitized application ID + :rtype: str + """ + + # Add a leading '/' if necessary. + if not app_id.startswith('/'): + app_id = '/' + app_id + return app_id + + def _response_to_error(self, response): + """ + :param response: HTTP resonse object + :type response: requests.Response + :returns: The error embedded in the response JSON + :rtype: Error + """ + + return Error('Error: {}'.format(response.json()['message'])) + + def get_app(self, app_id): + """Returns a representation of the requested application. + :param app_id: The ID of the application. + :type app_id: str + :returns: The requested Marathon application + :rtype: (dict, Error) + """ + + app_id = self._sanitize_app_id(app_id) + + url = self._create_url('v2/apps' + app_id) + response = requests.get(url) + + if response.status_code == 200: + app = response.json()['app'] + return (app, None) + else: + return (None, self._response_to_error(response)) + + def get_apps(self): + """Get a list of known applications. + :returns: List of known applications. + :rtype: (list of dict, Error) + """ + + url = self._create_url('v2/apps') + response = requests.get(url) + + if response.status_code == 200: + apps = response.json()['apps'] + return (apps, None) + else: + return (None, self._response_to_error(response)) + def start_app(self, app_resource): - """Create and start a new application + """Create and start a new application. :param app_resource: Application resource :type app_resource: dict, bytes, or file @@ -48,11 +115,77 @@ class Client(object): if response.status_code == 201: return (True, None) else: - return ( - None, - Error( - 'Error talking to Marathon: {}'.format( - response.json()['message']))) + return (None, self._response_to_error(response)) + + def scale_app(self, app_id, instances, force=None): + """Scales an application to the requested number of instances. + :param app_id: The ID of the application to scale. + :type app_id: str + :param instances: The requested number of instances. + :type instances: int + :param force: Whether to override running deployments. + :type force: bool + :returns: The resulting deployment ID. + :rtype: (bool, Error) + """ + + if force is None: + force = False + + app_id = self._sanitize_app_id(app_id) + + params = None + if force: + params = {'force': True} + + url = self._create_url('v2/apps{}'.format(app_id), params) + scale_json = json.loads('{{ "instances": {} }}'.format(int(instances))) + response = requests.put(url, json=scale_json) + + if response.status_code == 200: + deployment = response.json()['deploymentId'] + return (deployment, None) + else: + return (None, self._response_to_error(response)) + + def suspend_app(self, app_id, force=None): + """Scales an application to zero instances. + :param app_id: The ID of the application to suspend. + :type app_id: str + :param force: Whether to override running deployments. + :type force: bool + :returns: The resulting deployment ID. + :rtype: (bool, Error) + """ + + return self.scale_app(app_id, 0, force) + + def remove_app(self, app_id, force=None): + """Completely removes the requested application. + :param app_id: The ID of the application to suspend. + :type app_id: str + :param force: Whether to override running deployments. + :type force: bool + :returns: Status of trying to remove the application. + :rtype: (bool, Error) + """ + + if force is None: + force = False + + app_id = self._sanitize_app_id(app_id) + + params = None + if force: + params = {'force': True} + + url = self._create_url('v2/apps{}'.format(app_id), params) + response = requests.delete(url) + + if response.status_code == 200: + return (True, None) + else: + return (None, self._response_to_error(response)) class Error(errors.Error): diff --git a/dcos/cli/config/main.py b/dcos/cli/config/main.py index ba95df9..e18343f 100644 --- a/dcos/cli/config/main.py +++ b/dcos/cli/config/main.py @@ -14,8 +14,7 @@ import os import docopt import toml - -from ...api import config, constants +from dcos.api import config, constants def main(): @@ -34,7 +33,7 @@ def main(): elif args['config'] and args[''] is None: toml_config = config.Toml.load_from_path(config_path) - print(config[args['']]) + print(toml_config[args['']]) elif args['config']: toml_config = config.Toml.load_from_path(config_path) diff --git a/dcos/cli/help/main.py b/dcos/cli/help/main.py index b2372ee..3e8e972 100644 --- a/dcos/cli/help/main.py +++ b/dcos/cli/help/main.py @@ -15,8 +15,7 @@ import os import subprocess import docopt - -from ...api import constants, options +from dcos.api import constants, options def main(): diff --git a/dcos/cli/main.py b/dcos/cli/main.py index e3f51d5..39e3561 100644 --- a/dcos/cli/main.py +++ b/dcos/cli/main.py @@ -18,8 +18,7 @@ import os import subprocess import docopt - -from ..api import constants +from dcos.api import constants def main(): diff --git a/dcos/cli/marathon/main.py b/dcos/cli/marathon/main.py index adda1c6..e673fcd 100644 --- a/dcos/cli/marathon/main.py +++ b/dcos/cli/marathon/main.py @@ -1,7 +1,12 @@ """ Usage: dcos marathon info + dcos marathon list + dcos marathon describe dcos marathon start + dcos marathon scale [--force] + dcos marathon suspend [--force] + dcos marathon remove [--force] dcos marathon --help dcos marathon --version @@ -10,11 +15,11 @@ Options: --version Show version """ +import json import os import docopt - -from ...api import config, constants, marathon, options +from dcos.api import config, constants, marathon, options def main(): @@ -25,22 +30,99 @@ def main(): if args['marathon'] and args['info']: return _info() + elif args['marathon'] and args['list']: + toml_config = config.Toml.load_from_path(config_path) + return _list(toml_config) + elif args['marathon'] and args['describe']: + toml_config = config.Toml.load_from_path(config_path) + return _describe(args[''], toml_config) elif args['marathon'] and args['start']: toml_config = config.Toml.load_from_path(config_path) return _start(args[''], toml_config) + elif args['marathon'] and args['scale']: + toml_config = config.Toml.load_from_path(config_path) + return _scale(args[''], + args[''], + args['--force'], + toml_config) + elif args['marathon'] and args['suspend']: + toml_config = config.Toml.load_from_path(config_path) + return _suspend(args[''], args['--force'], toml_config) + elif args['marathon'] and args['remove']: + toml_config = config.Toml.load_from_path(config_path) + return _remove(args[''], args['--force'], toml_config) else: print(options.make_generic_usage_error(__doc__)) return 1 def _info(): - """Print marathon cli information + """Print marathon cli information. :returns: Process status :rtype: int """ - print('Deploy and manage containers for Mesos') + print('Deploy and manage applications on Apache Mesos') + return 0 + + +def _create_client(config): + """Creates a Marathon client with the supplied configuration. + + :param config: Configuration dictionary + :type config: config.Toml + :returns: Marathon client + :rtype: dcos.api.marathon.Client + """ + return marathon.Client(config['marathon.host'], config['marathon.port']) + + +def _list(config): + """Lists known Marathon applications. + + :param config: Configuration dictionary + :type config: config.Toml + :returns: Process status + :rtype: int + """ + client = _create_client(config) + + apps, err = client.get_apps() + if err is not None: + print(err.error()) + return 1 + + if not apps: + print("No apps to list.") + + for app in apps: + print(app['id']) + + return 0 + + +def _describe(app_id, config): + """Show details of a Marathon applications. + + :param app_id: ID of the app to suspend + :type app_id: str + :param config: Configuration dictionary + :type config: config.Toml + :returns: Process status + :rtype: int + """ + client = _create_client(config) + + app, err = client.get_app(app_id) + if err is not None: + print(err.error()) + return 1 + + print(json.dumps(app, + sort_keys=True, + indent=2)) + return 0 @@ -54,7 +136,7 @@ def _start(app_resource_path, config): :returns: Process status :rtype: int """ - client = marathon.Client(config['marathon.host'], config['marathon.port']) + client = _create_client(config) with open(app_resource_path) as app_resource_file: success, err = client.start_app(app_resource_file) @@ -63,3 +145,75 @@ def _start(app_resource_path, config): return 1 return 0 + + +def _scale(app_id, instances, force, config): + """Suspends a running Marathon application. + + :param app_id: ID of the app to suspend + :type app_id: str + :param instances: The requested number of instances. + :type instances: int + :param force: Whether to override running deployments. + :type force: bool + :param config: Configuration dictionary + :type config: config.Toml + :returns: Process status + :rtype: int + """ + client = _create_client(config) + + deployment, err = client.scale_app(app_id, instances, force) + if err is not None: + print(err.error()) + return 1 + + print('Created deployment {}'.format(deployment)) + + return 0 + + +def _suspend(app_id, force, config): + """Suspends a running Marathon application. + + :param app_id: ID of the app to suspend + :type app_id: str + :param force: Whether to override running deployments. + :type force: bool + :param config: Configuration dictionary + :type config: config.Toml + :returns: Process status + :rtype: int + """ + client = _create_client(config) + + deployment, err = client.suspend_app(app_id, force) + if err is not None: + print(err.error()) + return 1 + + print('Created deployment {}'.format(deployment)) + + return 0 + + +def _remove(app_id, force, config): + """Remove a Marathon application. + + :param app_id: ID of the app to remove + :type app_id: str + :param force: Whether to override running deployments. + :type force: bool + :param config: Configuration dictionary + :type config: config.Toml + :returns: Process status + :rtype: int + """ + client = _create_client(config) + + success, err = client.remove_app(app_id, force) + if err is not None: + print(err.error()) + return 1 + + return 0 diff --git a/dcos/cli/subcommand/main.py b/dcos/cli/subcommand/main.py index 8d7297d..273102e 100644 --- a/dcos/cli/subcommand/main.py +++ b/dcos/cli/subcommand/main.py @@ -11,8 +11,7 @@ Options: import subprocess import docopt - -from ...api import constants +from dcos.api import constants def main(): @@ -21,7 +20,7 @@ def main(): version='dcos-subcommand version {}'.format(constants.version)) if args['subcommand'] and args['info']: - print('Manage DCOS external commands') + print('Manage external DCOS commands') elif args['subcommand'] and args['install'] and args['python']: print('Trying to install a python subcommand') command = ['pip', 'install', args['']] diff --git a/setup.py b/setup.py index f661c5a..7924850 100644 --- a/setup.py +++ b/setup.py @@ -71,7 +71,13 @@ setup( # project is installed. For an analysis of "install_requires" vs pip's # requirements files see: # https://packaging.python.org/en/latest/requirements.html - install_requires=['docopt', 'toml', 'requests'], + install_requires=[ + 'docopt', + 'jsonschema', + 'pystache', + 'requests', + 'toml', + ], # List additional groups of dependencies here (e.g. development # dependencies). You can install these using the following syntax, for diff --git a/tests/data/marathon/sleep.json b/tests/data/marathon/sleep.json new file mode 100644 index 0000000..fd7c919 --- /dev/null +++ b/tests/data/marathon/sleep.json @@ -0,0 +1,11 @@ +{ + "id": "test-app", + "cmd": "sleep 1000", + "cpus": 0.1, + "mem": 16, + "instances": 1, + "labels": { + "PACKAGE_ID": "test-app", + "PACKAGE_VERSION": "1.2.3" + } +} diff --git a/tests/data/marathon_sleeping_app.json b/tests/data/marathon_sleeping_app.json deleted file mode 100644 index 336679b..0000000 --- a/tests/data/marathon_sleeping_app.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "cmd": "sleep 1000", - "cpus": 0.25, - "id": "sleeping-app", - "instances": 1, - "mem": 50 -} diff --git a/tox.ini b/tox.ini index 6f374ed..917b617 100644 --- a/tox.ini +++ b/tox.ini @@ -10,5 +10,5 @@ deps = commands = flake8 --verbose dcos tests - isort -rc -c -vb {envsitepackagesdir}/dcos + isort --recursive --check-only --diff --verbose {envsitepackagesdir}/dcos py.test --cov {envsitepackagesdir}/dcos tests