diff --git a/atomicapp/cli/main.py b/atomicapp/cli/main.py index 9d1a9840..47bdafa4 100644 --- a/atomicapp/cli/main.py +++ b/atomicapp/cli/main.py @@ -64,7 +64,8 @@ def cli_fetch(args): nm = NuleculeManager(app_spec=argdict['app_spec'], destination=destination, cli_answers=argdict['cli_answers'], - answers_file=argdict['answers']) + answers_file=argdict['answers'], + answers_format=argdict.get('answers_format')) nm.fetch(**argdict) # Clean up the files if the user asked us to. Otherwise # notify the user where they can manage the application @@ -81,7 +82,8 @@ def cli_run(args): nm = NuleculeManager(app_spec=argdict['app_spec'], destination=destination, cli_answers=argdict['cli_answers'], - answers_file=argdict['answers']) + answers_file=argdict['answers'], + answers_format=argdict.get('answers_format')) nm.run(**argdict) # Clean up the files if the user asked us to. Otherwise # notify the user where they can manage the application @@ -306,7 +308,7 @@ def create_parser(self): help="A file which will contain anwsers provided in interactive mode") run_subparser.add_argument( "--provider", - dest="cli_provider", + dest="provider", choices=PROVIDERS, help="The provider to use. Overrides provider value in answerfile.") run_subparser.add_argument( @@ -511,7 +513,8 @@ def run(self): # and make a dictionary of it to pass along in args. setattr(args, 'cli_answers', {}) for item in ['provider-api', 'provider-cafile', 'provider-auth', - 'provider-config', 'provider-tlsverify', 'namespace']: + 'provider-config', 'provider-tlsverify', 'namespace', + 'provider']: if hasattr(args, item) and getattr(args, item) is not None: args.cli_answers[item] = getattr(args, item) diff --git a/atomicapp/constants.py b/atomicapp/constants.py index 60f4e94c..7887d505 100644 --- a/atomicapp/constants.py +++ b/atomicapp/constants.py @@ -40,6 +40,7 @@ DEFAULTNAME_KEY = "default" PROVIDER_KEY = "provider" NAMESPACE_KEY = "namespace" +NAMESPACE_SEPARATOR = ":" REQUIREMENTS_KEY = "requirements" # Nulecule spec terminology vs the function within /providers diff --git a/atomicapp/nulecule/base.py b/atomicapp/nulecule/base.py index 8283f141..d0d76347 100644 --- a/atomicapp/nulecule/base.py +++ b/atomicapp/nulecule/base.py @@ -18,7 +18,6 @@ along with Atomic App. If not, see . """ import anymarkup -import copy import logging import os import yaml @@ -38,7 +37,7 @@ NAME_KEY, INHERIT_KEY, ARTIFACTS_KEY, - DEFAULT_PROVIDER) + NAMESPACE_SEPARATOR) from atomicapp.utils import Utils from atomicapp.requirements import Requirements from atomicapp.nulecule.lib import NuleculeBase @@ -76,7 +75,7 @@ def __init__(self, id, specversion, graph, basepath, metadata=None, metadata (dict): Nulecule metadata requirements (dict): Requirements for the Nulecule application params (list): List of params for the Nulecule application - config (dict): Config data for the Nulecule application + config (atomicapp.nulecule.config.Config): Config data namespace (str): Namespace of the current Nulecule application Returns: @@ -88,7 +87,7 @@ def __init__(self, id, specversion, graph, basepath, metadata=None, self.metadata = metadata or {} self.graph = graph self.requirements = requirements - self.config = config or {} + self.config = config @classmethod def unpack(cls, image, dest, config=None, namespace=GLOBAL_CONF, @@ -101,7 +100,7 @@ def unpack(cls, image, dest, config=None, namespace=GLOBAL_CONF, image (str): A Docker image name. dest (str): Destination path where Nulecule data from Docker image should be extracted. - config (dict): Dictionary, config data for Nulecule application. + config: An instance of atomicapp.nulecule.config.Config namespace (str): Namespace for Nulecule application. nodeps (bool): Don't pull external Nulecule dependencies when True. @@ -115,7 +114,7 @@ def unpack(cls, image, dest, config=None, namespace=GLOBAL_CONF, if Utils.running_on_openshift(): # pass general config data containing provider specific data # to Openshift provider - op = OpenshiftProvider(config.get('general', {}), './', False) + op = OpenshiftProvider(config.globals, './', False) op.artifacts = [] op.init() op.extract(image, APP_ENT_PATH, dest, update) @@ -138,7 +137,8 @@ def load_from_path(cls, src, config=None, namespace=GLOBAL_CONF, Args: src (str): Path to load Nulecule application from. - config (dict): Config data for Nulecule application. + config (atomicapp.nulecule.config.Config): Config data for + Nulecule application. namespace (str): Namespace for Nulecule application. nodeps (bool): Do not pull external applications if True. dryrun (bool): Do not make any change to underlying host. @@ -231,25 +231,23 @@ def load_config(self, config=None, ask=False, skip_asking=False): It updates self.config. Args: - config (dict): Existing config data, may be from ANSWERS - file or any other source. + config (atomicapp.nulecule.config.Config): Existing config data, + may be from ANSWERS file or any other source. Returns: None """ + if config is None: + config = self.config super(Nulecule, self).load_config( config=config, ask=ask, skip_asking=skip_asking) - if self.namespace == GLOBAL_CONF and self.config[GLOBAL_CONF].get('provider') is None: - self.config[GLOBAL_CONF]['provider'] = DEFAULT_PROVIDER - logger.info("Provider not specified, using default provider - {}". - format(DEFAULT_PROVIDER)) + for component in self.components: # FIXME: Find a better way to expose config data to components. # A component should not get access to all the variables, # but only to variables it needs. - component.load_config(config=copy.deepcopy(self.config), + component.load_config(config=config, ask=ask, skip_asking=skip_asking) - self.merge_config(self.config, component.config) def load_components(self, nodeps=False, dryrun=False): """ @@ -270,8 +268,8 @@ def load_components(self, nodeps=False, dryrun=False): node_name = node[NAME_KEY] source = Utils.getSourceImage(node) component = NuleculeComponent( - node_name, self.basepath, source, - node.get(PARAMS_KEY), node.get(ARTIFACTS_KEY), + self._get_component_namespace(node_name), self.basepath, + source, node.get(PARAMS_KEY), node.get(ARTIFACTS_KEY), self.config) component.load(nodeps, dryrun) components.append(component) @@ -294,6 +292,24 @@ def render(self, provider_key=None, dryrun=False): for component in self.components: component.render(provider_key=provider_key, dryrun=dryrun) + def _get_component_namespace(self, component_name): + """ + Get a unique namespace for a Nulecule graph item, by concatinating + the namespace of the current Nulecule (which could be the root Nulecule + app or a child or external Nulecule app) and name of the Nulecule + graph item. + + Args: + component_name (str): Name of the Nulecule graph item + + Returns: + A string + """ + current_namespace = '' if self.namespace == GLOBAL_CONF else self.namespace + return ( + '%s%s%s' % (current_namespace, NAMESPACE_SEPARATOR, component_name) + if current_namespace else component_name) + class NuleculeComponent(NuleculeBase): @@ -356,12 +372,13 @@ def load_config(self, config=None, ask=False, skip_asking=False): """ Load config for the Nulecule component. """ + if config is None: + config = self.config super(NuleculeComponent, self).load_config( config, ask=ask, skip_asking=skip_asking) if isinstance(self._app, Nulecule): - self._app.load_config(config=copy.deepcopy(self.config), + self._app.load_config(config=self.config, ask=ask, skip_asking=skip_asking) - self.merge_config(self.config, self._app.config) def load_external_application(self, dryrun=False, update=False): """ @@ -384,7 +401,8 @@ def load_external_application(self, dryrun=False, update=False): 'Found existing external application: %s ' 'Loading: ' % self.name) nulecule = Nulecule.load_from_path( - external_app_path, dryrun=dryrun, update=update) + external_app_path, dryrun=dryrun, update=update, + namespace=self.namespace) elif not dryrun: logger.info('Pulling external application: %s' % self.name) nulecule = Nulecule.unpack( @@ -436,7 +454,7 @@ def render(self, provider_key=None, dryrun=False): raise NuleculeException( "Data for provider \"%s\" are not part of this app" % provider_key) - context = self.get_context() + context = self.config.context(self.namespace) for provider in self.artifacts: if provider_key and provider != provider_key: continue diff --git a/atomicapp/nulecule/config.py b/atomicapp/nulecule/config.py new file mode 100644 index 00000000..b1d92b31 --- /dev/null +++ b/atomicapp/nulecule/config.py @@ -0,0 +1,173 @@ +import copy +import logging + +from atomicapp.constants import (GLOBAL_CONF, + LOGGER_COCKPIT, + DEFAULT_PROVIDER, + DEFAULT_ANSWERS) +from collections import defaultdict + +cockpit_logger = logging.getLogger(LOGGER_COCKPIT) + + +class Config(object): + """ + This class allows to store config data in different scopes along with + source info for the data. When fetching the value for a key in a scope, + the source info and the PRIORITY order of sources is taken into account. + + Data sources: + cli: Config data coming from the CLI + runtime: Config data resolved during atomic app runtime. For example, + when the value for a parameter in a Nulecule or Nulecule graph + item is missing in answers data, we first try to load the default + value for the parameter. When there's no default value, or when + the user has specified to forcefully ask the user for values, we + ask the user for data. These data collected/resolved during runtime + form the runtime data. + answers: Config data coming from answers file + defaults: Default config data specified in atomicapp/constants.py + + The priority order of the data sources is: + cli > runtime > answers > defaults + """ + + PRIORITY = ( + 'cli', + 'runtime', + 'answers', + 'defaults' + ) + + def __init__(self, answers=None, cli=None): + """ + Initialize a Config instance. + + Args: + answers (dict): Answers data + cli (dict): CLI data + """ + answers = answers or {} + cli = cli or {} + # We use a defaultdict of defaultdicts so that we can avoid doing + # redundant checks in a nested dictionary if the value of the keys + # are dictionaries or None. + self._data = defaultdict(defaultdict) + # Initialize default data dict + self._data['defaults'] = defaultdict(defaultdict) + # Initialize answers data dict + self._data['answers'] = defaultdict(defaultdict) + # Initialize cli data dict + self._data['cli'] = defaultdict(defaultdict) + # Initialize runtime data dict + self._data['runtime'] = defaultdict(defaultdict) + + # Load default answers + for scope, data in DEFAULT_ANSWERS.items(): + for key, value in data.items(): + self.set(key, value, scope=scope, source='defaults') + self.set('provider', DEFAULT_PROVIDER, scope=GLOBAL_CONF, source='defaults') + + # Load answers data + for scope, data in answers.items(): + for key, value in data.items(): + self.set(key, value, scope=scope, source='answers') + + # Load cli data + for key, value in cli.items(): + self.set(key, value, scope=GLOBAL_CONF, source='cli') + + def get(self, key, scope=GLOBAL_CONF, ignore_sources=[]): + """ + Get the value of a key in a scope. This takes care of resolving + the value by going through the PRIORITY order of the various + sources of data. + + Args: + key (str): Key + scope (str): Scope from which to fetch the value for the key + + Returns: + Value for the key. + """ + for source in self.PRIORITY: + if source in ignore_sources: + continue + value = self._data[source][scope].get(key) or self._data[source][ + GLOBAL_CONF].get(key) + if value: + return value + return None + + def set(self, key, value, source, scope=GLOBAL_CONF): + """ + Set the value for a key within a scope along with specifying the + source of the value. + + Args: + key (str): Key + value: Value + scope (str): Scope in which to store the value + source (str): Source of the value + """ + self._data[source][scope][key] = value + + def context(self, scope=GLOBAL_CONF): + """ + Get context data for the scope of Nulecule graph item by aggregating + the data from various sources taking their priority order into + account. This context data, which is a flat dictionary, is used to + render the variables in the artifacts of Nulecule graph item. + + Args: + scope (str): Scope (or namespace) for the Nulecule graph item. + Returns: + A dictionary + """ + result = {} + for source in reversed(self.PRIORITY): + source_data = self._data[source] + result.update(copy.deepcopy(source_data.get(GLOBAL_CONF) or {})) + if scope != GLOBAL_CONF: + result.update(copy.deepcopy(source_data.get(scope) or {})) + return result + + def runtime_answers(self): + """ + Get runtime answers. + + Returns: + A defaultdict containing runtime answers data. + """ + answers = defaultdict(dict) + + for source in reversed(self.PRIORITY): + for scope, data in (self._data.get(source) or {}).items(): + answers[scope].update(copy.deepcopy(data)) + + # Remove empty sections for answers + for key, value in answers.items(): + if not value: + answers.pop(key) + + return answers + + def update_source(self, source, data): + """ + Update answers data for a source. + + Args: + source (str): Source name + data (dict): Answers data + """ + data = data or {} + if source not in self._data: + raise + + # clean up source data + for k in self._data[source]: + self._data[source].pop(k) + + for scope, data in data.items(): + for key, value in data.items(): + self.set(key, value, scope=scope, source=source) diff --git a/atomicapp/nulecule/lib.py b/atomicapp/nulecule/lib.py index 0f2d1539..6157bda5 100644 --- a/atomicapp/nulecule/lib.py +++ b/atomicapp/nulecule/lib.py @@ -23,7 +23,6 @@ LOGGER_COCKPIT, NAME_KEY, DEFAULTNAME_KEY, - PROVIDER_KEY, PROVIDERS) from atomicapp.utils import Utils from atomicapp.plugin import Plugin @@ -63,47 +62,20 @@ def load_config(self, config, ask=False, skip_asking=False): Returns: None """ - for param in self.params: - value = config.get(self.namespace, {}).get(param[NAME_KEY]) or \ - config.get(GLOBAL_CONF, {}).get(param[NAME_KEY]) - if value is None and (ask or ( - not skip_asking and param.get(DEFAULTNAME_KEY) is None)): - cockpit_logger.info("%s is missing in answers.conf." % param[NAME_KEY]) - value = Utils.askFor(param[NAME_KEY], param, self.namespace) - elif value is None: - value = param.get(DEFAULTNAME_KEY) - if config.get(self.namespace) is None: - config[self.namespace] = {} - config[self.namespace][param[NAME_KEY]] = value self.config = config - - def merge_config(self, to_config, from_config): - """ - Merge values from from_config to to_config. If value for a key - in a group in to_config is missing, then only set it's value from - corresponding key in the same group in from_config. - - Args: - to_config (dict): Dictionary to merge config into - from_config (dict): Dictionary to merge config from - - Returns: - None - """ - for group, group_vars in from_config.items(): - to_config[group] = to_config.get(group) or {} - for key, value in (group_vars or {}).items(): - if to_config[group].get(key) is None: - to_config[group][key] = value - - def get_context(self): - """ - Get context data from config data for rendering an artifact. - """ - context = {} - context.update(self.config.get(GLOBAL_CONF) or {}) - context.update(self.config.get(self.namespace) or {}) - return context + for param in self.params: + value = config.get(param[NAME_KEY], scope=self.namespace, ignore_sources=['defaults']) + if value is None: + if ask or (not skip_asking and + param.get(DEFAULTNAME_KEY) is None): + cockpit_logger.info( + "%s is missing in answers.conf." % param[NAME_KEY]) + value = config.get(param[NAME_KEY], scope=self.namespace) \ + or Utils.askFor(param[NAME_KEY], param, self.namespace) + else: + value = param.get(DEFAULTNAME_KEY) + config.set(param[NAME_KEY], value, source='runtime', + scope=self.namespace) def get_provider(self, provider_key=None, dry=False): """ @@ -118,7 +90,7 @@ def get_provider(self, provider_key=None, dry=False): """ # If provider_key isn't provided via CLI, let's grab it the configuration if provider_key is None: - provider_key = self.config.get(GLOBAL_CONF)[PROVIDER_KEY] + provider_key = self.config.get('provider', scope=GLOBAL_CONF) provider_class = self.plugin.getProvider(provider_key) if provider_class is None: raise NuleculeException("Invalid Provider - '{}', provided in " @@ -126,7 +98,7 @@ def get_provider(self, provider_key=None, dry=False): .format(provider_key, ', ' .join(PROVIDERS))) return provider_key, provider_class( - self.get_context(), self.basepath, dry) + self.config.context(), self.basepath, dry) def run(self, provider_key=None, dry=False): raise NotImplementedError diff --git a/atomicapp/nulecule/main.py b/atomicapp/nulecule/main.py index a27577bb..cf3e4719 100644 --- a/atomicapp/nulecule/main.py +++ b/atomicapp/nulecule/main.py @@ -18,7 +18,6 @@ along with Atomic App. If not, see . """ import anymarkup -import copy import distutils.dir_util import logging import os @@ -27,20 +26,18 @@ import urllib from string import Template -from atomicapp.constants import (GLOBAL_CONF, - ANSWERS_FILE_SAMPLE_FORMAT, +from atomicapp.constants import (ANSWERS_FILE_SAMPLE_FORMAT, ANSWERS_FILE, ANSWERS_FILE_SAMPLE, ANSWERS_RUNTIME_FILE, - DEFAULT_ANSWERS, LOGGER_COCKPIT, LOGGER_DEFAULT, MAIN_FILE, - PROVIDER_KEY, __ATOMICAPPVERSION__, __NULECULESPECVERSION__) from atomicapp.nulecule.base import Nulecule from atomicapp.nulecule.exceptions import NuleculeException +from atomicapp.nulecule.config import Config from atomicapp.utils import Utils cockpit_logger = logging.getLogger(LOGGER_COCKPIT) @@ -54,7 +51,8 @@ class NuleculeManager(object): """ def __init__(self, app_spec, destination=None, - cli_answers=None, answers_file=None): + cli_answers=None, answers_file=None, + answers_format=None): """ init function for NuleculeManager. Sets a few instance variables. @@ -64,10 +62,9 @@ def __init__(self, app_spec, destination=None, destination: where to unpack a nulecule to if it isn't local cli_answers: some answer file values provided from cli args answers_file: the location of the answers file + answers_format (str): File format for writing sample answers file """ - self.answers = copy.deepcopy(DEFAULT_ANSWERS) - self.cli_answers = cli_answers - self.answers_format = None + self.answers_format = answers_format or ANSWERS_FILE_SAMPLE_FORMAT self.answers_file = None # The path to an answer file self.app_path = None # The path where the app resides or will reside self.image = None # The container image to pull the app from @@ -118,7 +115,7 @@ def __init__(self, app_spec, destination=None, # Process answers. self.answers_file = answers_file - self._process_answers() + self.config = Config(cli=cli_answers) @staticmethod def init(app_name, destination=None, app_version='1.0', @@ -220,20 +217,18 @@ def unpack(self, update=False, return Nulecule.load_from_path( self.app_path, dryrun=dryrun, config=config) - def genanswers(self, dryrun=False, answers_format=None, **kwargs): + def genanswers(self, dryrun=False, **kwargs): """ Renders artifacts and then generates an answer file. Finally copies answer file to the current working directory. Args: dryrun (bool): Do not make any change to the host system if True - answers_format (str): File format for writing sample answers file kwargs (dict): Extra keyword arguments Returns: None """ - self.answers_format = answers_format or ANSWERS_FILE_SAMPLE_FORMAT # Check to make sure an answers.conf file doesn't exist already answers_file = os.path.join(os.getcwd(), ANSWERS_FILE) @@ -242,17 +237,15 @@ def genanswers(self, dryrun=False, answers_format=None, **kwargs): "Can't generate answers.conf over existing file") # Call unpack to get the app code - self.nulecule = self.unpack(update=False, dryrun=dryrun, config=self.answers) + self.nulecule = self.unpack(update=False, dryrun=dryrun, config=self.config) - self.nulecule.load_config(config=self.nulecule.config, - skip_asking=True) + self.nulecule.load_config(skip_asking=True) # Get answers and write them out to answers.conf in cwd answers = self._get_runtime_answers( self.nulecule.config, None) self._write_answers(answers_file, answers, self.answers_format) - def fetch(self, nodeps=False, update=False, dryrun=False, - answers_format=ANSWERS_FILE_SAMPLE_FORMAT, **kwargs): + def fetch(self, nodeps=False, update=False, dryrun=False, **kwargs): """ Installs (unpacks) a Nulecule application from a Nulecule image to a target path. @@ -263,65 +256,55 @@ def fetch(self, nodeps=False, update=False, dryrun=False, update (bool): Pull requisite Nulecule image and install or update already installed Nulecule application dryrun (bool): Do not make any change to the host system if True - answers_format (str): File format for writing sample answers file kwargs (dict): Extra keyword arguments Returns: None """ - self.answers_format = answers_format or ANSWERS_FILE_SAMPLE_FORMAT - # Call unpack. If the app doesn't exist it will be pulled. If # it does exist it will be just be loaded and returned - self.nulecule = self.unpack(update, dryrun, config=self.answers) + self.nulecule = self.unpack(update, dryrun, config=self.config) - self.nulecule.load_config(config=self.nulecule.config, - skip_asking=True) + self.nulecule.load_config(skip_asking=True) runtime_answers = self._get_runtime_answers( self.nulecule.config, None) # write sample answers file self._write_answers( os.path.join(self.app_path, ANSWERS_FILE_SAMPLE), - runtime_answers, answers_format) + runtime_answers, self.answers_format) - cockpit_logger.info("Install Successful.") + cockpit_logger.info("Install Successful.") - def run(self, cli_provider, answers_output, ask, - answers_format=ANSWERS_FILE_SAMPLE_FORMAT, **kwargs): + def run(self, answers_output, ask, **kwargs): """ Runs a Nulecule application from a local path or a Nulecule image name. Args: answers (dict or str): Answers data or local path to answers file - cli_provider (str): Provider to use to run the Nulecule - application answers_output (str): Path to file to export runtime answers data to ask (bool): Ask for values for params with default values from user, if True - answers_format (str): File format for writing sample answers file kwargs (dict): Extra keyword arguments Returns: None """ - self.answers_format = answers_format or ANSWERS_FILE_SAMPLE_FORMAT dryrun = kwargs.get('dryrun') or False # Call unpack. If the app doesn't exist it will be pulled. If # it does exist it will be just be loaded and returned - self.nulecule = self.unpack(dryrun=dryrun, config=self.answers) + self.nulecule = self.unpack(dryrun=dryrun, config=self.config) - # If we didn't find an answers file before then call _process_answers - # again just in case the app developer embedded an answers file - if not self.answers_file: - self._process_answers() + # Process answers file + self._process_answers() - self.nulecule.load_config(config=self.nulecule.config, ask=ask) - self.nulecule.render(cli_provider, dryrun) - self.nulecule.run(cli_provider, dryrun) + self.nulecule.load_config(ask=ask) + provider = self.nulecule.config.get('provider') + self.nulecule.render(provider, dryrun) + self.nulecule.run(provider, dryrun) runtime_answers = self._get_runtime_answers( - self.nulecule.config, cli_provider) + self.nulecule.config, provider) self._write_answers( os.path.join(self.app_path, ANSWERS_RUNTIME_FILE), runtime_answers, self.answers_format) @@ -329,12 +312,11 @@ def run(self, cli_provider, answers_output, ask, self._write_answers(answers_output, runtime_answers, self.answers_format) - def stop(self, cli_provider, **kwargs): + def stop(self, **kwargs): """ Stops a running Nulecule application. Args: - cli_provider (str): Provider running the Nulecule application kwargs (dict): Extra keyword arguments """ # For stop we use the generated answer file from the run @@ -343,10 +325,11 @@ def stop(self, cli_provider, **kwargs): dryrun = kwargs.get('dryrun') or False self.nulecule = Nulecule.load_from_path( - self.app_path, config=self.answers, dryrun=dryrun) - self.nulecule.load_config(config=self.answers) - self.nulecule.render(cli_provider, dryrun=dryrun) - self.nulecule.stop(cli_provider, dryrun) + self.app_path, config=self.config, dryrun=dryrun) + self.nulecule.load_config() + self.nulecule.render(self.nulecule.config.get('provider'), + dryrun=dryrun) + self.nulecule.stop(self.nulecule.config.get('provider'), dryrun) def clean(self, force=False): # For future use @@ -368,6 +351,7 @@ def _process_answers(self): Returns: None """ + answers = None app_path_answers = os.path.join(self.app_path, ANSWERS_FILE) # If the user didn't provide an answers file then check the app @@ -394,12 +378,9 @@ def _process_answers(self): "Provided answers file doesn't exist: {}".format(self.answers_file)) # Load answers - self.answers = Utils.loadAnswers(self.answers_file) + answers = Utils.loadAnswers(self.answers_file, self.answers_format) - # If there is answers data from the cli then merge it in now - if self.cli_answers: - for k, v in self.cli_answers.iteritems(): - self.answers[GLOBAL_CONF][k] = v + self.config.update_source(source='answers', data=answers) def _write_answers(self, path, answers, answers_format): """ @@ -438,11 +419,4 @@ def _get_runtime_answers(self, config, cli_provider): Returns: dict """ - _config = copy.deepcopy(config) - _config[GLOBAL_CONF] = config.get(GLOBAL_CONF) or {} - - # If a provider is provided via CLI, override the config parameter - if cli_provider: - _config[GLOBAL_CONF][PROVIDER_KEY] = cli_provider - - return _config + return self.nulecule.config.runtime_answers() diff --git a/atomicapp/utils.py b/atomicapp/utils.py index 8aeddb10..022f3759 100644 --- a/atomicapp/utils.py +++ b/atomicapp/utils.py @@ -360,13 +360,22 @@ def getUniqueUUID(): return data @staticmethod - def loadAnswers(answers_file): + def loadAnswers(answers_file, format=None): if not os.path.isfile(answers_file): raise AtomicAppUtilsException( "Provided answers file does not exist: %s" % answers_file) logger.debug("Loading answers from file: %s", answers_file) - return anymarkup.parse_file(answers_file) + try: + # Try to load answers file with a specified answers file format + # or the default format. + result = anymarkup.parse_file(answers_file, format=format) + except anymarkup.AnyMarkupError: + # if no answers file format is provided and the answers file + # is not a JSON file, try to load it using anymarkup in a + # generic way. + result = anymarkup.parse_file(answers_file) + return result @staticmethod def copy_dir(src, dest, update=False, dryrun=False): diff --git a/tests/units/cli/test_default_provider.py b/tests/units/cli/test_default_provider.py index fa1e4272..c5e56ec7 100644 --- a/tests/units/cli/test_default_provider.py +++ b/tests/units/cli/test_default_provider.py @@ -70,7 +70,7 @@ def test_run_helloapache_app(self, capsys): print stdout # Since this a Docker-only provider test, docker *should* be in it, NOT Kubernetes - assert "u'provider': u'docker'" in stdout + assert "provider: Docker" in stdout assert "Deploying to Kubernetes" not in stdout assert exec_info.value.code == 0 diff --git a/tests/units/nulecule/test_lib.py b/tests/units/nulecule/test_lib.py index 742738d8..d313429e 100644 --- a/tests/units/nulecule/test_lib.py +++ b/tests/units/nulecule/test_lib.py @@ -3,6 +3,7 @@ from atomicapp.nulecule.lib import NuleculeBase from atomicapp.nulecule.exceptions import NuleculeException +from atomicapp.nulecule.config import Config class TestNuleculeBaseGetProvider(unittest.TestCase): @@ -16,7 +17,7 @@ def test_get_provider_success(self): provider_key = u'openshift' # method `get_provider` will read from this config, we give it here # since we have neither provided it before nor it is auto-generated - nb.config = {u'general': {u'provider': provider_key}} + nb.config = Config(answers={u'general': {u'provider': provider_key}}) return_provider = mock.Mock() # mocking return value of method plugin.getProvider,because it returns @@ -24,8 +25,10 @@ def test_get_provider_success(self): nb.plugin.getProvider = mock.Mock(return_value=return_provider) ret_provider_key, ret_provider = nb.get_provider() self.assertEqual(provider_key, ret_provider_key) - return_provider.assert_called_with({u'provider': provider_key}, - '', False) + return_provider.assert_called_with( + {'provider': provider_key, 'namespace': 'default'}, + '', + False) def test_get_provider_failure(self): """ @@ -35,6 +38,6 @@ def test_get_provider_failure(self): nb = NuleculeBase(params = [], basepath = '', namespace = '') # purposefully give the wrong provider key provider_key = u'mesos' - nb.config = {u'general': {u'provider': provider_key}} + nb.config = Config(answers={u'general': {u'provider': provider_key}}) with self.assertRaises(NuleculeException): nb.get_provider() diff --git a/tests/units/nulecule/test_nulecule.py b/tests/units/nulecule/test_nulecule.py index 1bb60179..13e3e648 100644 --- a/tests/units/nulecule/test_nulecule.py +++ b/tests/units/nulecule/test_nulecule.py @@ -4,6 +4,7 @@ import os from atomicapp.nulecule.base import Nulecule from atomicapp.nulecule.exceptions import NuleculeException +from atomicapp.nulecule.config import Config class TestNuleculeRun(unittest.TestCase): @@ -15,8 +16,9 @@ def test_run(self): dryrun = False mock_component_1 = mock.Mock() mock_component_2 = mock.Mock() + config = Config(answers={}) - n = Nulecule('some-id', '0.0.2', [{}], 'some/path', {}) + n = Nulecule('some-id', '0.0.2', [{}], 'some/path', {}, config=config) n.components = [mock_component_1, mock_component_2] n.run(provider) @@ -34,7 +36,9 @@ def test_stop(self): mock_component_1 = mock.Mock() mock_component_2 = mock.Mock() - n = Nulecule('some-id', '0.0.2', {}, [], 'some/path') + config = Config(answers={}) + + n = Nulecule('some-id', '0.0.2', {}, [], 'some/path', config=config) n.components = [mock_component_1, mock_component_2] n.stop(provider) @@ -46,76 +50,199 @@ class TestNuleculeLoadConfig(unittest.TestCase): """Test Nulecule load_config""" - def test_load_config_without_specified_provider(self): + def test_load_config_with_default_provider(self): """ - Test Nulecule load_config without specifying a provider. + Test Nulecule load_config with a default provider. """ - config = {'general': {}, 'group1': {'a': 'b'}} - mock_component_1 = mock.Mock() - mock_component_1.config = { - 'group1': {'a': 'c', 'k': 'v'}, - 'group2': {'1': '2'} - } + config = Config(answers={}) + + params = [ + { + "name": "key1", + "default": "val1", + }, + { + "name": "key3", + "default": "val3" + }, + { + "name": "provider", + "default": "docker" + } + ] + + graph = [ + { + "name": "component1", + "params": [ + { + "name": "key1", + }, + { + "name": "key2", + "default": "val2" + } + ], + "artifacts": [] + } + ] - n = Nulecule(id='some-id', specversion='0.0.2', metadata={}, graph=[], basepath='some/path') - n.components = [mock_component_1] + n = Nulecule(id='some-id', specversion='0.0.2', metadata={}, + graph=graph, params=params, basepath='some/path', + config=config) + n.load_components() n.load_config(config) - self.assertEqual(n.config, { - 'general': {'provider': 'kubernetes'}, - 'group1': {'a': 'b', 'k': 'v'}, - 'group2': {'1': '2'} + self.assertEqual(n.config.runtime_answers(), { + 'general': { + 'namespace': 'default', + 'provider': 'docker', + 'key1': 'val1', + 'key3': 'val3' + }, + 'component1': { + 'key2': 'val2' + } }) - def test_load_config_with_defaultprovider(self): + self.assertEqual( + n.components[0].config.context(scope=n.components[0].namespace), + {'key3': 'val3', + 'key2': 'val2', + 'key1': 'val1', + 'provider': 'docker', + 'namespace': 'default'} + ) + + def test_load_config_without_default_provider(self): """ - Test Nulecule load_config with default provider specified - in global params in Nulecule spec. + Test Nulecule load_config without specifying a default provider. """ - config = {'general': {}, 'group1': {'a': 'b'}} - mock_component_1 = mock.Mock() - mock_component_1.config = { - 'group1': {'a': 'c', 'k': 'v'}, - 'group2': {'1': '2'} - } - - n = Nulecule(id='some-id', specversion='0.0.2', metadata={}, graph=[], - basepath='some/path', - params=[{'name': 'provider', 'default': 'some-provider'}]) - n.components = [mock_component_1] - n.load_config(config) + config = Config() - self.assertEqual(n.config, { - 'general': {'provider': 'some-provider'}, - 'group1': {'a': 'b', 'k': 'v'}, - 'group2': {'1': '2'} + params = [ + { + "name": "key1", + "default": "val1", + }, + { + "name": "key3", + "default": "val3" + } + ] + + graph = [ + { + "name": "component1", + "params": [ + { + "name": "key1", + }, + { + "name": "key2", + "default": "val2" + } + ], + "artifacts": [] + } + ] + + n = Nulecule(id='some-id', specversion='0.0.2', metadata={}, + graph=graph, params=params, basepath='some/path', + config=config) + n.load_components() + n.load_config() + + self.assertEqual(n.config.runtime_answers(), { + 'general': { + 'namespace': 'default', + 'provider': 'kubernetes', + 'key1': 'val1', + 'key3': 'val3' + }, + 'component1': { + 'key2': 'val2' + } }) - def test_load_config_with_defaultprovider_overridden_by_provider_in_answers(self): + self.assertEqual( + n.components[0].config.context(n.components[0].namespace), + {'key3': 'val3', + 'key2': 'val2', + 'key1': 'val1', + 'namespace': 'default', + 'provider': 'kubernetes'} + ) + + def test_load_config_with_default_provider_overridden_by_answers(self): """ - Test Nulecule load_config with default provider specified - in global params in Nulecule spec, but overridden in answers config. + Test Nulecule load_config with default provider overridden by provider + in answers. """ - config = {'general': {'provider': 'new-provider'}, - 'group1': {'a': 'b'}} - mock_component_1 = mock.Mock() - mock_component_1.config = { - 'group1': {'a': 'c', 'k': 'v'}, - 'group2': {'1': '2'} - } - - n = Nulecule(id='some-id', specversion='0.0.2', metadata={}, graph=[], - basepath='some/path', - params=[{'name': 'provider', 'default': 'some-provider'}]) - n.components = [mock_component_1] + config = Config(answers={ + 'general': { + 'provider': 'openshift' + } + }) + + params = [ + { + "name": "key1", + "default": "val1", + }, + { + "name": "key3", + "default": "val3" + }, + { + "name": "provider", + "default": "docker" + } + ] + + graph = [ + { + "name": "component1", + "params": [ + { + "name": "key1", + }, + { + "name": "key2", + "default": "val2" + } + ], + "artifacts": [] + } + ] + + n = Nulecule(id='some-id', specversion='0.0.2', metadata={}, + graph=graph, params=params, basepath='some/path', + config=config) + n.load_components() n.load_config(config) - self.assertEqual(n.config, { - 'general': {'provider': 'new-provider'}, - 'group1': {'a': 'b', 'k': 'v'}, - 'group2': {'1': '2'} + self.assertEqual(n.config.runtime_answers(), { + 'general': { + 'namespace': 'default', + 'provider': 'openshift', + 'key1': 'val1', + 'key3': 'val3' + }, + 'component1': { + 'key2': 'val2' + } }) + self.assertEqual( + n.components[0].config.context(n.components[0].namespace), + {'key3': 'val3', + 'key2': 'val2', + 'key1': 'val1', + 'namespace': 'default', + 'provider': 'openshift'} + ) + class TestNuleculeLoadComponents(unittest.TestCase): @@ -137,15 +264,17 @@ def test_load_components(self, MockNuleculeComponent): } ] - n = Nulecule('some-id', '0.0.2', graph, 'some/path', {}) + config = Config(answers={}) + + n = Nulecule('some-id', '0.0.2', graph, 'some/path', config=config) n.load_components() MockNuleculeComponent.assert_any_call( graph[0]['name'], n.basepath, 'somecontainer', - graph[0]['params'], None, {}) + graph[0]['params'], None, config) MockNuleculeComponent.assert_any_call( graph[1]['name'], n.basepath, None, - graph[1].get('params'), graph[1].get('artifacts'), {}) + graph[1].get('params'), graph[1].get('artifacts'), config) class TestNuleculeRender(unittest.TestCase): diff --git a/tests/units/nulecule/test_nulecule_component.py b/tests/units/nulecule/test_nulecule_component.py index be2a8ab7..9054a2c4 100644 --- a/tests/units/nulecule/test_nulecule_component.py +++ b/tests/units/nulecule/test_nulecule_component.py @@ -1,7 +1,7 @@ -import copy import mock import unittest from atomicapp.nulecule.base import NuleculeComponent, Nulecule +from atomicapp.nulecule.config import Config from atomicapp.nulecule.exceptions import NuleculeException @@ -129,49 +129,52 @@ class TestNuleculeComponentLoadConfig(unittest.TestCase): def test_load_config_local_app(self): """Test load config for local app""" params = [ - {'name': 'key1'}, - {'name': 'key2'} + {'name': 'key1', 'description': 'key1'}, + {'name': 'key2', 'description': 'key2'} ] initial_config = { 'general': {'a': 'b', 'key2': 'val2'}, 'some-app': {'key1': 'val1'} } - - nc = NuleculeComponent('some-app', 'some/path', params=params) - nc.load_config(config=copy.deepcopy(initial_config)) - - self.assertEqual(nc.config, { - 'general': {'a': 'b', 'key2': 'val2'}, - 'some-app': {'key1': 'val1', 'key2': 'val2'} + conf = Config(answers=initial_config) + + nc = NuleculeComponent('some-app', 'some/path', + params=params, config=conf) + nc.load_config() + runtime_answers = nc.config.runtime_answers() + self.assertEqual(runtime_answers, { + 'general': { + 'a': 'b', + 'key2': 'val2', + 'provider': 'kubernetes', + 'namespace': 'default' + }, + 'some-app': {'key1': 'val1'} }) - @mock.patch('atomicapp.nulecule.base.NuleculeComponent.merge_config') - def test_load_config_external_app(self, mock_merge_config): + def test_load_config_external_app(self): """Test load config for external app""" - mock_nulecule = mock.Mock( - name='nulecule', - spec=Nulecule('some-id', '0.0.2', {}, [], 'some/path') - ) params = [ - {'name': 'key1'}, - {'name': 'key2'} + {'name': 'key1', 'description': 'key1'}, + {'name': 'key2', 'description': 'key2'} ] initial_config = { 'general': {'a': 'b', 'key2': 'val2'}, 'some-app': {'key1': 'val1'} } + config = Config(answers=initial_config) + mock_nulecule = mock.Mock( + name='nulecule', + spec=Nulecule('some-id', '0.0.2', config, [], 'some/path') + ) nc = NuleculeComponent('some-app', 'some/path', params=params) nc._app = mock_nulecule - nc.load_config(config=copy.deepcopy(initial_config)) + nc.config = config + nc.load_config() mock_nulecule.load_config.assert_called_once_with( - config={ - 'general': {'a': 'b', 'key2': 'val2'}, - 'some-app': {'key1': 'val1', 'key2': 'val2'} - }, ask=False, skip_asking=False) - mock_merge_config.assert_called_once_with( - nc.config, mock_nulecule.config) + config=config, ask=False, skip_asking=False) class TestNuleculeComponentLoadExternalApplication(unittest.TestCase): @@ -193,7 +196,8 @@ def test_loading_existing_app(self, mock_os_path_isdir, mock_Nulecule): mock_os_path_isdir.assert_called_once_with( expected_external_app_path) mock_Nulecule.load_from_path.assert_called_once_with( - expected_external_app_path, dryrun=dryrun, update=update) + expected_external_app_path, dryrun=dryrun, namespace='some-app', + update=update) # Use http://engineeringblog.yelp.com/2015/02/assert_called_once-threat-or-menace.html # by calling call_count == 1. In order to avoid the return_value = False of Utils.setFileOnwerGroup @@ -259,7 +263,7 @@ def test_render_for_local_app_with_missing_artifacts_for_provider(self): dryrun = False nc = NuleculeComponent(name='some-app', basepath='some/path') - nc.config = {} + nc.config = Config() nc.artifacts = {'x': ['some-artifact']} self.assertRaises(NuleculeException, nc.render, provider_key, dryrun) @@ -275,37 +279,44 @@ def test_render_for_local_app_with_missing_artifacts_from_nulecule(self): with self.assertRaises(NuleculeException): nc.render() - @mock.patch('atomicapp.nulecule.base.NuleculeComponent.get_context') @mock.patch('atomicapp.nulecule.base.NuleculeComponent.' 'get_artifact_paths_for_provider') @mock.patch('atomicapp.nulecule.base.NuleculeComponent.render_artifact') def test_render_for_local_app_with_artifacts_for_provider( - self, mock_render_artifact, mock_get_artifact_paths_for_provider, - mock_get_context): + self, mock_render_artifact, mock_get_artifact_paths_for_provider): """Test rendering artifacts for a local Nulecule component""" provider_key = 'some-provider' dryrun = False expected_rendered_artifacts = [ 'some/path/.artifact1', 'some/path/.artifact2'] - context = {'a': 'b'} mock_get_artifact_paths_for_provider.return_value = [ 'some/path/artifact1', 'some/path/artifact2'] mock_render_artifact.side_effect = lambda path, context, provider: path.replace('artifact', '.artifact') - mock_get_context.return_value = context + # mock_get_context.return_value = context nc = NuleculeComponent(name='some-app', basepath='some/path') - nc.config = {'general': {'key1': 'val1'}, 'some-provider': {'a': 'b'}} + nc.config = Config(answers={ + 'general': {'key1': 'val1'}, + 'some-provider': {'a': 'b'} + }) nc.artifacts = { 'some-provider': ['artifact1', 'artifact2'], 'x': ['foo'] } nc.render(provider_key, dryrun) + expected_context = { + 'key1': 'val1', + 'namespace': 'default', + 'provider': 'kubernetes' + } mock_get_artifact_paths_for_provider.assert_called_once_with( provider_key) - mock_render_artifact.assert_any_call('some/path/artifact1', context, + mock_render_artifact.assert_any_call('some/path/artifact1', + expected_context, 'some-provider') - mock_render_artifact.assert_any_call('some/path/artifact2', context, + mock_render_artifact.assert_any_call('some/path/artifact2', + expected_context, 'some-provider') mock_get_artifact_paths_for_provider.assert_called_once_with( provider_key)