DCOS-364 Implements dcos app start

This commit is contained in:
José Armando García Sancio
2015-02-13 06:56:58 +00:00
parent 080e80bfb7
commit 0b0b491fa0
9 changed files with 423 additions and 29 deletions

View File

@@ -105,12 +105,10 @@ class Client(object):
'v2/apps{}/versions/{}'.format(app_id, version)) 'v2/apps{}/versions/{}'.format(app_id, version))
logger.info('Getting %r', url) logger.info('Getting %r', url)
response = requests.get(url) response = requests.get(url)
logger.info('Got (%r): %r', response.status_code, response.json()) logger.info('Got (%r): %r', response.status_code, response.json())
if response.status_code == 200: if _success(response.status_code):
# Looks like Marathon return different JSON for versions # Looks like Marathon return different JSON for versions
if version is None: if version is None:
return (response.json()['app'], None) return (response.json()['app'], None)
@@ -136,12 +134,10 @@ class Client(object):
url = self._create_url('v2/apps{}/versions'.format(app_id)) url = self._create_url('v2/apps{}/versions'.format(app_id))
logger.info('Getting %r', url) logger.info('Getting %r', url)
response = requests.get(url) response = requests.get(url)
logger.info('Got (%r): %r', response.status_code, response.json()) logger.info('Got (%r): %r', response.status_code, response.json())
if response.status_code == 200: if _success(response.status_code):
if max_count is None: if max_count is None:
return (response.json()['versions'], None) return (response.json()['versions'], None)
else: else:
@@ -159,7 +155,7 @@ class Client(object):
url = self._create_url('v2/apps') url = self._create_url('v2/apps')
response = requests.get(url) response = requests.get(url)
if response.status_code == 200: if _success(response.status_code):
apps = response.json()['apps'] apps = response.json()['apps']
return (apps, None) return (apps, None)
else: else:
@@ -170,8 +166,8 @@ class Client(object):
:param app_resource: Application resource :param app_resource: Application resource
:type app_resource: dict, bytes or file :type app_resource: dict, bytes or file
:returns: Status of trying to start the application :returns: the application description
:rtype: Error :rtype: (dict, Error)
""" """
url = self._create_url('v2/apps') url = self._create_url('v2/apps')
@@ -182,13 +178,46 @@ class Client(object):
else: else:
app_json = app_resource app_json = app_resource
logger.info("Posting %r to %r", app_json, url) logger.info('Posting %r to %r', app_json, url)
response = requests.post(url, json=app_json) response = requests.post(url, json=app_json)
logger.info('Got (%r): %r', response.status_code, response.json())
if response.status_code == 201: if _success(response.status_code):
return None return (response.json(), None)
else: else:
return self._response_to_error(response) return (None, self._response_to_error(response))
def update_app(self, app_id, payload, force=None):
"""Update an application.
:param app_id: the application id
:type app_id: str
:param payload: the json payload
:type payload: dict
:param force: whether to override running deployments.
:type force: bool
:returns: the resulting deployment ID.
:rtype: (str, Error)
"""
app_id = self._sanitize_app_id(app_id)
if force is None or not force:
params = None
else:
params = {'force': True}
url = self._create_url('v2/apps{}'.format(app_id), params)
logger.info('Putting %r to %r', payload, url)
response = requests.put(url, json=payload)
logger.info('Got (%r): %r', response.status_code, response.json())
if _success(response.status_code):
deployment = response.json()['deploymentId']
return (deployment, None)
else:
return (None, self._response_to_error(response))
def scale_app(self, app_id, instances, force=None): def scale_app(self, app_id, instances, force=None):
"""Scales an application to the requested number of instances. """Scales an application to the requested number of instances.
@@ -215,7 +244,7 @@ class Client(object):
url = self._create_url('v2/apps{}'.format(app_id), params) url = self._create_url('v2/apps{}'.format(app_id), params)
response = requests.put(url, json={'instances': int(instances)}) response = requests.put(url, json={'instances': int(instances)})
if response.status_code == 200: if _success(response.status_code):
deployment = response.json()['deploymentId'] deployment = response.json()['deploymentId']
return (deployment, None) return (deployment, None)
else: else:
@@ -257,7 +286,7 @@ class Client(object):
url = self._create_url('v2/apps{}'.format(app_id), params) url = self._create_url('v2/apps{}'.format(app_id), params)
response = requests.delete(url) response = requests.delete(url)
if response.status_code == 200: if _success(response.status_code):
return None return None
else: else:
return self._response_to_error(response) return self._response_to_error(response)
@@ -281,3 +310,15 @@ class Error(errors.Error):
""" """
return self._message return self._message
def _success(status_code):
"""Returns true if the success status is between [200, 300).
:param response_status: the http response status
:type response_status: int
:returns: True for success status; False otherwise
:rtype: bool
"""
return status_code >= 200 and status_code < 300

