# -*- coding: utf-8 -*- # ----------------------------------------------------------------------------- # Copyright (c) 2016, Anaconda, Inc. All rights reserved. # # Licensed under the terms of the BSD 3-Clause License. # The full license is in the file LICENSE.txt, distributed with this software. # ----------------------------------------------------------------------------- """Types related to project requirement providers.""" from __future__ import absolute_import from abc import ABCMeta, abstractmethod from copy import deepcopy import os import shutil from anaconda_project.internal import conda_api from anaconda_project.internal import logged_subprocess from anaconda_project.internal.metaclass import with_metaclass from anaconda_project.internal.makedirs import makedirs_ok_if_exists from anaconda_project.internal.simple_status import SimpleStatus def _service_directory(local_state_file, relative_name): return os.path.join(os.path.dirname(local_state_file.filename), "services", relative_name) class ProvideContext(object): """A context passed to ``Provider.provide()`` representing state that can be modified.""" def __init__(self, environ, local_state_file, default_env_spec_name, status, mode, frontend): """Create a ProvideContext. Args: environ (dict): environment variables to be read and modified local_state_file (LocalStateFile): to store any created state status (RequirementStatus): current status mode (str): one of PROVIDE_MODE_PRODUCTION, PROVIDE_MODE_DEVELOPMENT, PROVIDE_MODE_CHECK """ self.environ = environ self._local_state_file = local_state_file self._default_env_spec_name = default_env_spec_name self._status = status self._mode = mode self._frontend = frontend def ensure_service_directory(self, relative_name): """Create a directory in PROJECT_DIR/services with the given name. The name should be unique to the ServiceRequirement creating the directory, so usually the requirement's env var. Args: relative_name (str): name to distinguish this dir from other service directories """ path = _service_directory(self._local_state_file, relative_name) makedirs_ok_if_exists(path) return path def transform_service_run_state(self, service_name, func): """Run a function which takes and potentially modifies the state of a service. If the function modifies the state it's given, the new state will be saved and passed in next time. Args: service_name (str): the name of the service, should be specific enough to uniquely identify the provider func (function): function to run, passing it the current state Returns: Whatever ``func`` returns. """ old_state = self._local_state_file.get_service_run_state(service_name) modified = deepcopy(old_state) result = func(modified) if modified != old_state: self._local_state_file.set_service_run_state(service_name, modified) self._local_state_file.save() return result @property def status(self): """Get the current ``RequirementStatus``.""" return self._status @property def local_state_file(self): """Get the LocalStateFile.""" return self._local_state_file @property def default_env_spec_name(self): """Get the default env spec.""" return self._default_env_spec_name @property def mode(self): """Get flavor of provide. Value should be ``PROVIDE_MODE_DEVELOPMENT``, ``PROVIDE_MODE_PRODUCTION``, or ``PROVIDE_MODE_CHECK``. """ return self._mode @property def frontend(self): """Get the current ``Frontend``.""" return self._frontend def shutdown_service_run_state(local_state_file, service_name): """Run any shutdown commands from the local state file for the given service. Also remove the shutdown commands from the file. Args: local_state_file (LocalStateFile): local state service_name (str): the name of the service, usually a variable name, should be specific enough to uniquely identify the provider Returns: a `Status` instance potentially containing errors """ run_states = local_state_file.get_all_service_run_states() if service_name not in run_states: return SimpleStatus(success=True, description=("Nothing to do to shut down %s." % service_name)) errors = [] state = run_states[service_name] if 'shutdown_commands' in state: commands = state['shutdown_commands'] for command in commands: code = logged_subprocess.call(command) if code != 0: errors.append("Shutting down %s, command %s failed with code %d." % (service_name, repr(command), code)) # clear out the run state once we try to shut it down local_state_file.set_service_run_state(service_name, dict()) local_state_file.save() if errors: return SimpleStatus(success=False, description=("Shutdown commands failed for %s." % service_name), errors=errors) else: return SimpleStatus(success=True, description=("Successfully shut down %s." % service_name)) def delete_service_directory(local_state_file, relative_name): """Delete a directory in PROJECT_DIR/services with the given name. The name should be unique to the ServiceRequirement creating the directory, so usually the requirement's env var. IF this fails, it does so silently (returns no errors). Args: relative_name (str): name to distinguish this dir from other service directories Returns: None """ path = _service_directory(local_state_file, relative_name) try: shutil.rmtree(path=path) except OSError: pass # also delete the services directory itself, if it's now empty try: # this fails on non-empty dir os.rmdir(os.path.dirname(path)) except OSError: pass class ProviderAnalysis(object): """A Provider's preflight check snapshotting the state prior to ``provide()``. Instances of this class are immutable, and are usually created as part of a ``RequirementStatus``. """ def __init__(self, config, missing_env_vars_to_configure, missing_env_vars_to_provide): """Create a ProviderAnalysis.""" self._config = deepcopy(config) # defensive copy so we don't modify the original self._missing_env_vars_to_configure = missing_env_vars_to_configure self._missing_env_vars_to_provide = missing_env_vars_to_provide @property def config(self): """Get the configuration dict from the time of analysis.""" return self._config @property def missing_env_vars_to_configure(self): """Get the env vars we were missing in order to configure, from the time of analysis.""" return self._missing_env_vars_to_configure @property def missing_env_vars_to_provide(self): """Get the env vars we were missing in order to provide, from the time of analysis.""" return self._missing_env_vars_to_provide class ProvideResult(object): """A Provider's results from the ``provide()`` call. Instances of this class are immutable, and are returned from ``provide()``. """ def __init__(self, errors=None): """Create a ProvideResult.""" if errors is None: errors = [] self._errors = errors def copy_with_additions(self, errors=None): """Copy this result, appending additional errors.""" if errors is None: errors = [] if len(errors) == 0: # we don't have to actually copy since we are immutable return self else: return ProvideResult(errors=(self._errors + errors)) @property def errors(self): """Get any fatal errors that occurred during provide() preventing success.""" return self._errors @classmethod def empty(cls): """Get an empty ProvideResult (currently a singleton since these are immutable).""" return _empty_provide_result # get this via ProvideResult.empty() _empty_provide_result = ProvideResult() class Provider(with_metaclass(ABCMeta)): """A Provider can take some action to meet a Requirement.""" @abstractmethod def missing_env_vars_to_configure(self, requirement, environ, local_state_file): """Get a list of unset environment variable names that must be set before configuring this provider. Args: requirement (Requirement): requirement instance we are providing for environ (dict): current environment variable dict local_state_file (LocalStateFile): local state file """ pass # pragma: no cover @abstractmethod def missing_env_vars_to_provide(self, requirement, environ, local_state_file): """Get a list of unset environment variable names that must be set before calling provide(). Args: requirement (Requirement): requirement instance we are providing for environ (dict): current environment variable dict local_state_file (LocalStateFile): local state file """ pass # pragma: no cover @abstractmethod def read_config(self, requirement, environ, local_state_file, default_env_spec_name, overrides): """Read a config dict from the local state file for the given requirement. You can think of this as the GET returning a web form for configuring the provider. And in fact it was once used for that, though we deleted the html stuff now. The returned 'config' has a 'source' field which was essentially a selected radio option for where to get the requirement, and other fields are entry boxes underneath each radio option. This method still exists in the code in case we want to do a textual version (or a new HTML version, but probably outside of the anaconda-project codebase). See also UI_MODE_TEXT_ASK_QUESTIONS in the cli code. Args: requirement (Requirement): the requirement we're providing environ (dict): current environment variables local_state_file (LocalStateFile): file to read from default_env_spec_name (str): the fallback env spec name overrides (UserConfigOverrides): user-supplied forced config """ pass # pragma: no cover def set_config_values_as_strings(self, requirement, environ, local_state_file, default_env_spec_name, overrides, values): """Set some config values in the state file (should not save the file). You can think of this as the POST submitting a web form for configuring the provider. And in fact it was once used for that, though we deleted the html stuff now. Args: requirement (Requirement): the requirement we're providing environ (dict): current environment variables local_state_file (LocalStateFile): file to save to default_env_spec_name (str): default env spec name for this prepare overrides (UserConfigOverrides): if any values in here change, delete the override values (dict): dict from string to string """ pass # silently ignore unknown config values def analyze(self, requirement, environ, local_state_file, default_env_spec_name, overrides): """Analyze whether and how we'll be able to provide the requirement. This is used to show the situation in the UI, and also to consolidate all IO-type work in one place (inside Requirement.check_status()). Returns: A ``ProviderAnalysis`` instance. """ config = self.read_config(requirement, environ, local_state_file, default_env_spec_name, overrides) missing_to_configure = self.missing_env_vars_to_configure(requirement, environ, local_state_file) missing_to_provide = self.missing_env_vars_to_provide(requirement, environ, local_state_file) return ProviderAnalysis(config=config, missing_env_vars_to_configure=missing_to_configure, missing_env_vars_to_provide=missing_to_provide) @abstractmethod def provide(self, requirement, context): """Execute the provider, fulfilling the requirement. The implementation should read and modify the passed-in ``environ`` rather than accessing the OS environment directly. Args: requirement (Requirement): requirement we want to meet context (ProvideContext): context containing project state Returns: a ``ProvideResult`` instance """ pass # pragma: no cover @abstractmethod def unprovide(self, requirement, environ, local_state_file, overrides, requirement_status=None): """Undo the provide, cleaning up any files or processes we created. The requirement may still be met after this, if our providing wasn't really needed. Args: requirement (Requirement): requirement we want to de-provide environ (dict): current env vars, often from a previous prepare local_state_file (LocalStateFile): the local state overrides (UserConfigOverrides): overrides to state requirement_status (RequirementStatus or None): requirement status if available Returns: a `Status` instance describing the (non)success of the unprovision """ pass # pragma: no cover class EnvVarProvider(Provider): """Meets a requirement for an env var by letting people set it manually.""" def _local_state_override(self, requirement, local_state_file): return local_state_file.get_value(["variables", requirement.env_var], default=None) def _disabled_local_state_override(self, requirement, local_state_file): return local_state_file.get_value(["disabled_variables", requirement.env_var], default=None) def missing_env_vars_to_configure(self, requirement, environ, local_state_file): """Override superclass to require env prefix.""" if self._get_env_prefix(environ) is not None: return () else: return (conda_api.conda_prefix_variable(), ) def missing_env_vars_to_provide(self, requirement, environ, local_state_file): """Override superclass to require env prefix.""" return self.missing_env_vars_to_configure(requirement, environ, local_state_file) def _get_env_prefix(self, environ): # on unix, ENV_PATH is the prefix and DEFAULT_ENV can be just a name, # on windows DEFAULT_ENV is always the prefix return environ.get(conda_api.conda_prefix_variable(), None) def read_config(self, requirement, environ, local_state_file, default_env_spec_name, overrides): """Override superclass to read env var value.""" config = dict() value = None if requirement.encrypted: # import keyring locally because it's an optional dependency # that prints a warning when it's needed but not found. import anaconda_project.internal.keyring as keyring env_prefix = self._get_env_prefix(environ) if env_prefix is None: value = None else: value = keyring.get(env_prefix, requirement.env_var) # note that we will READ an encrypted value from local # state if someone puts it in there by hand, but we won't # ever write one there ourselves. if value is None: value = self._local_state_override(requirement, local_state_file) disabled_value = self._disabled_local_state_override(requirement, local_state_file) was_disabled = value is None and disabled_value is not None if was_disabled: value = disabled_value if value is not None: config['value'] = value if value is not None and not was_disabled: source = 'variables' elif requirement.env_var in environ: source = 'environ' config['value'] = environ[requirement.env_var] elif 'default' in requirement.options: source = 'default' else: source = 'unset' config['source'] = source return config def set_config_values_as_strings(self, requirement, environ, local_state_file, default_env_spec_name, overrides, values): """Override superclass to set env var value.""" if requirement.encrypted: self._set_encrypted_config_values_as_strings(requirement, environ, local_state_file, default_env_spec_name, overrides, values) else: self._set_nonencrypted_config_values_as_strings(requirement, environ, local_state_file, default_env_spec_name, overrides, values) def _set_nonencrypted_config_values_as_strings(self, requirement, environ, local_state_file, default_env_spec_name, overrides, values): override_path = ["variables", requirement.env_var] disabled_path = ["disabled_variables", requirement.env_var] # we set override_path only if the source is variables, # otherwise the value goes in disabled_variables. If we # don't have a source that means the only option we # presented was to set the local override, so default to # 'variables' overriding = (values.get('source', 'variables') == 'variables') # If there's an existing override value and the source is not 'variables', # we need to be sure to move the existing to disabled_variables. # Also, we save values['value'] from the web form in local_state_file, # even if we aren't using it as the source right now. local_override_value = self._local_state_override(requirement, local_state_file) if local_override_value is None: local_override_value = self._disabled_local_state_override(requirement, local_state_file) value_string = values.get('value', local_override_value) if value_string is not None: if value_string == '': # the reason empty string unsets is that otherwise there's no easy # way to unset from a web form local_state_file.unset_value(override_path) local_state_file.unset_value(disabled_path) else: if overriding: local_state_file.set_value(override_path, value_string) local_state_file.unset_value(disabled_path) else: local_state_file.set_value(disabled_path, value_string) local_state_file.unset_value(override_path) def _set_encrypted_config_values_as_strings(self, requirement, environ, local_state_file, default_env_spec_name, overrides, values): # import keyring locally because it's an optional dependency # that prints a warning when it's needed but not found. import anaconda_project.internal.keyring as keyring env_prefix = self._get_env_prefix(environ) from_keyring = keyring.get(env_prefix, requirement.env_var) value_string = values.get('value', from_keyring) if value_string is not None: if value_string == '': keyring.unset(env_prefix, requirement.env_var) else: keyring.set(env_prefix, requirement.env_var, value_string) def provide(self, requirement, context): """Override superclass to use configured env var (or already-set env var).""" # We prefer the values in this order: # - value set in project-local state overrides everything # (otherwise the UI for configuring the value would end # up ignored) # - value in the keyring overrides (treated the same as # anaconda-project-local.yml, but for encrypted variables) # - then anything already set in the environment wins, so you # can override on the command line like `FOO=bar myapp` # - then the anaconda-project.yml default value local_state_override = None if requirement.encrypted: # import keyring locally because it's an optional dependency # that prints a warning when it's needed but not found. import anaconda_project.internal.keyring as keyring env_prefix = self._get_env_prefix(context.environ) if env_prefix is not None: local_state_override = keyring.get(env_prefix, requirement.env_var) # we will read encrypted vars from local state, though we never # put them in there ourselves if local_state_override is None: local_state_override = self._local_state_override(requirement, context.local_state_file) if local_state_override is not None: # anaconda-project-local.yml # # variables: # REDIS_URL: "redis://example.com:1234" context.environ[requirement.env_var] = local_state_override elif requirement.env_var in context.environ: # nothing to do here pass elif 'default' in requirement.options: # anaconda-project.yml # # variables: # REDIS_URL: # default: "redis://example.com:1234" value = requirement.options['default'] if value is not None: context.environ[requirement.env_var] = value else: pass return ProvideResult.empty().copy_with_additions(errors=[]) def unprovide(self, requirement, environ, local_state_file, overrides, requirement_status=None): """Override superclass to return success always.""" return SimpleStatus(success=True, description=("Nothing to clean up for %s." % requirement.env_var))