# Copyright (c) 2014 Rackspace # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or # implied. # See the License for the specific language governing permissions and # limitations under the License. """ Initial M1 Solum CLI commands implemented (but not REST communications): * plan create --repo="repo_url" [--build=no] plan_name * plan delete plan_name * plan list * plan show plan_id * assembly create assembly_name plan_name * assembly delete assembly_name * assembly list * assembly show assembly_id * languagepack create * languagepack list * languagepack show * languagepack delete * languagepack logs * component list Notes: * This code is expected to be replaced by the OpenStack Client (OSC) when it has progressed a little bit farther as described at: https://wiki.openstack.org/wiki/Solum/CLI * Internationalization will not be added in M1 since this is a prototype """ from __future__ import print_function import argparse import copy import json import re import sys import httplib2 from keystoneclient.v2_0 import client as keystoneclient import solumclient from solumclient.common import cli_utils from solumclient.common import exc from solumclient.common import github from solumclient.common import yamlutils from solumclient.openstack.common.apiclient import exceptions from solumclient.v1 import app as cli_app from solumclient.v1 import assembly as cli_assem from solumclient.v1 import languagepack as cli_lp from solumclient.v1 import pipeline as cli_pipe from solumclient.v1 import plan as cli_plan from solumclient.v1 import workflow as cli_wf def name_is_valid(string): try: re.match(r'^([a-zA-Z0-9-_]{1,100})$', string).group(0) except AttributeError: return False return True def ValidName(string): if not name_is_valid(string): raise AttributeError("Names must be 1-100 characters long and must " "only contain a-z,A-Z,0-9,-,_") return string def lpname_is_valid(string): try: re.match(r'^([a-z0-9-_]{1,100})$', string).group(0) except (TypeError, AttributeError): return False return True def ValidLPName(string): if not lpname_is_valid(string): raise AttributeError("LP names must be 1-100 characters long and " "must only contain a-z,0-9,-,_") return string def ValidPort(string): try: port_val = int(string) if 1 <= port_val <= 65535: return port_val else: raise ValueError except ValueError: raise AttributeError("The port should be an integer between 1 and " "65535") def transform_git_url(git_url, private): # try to use a correct git uri pt = re.compile(r'github\.com[:/](.+?)/(.+?)($|/.*$|\.git$|\.git/.*$)') match = pt.search(git_url) if match: user_org_name = match.group(1) repo = match.group(2) if private: right_uri = 'git@github.com:%s/%s.git' % (user_org_name, repo) else: right_uri = 'https://github.com/%s/%s.git' % (user_org_name, repo) return right_uri else: msg = "Provide the git uri in the following format: " if not private: msg = msg + "https://github.com//.git" else: msg = msg + "git@github.com:/.git" raise exc.CommandError(message=msg) def show_public_keys(artifacts): public_keys = {} if artifacts: for arti in artifacts: if arti.content and ('public_key' in arti.content): public_keys.update( {arti.content['href']: arti.content['public_key']}) if public_keys: print('Important:') print(' Solum has generated and uploaded SSH keypair for your ' + 'private github repository/ies.') print(' You may need to add these public SSH keys as github ' + 'deploy keys, if they were not uploaded successfully.') print(' This enables solum to securely ' + 'clone/pull your private repository/ies.') print(' More details on github deploy keys: ' + 'https://developer.github.com/guides/' + 'managing-deploy-keys/#deploy-keys\n') for href, pub_key in public_keys.items(): print('%s :\n %s' % (href, pub_key)) class PlanCommands(cli_utils.CommandsBase): """Commands for working with plans. Available commands: solum plan list Print an index of all available plans. solum plan show Print details about a plan. solum plan create [--param-file ] Register a plan with Solum. solum plan delete Destroy a plan. Plans with dependent assemblies cannot be deleted. """ def create(self): """Create a plan.""" self.parser.add_argument('plan_file', help="A yaml file that defines a plan," " check out solum repo for examples") self.parser.add_argument('--param-file', dest='param_file', help="A yaml file containing custom" " parameters to be used in the" " application, check out solum repo for" " examples") self.parser._names['plan_file'] = 'plan file' args = self.parser.parse_args() try: with open(args.plan_file) as definition_file: plan_definition = definition_file.read() definition = yamlutils.load(plan_definition) except IOError: message = "Could not open plan file %s." % args.plan_file raise exc.CommandError(message=message) except ValueError: message = ("Plan file %s was not a valid YAML mapping." % args.plan_file) raise exc.CommandError(message=message) if args.param_file: try: with open(args.param_file) as param_f: param_definition = param_f.read() definition['parameters'] = yamlutils.load(param_definition) except IOError: message = "Could not open param file %s." % args.param_file raise exc.CommandError(message=message) except ValueError: message = ("Param file %s was not a valid YAML mapping." % args.param_file) raise exc.CommandError(message=message) plan = self.client.plans.create(yamlutils.dump(definition)) fields = ['uuid', 'name', 'description', 'uri', 'artifacts'] artifacts = copy.deepcopy(vars(plan).get('artifacts')) self._print_dict(plan, fields, wrap=72) show_public_keys(artifacts) def delete(self): """Delete a plan.""" self.parser.add_argument('plan_uuid', help="Tenant/project-wide unique " "plan uuid or name") self.parser._names['plan_uuid'] = 'plan' args = self.parser.parse_args() plan = self.client.plans.find(name_or_id=args.plan_uuid) cli_plan.PlanManager(self.client).delete(plan_id=str(plan.uuid)) def show(self): """Show a plan's resource.""" self.parser.add_argument('plan_uuid', help="Plan uuid or name") self.parser._names['plan_uuid'] = 'plan' args = self.parser.parse_args() plan = self.client.plans.find(name_or_id=args.plan_uuid) fields = ['uuid', 'name', 'description', 'uri', 'artifacts'] artifacts = copy.deepcopy(vars(plan).get('artifacts')) self._print_dict(plan, fields, wrap=72) show_public_keys(artifacts) def list(self): """List all plans.""" fields = ['uuid', 'name', 'description'] plans = self.client.plans.list() self._print_list(plans, fields) class AssemblyCommands(cli_utils.CommandsBase): """Commands for working with assemblies. Available commands: solum assembly list Print an index of all available assemblies. solum assembly show Print the details of an assembly. solum assembly create [--description ] Create an assembly from a registered plan. solum assembly logs Print an index of all operation logs for an assembly. solum assembly delete Destroy an assembly. """ def create(self): """Create an assembly.""" self.parser.add_argument('name', type=ValidName, help="Assembly name") self.parser.add_argument('plan_uri', help="Tenant/project-wide unique " "plan (uri/uuid or name)") self.parser.add_argument('--description', help="Assembly description") self.parser._names['plan_uri'] = 'plan URI' args = self.parser.parse_args() name = args.name plan_uri = args.plan_uri if '/' not in plan_uri: # might be a plan uuid/name # let's try and be helpful and get the real plan_uri. plan = self.client.plans.find(name_or_id=args.plan_uri) plan_uri = plan.uri print('Note: using plan_uri=%s' % plan_uri) assembly = self.client.assemblies.create(name=name, description=args.description, plan_uri=plan_uri) fields = ['uuid', 'name', 'description', 'status', 'application_uri', 'trigger_uri'] self._print_dict(assembly, fields, wrap=72) def delete(self): """Delete an assembly.""" self.parser.add_argument('assembly_uuid', help="Assembly uuid or name") self.parser._names['assembly_uuid'] = 'assembly' args = self.parser.parse_args() assem = self.client.assemblies.find(name_or_id=args.assembly_uuid) cli_assem.AssemblyManager(self.client).delete( assembly_id=str(assem.uuid)) def list(self): """List all assemblies.""" fields = ['uuid', 'name', 'description', 'status', 'created_at', 'updated_at'] assemblies = self.client.assemblies.list() self._print_list(assemblies, fields, sortby_index=5) def logs(self): """Get Logs.""" print("Not Supported: Logs moved to workflows. Use following command." "\nsolum workflow logs app_id wf_id") def show(self): """Show an assembly's resource.""" self.parser.add_argument('assembly_uuid', help="Assembly uuid or name") self.parser._names['assembly_uuid'] = 'assembly' args = self.parser.parse_args() assemblies = self.client.assemblies.find(name_or_id=args.assembly_uuid) fields = ['uuid', 'name', 'description', 'status', 'application_uri', 'created_at', 'updated_at', 'workflow'] self._print_dict(assemblies, fields, wrap=72) class ComponentCommands(cli_utils.CommandsBase): """Commands for working with components. Available commands: solum component list Print an index of all available components. solum component show Print details about a component. """ def show(self): """Show a component's resource.""" self.parser.add_argument('component_uuid', help="Component uuid or name") self.parser._names['component_uuid'] = 'component' args = self.parser.parse_args() component = self.client.components.find(name_or_id=args.component_uuid) fields = ['uuid', 'name', 'description', 'uri', 'assembly_uuid'] self._print_dict(component, fields, wrap=72) def list(self): """List all components.""" fields = ['uuid', 'name', 'description', 'assembly_uuid'] components = self.client.components.list() self._print_list(components, fields) class PipelineCommands(cli_utils.CommandsBase): """Commands for working with pipelines. Available commands: solum pipeline list Print an index of all available pipelines. solum pipeline show Print details about a pipeline. solum pipeline create Create a pipeline from a given workbook and registered plan. solum pipeline delete Destroy a pipeline. """ def create(self): """Create a pipeline.""" self.parser.add_argument('plan_uri', help="Tenant/project-wide unique " "plan (uri/uuid or name)") self.parser.add_argument('workbook_name', help="Workbook name") self.parser.add_argument('name', type=ValidName, help="Pipeline name") self.parser._names['plan_uri'] = 'plan URI' self.parser._names['workbook_name'] = 'workbook' args = self.parser.parse_args() plan_uri = args.plan_uri if '/' not in plan_uri: # might be a plan uuid/name # let's try and be helpful and get the real plan_uri. plan = self.client.plans.find(name_or_id=args.plan_uri) plan_uri = plan.uri print('Note: using plan_uri=%s' % plan_uri) pipeline = self.client.pipelines.create( name=args.name, plan_uri=plan_uri, workbook_name=args.workbook_name) fields = ['uuid', 'name', 'description', 'trigger_uri'] self._print_dict(pipeline, fields, wrap=72) def delete(self): """Delete an pipeline.""" self.parser.add_argument('pipeline_uuid', help="Pipeline uuid or name") self.parser._names['pipeline_uuid'] = 'pipeline' args = self.parser.parse_args() pipeline = self.client.pipelines.find(name_or_id=args.pipeline_uuid) cli_pipe.PipelineManager(self.client).delete( pipeline_id=str(pipeline.uuid)) def list(self): """List all pipelines.""" fields = ['uuid', 'name', 'description'] pipelines = self.client.pipelines.list() self._print_list(pipelines, fields) def show(self): """Show a pipeline's resource.""" self.parser.add_argument('pipeline_uuid', help="Pipeline uuid or name") self.parser._names['pipeline_uuid'] = 'pipeline' args = self.parser.parse_args() pipelines = self.client.pipelines.find(name_or_id=args.pipeline_uuid) fields = ['uuid', 'name', 'description', 'trigger_uri', 'workbook_name', 'last_execution'] self._print_dict(pipelines, fields, wrap=72) class LanguagePackCommands(cli_utils.CommandsBase): """Commands for working with language packs. Available commands: solum lp create Create a new language pack from a git repo. solum lp list Print and index of all available language packs. solum lp show Print the details of a language pack. solum lp delete Destroy a language pack. solum lp logs Show logs for a language pack. """ def create(self): """Create a language pack.""" self.parser.add_argument('name', type=ValidLPName, help="Language pack name.") self.parser.add_argument('--name', type=ValidLPName, dest='name', help="Language pack name.") self.parser.add_argument('git_url', help=("Github url of custom " "language pack repository.")) self.parser.add_argument('--git_url', dest='git_url', help=("Github url of custom " "language pack repository.")) self.parser.add_argument('--lp_metadata', help="Language pack metadata file.") self.parser._names['git_url'] = 'repo URL' args = self.parser.parse_args() lp_metadata = None if args.lp_metadata: with open(args.lp_metadata) as lang_pack_metadata: try: lp_metadata = json.dumps(json.load(lang_pack_metadata)) except ValueError as excp: message = ("Malformed metadata file: %s" % str(excp)) raise exc.CommandError(message=message) languagepack = {} try: languagepack = self.client.languagepacks.create( name=args.name, source_uri=args.git_url, lp_metadata=lp_metadata) except exceptions.Conflict as conflict: message = ("%s" % conflict.message) raise exc.CommandError(message=message) fields = ['uuid', 'name', 'description', 'status', 'source_uri'] self._print_dict(languagepack, fields, wrap=72) def delete(self): """Delete a language pack.""" self.parser.add_argument('lp_id', help="Language pack id") self.parser._names['lp_id'] = 'languagepack' args = self.parser.parse_args() self.client.languagepacks.delete(lp_id=args.lp_id) def list(self): """List all language packs.""" fields = ['uuid', 'name', 'description', 'status', 'source_uri'] languagepacks = self.client.languagepacks.list() self._print_list(languagepacks, fields) def show(self): """Get a language pack.""" self.parser.add_argument('lp_id', help="Language pack id") self.parser._names['lp_id'] = 'languagepack' args = self.parser.parse_args() languagepack = self.client.languagepacks.find(name_or_id=args.lp_id) fields = ['uuid', 'name', 'description', 'status', 'source_uri'] self._print_dict(languagepack, fields, wrap=72) def logs(self): """Get Logs.""" self.parser.add_argument('lp_id', help="languagepack uuid or name") args = self.parser.parse_args() loglist = cli_lp.LanguagePackManager(self.client).logs( lp_id=str(args.lp_id)) fields = ["resource_uuid", "created_at"] for log in loglist: strategy_info = json.loads(log.strategy_info) if log.strategy == 'local': if 'local_storage' not in fields: fields.append('local_storage') log.local_storage = log.location elif log.strategy == 'swift': if 'swift_container' not in fields: fields.append('swift_container') if 'swift_path' not in fields: fields.append('swift_path') log.swift_container = strategy_info['container'] log.swift_path = log.location else: if 'location' not in fields: fields.append('location') self._print_list(loglist, fields) class AppCommands(cli_utils.CommandsBase): """Commands for working with actual applications. Available commands: solum app list Print an index of all deployed applications. solum app show Print detailed information about one application. solum app create [--app-file ] [--git-url ] [--lp ] [--param-file ] [--setup-trigger] [--trigger-workflow ] =(unittest | build | unittest+build) Without the --trigger-workflow flag, the workflow unittest+build+deploy is triggered (this is the default workflow) Register a new application with Solum. solum app deploy Deploy an application, building any applicable artifacts first. solum app delete Delete an application and all related artifacts. """ def _validate_app_file(self, app_data): if ('workflow_config' in app_data and app_data.get('workflow_config') is None): msg = "Workflow config cannot be empty" raise exc.CommandException(message=msg) if ('trigger_actions' in app_data and app_data.get('trigger_actions') is None): msg = "Trigger actions cannot be empty" raise exc.CommandException(message=msg) error_message = ("Application name must be 1-100 characters and must " "only contain a-z,A-Z,0-9,-,_") if app_data.get('name') is not None: if not name_is_valid(app_data.get('name')): raise exc.CommandError(message=error_message) if 'repo_token' not in app_data: app_data['repo_token'] = '' def _get_and_validate_app_name(self, app_data, args): # Check the appfile-supplied name first. error_message = ("Application name must be 1-100 characters and must " "only contain a-z,A-Z,0-9,-,_") app_name = '' if app_data.get('name') is not None: if not name_is_valid(app_data.get('name')): raise exc.CommandError(message=error_message) app_name = app_data.get('name') # Check the arguments next. elif args.name: app_name = args.name # Just ask. else: while True: app_name = raw_input("Please name the application.\n> ") if name_is_valid(app_name): break print(error_message) return app_name def _get_and_validate_languagepack(self, app_data, args): # Check for the language pack. Check args first, then appfile. # If it's neither of those places, prompt for it and update the # app data. languagepack = None if args.languagepack is not None: languagepack = args.languagepack elif app_data.get('languagepack') is not None: languagepack = app_data.get('languagepack') # check if given languagepack exists or not if languagepack: try: lp = self.client.languagepacks.find(name_or_id=languagepack) except Exception: raise exc.CommandError( "Languagepack '%s' not found." % languagepack) if lp is None or lp.status != 'READY': raise exc.CommandError("No languagepack in READY state. " "Create a languagepack first.") app_data['languagepack'] = languagepack else: languagepacks = self.client.languagepacks.list() filtered_list = cli_utils.filter_ready_lps(languagepacks) if len(filtered_list) > 0: lpnames = [lang_pack.name for lang_pack in filtered_list] lp_uuids = [lang_pack.uuid for lang_pack in filtered_list] fields = ['uuid', 'name', 'description', 'status', 'source_uri'] self._print_list(filtered_list, fields) languagepack = raw_input("Please choose a languagepack from " "the above list.\n> ") while languagepack not in lpnames + lp_uuids: languagepack = raw_input("You must choose one of the named" " language packs.\n> ") app_data['languagepack'] = languagepack else: raise exc.CommandError("No languagepack in READY state. " "Create a languagepack first.") def _get_app_repo_details(self, app_data, args): git_rev = 'master' git_url = None if (app_data.get('source') is not None and app_data.get('source').get('repository') is not None): git_url = app_data['source']['repository'] # Commandline flag overrides stuff in the app-file if args.git_url is not None: git_url = args.git_url # Take input from user elif (app_data.get('source') is None or app_data['source'].get('repository') is None or app_data['source']['repository'] is ''): git_url = raw_input("Please specify a git repository URL for " "your application.\n> ") git_rev_i = raw_input("Please specify revision" " (default is master).\n> ") if git_rev_i is '': git_rev = 'master' else: git_rev = git_rev_i assert(git_url is not None) assert(git_rev is not None) git_src = dict() git_src['repository'] = transform_git_url(git_url, False) git_src['revision'] = git_rev app_data['source'] = git_src def _get_run_command(self, app_data, args): run_cmd = None if args.run_cmd is not None: run_cmd = args.run_cmd elif (app_data.get('workflow_config') is None or app_data['workflow_config'].get('run_cmd') is '' or app_data['workflow_config'].get('run_cmd') is None): run_cmd = raw_input("Please specify start/run command for your " "application.\n> ") if app_data.get('workflow_config') is None: run_cmd_dict = dict() run_cmd_dict['run_cmd'] = run_cmd app_data['workflow_config'] = run_cmd_dict elif (app_data['workflow_config'].get('run_cmd') is '' or app_data['workflow_config'].get('run_cmd') is None): app_data['workflow_config']['run_cmd'] = run_cmd def _get_unittest_command(self, app_data, args): unittest_cmd = None if args.unittest_cmd is not None: unittest_cmd = args.unittest_cmd if app_data.get('workflow_config') is None: unittest_cmd_dict = dict() unittest_cmd_dict['test_cmd'] = unittest_cmd app_data['workflow_config'] = unittest_cmd_dict elif (app_data['workflow_config'].get('test_cmd') is '' or app_data['workflow_config'].get('test_cmd') is None): app_data['workflow_config']['test_cmd'] = unittest_cmd def _get_port(self, app_data, args): port_list = [] if (app_data.get('ports') is None or app_data['ports'] is '' or app_data['ports'] == [None]): if args.port: port_list.append(int(args.port)) else: print("Using 80 as the app's default listening port") port_list.append(int(80)) app_data['ports'] = port_list def _get_parameters(self, app_data, args): app_data['parameters'] = {} if args.param_file is not None: try: with open(args.param_file) as param_f: param_def = param_f.read() app_data['parameters'] = yamlutils.load(param_def) except IOError: message = "Could not open param file %s." % args.param_file raise exc.CommandError(message=message) except ValueError: message = ("Param file %s was not YAML." % args.param_file) raise exc.CommandError(message=message) def _setup_github_trigger(self, app_data, app, args): # If a token is supplied, we won't need to generate one. repo_token = '' if hasattr(app_data, 'repo_token'): repo_token = app_data['repo_token'] if args.setup_trigger or args.workflow: trigger_uri = vars(app).get('trigger_uri', '') if trigger_uri: workflow = None if args.workflow: workflow = args.workflow.replace('+', ' ').split(' ') try: git_url = app_data['source']['repository'] gha = github.GitHubAuth(git_url, repo_token=repo_token) gha.create_webhook(trigger_uri, workflow=workflow) except github.GitHubException as ghe: raise exc.CommandError(message=str(ghe)) def create(self): self.register() def register(self): """Register a new app.""" self.parser.add_argument('--app-file', dest='appfile', help="Local appfile location") self.parser.add_argument('--name', type=ValidName, help="Application name") self.parser.add_argument('--languagepack', help='Language pack') self.parser.add_argument('--lp', dest='languagepack', help='Language pack') self.parser.add_argument('--git-url', dest='git_url', help='Source repo') self.parser.add_argument('--run-cmd', dest='run_cmd', help="Application entry point") self.parser.add_argument('--unittest-cmd', dest='unittest_cmd', help="Command to execute unit tests") self.parser.add_argument('--port', dest="port", type=ValidPort, help="The port your application listens on") self.parser.add_argument('--param-file', dest='param_file', help="A yaml file containing custom" " parameters to be used in the" " application") self.parser.add_argument('--setup-trigger', action='store_true', dest='setup_trigger', help="Set up app trigger on git repo") trigger_help = ("Which of stages build, unittest, deploy to trigger " "from git. For example: " "--trigger-workflow=unittest+build+deploy. " "Implies --setup-trigger.") self.parser.add_argument('--trigger-workflow', default='', dest='workflow', help=trigger_help) args = self.parser.parse_args() app_data = None if args.appfile is not None: try: with open(args.appfile, 'r') as inf: app_data = yamlutils.load(inf.read()) self._validate_app_file(app_data) except Exception as exp: raise exc.CommandException(str(exp)) else: app_data = { 'version': 1, 'description': 'default app description.', 'source': { 'repository': '', 'revision': 'master' }, 'workflow_config': { 'test_cmd': '', 'run_cmd': '' }, 'trigger_actions': ['build', 'deploy'], 'repo_token': '' } # app file schema schema = { "title": "app file schema", "type": "object", "properties": { "version": { "type": "integer" }, "name": { "type": "string" }, "description": { "type": "string" }, "languagepack": { "type": "string" }, "source": { "type": "object" }, "workflow_config": { "type": "object" }, "repo_token": { "type": "string" }, "trigger_actions": { "type": "array" }, "ports": { "type": "array" }, "parameters": { "type": "array" } }, "required": ["version", "name", "description", "languagepack", "source", "workflow_config", "trigger_actions", "ports"] } app_name = self._get_and_validate_app_name(app_data, args) app_data['name'] = app_name self._get_and_validate_languagepack(app_data, args) self._get_app_repo_details(app_data, args) self._get_run_command(app_data, args) self._get_unittest_command(app_data, args) self._get_port(app_data, args) self._get_parameters(app_data, args) # TODO(vijendar): currently doing very basic validation. # Need to implement more robust schema based validation appdata_keys = app_data.keys() appdata_keys.sort() schema_keys = schema['properties'].keys() schema_keys.sort() if appdata_keys != schema_keys: message = "Unknown key(s) in app data file: %s" % list( set(appdata_keys) - set(schema_keys)) raise exc.CommandError(message=message) app = self.client.apps.create(**app_data) self._setup_github_trigger(app_data, app, args) app.trigger = app.trigger_actions app.workflow = app.workflow_config fields = ['name', 'id', 'created_at', 'description', 'languagepack', 'ports', 'source', 'workflow', 'trigger_uuid', 'trigger', 'trigger_uri'] self._print_dict(app, fields, wrap=72) def update(self): """Update the registration of an existing app.""" self.parser.add_argument('app') self.parser.add_argument('--name', type=str) self.parser.add_argument('--desc', type=str) self.parser.add_argument('--lp', type=str) self.parser.add_argument('--ports', type=str) self.parser.add_argument('--source.repo', dest='source_repo', type=str) self.parser.add_argument('--source.rev', dest='source_rev', type=str) self.parser.add_argument('--test_cmd', type=str) self.parser.add_argument('--run_cmd', type=str) self.parser.add_argument('--trigger', type=str) args = self.parser.parse_args() app = self.client.apps.find(name_or_id=args.app) to_update = {} if args.name: to_update['name'] = args.name if args.desc: to_update['description'] = args.desc if args.lp: to_update['languagepack'] = args.lp if args.ports: ports = args.ports.strip('[]').replace(',', ' ') ports = [int(p, 10) for p in ports.split(' ') if p] to_update['ports'] = ports if args.source_repo: to_update['source'] = to_update.get('source', {}) to_update['source']['repository'] = args.source_repo if args.source_rev: to_update['source'] = to_update.get('source', {}) to_update['source']['revision'] = args.source_rev if args.test_cmd: to_update['workflow_config'] = to_update.get('trigger', {}) to_update['workflow_config']['test_cmd'] = args.test_cmd if args.run_cmd: to_update['workflow_config'] = to_update.get('trigger', {}) to_update['workflow_config']['run_cmd'] = args.run_cmd if args.trigger: trigger = args.trigger.strip('[]').replace(',', ' ').split(' ') to_update['trigger_actions'] = trigger if not to_update: raise exc.CommandException(message="Nothing to update") updated_app = self.client.apps.patch(app_id=app.id, **to_update) updated_app.trigger = updated_app.trigger_actions updated_app.workflow = updated_app.workflow_config fields = ['name', 'id', 'created_at', 'description', 'languagepack', 'entry_points', 'ports', 'source', 'workflow', 'trigger_uuid', 'trigger'] self._print_dict(updated_app, fields, wrap=72) def list(self): """List all apps.""" apps = self.client.apps.list() fields = ['name', 'id', 'created_at', 'description', 'languagepack'] self._print_list(apps, fields) def show(self): """Show details of one app.""" self.parser.add_argument('name') args = self.parser.parse_args() app = self.client.apps.find(name_or_id=args.name) app.trigger = app.trigger_actions app.workflow = app.workflow_config fields = ['name', 'id', 'created_at', 'description', 'languagepack', 'entry_points', 'ports', 'source', 'trigger_uuid', 'trigger', 'app_url'] self._print_dict(app, fields, wrap=72) wfman = cli_wf.WorkflowManager(self.client, app_id=app.id) wfs = wfman.list() fields = ['wf_id', 'id', 'status'] print("'%s' workflows and their status:" % args.name) self._print_list(wfs, fields) def delete(self): """Delete an app.""" self.parser.add_argument('name') args = self.parser.parse_args() app = self.client.apps.find(name_or_id=args.name) cli_app.AppManager(self.client).delete( app_id=str(app.id)) def _create_workflow(self, actions): self.parser.add_argument('name') args = self.parser.parse_args() app = self.client.apps.find(name_or_id=args.name) wf = (cli_wf.WorkflowManager(self.client, app_id=app.id).create(actions=actions)) fields = ['wf_id', 'app_id', 'actions', 'config', 'source', 'id', 'created_at', 'updated_at'] self._print_dict(wf, fields, wrap=72) def unittest(self): """Create a new workflow for an app.""" actions = ['unittest'] self._create_workflow(actions) def build(self): """Create a new workflow for an app.""" actions = ['unittest', 'build'] self._create_workflow(actions) def deploy(self): """Create a new workflow for an app.""" actions = ['unittest', 'build', 'deploy'] self._create_workflow(actions) class WorkflowCommands(cli_utils.CommandsBase): """Commands for working with workflows. Available commands: solum workflow list List all application workflows. solum workflow show Print the details of a workflow. solum workflow logs List all the logs of a given workflow. """ def list(self): """Show all of an app's live workflows.""" self.parser.add_argument('app') args = self.parser.parse_args() app = self.client.apps.find(name_or_id=args.app) wfs = cli_wf.WorkflowManager(self.client, app_id=app.id).list() fields = ['wf_id', 'id', 'actions', 'status', 'created_at', 'updated_at'] self._print_list(wfs, fields) def show(self): """Show one of an app's live workflows.""" # Either "solum workflow show # Or "solum workflow show self.parser.add_argument('app') self.parser.add_argument('workflow') args = self.parser.parse_args() revision = args.workflow try: revision = int(revision, 10) except ValueError: revision = args.workflow app = self.client.apps.find(name_or_id=args.app) wfman = cli_wf.WorkflowManager(self.client, app_id=app.id) wf = wfman.find(revision_or_id=revision) fields = ['wf_id', 'app_id', 'actions', 'config', 'source', 'id', 'created_at', 'updated_at', 'status'] self._print_dict(wf, fields, wrap=72) def logs(self): """Get Logs.""" self.parser.add_argument('app', help="App uuid or name") self.parser.add_argument('workflow', help="Workflow id or uuid") args = self.parser.parse_args() revision = args.workflow try: revision = int(revision, 10) except ValueError: revision = args.workflow app = self.client.apps.find(name_or_id=args.app) wfman = cli_wf.WorkflowManager(self.client, app_id=app.id) loglist = wfman.logs(revision_or_id=revision) fields = ["resource_uuid"] for log in loglist: strategy_info = json.loads(log.strategy_info) if log.strategy == 'local': if 'local_storage' not in fields: fields.append('local_storage') log.local_storage = log.location elif log.strategy == 'swift': if 'swift_container' not in fields: fields.append('swift_container') if 'swift_path' not in fields: fields.append('swift_path') log.swift_container = strategy_info['container'] log.swift_path = log.location else: if 'location' not in fields: fields.append('location') self._print_list(loglist, fields) class OldAppCommands(cli_utils.CommandsBase): """Commands for working with applications. Available commands: solum app list Print an index of all deployed applications. solum app show Print detailed information about one application. solum app create [--plan-file ] [--git-url ] [--lp ] [--run-cmd ] [--unittest-cmd ] [--name ] [--port ] [--param-file ] [--desc ] [--setup-trigger] [--private-repo] [--trigger-workflow ] Register a new application with Solum. solum app deploy Deploy an application, building any applicable artifacts first. solum app logs Show the logs of an application for all the deployments. solum app delete Delete an application and all related artifacts. """ def _get_assemblies_by_plan(self, plan): # TODO(datsun180b): Write this. return [] def _validate_plan_file(self, plan_definition): if 'artifacts' not in plan_definition: raise exc.CommandException(message="Missing artifacts section") elif plan_definition['artifacts'] is None: raise exc.CommandException(message="Artifacts cannot be empty") elif 'content' not in plan_definition['artifacts'][0]: raise exc.CommandException(message="Artifact content missing") error_message = ("Application name must be 1-100 characters and must " "only contain a-z,A-Z,0-9,-,_") if plan_definition.get('name') is not None: if not name_is_valid(plan_definition.get('name')): raise exc.CommandError(message=error_message) def list(self): """Print a list of all deployed applications.""" # This is just "plan list". # TODO(datsun180b): List each plan and its associated # assemblies. fields = ['uuid', 'name', 'description'] plans = self.client.plans.list() self._print_list(plans, fields) def logs(self): """Print a list of all logs belonging to a single app.""" self.parser.add_argument('app', help="Application name") self.parser._names['app'] = 'application' args = self.parser.parse_args() assemblies = self.client.assemblies.list() all_logs_list = [] fields = ["resource_uuid", "created_at"] for a in assemblies: plan_uuid = a.plan_uri.split('/')[-1] if args.app not in [plan_uuid, a.name]: continue loglist = cli_assem.AssemblyManager(self.client).logs( assembly_id=str(a.uuid)) for log in loglist: all_logs_list.append(log) strategy_info = json.loads(log.strategy_info) if log.strategy == 'local': if 'local_storage' not in fields: fields.append('local_storage') log.local_storage = log.location elif log.strategy == 'swift': if 'swift_container' not in fields: fields.append('swift_container') if 'swift_path' not in fields: fields.append('swift_path') log.swift_container = strategy_info['container'] log.swift_path = log.location else: if 'location' not in fields: fields.append('location') self._print_list(all_logs_list, fields) def show(self): """Print detailed information about one application.""" # This is just "plan show ". # TODO(datsun180b): List the details of the plan, and # also the current build state, build number, and running # assembly status. We don't have all the pieces for that yet. self.parser.add_argument('app', help="Application name") self.parser._names['app'] = 'application' args = self.parser.parse_args() try: plan = self.client.plans.find(name_or_id=args.app) except exceptions.NotFound: message = "No app named '%s'." % args.app raise exceptions.NotFound(message=message) # Fetch the most recent app_uri. assemblies = self.client.assemblies.list() app_uri = '' updated = '' app_status = 'REGISTERED' for a in assemblies: plan_uuid = a.plan_uri.split('/')[-1] if plan_uuid != plan.uuid: continue if a.updated_at >= updated: updated = a.updated_at app_uri = a.application_uri app_status = a.status plan.application_uri = app_uri plan.status = app_status fields = ['uuid', 'name', 'description', 'uri', 'artifacts', 'trigger_uri', 'application_uri', 'status'] self._print_dict(plan, fields, wrap=72) artifacts = copy.deepcopy(vars(plan).get('artifacts')) show_public_keys(artifacts) def create(self): """Register a new application with Solum.""" # This is just "plan create" with a little proactive # parsing of the planfile. self.parser.add_argument('--plan-file', dest='planfile', help="Local planfile location") self.parser.add_argument('--git-url', help='Source repo') self.parser.add_argument('--languagepack', help='Language pack') self.parser.add_argument('--lp', dest='languagepack', help='Language pack') self.parser.add_argument('--run-cmd', help="Application entry point") self.parser.add_argument('--unittest-cmd', help="Command to execute unit tests") self.parser.add_argument('--port', type=ValidPort, help="The port your application listens on") self.parser.add_argument('--name', type=ValidName, help="Application name") self.parser.add_argument('--desc', help="Application description") self.parser.add_argument('--param-file', dest='param_file', help="A yaml file containing custom" " parameters to be used in the" " application") self.parser.add_argument('--setup-trigger', action='store_true', dest='setup_trigger', help="Set up app trigger on git repo") self.parser.add_argument('--private-repo', action='store_true', dest='private_repo', help="Source repo requires authentication.") trigger_help = ("Which of stages build, unittest, deploy to trigger " "from git. For example: " "--trigger-workflow=unittest+build+deploy. " "Implies --setup-trigger.") self.parser.add_argument('--trigger-workflow', default='', dest='workflow', help=trigger_help) args = self.parser.parse_args() # Get the plan file. Either get it from args, or supply # a skeleton. plan_definition = None if args.planfile is not None: planfile = args.planfile try: with open(planfile) as definition_file: definition = definition_file.read() plan_definition = yamlutils.load(definition) self._validate_plan_file(plan_definition) if (plan_definition['artifacts'][0].get('language_pack') is not None): lp = plan_definition['artifacts'][0]['language_pack'] if lp != 'auto': try: lp1 = ( self.client.languagepacks.find (name_or_id=lp) ) except Exception as e: if type(e).__name__ == 'NotFound': raise exc.CommandError("Languagepack %s " "not registered" % lp) filtered_list = cli_utils.filter_ready_lps([lp1]) if len(filtered_list) <= 0: raise exc.CommandError("Languagepack %s " "not READY" % lp) if plan_definition['artifacts'][0].get('ports') is None: print("No application port specified in plan file.") print("Defaulting to port 80.") plan_definition['artifacts'][0]['ports'] = 80 except IOError: message = "Could not open plan file %s." % planfile raise exc.CommandError(message=message) except ValueError: message = ("Plan file %s was not a valid YAML mapping." % planfile) raise exc.CommandError(message=message) else: plan_definition = { 'version': 1, 'artifacts': [{ 'artifact_type': 'heroku', 'name': '', 'content': {}, }]} # NOTE: This assumes the plan contains exactly one artifact. # Check the planfile-supplied name first. error_message = ("Application name must be 1-100 characters and must " "only contain a-z,A-Z,0-9,-,_") app_name = '' if plan_definition.get('name') is not None: if not name_is_valid(plan_definition.get('name')): raise exc.CommandError(message=error_message) app_name = plan_definition.get('name') # Check the arguments next. elif args.name: if name_is_valid(args.name): app_name = args.name # Just ask. else: while True: app_name = raw_input("Please name the application.\n> ") if name_is_valid(app_name): break print(error_message) plan_definition['name'] = app_name # Check for the language pack. Check args first, then planfile. # If it's neither of those places, prompt for it and update the # plan definition. languagepack = None if args.languagepack is not None: languagepack = args.languagepack plan_definition['artifacts'][0]['language_pack'] = languagepack elif plan_definition['artifacts'][0].get('language_pack') is None: languagepacks = self.client.languagepacks.list() filtered_list = cli_utils.filter_ready_lps(languagepacks) if len(filtered_list) > 0: lpnames = [lang_pack.name for lang_pack in filtered_list] lp_uuids = [lang_pack.uuid for lang_pack in filtered_list] fields = ['uuid', 'name', 'description', 'status', 'source_uri'] self._print_list(filtered_list, fields) languagepack = raw_input("Please choose a languagepack from " "the above list.\n> ") while languagepack not in lpnames + lp_uuids: languagepack = raw_input("You must choose one of the named" " language packs.\n> ") plan_definition['artifacts'][0]['language_pack'] = languagepack else: raise exc.CommandError("No languagepack in READY state. " "Create a languagepack first.") # Check for the git repo URL. Check args first, then the planfile. # If it's neither of those places, prompt for it and update the # plan definition. git_url = None if args.git_url is not None: plan_definition['artifacts'][0]['content']['href'] = args.git_url if plan_definition['artifacts'][0]['content'].get('href') is None: git_url = raw_input("Please specify a git repository URL for " "your application.\n> ") plan_definition['artifacts'][0]['content']['href'] = git_url git_url = plan_definition['artifacts'][0]['content']['href'] # If a token is supplied, we won't need to generate one. artifact = plan_definition['artifacts'][0] repo_token = artifact.get('repo_token') is_private = (args.private_repo or artifact['content'].get('private')) plan_definition['artifacts'][0]['content']['private'] = is_private git_url = transform_git_url(git_url, is_private) plan_definition['artifacts'][0]['content']['href'] = git_url # If we'll be adding a trigger, or the repo is private, # we'll need to use a personal access token. # The GitHubAuth object will create one if repo_token is null. if is_private or args.setup_trigger or args.workflow: gha = github.GitHubAuth(git_url, repo_token=repo_token) repo_token = repo_token or gha.repo_token # Created or provided, the repo token needs to be in the # plan data before we call client.plans.create. plan_definition['artifacts'][0]['repo_token'] = repo_token # Check for the entry point. Check args first, then the planfile. # If it's neither of those places, prompt for it and update the # plan definition. run_cmd = None if args.run_cmd is not None: plan_definition['artifacts'][0]['run_cmd'] = args.run_cmd if plan_definition['artifacts'][0].get('run_cmd') is None: run_cmd = raw_input("Please specify start/run command for your " "application.\n> ") plan_definition['artifacts'][0]['run_cmd'] = run_cmd # Check for unit test command if args.unittest_cmd is not None: plan_definition['artifacts'][0]['unittest_cmd'] = args.unittest_cmd # Check for the port. if args.port is not None: plan_definition['artifacts'][0]['ports'] = int(args.port) if plan_definition['artifacts'][0].get('ports') is None: plan_definition['artifacts'][0]['ports'] = 80 # Update name and description if specified. a_name = '' if (plan_definition['artifacts'][0].get('name') is not None and plan_definition['artifacts'][0]['name'] is not ''): a_name = plan_definition['artifacts'][0]['name'] a_name = a_name.lower() else: a_name = app_name.lower() if not lpname_is_valid(a_name): # https://github.com/docker/compose/issues/941 # Docker build only allows lowercase names for now. msg = ("Artifact names must be 1-100 characters long and " "must only contain a-z,0-9,-,_") raise exc.CommandError(msg) plan_definition['artifacts'][0]['name'] = a_name if args.desc is not None: plan_definition['description'] = args.desc elif plan_definition.get('description') is None: plan_definition['description'] = '' if args.param_file is not None: try: with open(args.param_file) as param_f: param_def = param_f.read() plan_definition['parameters'] = yamlutils.load(param_def) except IOError: message = "Could not open param file %s." % args.param_file raise exc.CommandError(message=message) except ValueError: message = ("Param file %s was not YAML." % args.param_file) raise exc.CommandError(message=message) plan = self.client.plans.create(yamlutils.dump(plan_definition)) plan.status = 'REGISTERED' fields = ['uuid', 'name', 'description', 'uri', 'artifacts', 'trigger_uri', 'status'] artifacts = copy.deepcopy(vars(plan).get('artifacts')) self._print_dict(plan, fields, wrap=72) # Solum generated a keypair; only upload the public key if we already # have a repo_token, since we'd only have one if we've authed against # github already. content = vars(artifacts[0]).get('content') if content: public_key = content.get('public_key', '') if repo_token and is_private and public_key: try: gha = github.GitHubAuth(git_url, repo_token=repo_token) gha.add_ssh_key(public_key=public_key) except github.GitHubException as ghe: raise exc.CommandError(message=str(ghe)) if args.setup_trigger or args.workflow: trigger_uri = vars(plan).get('trigger_uri', '') if trigger_uri: workflow = None if args.workflow: workflow = args.workflow.replace('+', ' ').split(' ') try: gha = github.GitHubAuth(git_url, repo_token=repo_token) gha.create_webhook(trigger_uri, workflow=workflow) except github.GitHubException as ghe: raise exc.CommandError(message=str(ghe)) def deploy(self): """Deploy an application, building any applicable artifacts first.""" # This is just "assembly create" with a little bit of introspection. # TODO(datsun180b): Add build() method, and add --build-id argument # to this method to allow for build-only and deploy-only workflows. self.parser.add_argument('app', help="Application name") self.parser._names['app'] = 'application' args = self.parser.parse_args() try: plan = self.client.plans.find(name_or_id=args.app) except exceptions.NotFound: message = "No app named '%s'." % args.app raise exceptions.NotFound(message=message) assembly = self.client.assemblies.create(name=plan.name, description=plan.description, plan_uri=plan.uri) fields = ['uuid', 'name', 'status', 'application_uri'] self._print_dict(assembly, fields, wrap=72) def delete(self): """Delete an application and all related artifacts.""" # This is "assembly delete" followed by "plan delete". self.parser.add_argument('app', help="Application name") self.parser._names['app'] = 'application' args = self.parser.parse_args() try: plan = self.client.plans.find(name_or_id=args.app) except exceptions.NotFound: message = "No app named '%s'." % args.app raise exceptions.NotFound(message=message) cli_plan.PlanManager(self.client).delete(plan_id=str(plan.uuid)) class InfoCommands(cli_utils.NoSubCommands): """Show Solum server connection information. Available commands: solum info Show Solum endpoint and API release version. """ def info(self): parsed, _ = self.parser.parse_known_args() # Use the api_endpoint to get the catalog ks_kwargs = { 'username': parsed.os_username, 'password': parsed.os_password, 'tenant_name': parsed.os_tenant_name, 'auth_url': parsed.os_auth_url, } solum_api_endpoint = parsed.solum_url if not solum_api_endpoint: ksclient = keystoneclient.Client(**ks_kwargs) services = ksclient.auth_ref.service_catalog services = services.catalog['serviceCatalog'] solum_service = [s for s in services if s['name'] == 'solum'] try: endpoint = solum_service[0]['endpoints'] solum_api_endpoint = endpoint[0]['publicURL'] except (IndexError, KeyError): print("Error: SOLUM_URL not set, and no Solum endpoint " "could be found in service catalog.") solum_api_version = '' if solum_api_endpoint: kwargs = {"disable_ssl_certificate_validation": not self.verify} try: resp, content = httplib2.Http(**kwargs).request( solum_api_endpoint, 'GET') solum_api_version = resp.get('x-solum-release', '') except Exception: print("Error: Solum endpoint could not be contacted;" " API version could not be determined.") print("python-solumclient version %s" % solumclient.__version__) print("solum API endpoint: %s" % solum_api_endpoint) print("solum API version: %s" % solum_api_version) print("solum auth endpoint: %s" % parsed.os_auth_url) print("solum auth username: %s" % parsed.os_username) print("solum auth tenant/project: %s" % parsed.os_tenant_name) class PermissiveParser(argparse.ArgumentParser): """An ArgumentParser that handles errors without exiting. An argparse.ArgumentParser that doesn't sys.exit(2) when it gets the wrong number of arguments. Gives us better control over exception handling. """ # Used in _check_positional_arguments to give a clearer name to missing # positional arguments. _names = None def __init__(self, *args, **kwargs): self._names = {} kwargs['add_help'] = False kwargs['description'] = argparse.SUPPRESS kwargs['usage'] = argparse.SUPPRESS super(PermissiveParser, self).__init__(*args, **kwargs) def error(self, message): raise exc.CommandError(message=message) def _report_missing_args(self): pass def parse_args(self, *args, **kwargs): ns, rem = self.parse_known_args(*args, **kwargs) if rem: unrec = ', '.join([a.split('=')[0].lstrip('-') for a in rem]) raise exc.CommandError("Unrecognized arguments: %s" % unrec) return ns def parse_known_args(self, *args, **kwargs): ns, rem = argparse.Namespace(), [] try: kwargs['namespace'] = ns ns, rem = super(PermissiveParser, self).parse_known_args( *args, **kwargs) except exc.CommandError: pass self._check_positional_arguments(ns) return ns, rem def _check_positional_arguments(self, namespace): for argument in self._positionals._group_actions: argname = argument.dest localname = self._names.get(argname, argname) article = 'an' if localname[0] in 'AEIOUaeiou' else 'a' if not vars(namespace).get(argname): message = 'You must specify %(article)s %(localname)s.' message %= {'article': article, 'localname': localname} raise exc.CommandError(message=message) def main(): """Solum command-line client. Available commands: solum help Show this help message. solum info Show Solum endpoint and API release version. solum --version Show current Solum client version and exit. solum lp help Show a help message specific to languagepack commands. solum lp create Create a new language pack from a git repo. solum lp list Print and index of all available language packs. solum lp show Print the details of a language pack. solum lp delete Destroy a language pack. solum lp logs Show logs for a language pack. solum app help Show a help message specific to app commands. solum app list Print an index of all deployed applications. solum app show Print detailed information about one application. solum app create [--app-file ] [--git-url ] [--lp ] [--param-file ] [--setup-trigger] [--trigger-workflow ] =(unittest | build | unittest+build) Without the --trigger-workflow flag, the workflow unittest+build+deploy is triggered (this is the default workflow) Register a new application with Solum. solum app deploy Deploy an application, building any applicable artifacts first. solum app delete Delete an application and all related artifacts. solum app logs Show the logs of an application for all the deployments. solum workflow list List all application workflows. solum workflow show Print the details of a workflow. solum workflow logs List all the logs of a given workflow. SOON TO BE DEPRECATED: solum oldapp create [--plan-file ] [--git-url ] [--lp ] [--run-cmd ] [--unittest-cmd ] [--name ] [--port ] [--param-file ] [--desc ] [--setup-trigger] [--private-repo] [--trigger-workflow ] Register a new application with Solum. solum assembly list Print an index of all available assemblies. solum assembly create [--description ] Create an assembly from a registered plan. solum assembly delete Destroy an assembly. """ parser = PermissiveParser() resources = { 'oldapp': OldAppCommands, 'app': AppCommands, 'plan': PlanCommands, 'assembly': AssemblyCommands, 'pipeline': PipelineCommands, 'lp': LanguagePackCommands, 'languagepack': LanguagePackCommands, 'component': ComponentCommands, 'info': InfoCommands, 'wf': WorkflowCommands, 'workflow': WorkflowCommands, } choices = resources.keys() parser.add_argument('resource', choices=choices, default='help', help="Target noun to act upon") parser.add_argument('-V', '--version', action='store_true', dest='show_version', help="Report solum version.") parser.add_argument('-E', '--show-errors', dest='show_errors', action='store_true', help='Debug. Show traceback on error.') parsed, _ = parser.parse_known_args() if parsed.show_version: print("python-solumclient version %s" % solumclient.__version__) return resource = vars(parsed).get('resource') if resource in resources: if parsed.show_errors: resources[resource](parser) else: try: resources[resource](parser) except Exception as e: if hasattr(e, 'message'): print("ERROR: %s" % e.message) else: print("ERROR: %s" % e) else: print(main.__doc__) if __name__ == '__main__': sys.exit(main())