View File

@@ -7,7 +7,6 @@ import shutil
import subprocess import subprocess
import git import git
import jsonschema
import portalocker import portalocker
import pystache import pystache
from dcos.api import errors, util from dcos.api import errors, util
@@ -59,10 +58,9 @@ def install(pkg, version, init_client, user_options, cfg):
options = dict(list(default_options.items()) + list(user_options.items())) options = dict(list(default_options.items()) + list(user_options.items()))
# Validate options with the config schema # Validate options with the config schema
try: err = util.validate_json(options, config_schema)
jsonschema.validate(options, config_schema) if err is not None:
except jsonschema.ValidationError as ve: return err
return Error(ve.message)
# Insert option parameters into the init template # Insert option parameters into the init template
init_template, tmpl_error = pkg.marathon_template(version) init_template, tmpl_error = pkg.marathon_template(version)
@@ -87,7 +85,8 @@ def install(pkg, version, init_client, user_options, cfg):
# TODO(CD): Is this necessary / desirable at this point? # TODO(CD): Is this necessary / desirable at this point?
# Send the descriptor to init # Send the descriptor to init
return init_client.add_app(init_desc) _, err = init_client.add_app(init_desc)
return err
def list_installed_packages(init_client): def list_installed_packages(init_client):

View File

@@ -6,6 +6,7 @@ import shutil
import sys import sys
import tempfile import tempfile
import jsonschema
from dcos.api import constants, errors from dcos.api import constants, errors
@@ -118,3 +119,20 @@ def load_jsons(value):
value, value,
error) error)
return (None, errors.DefaultError('Error loading JSON.')) return (None, errors.DefaultError('Error loading JSON.'))
def validate_json(instance, schema):
"""Validate an instance under the given schema.
:param instance: the instance to validate
:type instance: dict
:param schema: the schema to validate with
:type schema: dict
:returns: an error if the validation failed; None otherwise
:rtype: Error
"""
try:
jsonschema.validate(instance, schema)
return None
except jsonschema.ValidationError as ve:
return errors.DefaultError(ve.message)

View File

