http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/commands/workflows.py ---------------------------------------------------------------------- diff --git a/aria/cli/commands/workflows.py b/aria/cli/commands/workflows.py new file mode 100644 index 0000000..221dbc4 --- /dev/null +++ b/aria/cli/commands/workflows.py @@ -0,0 +1,100 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + +from .. import table +from ..core import aria +from ..exceptions import AriaCliError + +WORKFLOW_COLUMNS = ['name', 'service_template_name', 'service_name'] + + [email protected](name='workflows') +def workflows(): + """Handle service workflows + """ + pass + + [email protected](name='show', + short_help='Show workflow information') [email protected]('workflow-name') [email protected]_name(required=True) [email protected]() [email protected]_model_storage [email protected]_logger +def show(workflow_name, service_name, model_storage, logger): + """Show information for a specific workflow of a specific service + + `WORKFLOW_NAME` is the name of the workflow to get information on. + """ + logger.info('Retrieving workflow {0} for service {1}'.format( + workflow_name, service_name)) + service = model_storage.service.get_by_name(service_name) + workflow = next((wf for wf in service.workflows.values() if + wf.name == workflow_name), None) + if not workflow: + raise AriaCliError( + 'Workflow {0} not found for service {1}'.format(workflow_name, service_name)) + + defaults = { + 'service_template_name': service.service_template_name, + 'service_name': service.name + } + table.print_data(WORKFLOW_COLUMNS, workflow, 'Workflows:', defaults=defaults) + + # print workflow inputs + required_inputs = dict() + optional_inputs = dict() + for input_name, input in workflow.inputs.iteritems(): + inputs_group = optional_inputs if input.value is not None else required_inputs + inputs_group[input_name] = input + + logger.info('Workflow Inputs:') + logger.info('\tMandatory Inputs:') + for input_name, input in required_inputs.iteritems(): + if input.description is not None: + logger.info('\t\t{0}\t({1})'.format(input_name, + input.description)) + else: + logger.info('\t\t{0}'.format(input_name)) + + logger.info('\tOptional Inputs:') + for input_name, input in optional_inputs.iteritems(): + if input.description is not None: + logger.info('\t\t{0}: \t{1}\t({2})'.format( + input_name, input.value, input.description)) + else: + logger.info('\t\t{0}: \t{1}'.format(input_name, + input.value)) + + [email protected](name='list', + short_help='List workflows for a service') [email protected]_name(required=True) [email protected]() [email protected]_model_storage [email protected]_logger +def list(service_name, model_storage, logger): + """List all workflows of a specific service + """ + logger.info('Listing workflows for service {0}...'.format(service_name)) + service = model_storage.service.get_by_name(service_name) + workflows_list = sorted(service.workflows.values(), key=lambda w: w.name) + + defaults = { + 'service_template_name': service.service_template_name, + 'service_name': service.name + } + table.print_data(WORKFLOW_COLUMNS, workflows_list, 'Workflows:', defaults=defaults)
http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/config.py ---------------------------------------------------------------------- diff --git a/aria/cli/config.py b/aria/cli/config.py deleted file mode 100644 index d82886d..0000000 --- a/aria/cli/config.py +++ /dev/null @@ -1,46 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one or more -# contributor license agreements. See the NOTICE file distributed with -# this work for additional information regarding copyright ownership. -# The ASF licenses this file to You 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. - -""" -CLI configuration -""" - -import os -import logging -from getpass import getuser -from tempfile import gettempdir - -from yaml import safe_load - -from .storage import config_file_path - -# path to a file where cli logs will be saved. -logging_filename = os.path.join(gettempdir(), 'aria_cli_{0}.log'.format(getuser())) -# loggers log level to show -logger_level = logging.INFO -# loggers log level to show -colors = True - -import_resolver = None - - -def load_configurations(): - """ - Dynamically load attributes into the config module from the ``config.yaml`` defined in the user - configuration directory - """ - config_path = config_file_path() - with open(config_path) as config_file: - globals().update(safe_load(config_file) or {}) http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/config/__init__.py ---------------------------------------------------------------------- diff --git a/aria/cli/config/__init__.py b/aria/cli/config/__init__.py new file mode 100644 index 0000000..ae1e83e --- /dev/null +++ b/aria/cli/config/__init__.py @@ -0,0 +1,14 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/config/config.py ---------------------------------------------------------------------- diff --git a/aria/cli/config/config.py b/aria/cli/config/config.py new file mode 100644 index 0000000..99f46ca --- /dev/null +++ b/aria/cli/config/config.py @@ -0,0 +1,73 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + + +import os +import yaml +import pkg_resources + +from jinja2.environment import Template + + +CONFIG_FILE_NAME = 'config.yaml' + + +class CliConfig(object): + + def __init__(self, config_path): + with open(config_path) as f: + self._config = yaml.safe_load(f.read()) + + @classmethod + def create_config(cls, workdir): + config_path = os.path.join(workdir, CONFIG_FILE_NAME) + if not os.path.isfile(config_path): + config_template = pkg_resources.resource_string( + __package__, + 'config_template.yaml') + + default_values = { + 'log_path': os.path.join(workdir, 'cli.log'), + 'enable_colors': True + } + + template = Template(config_template) + rendered = template.render(**default_values) + with open(config_path, 'w') as f: + f.write(rendered) + f.write(os.linesep) + + return cls(config_path) + + @property + def colors(self): + return self._config.get('colors', False) + + @property + def logging(self): + return self.Logging(self._config.get('logging')) + + class Logging(object): + + def __init__(self, logging): + self._logging = logging or {} + + @property + def filename(self): + return self._logging.get('filename') + + @property + def loggers(self): + return self._logging.get('loggers', {}) http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/config/config_template.yaml ---------------------------------------------------------------------- diff --git a/aria/cli/config/config_template.yaml b/aria/cli/config/config_template.yaml new file mode 100644 index 0000000..13f2cf9 --- /dev/null +++ b/aria/cli/config/config_template.yaml @@ -0,0 +1,12 @@ +colors: {{ enable_colors }} + +logging: + + # path to a file where cli logs will be saved. + filename: {{ log_path }} + + # configuring level per logger + loggers: + + # main logger of the cli. provides basic descriptions for executed operations. + aria.cli.main: info http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/core/__init__.py ---------------------------------------------------------------------- diff --git a/aria/cli/core/__init__.py b/aria/cli/core/__init__.py new file mode 100644 index 0000000..ae1e83e --- /dev/null +++ b/aria/cli/core/__init__.py @@ -0,0 +1,14 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/core/aria.py ---------------------------------------------------------------------- diff --git a/aria/cli/core/aria.py b/aria/cli/core/aria.py new file mode 100644 index 0000000..ed7c490 --- /dev/null +++ b/aria/cli/core/aria.py @@ -0,0 +1,429 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + + +import os +import sys +import difflib +import StringIO +import traceback +from functools import wraps + +import click + +from ..env import ( + env, + logger +) +from .. import defaults +from .. import helptexts +from ..inputs import inputs_to_dict +from ... import __version__ +from ...utils.exceptions import get_exception_as_string + + +CLICK_CONTEXT_SETTINGS = dict( + help_option_names=['-h', '--help'], + token_normalize_func=lambda param: param.lower()) + + +class MutuallyExclusiveOption(click.Option): + """Makes options mutually exclusive. The option must pass a `cls` argument + with this class name and a `mutually_exclusive` argument with a list of + argument names it is mutually exclusive with. + + NOTE: All mutually exclusive options must use this. It's not enough to + use it in just one of the options. + """ + + def __init__(self, *args, **kwargs): + self.mutually_exclusive = set(kwargs.pop('mutually_exclusive', [])) + self.mutuality_error_message = \ + kwargs.pop('mutuality_error_message', + helptexts.DEFAULT_MUTUALITY_MESSAGE) + self.mutuality_string = ', '.join(self.mutually_exclusive) + if self.mutually_exclusive: + help = kwargs.get('help', '') + kwargs['help'] = ( + '{0}. This argument is mutually exclusive with ' + 'arguments: [{1}] ({2})'.format( + help, + self.mutuality_string, + self.mutuality_error_message)) + super(MutuallyExclusiveOption, self).__init__(*args, **kwargs) + + def handle_parse_result(self, ctx, opts, args): + if self.mutually_exclusive.intersection(opts) and self.name in opts: + raise click.UsageError( + 'Illegal usage: `{0}` is mutually exclusive with ' + 'arguments: [{1}] ({2}).'.format( + self.name, + self.mutuality_string, + self.mutuality_error_message)) + return super(MutuallyExclusiveOption, self).handle_parse_result( + ctx, opts, args) + + +def _format_version_data(version, + prefix=None, + suffix=None, + infix=None): + all_data = dict(version=version) + all_data['prefix'] = prefix or '' + all_data['suffix'] = suffix or '' + all_data['infix'] = infix or '' + output = StringIO.StringIO() + output.write('{prefix}{version}'.format(**all_data)) + output.write('{suffix}'.format(**all_data)) + return output.getvalue() + + +def show_version(ctx, param, value): + if not value: + return + + cli_version = _format_version_data( + __version__, + prefix='ARIA CLI ', + infix=' ' * 5, + suffix='') + + logger.info(cli_version) + ctx.exit() + + +def inputs_callback(ctx, param, value): + """Allow to pass any inputs we provide to a command as + processed inputs instead of having to call `inputs_to_dict` + inside the command. + + `@aria.options.inputs` already calls this callback so that + every time you use the option it returns the inputs as a + dictionary. + """ + if not value: + return {} + + return inputs_to_dict(value) + + +def set_verbosity_level(ctx, param, value): + if not value: + return + + env.logging.verbosity_level = value + + +def set_cli_except_hook(): + + def recommend(possible_solutions): + logger.info('Possible solutions:') + for solution in possible_solutions: + logger.info(' - {0}'.format(solution)) + + def new_excepthook(tpe, value, trace): + if env.logging.is_high_verbose_level(): + # log error including traceback + logger.error(get_exception_as_string(tpe, value, trace)) + else: + # write the full error to the log file + with open(env.logging.log_file, 'a') as log_file: + traceback.print_exception( + etype=tpe, + value=value, + tb=trace, + file=log_file) + # print only the error message + print value + + if hasattr(value, 'possible_solutions'): + recommend(getattr(value, 'possible_solutions')) + + sys.excepthook = new_excepthook + + +def pass_logger(func): + """Simply passes the logger to a command. + """ + # Wraps here makes sure the original docstring propagates to click + @wraps(func) + def wrapper(*args, **kwargs): + return func(logger=logger, *args, **kwargs) + + return wrapper + + +def pass_plugin_manager(func): + """Simply passes the plugin manager to a command. + """ + # Wraps here makes sure the original docstring propagates to click + @wraps(func) + def wrapper(*args, **kwargs): + return func(plugin_manager=env.plugin_manager, *args, **kwargs) + + return wrapper + + +def pass_model_storage(func): + """Simply passes the model storage to a command. + """ + # Wraps here makes sure the original docstring propagates to click + @wraps(func) + def wrapper(*args, **kwargs): + return func(model_storage=env.model_storage, *args, **kwargs) + + return wrapper + + +def pass_resource_storage(func): + """Simply passes the resource storage to a command. + """ + # Wraps here makes sure the original docstring propagates to click + @wraps(func) + def wrapper(*args, **kwargs): + return func(resource_storage=env.resource_storage, *args, **kwargs) + + return wrapper + + +def pass_context(func): + """Make click context ARIA specific + + This exists purely for aesthetic reasons, otherwise + Some decorators are called `@click.something` instead of + `@aria.something` + """ + return click.pass_context(func) + + +class AliasedGroup(click.Group): + def __init__(self, *args, **kwargs): + self.max_suggestions = kwargs.pop("max_suggestions", 3) + self.cutoff = kwargs.pop("cutoff", 0.5) + super(AliasedGroup, self).__init__(*args, **kwargs) + + def get_command(self, ctx, cmd_name): + cmd = click.Group.get_command(self, ctx, cmd_name) + if cmd is not None: + return cmd + matches = \ + [x for x in self.list_commands(ctx) if x.startswith(cmd_name)] + if not matches: + return None + elif len(matches) == 1: + return click.Group.get_command(self, ctx, matches[0]) + ctx.fail('Too many matches: {0}'.format(', '.join(sorted(matches)))) + + def resolve_command(self, ctx, args): + """Override clicks ``resolve_command`` method + and appends *Did you mean ...* suggestions + to the raised exception message. + """ + try: + return super(AliasedGroup, self).resolve_command(ctx, args) + except click.exceptions.UsageError as error: + error_msg = str(error) + original_cmd_name = click.utils.make_str(args[0]) + matches = difflib.get_close_matches( + original_cmd_name, + self.list_commands(ctx), + self.max_suggestions, + self.cutoff) + if matches: + error_msg += '{0}{0}Did you mean one of these?{0} {1}'.format( + os.linesep, + '{0} '.format(os.linesep).join(matches, )) + raise click.exceptions.UsageError(error_msg, error.ctx) + + +def group(name): + """Allow to create a group with a default click context + and a cls for click's `didyoueamn` without having to repeat + it for every group. + """ + return click.group( + name=name, + context_settings=CLICK_CONTEXT_SETTINGS, + cls=AliasedGroup) + + +def command(*args, **kwargs): + """Make Click commands ARIA specific + + This exists purely for aesthetical reasons, otherwise + Some decorators are called `@click.something` instead of + `@aria.something` + """ + return click.command(*args, **kwargs) + + +def argument(*args, **kwargs): + """Make Click arguments ARIA specific + + This exists purely for aesthetic reasons, otherwise + Some decorators are called `@click.something` instead of + `@aria.something` + """ + return click.argument(*args, **kwargs) + + +class Options(object): + def __init__(self): + """The options api is nicer when you use each option by calling + `@aria.options.some_option` instead of `@aria.some_option`. + + Note that some options are attributes and some are static methods. + The reason for that is that we want to be explicit regarding how + a developer sees an option. It it can receive arguments, it's a + method - if not, it's an attribute. + """ + self.version = click.option( + '--version', + is_flag=True, + callback=show_version, + expose_value=False, + is_eager=True, + help=helptexts.VERSION) + + self.json_output = click.option( + '--json-output', + is_flag=True, + help=helptexts.JSON_OUTPUT) + + self.dry_execution = click.option( + '--dry', + is_flag=True, + help=helptexts.DRY_EXECUTION) + + self.reset_config = click.option( + '--reset-config', + is_flag=True, + help=helptexts.RESET_CONFIG) + + self.descending = click.option( + '--descending', + required=False, + is_flag=True, + default=defaults.SORT_DESCENDING, + help=helptexts.DESCENDING) + + self.service_template_filename = click.option( + '-n', + '--service-template-filename', + default=defaults.SERVICE_TEMPLATE_FILENAME, + help=helptexts.SERVICE_TEMPLATE_FILENAME) + + @staticmethod + def verbose(expose_value=False): + return click.option( + '-v', + '--verbose', + count=True, + callback=set_verbosity_level, + expose_value=expose_value, + is_eager=True, + help=helptexts.VERBOSE) + + @staticmethod + def inputs(help): + return click.option( + '-i', + '--inputs', + multiple=True, + callback=inputs_callback, + help=help) + + @staticmethod + def force(help): + return click.option( + '-f', + '--force', + is_flag=True, + help=help) + + @staticmethod + def task_max_attempts(default=defaults.TASK_MAX_ATTEMPTS): + return click.option( + '--task-max-attempts', + type=int, + default=default, + help=helptexts.TASK_MAX_ATTEMPTS.format(default)) + + @staticmethod + def sort_by(default='created_at'): + return click.option( + '--sort-by', + required=False, + default=default, + help=helptexts.SORT_BY) + + @staticmethod + def task_retry_interval(default=defaults.TASK_RETRY_INTERVAL): + return click.option( + '--task-retry-interval', + type=int, + default=default, + help=helptexts.TASK_RETRY_INTERVAL.format(default)) + + @staticmethod + def service_id(required=False): + return click.option( + '-s', + '--service-id', + required=required, + help=helptexts.SERVICE_ID) + + @staticmethod + def execution_id(required=False): + return click.option( + '-e', + '--execution-id', + required=required, + help=helptexts.EXECUTION_ID) + + @staticmethod + def service_template_id(required=False): + return click.option( + '-t', + '--service-template-id', + required=required, + help=helptexts.SERVICE_TEMPLATE_ID) + + @staticmethod + def service_template_path(required=False): + return click.option( + '-p', + '--service-template-path', + required=required, + type=click.Path(exists=True)) + + @staticmethod + def service_name(required=False): + return click.option( + '-s', + '--service-name', + required=required, + help=helptexts.SERVICE_ID) + + @staticmethod + def service_template_name(required=False): + return click.option( + '-t', + '--service-template-name', + required=required, + help=helptexts.SERVICE_ID) + + +options = Options() http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/csar.py ---------------------------------------------------------------------- diff --git a/aria/cli/csar.py b/aria/cli/csar.py index b185f46..5bc35ac 100644 --- a/aria/cli/csar.py +++ b/aria/cli/csar.py @@ -14,12 +14,13 @@ # limitations under the License. import os +import logging import pprint import tempfile import zipfile import requests -from ruamel import yaml # @UnresolvedImport +from ruamel import yaml META_FILE = 'TOSCA-Metadata/TOSCA.meta' @@ -135,7 +136,7 @@ class _CSARReader(object): self.logger.debug('Attempting to parse CSAR metadata YAML') with open(csar_metafile) as f: self.metadata.update(yaml.load(f)) - self.logger.debug('CSAR metadata:\n{0}'.format(pprint.pformat(self.metadata))) + self.logger.debug('CSAR metadata:{0}{1}'.format(os.linesep, pprint.pformat(self.metadata))) def _validate(self): def validate_key(key, expected=None): @@ -167,5 +168,11 @@ class _CSARReader(object): f.write(chunk) -def read(source, destination, logger): +def read(source, destination=None, logger=None): + destination = destination or tempfile.mkdtemp() + logger = logger or logging.getLogger('dummy') return _CSARReader(source=source, destination=destination, logger=logger) + + +def is_csar_archive(source): + return source.endswith('.csar') http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/defaults.py ---------------------------------------------------------------------- diff --git a/aria/cli/defaults.py b/aria/cli/defaults.py new file mode 100644 index 0000000..5c16938 --- /dev/null +++ b/aria/cli/defaults.py @@ -0,0 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + + +SERVICE_TEMPLATE_FILENAME = 'service_template.yaml' +TASK_MAX_ATTEMPTS = 30 +TASK_RETRY_INTERVAL = 30 +SORT_DESCENDING = False http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/dry.py ---------------------------------------------------------------------- diff --git a/aria/cli/dry.py b/aria/cli/dry.py deleted file mode 100644 index fc6c0c5..0000000 --- a/aria/cli/dry.py +++ /dev/null @@ -1,93 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one or more -# contributor license agreements. See the NOTICE file distributed with -# this work for additional information regarding copyright ownership. -# The ASF licenses this file to You 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. - -from threading import RLock - -from ..modeling import models -from ..orchestrator.decorators import operation -from ..utils.collections import OrderedDict -from ..utils.console import puts, Colored -from ..utils.formatting import safe_repr - - -_TERMINAL_LOCK = RLock() - - -def convert_to_dry(service): - """ - Converts all operations on the service (on workflows, node interfaces, and relationship - interfaces) to run dryly. - """ - - for workflow in service.workflows.itervalues(): - convert_operation_to_dry(workflow) - - for node in service.nodes.itervalues(): - for interface in node.interfaces.itervalues(): - for oper in interface.operations.itervalues(): - convert_operation_to_dry(oper) - for relationship in node.outbound_relationships: - for interface in relationship.interfaces.itervalues(): - for oper in interface.operations.itervalues(): - convert_operation_to_dry(oper) - - for group in service.groups.itervalues(): - for interface in group.interfaces.itervalues(): - for oper in interface.operations.itervalues(): - convert_operation_to_dry(oper) - - -def convert_operation_to_dry(oper): - """ - Converts a single :class:`Operation` to run dryly. - """ - - plugin = oper.plugin.name \ - if oper.plugin is not None else None - if oper.inputs is None: - oper.inputs = OrderedDict() - oper.inputs['_implementation'] = models.Parameter(name='_implementation', - type_name='string', - value=oper.implementation) - oper.inputs['_plugin'] = models.Parameter(name='_plugin', - type_name='string', - value=plugin) - oper.implementation = '{0}.{1}'.format(__name__, 'dry_operation') - oper.plugin_specification = None - - -@operation -def dry_operation(ctx, _plugin, _implementation, **kwargs): - """ - The dry operation simply prints out information about the operation to the console. - """ - - with _TERMINAL_LOCK: - print ctx.name - if hasattr(ctx, 'relationship'): - puts('> Relationship: {0} -> {1}'.format( - Colored.red(ctx.relationship.source_node.name), - Colored.red(ctx.relationship.target_node.name))) - else: - puts('> Node: {0}'.format(Colored.red(ctx.node.name))) - puts(' Operation: {0}'.format(Colored.green(ctx.name))) - _dump_implementation(_plugin, _implementation) - - -def _dump_implementation(plugin, implementation): - if plugin: - puts(' Plugin: {0}'.format(Colored.magenta(plugin, bold=True))) - if implementation: - puts(' Implementation: {0}'.format(Colored.magenta(safe_repr(implementation)))) http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/env.py ---------------------------------------------------------------------- diff --git a/aria/cli/env.py b/aria/cli/env.py new file mode 100644 index 0000000..52a4ec6 --- /dev/null +++ b/aria/cli/env.py @@ -0,0 +1,124 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + + +import os +import shutil + +from .config import config +from .logger import Logging +from .. import (application_model_storage, application_resource_storage) +from ..orchestrator.plugin import PluginManager +from ..storage.sql_mapi import SQLAlchemyModelAPI +from ..storage.filesystem_rapi import FileSystemResourceAPI + + +ARIA_DEFAULT_WORKDIR_NAME = '.aria' + + +class _Environment(object): + + def __init__(self, workdir): + + self._workdir = workdir + self._init_workdir() + + self._config = config.CliConfig.create_config(workdir) + self._logging = Logging(self._config) + + self._model_storage_dir = os.path.join(workdir, 'models') + self._resource_storage_dir = os.path.join(workdir, 'resources') + self._plugins_dir = os.path.join(workdir, 'plugins') + + # initialized lazily + self._model_storage = None + self._resource_storage = None + self._plugin_manager = None + + @property + def workdir(self): + return self._workdir + + @property + def config(self): + return self._config + + @property + def logging(self): + return self._logging + + @property + def model_storage(self): + if not self._model_storage: + self._model_storage = self._init_sqlite_model_storage() + return self._model_storage + + @property + def resource_storage(self): + if not self._resource_storage: + self._resource_storage = self._init_fs_resource_storage() + return self._resource_storage + + @property + def plugin_manager(self): + if not self._plugin_manager: + self._plugin_manager = self._init_plugin_manager() + return self._plugin_manager + + def reset(self, reset_config): + if reset_config: + shutil.rmtree(self._workdir) + else: + _, dirs, files = next(os.walk(self._workdir)) + files.remove(config.CONFIG_FILE_NAME) + + for dir_ in dirs: + shutil.rmtree(os.path.join(self._workdir, dir_)) + for file_ in files: + os.remove(os.path.join(self._workdir, file_)) + + def _init_workdir(self): + if not os.path.exists(self._workdir): + os.makedirs(self._workdir) + + def _init_sqlite_model_storage(self): + if not os.path.exists(self._model_storage_dir): + os.makedirs(self._model_storage_dir) + + initiator_kwargs = dict(base_dir=self._model_storage_dir) + return application_model_storage( + SQLAlchemyModelAPI, + initiator_kwargs=initiator_kwargs) + + def _init_fs_resource_storage(self): + if not os.path.exists(self._resource_storage_dir): + os.makedirs(self._resource_storage_dir) + + fs_kwargs = dict(directory=self._resource_storage_dir) + return application_resource_storage( + FileSystemResourceAPI, + api_kwargs=fs_kwargs) + + def _init_plugin_manager(self): + if not os.path.exists(self._plugins_dir): + os.makedirs(self._plugins_dir) + + return PluginManager(self.model_storage, self._plugins_dir) + + +env = _Environment(os.path.join( + os.environ.get('ARIA_WORKDIR', os.path.expanduser('~')), ARIA_DEFAULT_WORKDIR_NAME)) + +logger = env.logging.logger http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/exceptions.py ---------------------------------------------------------------------- diff --git a/aria/cli/exceptions.py b/aria/cli/exceptions.py index 6897731..89cfacd 100644 --- a/aria/cli/exceptions.py +++ b/aria/cli/exceptions.py @@ -13,59 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -""" -CLI various exception classes -""" +from ..exceptions import AriaError -class AriaCliError(Exception): - """ - General CLI Exception class - """ - pass - - -class AriaCliFormatInputsError(AriaCliError): - """ - Raised when provided inputs are malformed. - """ - - def __init__(self, message, inputs): - self.inputs = inputs - super(AriaCliFormatInputsError, self).__init__(message) - - def user_message(self): - """ - Describes the format error in detail. - """ - return ( - 'Invalid input format: {0}, ' - 'the expected format is: ' - 'key1=value1;key2=value2'.format(self.inputs)) - -class AriaCliYAMLInputsError(AriaCliError): - """ - Raised when an invalid yaml file is provided - """ +class AriaCliError(AriaError): pass - - -class AriaCliInvalidInputsError(AriaCliFormatInputsError): - """ - Raised when provided inputs are invalid. - """ - - def user_message(self): - """ - Describes the error in detail. - """ - return ( - 'Invalid input: {0}. input must represent a dictionary.\n' - 'Valid values can be one of:\n' - '- a path to a YAML file\n' - '- a path to a directory containing YAML files\n' - '- a single quoted wildcard based path (e.g. "*-inputs.yaml")\n' - '- a string formatted as JSON\n' - '- a string formatted as key1=value1;key2=value2'.format(self.inputs) - ) http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/helptexts.py ---------------------------------------------------------------------- diff --git a/aria/cli/helptexts.py b/aria/cli/helptexts.py new file mode 100644 index 0000000..1a3f6c0 --- /dev/null +++ b/aria/cli/helptexts.py @@ -0,0 +1,49 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + + +DEFAULT_MUTUALITY_MESSAGE = 'Cannot be used simultaneously' +VERBOSE = \ + "Show verbose output. You can supply this up to three times (i.e. -vvv)" + +VERSION = "Display the version and exit" +FORCE_RESET = "Confirmation for resetting ARIA's working directory" +RESET_CONFIG = "Reset ARIA's user configuration" + +SERVICE_TEMPLATE_ID = "The unique identifier for the service template" +SERVICE_ID = "The unique identifier for the service" +EXECUTION_ID = "The unique identifier for the execution" + +SERVICE_TEMPLATE_PATH = "The path to the application's service template file" +SERVICE_TEMPLATE_FILENAME = ( + "The name of the archive's main service template file. " + "This is only relevant if uploading a (non-csar) archive") +INPUTS_PARAMS_USAGE = ( + '(Can be provided as wildcard based paths ' + '(*.yaml, /my_inputs/, etc..) to YAML files, a JSON string or as ' + 'key1=value1;key2=value2). This argument can be used multiple times') +SERVICE_INPUTS = "Inputs for the service {0}".format(INPUTS_PARAMS_USAGE) +EXECUTION_INPUTS = "Inputs for the execution {0}".format(INPUTS_PARAMS_USAGE) + +TASK_RETRY_INTERVAL = \ + "How long of a minimal interval should occur between task retry attempts [default: {0}]" +TASK_MAX_ATTEMPTS = \ + "How many times should a task be attempted in case of failures [default: {0}]" +DRY_EXECUTION = "Execute a workflow dry run (prints operations information without causing side " \ + "effects)" +IGNORE_AVAILABLE_NODES = "Delete the service even if it has available nodes" +SORT_BY = "Key for sorting the list" +DESCENDING = "Sort list in descending order [default: False]" +JSON_OUTPUT = "Output logs in a consumable JSON format" http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/inputs.py ---------------------------------------------------------------------- diff --git a/aria/cli/inputs.py b/aria/cli/inputs.py new file mode 100644 index 0000000..0ff48dc --- /dev/null +++ b/aria/cli/inputs.py @@ -0,0 +1,118 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + +import os +import glob +import yaml + +from .env import logger +from .exceptions import AriaCliError + + +def inputs_to_dict(resources): + """Returns a dictionary of inputs + + `resources` can be: + - A list of files. + - A single file + - A directory containing multiple input files + - A key1=value1;key2=value2 pairs string. + - A string formatted as JSON/YAML. + - Wildcard based string (e.g. *-inputs.yaml) + """ + if not resources: + return dict() + + parsed_dict = {} + + for resource in resources: + logger.debug('Processing inputs source: {0}'.format(resource)) + # Workflow parameters always pass an empty dictionary. We ignore it + if isinstance(resource, basestring): + try: + parsed_dict.update(_parse_single_input(resource)) + except AriaCliError: + raise AriaCliError( + "Invalid input: {0}. It must represent a dictionary. " + "Valid values can be one of:{1} " + "- A path to a YAML file{1} " + "- A path to a directory containing YAML files{1} " + "- A single quoted wildcard based path " + "(e.g. '*-inputs.yaml'){1} " + "- A string formatted as JSON/YAML{1} " + "- A string formatted as key1=value1;key2=value2".format( + resource, os.linesep)) + return parsed_dict + + +def _parse_single_input(resource): + try: + # parse resource as string representation of a dictionary + return _plain_string_to_dict(resource) + except AriaCliError: + input_files = glob.glob(resource) + parsed_dict = dict() + if os.path.isdir(resource): + for input_file in os.listdir(resource): + parsed_dict.update( + _parse_yaml_path(os.path.join(resource, input_file))) + elif input_files: + for input_file in input_files: + parsed_dict.update(_parse_yaml_path(input_file)) + else: + parsed_dict.update(_parse_yaml_path(resource)) + return parsed_dict + + +def _parse_yaml_path(resource): + + try: + # if resource is a path - parse as a yaml file + if os.path.isfile(resource): + with open(resource) as f: + content = yaml.load(f.read()) + else: + # parse resource content as yaml + content = yaml.load(resource) + except yaml.error.YAMLError as e: + raise AriaCliError("'{0}' is not a valid YAML. {1}".format( + resource, str(e))) + + # Emtpy files return None + content = content or dict() + if not isinstance(content, dict): + raise AriaCliError() + + return content + + +def _plain_string_to_dict(input_string): + input_string = input_string.strip() + input_dict = {} + mapped_inputs = input_string.split(';') + for mapped_input in mapped_inputs: + mapped_input = mapped_input.strip() + if not mapped_input: + continue + split_mapping = mapped_input.split('=') + try: + key = split_mapping[0].strip() + value = split_mapping[1].strip() + except IndexError: + raise AriaCliError( + "Invalid input format: {0}, the expected format is: " + "key1=value1;key2=value2".format(input_string)) + input_dict[key] = value + return input_dict http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/logger.py ---------------------------------------------------------------------- diff --git a/aria/cli/logger.py b/aria/cli/logger.py new file mode 100644 index 0000000..1ffa918 --- /dev/null +++ b/aria/cli/logger.py @@ -0,0 +1,114 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + + +import os +import copy +import logging +from logutils import dictconfig + + +HIGH_VERBOSE = 3 +MEDIUM_VERBOSE = 2 +LOW_VERBOSE = 1 +NO_VERBOSE = 0 + +LOGGER_CONFIG_TEMPLATE = { + "version": 1, + "formatters": { + "file": { + "format": "%(asctime)s [%(levelname)s] %(message)s" + }, + "console": { + "format": "%(message)s" + } + }, + "handlers": { + "file": { + "class": "logging.handlers.RotatingFileHandler", + "formatter": "file", + "maxBytes": "5000000", + "backupCount": "20" + }, + "console": { + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + "formatter": "console" + } + }, + "disable_existing_loggers": False +} + + +class Logging(object): + + def __init__(self, config): + self._log_file = None + self._verbosity_level = NO_VERBOSE + self._all_loggers_names = [] + self._configure_loggers(config) + self._lgr = logging.getLogger('aria.cli.main') + + @property + def logger(self): + return self._lgr + + @property + def log_file(self): + return self._log_file + + @property + def verbosity_level(self): + return self._verbosity_level + + @verbosity_level.setter + def verbosity_level(self, level): + self._verbosity_level = level + if self.is_high_verbose_level(): + for logger_name in self._all_loggers_names: + logging.getLogger(logger_name).setLevel(logging.DEBUG) + + def is_high_verbose_level(self): + return self.verbosity_level == HIGH_VERBOSE + + def _configure_loggers(self, config): + loggers_config = config.logging.loggers + logfile = config.logging.filename + + logger_dict = copy.deepcopy(LOGGER_CONFIG_TEMPLATE) + if logfile: + # set filename on file handler + logger_dict['handlers']['file']['filename'] = logfile + logfile_dir = os.path.dirname(logfile) + if not os.path.exists(logfile_dir): + os.makedirs(logfile_dir) + self._log_file = logfile + else: + del logger_dict['handlers']['file'] + + # add handlers to all loggers + loggers = {} + for logger_name in loggers_config: + loggers[logger_name] = dict(handlers=list(logger_dict['handlers'].keys())) + self._all_loggers_names.append(logger_name) + logger_dict['loggers'] = loggers + + # set level for all loggers + for logger_name, logging_level in loggers_config.iteritems(): + log = logging.getLogger(logger_name) + level = logging._levelNames[logging_level.upper()] + log.setLevel(level) + + dictconfig.dictConfig(logger_dict) http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/main.py ---------------------------------------------------------------------- diff --git a/aria/cli/main.py b/aria/cli/main.py new file mode 100644 index 0000000..02cf095 --- /dev/null +++ b/aria/cli/main.py @@ -0,0 +1,58 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + +from aria import install_aria_extensions +from aria.cli import commands +from aria.cli.core import aria + + [email protected](name='aria') [email protected]() [email protected] +def _aria(): + """ARIA's Command Line Interface + + To activate bash-completion. Run: `eval "$(_ARIA_COMPLETE=source aria)"` + + ARIA's working directory resides by default in ~/.aria. To change it, set + the environment variable `ARIA_WORKDIR` to something else (e.g. /tmp/). + """ + aria.set_cli_except_hook() + + +def _register_commands(): + """ + Register the CLI's commands. + """ + + _aria.add_command(commands.service_templates.service_templates) + _aria.add_command(commands.node_templates.node_templates) + _aria.add_command(commands.services.services) + _aria.add_command(commands.nodes.nodes) + _aria.add_command(commands.workflows.workflows) + _aria.add_command(commands.executions.executions) + _aria.add_command(commands.plugins.plugins) + _aria.add_command(commands.logs.logs) + _aria.add_command(commands.reset.reset) + + +def main(): + install_aria_extensions() + _register_commands() + _aria() + + +if __name__ == '__main__': + main() http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/service_template_utils.py ---------------------------------------------------------------------- diff --git a/aria/cli/service_template_utils.py b/aria/cli/service_template_utils.py new file mode 100644 index 0000000..382cce1 --- /dev/null +++ b/aria/cli/service_template_utils.py @@ -0,0 +1,121 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + +import os +from urlparse import urlparse + +from . import csar +from . import utils +from .exceptions import AriaCliError +from ..utils import archive as archive_utils + + +def get(source, service_template_filename): + """Get a source and return a path to the main service template file + + The behavior based on then source argument content is: + - local yaml file: return the file + - local archive: + extract it locally and return path service template file + - URL: + - download and get service template from downloaded archive + - github repo: + - download and get service template from downloaded archive + + Supported archive types are: csar, zip, tar, tar.gz and tar.bz2 + + :param source: Path/URL/github repo to archive/service-template file + :type source: str + :param service_template_filename: Path to service template (if source is an archive [but + not a csar archive - with csars archives, this is read from the metadata file]) + :type service_template_filename: str + :return: Path to main service template file + :rtype: str + """ + if urlparse(source).scheme: + downloaded_file = utils.download_file(source) + return _get_service_template_file_from_archive( + downloaded_file, service_template_filename) + elif os.path.isfile(source): + if _is_archive(source): + return _get_service_template_file_from_archive(source, service_template_filename) + else: + # Maybe check if yaml. + return source + elif len(source.split('/')) == 2: + url = _map_to_github_url(source) + downloaded_file = utils.download_file(url) + return _get_service_template_file_from_archive( + downloaded_file, service_template_filename) + else: + raise AriaCliError( + 'You must provide either a path to a local file, a remote URL ' + 'or a GitHub `organization/repository[:tag/branch]`') + + +def _get_service_template_file_from_archive(archive, service_template_filename): + """Extract archive to temporary location and get path to service template file. + + :param archive: Path to archive file + :type archive: str + :param service_template_filename: Path to service template file relative to archive + :type service_template_filename: str + :return: Absolute path to service template file + :rtype: str + + """ + if csar.is_csar_archive(archive): + service_template_file = _extract_csar_archive(archive) + else: + extract_directory = archive_utils.extract_archive(archive) + service_template_dir = os.path.join( + extract_directory, + os.listdir(extract_directory)[0], + ) + service_template_file = os.path.join(service_template_dir, service_template_filename) + + if not os.path.isfile(service_template_file): + raise AriaCliError( + 'Could not find `{0}`. Please provide the name of the main ' + 'service template file by using the `-n/--service-template-filename` flag' + .format(service_template_filename)) + return service_template_file + + +def _map_to_github_url(source): + """Returns a path to a downloaded github archive. + + :param source: github repo in the format of `org/repo[:tag/branch]`. + :type source: str + :return: URL to the archive file for the given repo in github + :rtype: str + + """ + source_parts = source.split(':', 1) + repo = source_parts[0] + tag = source_parts[1] if len(source_parts) == 2 else 'master' + url = 'https://github.com/{0}/archive/{1}.tar.gz'.format(repo, tag) + return url + + +def _is_archive(source): + return archive_utils.is_archive(source) or csar.is_csar_archive(source) + + +def _extract_csar_archive(archive): + reader = csar.read(source=archive) + main_service_template_file_name = os.path.basename(reader.entry_definitions) + return os.path.join(reader.destination, + main_service_template_file_name) http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/storage.py ---------------------------------------------------------------------- diff --git a/aria/cli/storage.py b/aria/cli/storage.py deleted file mode 100644 index fa1518b..0000000 --- a/aria/cli/storage.py +++ /dev/null @@ -1,95 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one or more -# contributor license agreements. See the NOTICE file distributed with -# this work for additional information regarding copyright ownership. -# The ASF licenses this file to You 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. - -""" -Filesystem related CLI storage location and configuration -""" - -import os -import getpass -from shutil import rmtree - -work_space_directory = '.aria' -storage_directory_name = 'local-storage' - - -def user_space(user_name=getpass.getuser()): - """ - Base work directory - """ - user_path = '~{0}'.format(user_name) - real_path = os.path.expanduser(user_path) - if os.path.exists(real_path): - return os.path.join(real_path, work_space_directory) - return os.path.join(os.getcwd(), work_space_directory) - - -def local_storage(user_name=getpass.getuser()): - """ - Base storage directory - """ - return os.path.join(user_space(user_name), storage_directory_name) - - -def local_model_storage(): - """ - Model storage directory - """ - return os.path.join(local_storage(), 'models') - - -def local_resource_storage(): - """ - Resource storage directory - """ - return os.path.join(local_storage(), 'resources') - - -def config_file_path(): - """ - Configuration file path - """ - path = os.path.join(user_space(), 'config.yaml') - if not os.path.exists(path): - open(path, 'w').close() - return path - - -def create_user_space(user_name=getpass.getuser(), override=False): - """ - Creates the base work directory - """ - path = user_space(user_name) - if os.path.exists(path): - if override: - rmtree(path, ignore_errors=True) - else: - raise IOError('user space {0} already exists'.format(path)) - os.mkdir(path) - return path - - -def create_local_storage(user_name=getpass.getuser(), override=False): - """ - Creates the base storage directory - """ - path = local_storage(user_name) - if os.path.exists(path): - if override: - rmtree(path, ignore_errors=True) - else: - raise IOError('local storage {0} already exists'.format(path)) - os.mkdir(path) - return path http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/table.py ---------------------------------------------------------------------- diff --git a/aria/cli/table.py b/aria/cli/table.py new file mode 100644 index 0000000..408f81e --- /dev/null +++ b/aria/cli/table.py @@ -0,0 +1,116 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + +import os +from datetime import datetime + +from prettytable import PrettyTable + +from .env import logger + + +def print_data(columns, items, header_text, + column_formatters=None, col_max_width=None, defaults=None): + if items is None: + items = [] + elif not isinstance(items, list): + items = [items] + + pretty_table = _generate(columns, data=items, column_formatters=column_formatters, + defaults=defaults) + if col_max_width: + pretty_table.max_width = col_max_width + _log(header_text, pretty_table) + + +def _log(title, table): + logger.info('{0}{1}{0}{2}{0}'.format(os.linesep, title, table)) + + +def _generate(cols, data, column_formatters=None, defaults=None): + """ + Return a new PrettyTable instance representing the list. + + Arguments: + + cols - An iterable of strings that specify what + are the columns of the table. + + for example: ['id','name'] + + data - An iterable of dictionaries or objects, each element must + have keys or attributes corresponding to the cols items. + + for example: [{'id':'123', 'name':'Pete'}] + + column_formatters - A dictionary from a column name to a formatter - a function that + may manipulate the string values printed for this column. + (See below for a few built-in formatter examples) + + for example: {'created_at': timestamp_formatter} + + defaults - A dictionary specifying default values for + key's that don't exist in the data itself. + + for example: {'serviceId':'123'} will set the + serviceId value for all rows to '123'. + + """ + def get_values_per_column(column, row_data): + if hasattr(row_data, column) or (isinstance(row_data, dict) and column in row_data): + val = row_data[column] if isinstance(row_data, dict) else getattr(row_data, column) + + if val and isinstance(val, list): + val = [str(element) for element in val] + val = ','.join(val) + elif val is None or isinstance(val, list): + # don't print `[]` or `None` (but do print `0`, `False`, etc.) + val = '' + + if column in column_formatters: + # calling the user's column formatter to manipulate the value + val = column_formatters[column](val) + + return val + else: + return defaults[column] + + column_formatters = column_formatters or dict() + pretty_table = PrettyTable(list(cols)) + + for datum in data: + values_row = [] + for col in cols: + values_row.append(get_values_per_column(col, datum)) + pretty_table.add_row(values_row) + + return pretty_table + + +def timestamp_formatter(value): + try: + datetime.strptime(value[:10], '%Y-%m-%d') + return value.replace('T', ' ').replace('Z', ' ') + except ValueError: + # not a timestamp + return value + + +def trim_formatter_generator(max_length): + def trim_formatter(value): + if len(value) >= max_length: + value = '{0}..'.format(value[:max_length - 2]) + return value + return trim_formatter http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/cli/utils.py ---------------------------------------------------------------------- diff --git a/aria/cli/utils.py b/aria/cli/utils.py new file mode 100644 index 0000000..852f24d --- /dev/null +++ b/aria/cli/utils.py @@ -0,0 +1,115 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + +import os +import sys +from StringIO import StringIO + +from backports.shutil_get_terminal_size import get_terminal_size + +from .env import logger +from .exceptions import AriaCliError +from ..utils import http + + +def storage_sort_param(sort_by, descending): + return {sort_by: 'desc' if descending else 'asc'} + + +def get_parameter_templates_as_string(parameter_templates): + params_string = StringIO() + + for param_name, param_template in parameter_templates.iteritems(): + params_string.write('\t{0}:{1}'.format(param_name, os.linesep)) + param_dict = param_template.to_dict() + del param_dict['id'] # not interested in printing the id + for k, v in param_dict.iteritems(): + params_string.write('\t\t{0}: {1}{2}'.format(k, v, os.linesep)) + + params_string.write(os.linesep) + return params_string.getvalue() + + +def check_overriding_storage_exceptions(e, model_class, name): + """ + This method checks whether the storage exception is a known type where we'd like to override + the exception message; If so, it raises a new error. Otherwise it simply returns. + """ + assert isinstance(e, BaseException) + if 'UNIQUE constraint failed' in e.message: + new_message = \ + 'Could not store {model_class} `{name}`{linesep}' \ + 'There already a exists a {model_class} with the same name' \ + .format(model_class=model_class, name=name, linesep=os.linesep) + trace = sys.exc_info()[2] + raise type(e), type(e)(new_message), trace # pylint: disable=raising-non-exception + + +def download_file(url): + progress_bar = generate_progress_handler(url, 'Downloading') + try: + destination = http.download_file(url, logger=logger, progress_handler=progress_bar) + except Exception as e: + raise AriaCliError( + 'Failed to download {0}. ({1})'.format(url, str(e))) + return destination + + +def generate_progress_handler(file_path, action='', max_bar_length=80): + """Returns a function that prints a progress bar in the terminal + + :param file_path: The name of the file being transferred + :param action: Uploading/Downloading + :param max_bar_length: Maximum allowed length of the bar. Default: 80 + :return: The configured print_progress function + """ + # We want to limit the maximum line length to 80, but allow for a smaller + # terminal size. We also include the action string, and some extra chars + terminal_width = get_terminal_size().columns + + # This takes care of the case where there is no terminal (e.g. unittest) + terminal_width = terminal_width or max_bar_length + bar_length = min(max_bar_length, terminal_width) - len(action) - 12 + + # Shorten the file name if it's too long + file_name = os.path.basename(file_path) + if len(file_name) > (bar_length / 4) + 3: + file_name = file_name[:bar_length / 4] + '...' + + bar_length -= len(file_name) + + def print_progress(read_bytes, total_bytes): + """Print upload/download progress on a single line + + Call this function in a loop to create a progress bar in the terminal + + :param read_bytes: Number of bytes already processed + :param total_bytes: Total number of bytes in the file + """ + + filled_length = min(bar_length, int(round(bar_length * read_bytes / + float(total_bytes)))) + percents = min(100.00, round( + 100.00 * (read_bytes / float(total_bytes)), 2)) + bar = '#' * filled_length + '-' * (bar_length - filled_length) # pylint: disable=blacklisted-name + + # The \r caret makes sure the cursor moves back to the beginning of + # the line + sys.stdout.write('\r{0} {1} |{2}| {3}%'.format( + action, file_name, bar, percents)) + if read_bytes >= total_bytes: + sys.stdout.write(os.linesep) + + return print_progress http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/core.py ---------------------------------------------------------------------- diff --git a/aria/core.py b/aria/core.py new file mode 100644 index 0000000..af1984a --- /dev/null +++ b/aria/core.py @@ -0,0 +1,124 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + +from . import exceptions +from .parser import consumption +from .parser.loading.location import UriLocation + + +class Core(object): + + def __init__(self, + model_storage, + resource_storage, + plugin_manager): + self._model_storage = model_storage + self._resource_storage = resource_storage + self._plugin_manager = plugin_manager + + @property + def model_storage(self): + return self._model_storage + + @property + def resource_storage(self): + return self._resource_storage + + @property + def plugin_manager(self): + return self._plugin_manager + + def validate_service_template(self, service_template_path): + self._parse_service_template(service_template_path) + + def create_service_template(self, service_template_path, service_template_dir, + service_template_name): + context = self._parse_service_template(service_template_path) + service_template = context.modeling.template + service_template.name = service_template_name + self.model_storage.service_template.put(service_template) + self.resource_storage.service_template.upload( + entry_id=str(service_template.id), source=service_template_dir) + + def delete_service_template(self, service_template_id): + service_template = self.model_storage.service_template.get(service_template_id) + if service_template.services: + raise exceptions.DependentServicesError( + "Can't delete service template {0} - Service template has existing services") + + self.model_storage.service_template.delete(service_template) + self.resource_storage.service_template.delete(entry_id=str(service_template.id)) + + def create_service(self, service_template_id, inputs, service_name=None): + + service_template = self.model_storage.service_template.get(service_template_id) + + # creating an empty ConsumptionContext, initiating a threadlocal context + context = consumption.ConsumptionContext() + + storage_session = self.model_storage._all_api_kwargs['session'] + # setting no autoflush for the duration of instantiation - this helps avoid dependency + # constraints as they're being set up + with storage_session.no_autoflush: + service = service_template.instantiate(None, self.model_storage, inputs=inputs) + + consumption.ConsumerChain( + context, + ( + consumption.SatisfyRequirements, + consumption.ValidateCapabilities, + consumption.FindHosts, + consumption.ConfigureOperations + )).consume() + if context.validation.dump_issues(): + raise exceptions.InstantiationError('Failed to instantiate service template') + + storage_session.flush() # flushing so service.id would auto-populate + service.name = service_name or '{0}_{1}'.format(service_template.name, service.id) + self.model_storage.service.put(service) + return service + + def delete_service(self, service_id, force=False): + service = self.model_storage.service.get(service_id) + + active_executions = [e for e in service.executions if e.is_active()] + if active_executions: + raise exceptions.DependentActiveExecutionsError( + "Can't delete service {0} - there is an active execution for this service. " + "Active execution id: {1}".format(service.name, active_executions[0].id)) + + if not force: + available_nodes = [str(n.id) for n in service.nodes.values() if n.is_available()] + if available_nodes: + raise exceptions.DependentAvailableNodesError( + "Can't delete service {0} - there are available nodes for this service. " + "Available node ids: {1}".format(service.name, ', '.join(available_nodes))) + + self.model_storage.service.delete(service) + + @staticmethod + def _parse_service_template(service_template_path): + context = consumption.ConsumptionContext() + context.presentation.location = UriLocation(service_template_path) + consumption.ConsumerChain( + context, + ( + consumption.Read, + consumption.Validate, + consumption.ServiceTemplate + )).consume() + if context.validation.dump_issues(): + raise exceptions.ParsingError('Failed to parse service template') + return context http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/exceptions.py ---------------------------------------------------------------------- diff --git a/aria/exceptions.py b/aria/exceptions.py index a180ce1..93987dc 100644 --- a/aria/exceptions.py +++ b/aria/exceptions.py @@ -44,3 +44,32 @@ class AriaException(Exception): # Make sure it's our traceback cause_traceback = traceback self.cause_traceback = cause_traceback + + +class DependentServicesError(AriaError): + """ + Raised when attempting to delete a service template which has existing services + """ + pass + + +class DependentActiveExecutionsError(AriaError): + """ + Raised when attempting to delete a service which has active executions + """ + pass + + +class DependentAvailableNodesError(AriaError): + """ + Raised when attempting to delete a service which has available nodes + """ + pass + + +class ParsingError(AriaError): + pass + + +class InstantiationError(AriaError): + pass http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/logger.py ---------------------------------------------------------------------- diff --git a/aria/logger.py b/aria/logger.py index e3039f5..dd54264 100644 --- a/aria/logger.py +++ b/aria/logger.py @@ -19,8 +19,20 @@ Logging related mixins and functions import logging from logging import handlers as logging_handlers +# NullHandler doesn't exist in < 27. this workaround is from +# http://docs.python.org/release/2.6/library/logging.html#configuring-logging-for-a-library +try: + from logging import NullHandler # pylint: disable=unused-import +except ImportError: + class NullHandler(logging.Handler): + def emit(self, record): + pass from datetime import datetime + +TASK_LOGGER_NAME = 'aria.executions.task' + + _base_logger = logging.getLogger('aria') http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/modeling/__init__.py ---------------------------------------------------------------------- diff --git a/aria/modeling/__init__.py b/aria/modeling/__init__.py index 4dfc39d..4ac79e7 100644 --- a/aria/modeling/__init__.py +++ b/aria/modeling/__init__.py @@ -19,6 +19,7 @@ from . import ( mixins, types, models, + utils, service_template as _service_template_bases, service_instance as _service_instance_bases, service_changes as _service_changes_bases, @@ -45,4 +46,5 @@ __all__ = ( 'types', 'models', 'model_bases', + 'utils' ) http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/modeling/exceptions.py ---------------------------------------------------------------------- diff --git a/aria/modeling/exceptions.py b/aria/modeling/exceptions.py index 6931c78..19fd942 100644 --- a/aria/modeling/exceptions.py +++ b/aria/modeling/exceptions.py @@ -22,6 +22,13 @@ class ModelingException(AriaException): """ +class InputsException(ModelingException): + """ + ARIA inputs exception. + """ + pass + + class ValueFormatException(ModelingException): """ ARIA modeling exception: the value is in the wrong format. @@ -32,3 +39,21 @@ class CannotEvaluateFunctionException(ModelingException): """ ARIA modeling exception: cannot evaluate the function at this time. """ + + +class MissingRequiredInputsException(InputsException): + """ + ARIA modeling exception: Required inputs have been omitted. + """ + + +class InputsOfWrongTypeException(InputsException): + """ + ARIA modeling exception: Inputs of the wrong types have been provided. + """ + + +class UndeclaredInputsException(InputsException): + """ + ARIA modeling exception: Undeclared inputs have been provided. + """ http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/modeling/models.py ---------------------------------------------------------------------- diff --git a/aria/modeling/models.py b/aria/modeling/models.py index 170efb2..584b877 100644 --- a/aria/modeling/models.py +++ b/aria/modeling/models.py @@ -16,6 +16,10 @@ # pylint: disable=abstract-method from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy import ( + Column, + Text +) from . import ( service_template, @@ -26,7 +30,6 @@ from . import ( mixins, ) - aria_declarative_base = declarative_base(cls=mixins.ModelIDMixin) @@ -84,7 +87,7 @@ __all__ = ( # region service template models class ServiceTemplate(aria_declarative_base, service_template.ServiceTemplateBase): - pass + name = Column(Text, index=True, unique=True) class NodeTemplate(aria_declarative_base, service_template.NodeTemplateBase): @@ -140,7 +143,7 @@ class PluginSpecification(aria_declarative_base, service_template.PluginSpecific # region service instance models class Service(aria_declarative_base, service_instance.ServiceBase): - pass + name = Column(Text, index=True, unique=True) class Node(aria_declarative_base, service_instance.NodeBase): http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/modeling/orchestration.py ---------------------------------------------------------------------- diff --git a/aria/modeling/orchestration.py b/aria/modeling/orchestration.py index b32a8a1..01ab2e8 100644 --- a/aria/modeling/orchestration.py +++ b/aria/modeling/orchestration.py @@ -39,7 +39,6 @@ from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.declarative import declared_attr from ..orchestrator.exceptions import (TaskAbortException, TaskRetryException) -from .types import Dict from .mixins import ModelMixin from . import ( relationship, @@ -55,9 +54,7 @@ class ExecutionBase(ModelMixin): __tablename__ = 'execution' __private_fields__ = ['service_fk', - 'service_name', - 'service_template', - 'service_template_name'] + 'service_template'] TERMINATED = 'terminated' FAILED = 'failed' @@ -97,17 +94,14 @@ class ExecutionBase(ModelMixin): ended_at = Column(DateTime, nullable=True, index=True) error = Column(Text, nullable=True) is_system_workflow = Column(Boolean, nullable=False, default=False) - parameters = Column(Dict) status = Column(Enum(*STATES, name='execution_status'), default=PENDING) workflow_name = Column(Text) - @property def has_ended(self): return self.status in self.END_STATES - @property def is_active(self): - return not self.has_ended + return not self.has_ended() and self.status != self.PENDING @declared_attr def logs(cls): @@ -121,6 +115,10 @@ class ExecutionBase(ModelMixin): def tasks(cls): return relationship.one_to_many(cls, 'task') + @declared_attr + def inputs(cls): + return relationship.many_to_many(cls, 'parameter', prefix='inputs', dict_key='name') + # region foreign keys @declared_attr @@ -264,10 +262,7 @@ class TaskBase(ModelMixin): __private_fields__ = ['node_fk', 'relationship_fk', 'plugin_fk', - 'execution_fk' - 'node_name', - 'relationship_name', - 'execution_name'] + 'execution_fk'] PENDING = 'pending' RETRYING = 'retrying' @@ -322,11 +317,9 @@ class TaskBase(ModelMixin): ended_at = Column(DateTime, default=None) retry_count = Column(Integer, default=0) - @property def has_ended(self): return self.status in (self.SUCCESS, self.FAILED) - @property def is_waiting(self): return self.status in (self.PENDING, self.RETRYING) http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/modeling/service_changes.py ---------------------------------------------------------------------- diff --git a/aria/modeling/service_changes.py b/aria/modeling/service_changes.py index b1a75a2..1974424 100644 --- a/aria/modeling/service_changes.py +++ b/aria/modeling/service_changes.py @@ -45,9 +45,7 @@ class ServiceUpdateBase(ModelMixin): __tablename__ = 'service_update' __private_fields__ = ['service_fk', - 'execution_fk', - 'execution_name', - 'service_name'] + 'execution_fk'] created_at = Column(DateTime, nullable=False, index=True) service_plan = Column(Dict, nullable=False) @@ -125,8 +123,7 @@ class ServiceUpdateStepBase(ModelMixin): __tablename__ = 'service_update_step' - __private_fields__ = ['service_update_fk', - 'service_update_name'] + __private_fields__ = ['service_update_fk'] _action_types = namedtuple('ACTION_TYPES', 'ADD, REMOVE, MODIFY') ACTION_TYPES = _action_types(ADD='add', REMOVE='remove', MODIFY='modify') @@ -222,8 +219,7 @@ class ServiceModificationBase(ModelMixin): __tablename__ = 'service_modification' - __private_fields__ = ['service_fk', - 'service_name'] + __private_fields__ = ['service_fk'] STARTED = 'started' FINISHED = 'finished' http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/modeling/service_common.py ---------------------------------------------------------------------- diff --git a/aria/modeling/service_common.py b/aria/modeling/service_common.py index 1fcbc5f..1188f34 100644 --- a/aria/modeling/service_common.py +++ b/aria/modeling/service_common.py @@ -87,6 +87,9 @@ class ParameterBase(TemplateModelMixin): if self.description: console.puts(context.style.meta(self.description)) + def unwrap(self): + return self.name, self.value + @classmethod def wrap(cls, name, value, description=None): """ @@ -98,13 +101,11 @@ class ParameterBase(TemplateModelMixin): :param description: Description (optional) :type description: basestring """ - - from . import models - return models.Parameter(name=name, - type_name=formatting.full_type_name(value) - if value is not None else None, - value=value, - description=description) + return cls(name=name, + type_name=formatting.full_type_name(value) + if value is not None else None, + value=value, + description=description) class TypeBase(InstanceModelMixin): http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/8e5a1ec2/aria/modeling/service_instance.py ---------------------------------------------------------------------- diff --git a/aria/modeling/service_instance.py b/aria/modeling/service_instance.py index 40d43fa..6d8f3fe 100644 --- a/aria/modeling/service_instance.py +++ b/aria/modeling/service_instance.py @@ -88,8 +88,7 @@ class ServiceBase(InstanceModelMixin): __tablename__ = 'service' __private_fields__ = ['substitution_fk', - 'service_template_fk', - 'service_template_name'] + 'service_template_fk'] # region foreign keys @@ -371,8 +370,7 @@ class NodeBase(InstanceModelMixin): __private_fields__ = ['type_fk', 'host_fk', 'service_fk', - 'node_template_fk', - 'service_name'] + 'node_template_fk'] INITIAL = 'initial' CREATING = 'creating' @@ -417,7 +415,6 @@ class NodeBase(InstanceModelMixin): except KeyError: return None - @property def is_available(self): return self.state not in (self.INITIAL, self.DELETED, self.ERROR) @@ -452,6 +449,11 @@ class NodeBase(InstanceModelMixin): """Required for use by SQLAlchemy queries""" return association_proxy('service', 'name') + @declared_attr + def node_template_name(cls): + """Required for use by SQLAlchemy queries""" + return association_proxy('node_template', 'name') + # endregion # region one_to_one relationships @@ -1183,9 +1185,7 @@ class RelationshipBase(InstanceModelMixin): 'target_node_fk', 'target_capability_fk', 'requirement_template_fk', - 'relationship_template_fk', - 'source_node_name', - 'target_node_name'] + 'relationship_template_fk'] # region foreign keys
