Files
deb-python-dcos/cli/dcoscli/package/main.py
2015-12-11 16:51:19 -08:00

737 lines
22 KiB
Python

import hashlib
import json
import os
import sys
import tempfile
import zipfile
import dcoscli
import docopt
import pkg_resources
from dcos import (cmds, emitting, errors, http, marathon, options, package,
subcommand, util)
from dcos.errors import DCOSException
from dcoscli import tables
from dcoscli.main import decorate_docopt_usage
logger = util.get_logger(__name__)
emitter = emitting.FlatEmitter()
def main():
try:
return _main()
except DCOSException as e:
emitter.publish(e)
return 1
def _doc():
return pkg_resources.resource_string(
'dcoscli',
'data/help/package.txt').decode('utf-8')
@decorate_docopt_usage
def _main():
util.configure_process_from_environ()
args = docopt.docopt(
_doc(),
version='dcos-package version {}'.format(dcoscli.version))
http.silence_requests_warnings()
return cmds.execute(_cmds(), args)
def _cmds():
"""
:returns: All of the supported commands
:rtype: dcos.cmds.Command
"""
return [
cmds.Command(
hierarchy=['package', 'sources'],
arg_keys=[],
function=_list_sources),
cmds.Command(
hierarchy=['package', 'update'],
arg_keys=['--validate'],
function=_update),
cmds.Command(
hierarchy=['package', 'describe'],
arg_keys=['<package-name>', '--app', '--cli', '--options',
'--render', '--package-versions', '--package-version',
'--config'],
function=_describe),
cmds.Command(
hierarchy=['package', 'install'],
arg_keys=['<package-name>', '--package-version', '--options',
'--app-id', '--cli', '--app', '--yes'],
function=_install),
cmds.Command(
hierarchy=['package', 'list'],
arg_keys=['--json', '--endpoints', '--app-id', '<package-name>'],
function=_list),
cmds.Command(
hierarchy=['package', 'search'],
arg_keys=['--json', '<query>'],
function=_search),
cmds.Command(
hierarchy=['package', 'uninstall'],
arg_keys=['<package-name>', '--all', '--app-id', '--cli', '--app'],
function=_uninstall),
cmds.Command(
hierarchy=['package', 'bundle'],
arg_keys=['<package-directory>', '--output-directory'],
function=_bundle),
cmds.Command(
hierarchy=['package'],
arg_keys=['--config-schema', '--info'],
function=_package),
]
def _package(config_schema, info):
"""
:param config_schema: Whether to output the config schema
:type config_schema: boolean
:param info: Whether to output a description of this subcommand
:type info: boolean
:returns: Process status
:rtype: int
"""
if config_schema:
schema = json.loads(
pkg_resources.resource_string(
'dcoscli',
'data/config-schema/package.json').decode('utf-8'))
emitter.publish(schema)
elif info:
_info()
else:
emitter.publish(options.make_generic_usage_message(_doc()))
return 1
return 0
def _info():
"""Print package cli information.
:returns: Process status
:rtype: int
"""
emitter.publish(_doc().split('\n')[0])
return 0
def _list_sources():
"""List configured package sources.
:returns: Process status
:rtype: int
"""
config = util.get_config()
sources = package.list_sources(config)
for source in sources:
emitter.publish("{} {}".format(source.hash(), source.url))
return 0
def _update(validate):
"""Update local package definitions from sources.
:param validate: Whether to validate package content when updating sources.
:type validate: bool
:returns: Process status
:rtype: int
"""
config = util.get_config()
package.update_sources(config, validate)
return 0
def _describe(package_name,
app,
cli,
options_path,
render,
package_versions,
package_version,
config):
"""Describe the specified package.
:param package_name: The package to describe
:type package_name: str
:param app: If True, marathon.json will be printed
:type app: boolean
:param cli: If True, command.json should be printed
:type cli: boolean
:param options_path: Path to json file with options to override
config.json defaults.
:type options_path: str
:param render: If True, marathon.json and/or command.json templates
will be rendered
:type render: boolean
:param package_versions: If True, a list of all package versions will
be printed
:type package_versions: boolean
:param package_version: package version
:type package_version: str | None
:param config: If True, config.json will be printed
:type config: boolean
:returns: Process status
:rtype: int
"""
# If the user supplied template options, they definitely want to
# render the template
if options_path:
render = True
if package_versions and \
(app or cli or options_path or render or package_version or config):
raise DCOSException(
'If --package-versions is provided, no other option can be '
'provided')
pkg = package.resolve_package(package_name)
if pkg is None:
raise DCOSException("Package [{}] not found".format(package_name))
pkg_revision = pkg.latest_package_revision(package_version)
if pkg_revision is None:
raise DCOSException("Version {} of package [{}] is not available".
format(package_version, package_name))
pkg_json = pkg.package_json(pkg_revision)
if package_version is None:
revision_map = pkg.package_revisions_map()
pkg_versions = list(revision_map.values())
del pkg_json['version']
pkg_json['versions'] = pkg_versions
if package_versions:
emitter.publish('\n'.join(pkg_json['versions']))
elif cli or app or config:
user_options = _user_options(options_path)
options = pkg.options(pkg_revision, user_options)
if cli:
if render:
cli_output = pkg.command_json(pkg_revision, options)
else:
cli_output = pkg.command_template(pkg_revision)
if cli_output and cli_output[-1] == '\n':
cli_output = cli_output[:-1]
emitter.publish(cli_output)
if app:
if render:
app_output = pkg.marathon_json(pkg_revision, options)
else:
app_output = pkg.marathon_template(pkg_revision)
if app_output and app_output[-1] == '\n':
app_output = app_output[:-1]
emitter.publish(app_output)
if config:
config_output = pkg.config_json(pkg_revision)
emitter.publish(config_output)
else:
pkg_json = pkg.package_json(pkg_revision)
emitter.publish(pkg_json)
return 0
def _user_options(path):
""" Read the options at the given file path.
:param path: file path
:type path: str
:returns: options
:rtype: dict
"""
if path is None:
return {}
else:
with util.open_file(path) as options_file:
return util.load_json(options_file)
def _confirm(prompt, yes):
"""
:param prompt: message to display to the terminal
:type prompt: str
:param yes: whether to assume that the user responded with yes
:type yes: bool
:returns: True if the user responded with yes; False otherwise
:rtype: bool
"""
if yes:
return True
else:
while True:
sys.stdout.write('{} [yes/no] '.format(prompt))
sys.stdout.flush()
response = sys.stdin.readline().strip().lower()
if response == 'yes' or response == 'y':
return True
elif response == 'no' or response == 'n':
return False
else:
emitter.publish(
"'{}' is not a valid response.".format(response))
def _install(package_name, package_version, options_path, app_id, cli, app,
yes):
"""Install the specified package.
:param package_name: the package to install
:type package_name: str
:param package_version: package version to install
:type package_version: str
:param options_path: path to file containing option values
:type options_path: str
:param app_id: app ID for installation of this package
:type app_id: str
:param cli: indicates if the cli should be installed
:type cli: bool
:param app: indicate if the application should be installed
:type app: bool
:param yes: automatically assume yes to all prompts
:type yes: bool
:returns: process status
:rtype: int
"""
if cli is False and app is False:
# Install both if neither flag is specified
cli = app = True
config = util.get_config()
pkg = package.resolve_package(package_name, config)
if pkg is None:
msg = "Package [{}] not found\n".format(package_name) + \
"You may need to run 'dcos package update' to update your " + \
"repositories"
raise DCOSException(msg)
pkg_revision = pkg.latest_package_revision(package_version)
if pkg_revision is None:
if package_version is not None:
msg = "Version {} of package [{}] is not available".format(
package_version, package_name)
else:
msg = "Package [{}] not available".format(package_name)
raise DCOSException(msg)
user_options = _user_options(options_path)
pkg_json = pkg.package_json(pkg_revision)
pre_install_notes = pkg_json.get('preInstallNotes')
if pre_install_notes:
emitter.publish(pre_install_notes)
if not _confirm('Continue installing?', yes):
emitter.publish('Exiting installation.')
return 0
options = pkg.options(pkg_revision, user_options)
revision_map = pkg.package_revisions_map()
package_version = revision_map.get(pkg_revision)
if app and (pkg.has_marathon_definition(pkg_revision) or
pkg.has_marathon_mustache_definition(pkg_revision)):
# Install in Marathon
msg = 'Installing Marathon app for package [{}] version [{}]'.format(
pkg.name(), package_version)
if app_id is not None:
msg += ' with app id [{}]'.format(app_id)
emitter.publish(msg)
init_client = marathon.create_client(config)
package.install_app(
pkg,
pkg_revision,
init_client,
options,
app_id)
if cli and pkg.has_command_definition(pkg_revision):
# Install subcommand
msg = 'Installing CLI subcommand for package [{}] version [{}]'.format(
pkg.name(), package_version)
emitter.publish(msg)
subcommand.install(pkg, pkg_revision, options)
subcommand_paths = subcommand.get_package_commands(package_name)
new_commands = [os.path.basename(p).replace('-', ' ', 1)
for p in subcommand_paths]
if new_commands:
commands = ', '.join(new_commands)
plural = "s" if len(new_commands) > 1 else ""
emitter.publish("New command{} available: {}".format(plural,
commands))
post_install_notes = pkg_json.get('postInstallNotes')
if post_install_notes:
emitter.publish(post_install_notes)
return 0
def _list(json_, endpoints, app_id, package_name):
"""List installed apps
:param json_: output json if True
:type json_: bool
:param endpoints: Whether to include a list of
endpoints as port-host pairs
:type endpoints: boolean
:param app_id: App ID of app to show
:type app_id: str
:param package_name: The package to show
:type package_name: str
:returns: process return code
:rtype: int
"""
config = util.get_config()
init_client = marathon.create_client(config)
installed = package.installed_packages(init_client, endpoints)
# only emit those packages that match the provided package_name and app_id
results = []
for pkg in installed:
pkg_info = pkg.dict()
if (_matches_package_name(package_name, pkg_info) and
_matches_app_id(app_id, pkg_info)):
if app_id:
# if the user is asking a specific id then only show that id
pkg_info['apps'] = [
app for app in pkg_info['apps']
if app == app_id
]
results.append(pkg_info)
if results or json_:
emitting.publish_table(emitter, results, tables.package_table, json_)
else:
msg = ("There are currently no installed packages. "
"Please use `dcos package install` to install a package.")
raise DCOSException(msg)
return 0
def _matches_package_name(name, pkg_info):
"""
:param name: the name of the package
:type name: str
:param pkg_info: the package description
:type pkg_info: dict
:returns: True if the name is not defined or the package matches that name;
False otherwise
:rtype: bool
"""
return name is None or pkg_info['name'] == name
def _matches_app_id(app_id, pkg_info):
"""
:param app_id: the application id
:type app_id: str
:param pkg_info: the package description
:type pkg_info: dict
:returns: True if the app id is not defined or the package matches that app
id; False otherwize
:rtype: bool
"""
return app_id is None or app_id in pkg_info.get('apps')
def _search(json_, query):
"""Search for matching packages.
:param json_: output json if True
:type json_: bool
:param query: The search term
:type query: str
:returns: Process status
:rtype: int
"""
if not query:
query = ''
config = util.get_config()
results = [index_entry.as_dict()
for index_entry in package.search(query, config)]
if any(result['packages'] for result in results) or json_:
emitting.publish_table(emitter,
results,
tables.package_search_table,
json_)
else:
raise DCOSException('No packages found.')
return 0
def _uninstall(package_name, remove_all, app_id, cli, app):
"""Uninstall the specified package.
:param package_name: The package to uninstall
:type package_name: str
:param remove_all: Whether to remove all instances of the named package
:type remove_all: boolean
:param app_id: App ID of the package instance to uninstall
:type app_id: str
:returns: Process status
:rtype: int
"""
err = package.uninstall(package_name, remove_all, app_id, cli, app)
if err is not None:
emitter.publish(err)
return 1
return 0
def _bundle(package_directory, output_directory):
"""
:param package_directory: directory containing the package
:type package_directory: str
:param output_directory: directory where to save the package zip file
:type output_directory: str
:returns: process status
:rtype: int
"""
if output_directory is None:
output_directory = os.getcwd()
logger.debug('Using [%s] as the ouput directory', output_directory)
# Find package.json file and parse it
if not os.path.exists(os.path.join(package_directory, 'package.json')):
raise DCOSException(
('The file package.json is required in the package directory '
'[{}]').format(package_directory))
package_json = _validate_json_file(
os.path.join(package_directory, 'package.json'))
with tempfile.NamedTemporaryFile() as temp_file:
with zipfile.ZipFile(
temp_file.name,
mode='w',
compression=zipfile.ZIP_DEFLATED,
allowZip64=True) as zip_file:
# list through package directory and add files zip archive
for filename in sorted(os.listdir(package_directory)):
fullpath = os.path.join(package_directory, filename)
if filename == 'marathon.json.mustache':
zip_file.write(fullpath, arcname=filename)
elif filename in ['config.json', 'command.json',
'package.json']:
# schema check the config and command json file
_validate_json_file(fullpath)
zip_file.write(fullpath, arcname=filename)
elif filename == 'assets' and os.path.isdir(fullpath):
_bundle_assets(fullpath, zip_file)
elif filename == 'images' and os.path.isdir(fullpath):
_bundle_images(fullpath, zip_file)
else:
# anything else is an error
raise DCOSException(
('Error bundling package. Extra file in package '
'directory [{}]').format(fullpath))
# Compute the name of the package file
zip_file_name = os.path.join(
output_directory,
'{}-{}-{}.zip'.format(
package_json['name'],
package_json['version'],
_hashfile(temp_file.name)))
if os.path.exists(zip_file_name):
raise DCOSException(
'Output file [{}] already exists'.format(
zip_file_name))
# rename with digest
util.sh_copy(temp_file.name, zip_file_name)
# Print the full path to the file
emitter.publish(
errors.DefaultError(
'Created DCOS Universe package [{}].'.format(zip_file_name)))
return 0
def _validate_json_file(fullpath):
"""Validates the content of the file against its schema. Throws an
exception if the file is not valid.
:param fullpath: full path to the file.
:type fullpath: str
:return: json object if it is a special file
:rtype: dict
"""
filename = os.path.basename(fullpath)
if filename in ['command.json', 'config.json', 'package.json']:
schema_path = 'data/universe-schema/{}'.format(filename)
else:
raise DCOSException(
('Error bundling package. Unknown file in package '
'directory [{}]').format(fullpath))
special_schema = util.load_jsons(
pkg_resources.resource_string('dcoscli', schema_path).decode('utf-8'))
with util.open_file(fullpath) as special_file:
special_json = util.load_json(special_file)
errs = util.validate_json(special_json, special_schema)
if errs:
emitter.publish(
errors.DefaultError(
'Error validating JSON file [{}]'.format(fullpath)))
raise DCOSException(util.list_to_err(errs))
return special_json
def _hashfile(filename):
"""Calculates the sha256 of a file
:param filename: path to the file to sum
:type filename: str
:returns: digest in hexadecimal
:rtype: str
"""
hasher = hashlib.sha256()
with open(filename, 'rb') as f:
for chunk in iter(lambda: f.read(4096), b''):
hasher.update(chunk)
return hasher.hexdigest()
def _bundle_assets(assets_directory, zip_file):
"""Bundle the assets directory
:param assets_directory: path to the assets directory
:type assets_directory: str
:param zip_file: zip file object
:type zip_file: zipfile.ZipFile
:rtype: None
"""
for filename in sorted(os.listdir(assets_directory)):
fullpath = os.path.join(assets_directory, filename)
if filename == 'uris' and os.path.isdir(fullpath):
_bundle_uris(fullpath, zip_file)
else:
# anything else is an error
raise DCOSException(
('Error bundling package. Extra file in package '
'directory [{}]').format(fullpath))
def _bundle_uris(uris_directory, zip_file):
"""Bundle the uris directory
:param uris_directory: path to the uris directory
:type uris_directory: str
:param zip_file: zip file object
:type zip_file: zipfile.ZipFile
:rtype: None
"""
for filename in sorted(os.listdir(uris_directory)):
fullpath = os.path.join(uris_directory, filename)
zip_file.write(fullpath, arcname='assets/uris/{}'.format(filename))
def _bundle_images(images_directory, zip_file):
"""Bundle the images directory
:param images_directory: path to the images directory
:type images_directory: str
:param zip_file: zip file object
:type zip_file: zipfile.ZipFile
:rtype: None
"""
for filename in sorted(os.listdir(images_directory)):
fullpath = os.path.join(images_directory, filename)
if (filename == 'icon-small.png' or
filename == 'icon-medium.png' or
filename == 'icon-large.png'):
util.validate_png(fullpath)
zip_file.write(fullpath, arcname='images/{}'.format(filename))
elif filename == 'screenshots' and os.path.isdir(fullpath):
_bundle_screenshots(fullpath, zip_file)
else:
# anything else is an error
raise DCOSException(
('Error bundling package. Extra file in package '
'directory [{}]').format(fullpath))
def _bundle_screenshots(screenshot_directory, zip_file):
"""Bundle the screenshots directory
:param screenshot_directory: path to the screenshots directory
:type screenshot_directory: str
:param zip_file: zip file object
:type zip_file: zipfile.ZipFile
:rtype: None
"""
for filename in sorted(os.listdir(screenshot_directory)):
fullpath = os.path.join(screenshot_directory, filename)
util.validate_png(fullpath)
zip_file.write(
fullpath,
arcname='images/screenshots/{}'.format(filename))