@@ -5,6 +5,7 @@ Usage:
dcos app list dcos app list
dcos app remove [--force] <app-id> dcos app remove [--force] <app-id>
dcos app show [--app-version=<app-version>] <app-id> dcos app show [--app-version=<app-version>] <app-id>
dcos app start [--force] <app-id> [<instances>]
Options: Options:
-h, --help Show this screen -h, --help Show this screen
@@ -21,10 +22,12 @@ Options:
version from the currently deployed version from the currently deployed
application definition. application definition.
""" """
import json
import os import os
import sys import sys
import docopt import docopt
import pkg_resources
from dcos.api import (config, constants, emitting, errors, marathon, options, from dcos.api import (config, constants, emitting, errors, marathon, options,
util) util)
@@ -61,7 +64,14 @@ def main():
if args['show']: if args['show']:
return _show(args['<app-id>'], args['--app-version']) return _show(args['<app-id>'], args['--app-version'])
if args['start']:
return _start(
args['<app-id>'],
args['<instances>'],
args['--force'])
emitter.publish(options.make_generic_usage_error(__doc__)) emitter.publish(options.make_generic_usage_error(__doc__))
return 1 return 1
@@ -99,7 +109,7 @@ def _add():
client = marathon.create_client( client = marathon.create_client(
config.load_from_path( config.load_from_path(
os.environ[constants.DCOS_CONFIG_ENV])) os.environ[constants.DCOS_CONFIG_ENV]))
err = client.add_app(application_resource) _, err = client.add_app(application_resource)
if err is not None: if err is not None:
emitter.publish(err) emitter.publish(err)
return 1 return 1
@@ -184,6 +194,78 @@ def _show(app_id, version):
return 0 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 json_items: the list of json item
: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]))
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(
'dcos',
'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 = _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 _calculate_version(client, app_id, version): def _calculate_version(client, app_id, version):
""" """
:param client: Marathon client :param client: Marathon client

View File

@@ -138,7 +138,7 @@ def _start(app_resource_path, config):
client = marathon.create_client(config) client = marathon.create_client(config)
with open(app_resource_path) as app_resource_file: with open(app_resource_path) as app_resource_file:
err = client.add_app(app_resource_file) _, err = client.add_app(app_resource_file)
if err is not None: if err is not None:
print(err.error()) print(err.error())
return 1 return 1

View File

@@ -0,0 +1,215 @@
{
"$schema": "http://json-schema.org/schema#",
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^/?(([a-z0-9]|[a-z0-9][a-z0-9\\-]*[a-z0-9])\\.)*([a-z0-9]|[a-z0-9][a-z0-9\\-]*[a-z0-9])$"
},
"cmd": {
"type": "string"
},
"args": {
"type": "array",
"items": {
"type": "string"
}
},
"user": {
"type": "string"
},
"env": {
"type": "object",
"patternProperties": {
".*": { "type": "string" }
}
},
"instances": {
"type": "integer",
"minimum": 0
},
"cpus": {
"type": "number",
"minimum": 0
},
"mem": {
"type": "number",
"minimum": 0
},
"disk": {
"type": "number",
"minimum": 0
},
"executor": {
"type": "string"
},
"constraints": {
},
"uris": {
"type": "array",
"items": {
"type": "string"
}
},
"storeUrls": {
"type": "array",
"items": {
"type": "string"
}
},
"ports": {
"type": "array",
"items": {
"type": "integer",
"minimum": 0,
"maximum": 65535
}
},
"requirePorts": {
"type": "boolean"
},
"backoffSeconds": {
"type": "integer",
"minimum": 0
},
"backoffFactor": {
"type": "number",
"minimum": 1.0
},
"container": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"docker": {
"type": "object",
"properties": {
"image": {
"type": "string"
},
"network": {
"type": "string"
},
"portMappings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"containerPort": {
"type": "integer",
"minimum": 0,
"maximum": 65535
},
"hostPort": {
"type": "integer",
"minimum": 0,
"maximum": 65535
},
"servicePort": {
"type": "integer",
"minimum": 0,
"maximum": 65535
},
"protocol": {
"type": "string"
}
},
"additionalProperties": false
}
}
},
"additionalProperties": false,
"required": [
"image"
]
},
"volumes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"containerPath": {
"type": "string"
},
"hostPath": {
"type": "string"
},
"mode": {
"type": "string"
}
},
"additionalProperties": false
}
}
},
"additionalProperties": false
},
"healthChecks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"protocol": {
"type": "string"
},
"command": {
"type": "string"
},
"path": {
"type": "string"
},
"portIndex": {
"type": "integer",
"minimum": 0
},
"gracePeriodSeconds": {
"type": "integer",
"minimum": 0
},
"intervalSeconds": {
"type": "integer",
"minimum": 0
},
"timeoutSeconds": {
"type": "integer",
"minimum": 0
},
"maxConsecutiveFailures": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
}
},
"dependencies": {
"type": "array",
"items": {
"type": "string",
"pattern": "^/?(([a-z0-9]|[a-z0-9][a-z0-9\\-]*[a-z0-9])\\.)*([a-z0-9]|[a-z0-9][a-z0-9\\-]*[a-z0-9])$"
}
},
"upgradeStrategy": {
"type": "object",
"properties": {
"minimumHealthCapacity": {
"type": "number",
"minimum": 0.0,
"maximum": 1.0
}
},
"additionalProperties": false
},
"labels": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"additionalProperties": false,
"required": [
"id"
]
}

