Files
deb-python-dcos/cli/dcoscli/marathon/main.py
José Armando García Sancio d42a0c3321 dcos-588 Improve config subcommand
There is quite a bit going on in this commit. At a high-level this
improves the config subcommand so that it can support more value type
and not just strings. To do this we need to know the schema for each
subsection. The config subcommand queries all of the subcommands for
this information. Here are all of the changes includes in this commit:

* Fix the makefile so that the test, doc and packages target depend on
  env.
* Add support for append, prepend, unset a list element and validate in
  the config subcommand.
* Extend the marathon, package and subcommand subcommands so that they
  return the schema for their section of the config file.
* Adds config schema file the python package.
* The module jsonschema returns poorly formatted validation errors. This
  commit includes regular expression to clean up those messages.
2015-03-30 23:41:28 -07:00

822 lines
22 KiB
Python

"""Deploy and manage applications on the DCOS
Usage:
dcos marathon --config-schema
dcos marathon app add [<app-resource>]
dcos marathon app list
dcos marathon app remove [--force] <app-id>
dcos marathon app restart [--force] <app-id>
dcos marathon app show [--app-version=<app-version>] <app-id>
dcos marathon app start [--force] <app-id> [<instances>]
dcos marathon app stop [--force] <app-id>
dcos marathon app update [--force] <app-id> [<properties>...]
dcos marathon app version list [--max-count=<max-count>] <app-id>
dcos marathon deployment list [<app-id>]
dcos marathon deployment rollback <deployment-id>
dcos marathon deployment stop <deployment-id>
dcos marathon deployment watch [--max-count=<max-count>]
[--interval=<interval>] <deployment-id>
dcos marathon info
dcos marathon task list [<app-id>]
dcos marathon task show <task-id>
Options:
-h, --help Show this screen
--version Show version
--force This flag disable checks in Marathon during
update operations
--app-version=<app-version> This flag specifies the application version to
use for the command. The application version
(<app-version>) can be specified as an
absolute value or as relative value. Absolute
version values must be in ISO8601 date format.
Relative values must be specified as a
negative integer and they represent the
version from the currently deployed
application definition
--config-schema Show the configuration schema for the Marathon
subcommand
--max-count=<max-count> Maximum number of entries to try to fetch and
return
--interval=<interval> Number of seconds to wait between actions
Positional arguments:
<app-id> The application id
<app-resource> The application resource; for a detailed
description see (https://mesosphere.github.io/
marathon/docs/rest-api.html#post-/v2/apps)
<deployment-id> The deployment id
<instances> The number of instances to start
<properties> Optional key-value pairs to be included in the
command. The separator between the key and
value must be the '=' character. E.g. cpus=2.0
<task-id> The task id
"""
import json
import os
import sys
import time
import dcoscli
import docopt
import pkg_resources
from dcos.api import (cmds, config, constants, emitting, errors, jsonitem,
marathon, options, util)
logger = util.get_logger(__name__)
emitter = emitting.FlatEmitter()
def main():
err = util.configure_logger_from_environ()
if err is not None:
emitter.publish(err)
return 1
args = docopt.docopt(
__doc__,
version='dcos-marathon version {}'.format(dcoscli.version))
returncode, err = cmds.execute(_cmds(), args)
if err is not None:
emitter.publish(err)
emitter.publish(options.make_generic_usage_message(__doc__))
return 1
return returncode
def _cmds():
"""
:returns: all the supported commands
:rtype: dcos.api.cmds.Command
"""
return [
cmds.Command(
hierarchy=['marathon', 'version', 'list'],
arg_keys=['<app-id>', '--max-count'],
function=_version_list),
cmds.Command(
hierarchy=['marathon', 'deployment', 'list'],
arg_keys=['<app-id>'],
function=_deployment_list),
cmds.Command(
hierarchy=['marathon', 'deployment', 'rollback'],
arg_keys=['<deployment-id>'],
function=_deployment_rollback),
cmds.Command(
hierarchy=['marathon', 'deployment', 'stop'],
arg_keys=['<deployment-id>'],
function=_deployment_stop),
cmds.Command(
hierarchy=['marathon', 'deployment', 'watch'],
arg_keys=['<deployment-id>', '--max-count', '--interval'],
function=_deployment_watch),
cmds.Command(
hierarchy=['marathon', 'task', 'list'],
arg_keys=['<app-id>'],
function=_task_list),
cmds.Command(
hierarchy=['marathon', 'task', 'show'],
arg_keys=['<task-id>'],
function=_task_show),
cmds.Command(
hierarchy=['marathon', 'info'],
arg_keys=[],
function=_info),
cmds.Command(
hierarchy=['marathon', 'app', 'add'],
arg_keys=['<app-resource>'],
function=_add),
cmds.Command(
hierarchy=['marathon', 'app', 'list'],
arg_keys=[],
function=_list),
cmds.Command(
hierarchy=['marathon', 'app', 'remove'],
arg_keys=['<app-id>', '--force'],
function=_remove),
cmds.Command(
hierarchy=['marathon', 'app', 'show'],
arg_keys=['<app-id>', '--app-version'],
function=_show),
cmds.Command(
hierarchy=['marathon', 'app', 'start'],
arg_keys=['<app-id>', '<instances>', '--force'],
function=_start),
cmds.Command(
hierarchy=['marathon', 'app', 'stop'],
arg_keys=['<app-id>', '--force'],
function=_stop),
cmds.Command(
hierarchy=['marathon', 'app', 'update'],
arg_keys=['<app-id>', '<properties>', '--force'],
function=_update),
cmds.Command(
hierarchy=['marathon', 'app', 'restart'],
arg_keys=['<app-id>', '--force'],
function=_restart),
cmds.Command(
hierarchy=['marathon'],
arg_keys=['--config-schema'],
function=_marathon),
]
def _marathon(config_schema):
"""
:returns: Process status
:rtype: int
"""
if config_schema:
schema = json.loads(
pkg_resources.resource_string(
'dcoscli',
'data/config-schema/marathon.json').decode('utf-8'))
emitter.publish(schema)
else:
emitter.publish(options.make_generic_usage_message(__doc__))
return 1
return 0
def _info():
"""
:returns: Process status
:rtype: int
"""
emitter.publish(__doc__.split('\n')[0])
return 0
def _add(app_resource):
"""
:param app_resource: optional filename for the application resource
:type app_resource: str
:returns: Process status
:rtype: int
"""
if app_resource is not None:
with open(app_resource) as fd:
application_resource, err = util.load_json(fd)
else:
# Check that stdin is not tty
if sys.stdin.isatty():
# We don't support TTY right now. In the future we will start an
# editor
emitter.publish(
"We currently don't support reading from the TTY. Please "
"specify an application JSON.\n"
"Usage: dcos app add < app_resource.json")
return 1
application_resource, err = util.load_json(sys.stdin)
if err is not None:
emitter.publish(err)
return 1
schema = json.loads(
pkg_resources.resource_string(
'dcoscli',
'data/marathon-schema.json').decode('utf-8'))
err = util.validate_json(application_resource, schema)
if err is not None:
emitter.publish(err)
return 1
# Add application to marathon
client = marathon.create_client(
config.load_from_path(
os.environ[constants.DCOS_CONFIG_ENV]))
# Check that the application doesn't exist
app_id = client.normalize_app_id(application_resource['id'])
app, err = client.get_app(app_id)
if app is not None:
emitter.publish("Application '{}' already exists".format(app_id))
return 1
_, err = client.add_app(application_resource)
if err is not None:
emitter.publish(err)
return 1
return 0
def _list():
"""
:returns: process status
:rtype: int
"""
client = marathon.create_client(
config.load_from_path(
os.environ[constants.DCOS_CONFIG_ENV]))
apps, err = client.get_apps()
if err is not None:
emitter.publish(err)
return 1
emitter.publish(apps)
return 0
def _remove(app_id, force):
"""
:param app_id: ID of the app to remove
:type app_id: str
:param force: Whether to override running deployments.
:type force: bool
:returns: Process status
:rtype: int
"""
client = marathon.create_client(
config.load_from_path(
os.environ[constants.DCOS_CONFIG_ENV]))
err = client.remove_app(app_id, force)
if err is not None:
emitter.publish(err)
return 1
return 0
def _show(app_id, version):
"""Show details of a Marathon application.
:param app_id: The id for the application
:type app_id: str
:param version: The version, either absolute (date-time) or relative
:type version: str
:returns: Process status
:rtype: int
"""
client = marathon.create_client(
config.load_from_path(
os.environ[constants.DCOS_CONFIG_ENV]))
if version is not None:
version, err = _calculate_version(client, app_id, version)
if err is not None:
emitter.publish(err)
return 1
app, err = client.get_app(app_id, version=version)
if err is not None:
emitter.publish(err)
return 1
emitter.publish(app)
return 0
def _start(app_id, instances, force):
"""Start a Marathon application.
:param app_id: the id for the application
:type app_id: str
:param instances: the number of instances to start
:type instances: str
:param force: whether to override running deployments
:type force: bool
:returns: Process status
:rtype: int
"""
# Check that the application exists
client = marathon.create_client(
config.load_from_path(
os.environ[constants.DCOS_CONFIG_ENV]))
desc, err = client.get_app(app_id)
if err is not None:
emitter.publish(err)
return 1
if desc['instances'] > 0:
emitter.publish(
'Application {!r} already started: {!r} instances.'.format(
app_id,
desc['instances']))
return 1
schema = json.loads(
pkg_resources.resource_string(
'dcoscli',
'data/marathon-schema.json').decode('utf-8'))
app_json = {}
# Need to add the 'id' because it is required
app_json['id'] = app_id
# Set instances to 1 if not specified
if instances is None:
instances = 1
else:
instances, err = util.parse_int(instances)
if err is not None:
emitter.publish(err)
return 1
if instances <= 0:
emitter.publish(
'The number of instances must be positive: {!r}.'.format(
instances))
return 1
app_json['instances'] = instances
err = util.validate_json(app_json, schema)
if err is not None:
emitter.publish(err)
return 1
deployment, err = client.update_app(app_id, app_json, force)
if err is not None:
emitter.publish(err)
return 1
emitter.publish('Created deployment {}'.format(deployment))
return 0
def _stop(app_id, force):
"""Stop a Marathon application
:param app_id: the id of the application
:type app_id: str
:param force: whether to override running deployments
:type force: bool
:returns: process status
:rtype: int
"""
# Check that the application exists
client = marathon.create_client(
config.load_from_path(
os.environ[constants.DCOS_CONFIG_ENV]))
desc, err = client.get_app(app_id)
if err is not None:
emitter.publish(err)
return 1
if desc['instances'] <= 0:
emitter.publish(
'Application {!r} already stopped: {!r} instances.'.format(
app_id,
desc['instances']))
return 1
app_json = {'instances': 0}
deployment, err = client.update_app(app_id, app_json, force)
if err is not None:
emitter.publish(err)
return 1
emitter.publish('Created deployment {}'.format(deployment))
def _update(app_id, json_items, force):
"""
:param app_id: the id of the application
:type app_id: str
:param json_items: json update items
:type json_items: list of str
:param force: whether to override running deployments
:type force: bool
:returns: process status
:rtype: int
"""
# Check that the application exists
client = marathon.create_client(
config.load_from_path(
os.environ[constants.DCOS_CONFIG_ENV]))
_, err = client.get_app(app_id)
if err is not None:
emitter.publish(err)
return 1
if len(json_items) == 0:
if sys.stdin.isatty():
# We don't support TTY right now. In the future we will start an
# editor
emitter.publish(
"We currently don't support reading from the TTY. Please "
"specify an application JSON.\n"
"E.g. dcos app update < app_update.json")
return 1
else:
return _update_from_stdin(app_id, force)
schema = json.loads(
pkg_resources.resource_string(
'dcoscli',
'data/marathon-schema.json').decode('utf-8'))
app_json = {}
# Need to add the 'id' because it is required
app_json['id'] = app_id
for json_item in json_items:
key_value, err = jsonitem.parse_json_item(json_item, schema)
if err is not None:
emitter.publish(err)
return 1
key, value = key_value
if key in app_json:
emitter.publish(
'Key {!r} was specified more than once'.format(key))
return 1
else:
app_json[key] = value
err = util.validate_json(app_json, schema)
if err is not None:
emitter.publish(err)
return 1
deployment, err = client.update_app(app_id, app_json, force)
if err is not None:
emitter.publish(err)
return 1
emitter.publish('Created deployment {}'.format(deployment))
return 0
def _restart(app_id, force):
"""
:param app_id: the id of the application
:type app_id: str
:param force: whether to override running deployments
:type force: bool
:returns: process status
:rtype: int
"""
client = marathon.create_client(
config.load_from_path(
os.environ[constants.DCOS_CONFIG_ENV]))
desc, err = client.get_app(app_id)
if err is not None:
emitter.publish(err)
return 1
if desc['instances'] <= 0:
app_id = client.normalize_app_id(app_id)
emitter.publish(
'Unable to restart application {!r} because it is stopped'.format(
app_id,
desc['instances']))
return 1
payload, err = client.restart_app(app_id, force)
if err is not None:
emitter.publish(err)
return 1
emitter.publish('Created deployment {}'.format(payload['deploymentId']))
return 0
def _version_list(app_id, max_count):
"""
:param app_id: the id of the application
:type app_id: str
:param max_count: the maximum number of version to fetch and return
:type max_count: str
:returns: process status
:rtype: int
"""
if max_count is not None:
max_count, err = util.parse_int(max_count)
if err is not None:
emitter.publish(err)
return 1
client = marathon.create_client(
config.load_from_path(
os.environ[constants.DCOS_CONFIG_ENV]))
versions, err = client.get_app_versions(app_id, max_count)
if err is not None:
emitter.publish(err)
return 1
emitter.publish(versions)
return 0
def _deployment_list(app_id):
"""
:param app_id: the application id
:type app_id: str
:returns: process status
:rtype: int
"""
client = marathon.create_client(
config.load_from_path(
os.environ[constants.DCOS_CONFIG_ENV]))
deployments, err = client.get_deployments(app_id)
if err is not None:
emitter.publish(err)
return 1
emitter.publish(deployments)
return 0
def _deployment_stop(deployment_id):
"""
:param deployment_id: the application id
:type deployment_di: str
:returns: process status
:rtype: int
"""
client = marathon.create_client(
config.load_from_path(
os.environ[constants.DCOS_CONFIG_ENV]))
err = client.stop_deployment(deployment_id)
if err is not None:
emitter.publish(err)
return 1
return 0
def _deployment_rollback(deployment_id):
"""
:param deployment_id: the application id
:type deployment_di: str
:returns: process status
:rtype: int
"""
client = marathon.create_client(
config.load_from_path(
os.environ[constants.DCOS_CONFIG_ENV]))
deployment, err = client.rollback_deployment(deployment_id)
if err is not None:
emitter.publish(err)
return 1
emitter.publish(deployment)
return 0
def _deployment_watch(deployment_id, max_count, interval):
"""
:param deployment_id: the application id
:type deployment_di: str
:param max_count: maximum number of polling calls
:type max_count: str
:param interval: wait interval in seconds between polling calls
:type interval: str
:returns: process status
:rtype: int
"""
if max_count is not None:
max_count, err = util.parse_int(max_count)
if err is not None:
emitter.publish(err)
return 1
if interval is not None:
interval, err = util.parse_int(interval)
if err is not None:
emitter.publish(err)
return 1
else:
interval = 1
client = marathon.create_client(
config.load_from_path(
os.environ[constants.DCOS_CONFIG_ENV]))
count = 0
while max_count is None or count < max_count:
deployment, err = client.get_deployment(deployment_id)
if err is not None:
emitter.publish(err)
return 1
if deployment is None:
return 0
emitter.publish(deployment)
time.sleep(interval)
count += 1
return 0
def _task_list(app_id):
"""
:param app_id: the id of the application
:type app_id: str
:returns: process status
:rtype: int
"""
client = marathon.create_client(
config.load_from_path(
os.environ[constants.DCOS_CONFIG_ENV]))
tasks, err = client.get_tasks(app_id)
if err is not None:
emitter.publish(err)
return 1
emitter.publish(tasks)
return 0
def _task_show(task_id):
"""
:param task_id: the task id
:type task_id: str
:returns: process status
:rtype: int
"""
client = marathon.create_client(
config.load_from_path(
os.environ[constants.DCOS_CONFIG_ENV]))
task, err = client.get_task(task_id)
if err is not None:
emitter.publish(err)
return 1
if task is None:
emitter.publish(
errors.DefaultError("Task '{}' does not exist".format(task_id)))
return 1
emitter.publish(task)
return 0
def _update_from_stdin(app_id, force):
"""
:param app_id: the id of the application
:type app_id: str
:param force: whether to override running deployments
:type force: bool
:returns: process status
:rtype: int
"""
logger.info('Updating %r from JSON object from stdin', app_id)
application_resource, err = util.load_jsons(sys.stdin.read())
if err is not None:
emitter.publish(err)
return 1
# Add application to marathon
client = marathon.create_client(
config.load_from_path(
os.environ[constants.DCOS_CONFIG_ENV]))
_, err = client.update_app(app_id, application_resource, force)
if err is not None:
emitter.publish(err)
return 1
return 0
def _calculate_version(client, app_id, version):
"""
:param client: Marathon client
:type client: dcos.api.marathon.Client
:param app_id: The ID of the application
:type app_id: str
:param version: Relative or absolute version or None
:type version: str
:returns: The absolute version as an ISO8601 date-time; Error otherwise
:rtype: (str, Error)
"""
# First let's try to parse it as a negative integer
value, err = util.parse_int(version)
if err is None and value < 0:
value = -1 * value
# We have a negative value let's ask Marathon for the last abs(value)
versions, err = client.get_app_versions(app_id, value + 1)
if err is not None:
return (None, err)
if len(versions) <= value:
# We don't have enough versions. Return an error.
msg = "Application {!r} only has {!r} version(s)."
return (
None,
errors.DefaultError(msg.format(app_id, len(versions), value))
)
else:
return (versions[value], None)
elif err is None:
return (
None,
errors.DefaultError(
'Relative versions must be negative: {}'.format(version))
)
else:
# Let's assume that we have an absolute version
return (version, None)