View File

@@ -14,7 +14,7 @@ def exec_command(cmd, env=None, stdin=None):
:rtype: (int, bytes, bytes) :rtype: (int, bytes, bytes)
""" """
print('CMD: %r', cmd) print('CMD: {!r}'.format(cmd))
process = subprocess.Popen( process = subprocess.Popen(
cmd, cmd,
@@ -26,7 +26,7 @@ def exec_command(cmd, env=None, stdin=None):
stdout, stderr = process.communicate() stdout, stderr = process.communicate()
# We should always print the stdout and stderr # We should always print the stdout and stderr
print('STDOUT: %s', stdout.decode('utf-8')) print('STDOUT: {!r}'.format(stdout.decode('utf-8')))
print('STDERR: %s', stderr.decode('utf-8')) print('STDERR: {!r}'.format(stderr.decode('utf-8')))
return (process.returncode, stdout, stderr) return (process.returncode, stdout, stderr)

View File

@@ -13,6 +13,7 @@ def test_help():
dcos app list dcos app list
dcos app remove [--force] <app-id> dcos app remove [--force] <app-id>
dcos app show [--app-version=<app-version>] <app-id> dcos app show [--app-version=<app-version>] <app-id>
dcos app start [--force] <app-id> [<instances>]
Options: Options:
-h, --help Show this screen -h, --help Show this screen
@@ -160,6 +161,36 @@ def test_show_bad_relative_app_version():
_remove_app('zero-instance-app') _remove_app('zero-instance-app')
def test_start_missing_app():
returncode, stdout, stderr = exec_command(
['dcos', 'app', 'start', 'missing-id'])
assert returncode == 1
assert stdout == b"Error: App '/missing-id' does not exist\n"
assert stderr == b''
def test_start_app():
_add_app('tests/data/marathon/zero_instance_sleep.json')
_start_app('zero-instance-app')
_remove_app('zero-instance-app')
def test_start_already_started_app():
_add_app('tests/data/marathon/zero_instance_sleep.json')
_start_app('zero-instance-app')
returncode, stdout, stderr = exec_command(
['dcos', 'app', 'start', 'zero-instance-app'])
assert returncode == 1
assert (stdout ==
b"Application 'zero-instance-app' already started: 1 instances.\n")
assert stderr == b''
_remove_app('zero-instance-app')
def _list_apps(app_id=None): def _list_apps(app_id=None):
returncode, stdout, stderr = exec_command(['dcos', 'app', 'list']) returncode, stdout, stderr = exec_command(['dcos', 'app', 'list'])
@@ -212,3 +243,12 @@ def _show_app(app_id, version=None):
assert stderr == b'' assert stderr == b''
return result return result
def _start_app(app_id):
returncode, stdout, stderr = exec_command(
['dcos', 'app', 'start', app_id])
assert returncode == 0
assert stdout.decode().startswith('Created deployment ')
assert stderr == b''

View File

@@ -20,7 +20,7 @@ setup(
# https://packaging.python.org/en/latest/single_source_version.html # https://packaging.python.org/en/latest/single_source_version.html
version=constants.version, version=constants.version,
description='Dcos cli poc project', description='DCOS Command Line Interface',
long_description=long_description, long_description=long_description,
# The project's main homepage. # The project's main homepage.
@@ -76,7 +76,6 @@ setup(
'portalocker', 'portalocker',
'pystache', 'pystache',
'requests', 'requests',
# 'toml',
], ],
# List additional groups of dependencies here (e.g. development # List additional groups of dependencies here (e.g. development
@@ -92,7 +91,7 @@ setup(
# installed, specify them here. If using Python 2.6 or less, then these # installed, specify them here. If using Python 2.6 or less, then these
# have to be included in MANIFEST.in as well. # have to be included in MANIFEST.in as well.
package_data={ package_data={
'sample': ['package_data.dat'], 'dcos': ['data/*'],
}, },
# Although 'package_data' is the preferred approach, in some case you may # Although 'package_data' is the preferred approach, in some case you may