# Copyright 2018 Spotify AB # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # http://www.apache.org/licenses/LICENSE-2.0 # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import argparse import functools as ft import itertools as it import logging import os import re import jsonschema import pkg_resources import yaml from .. import util class Plugin: """ Abstract base class for plugins. """ ENABLED = True SCHEMA = {} ORDER = None COMMANDS = None def __init__(self): self.logger = logging.getLogger(self.__class__.__name__) self.arguments = {} def add_argument(self, parser, path, name=None, schema=None, **kwargs): """ Add an argument to the `parser` based on a schema definition. Parameters ---------- parser : argparse.ArgumentParser parser to add an argument to path : str path in the configuration document to add an argument for name : str or None name of the command line parameter (defaults to the name in the schema) schema : dict JSON schema definition (defaults to the schema of the plugin) Returns ------- arg : command line argument definition """ schema = schema or self.SCHEMA name = name or ('--%s' % os.path.basename(path)) self.arguments[name.strip('-')] = path # Build a path to the help in the schema path = util.split_path(path) path = os.path.sep.join( it.chain([os.path.sep], *zip(it.repeat("properties"), path))) property_ = util.get_value(schema, path) kwargs.setdefault('choices', property_.get('enum')) kwargs.setdefault('help', property_.get('description')) type_ = property_.get('type') if type_: kwargs.setdefault('type', util.TYPES[type_]) return parser.add_argument(name, **kwargs) def add_arguments(self, parser): """ Add arguments to the parser. Inheriting plugins should implement this method to add parameters to the command line parser. Parameters ---------- parser : argparse.ArgumentParser parser to add arguments to """ pass def apply(self, configuration, schema, args): """ Apply the plugin to the configuration. Inheriting plugins should implement this method to add additional functionality. Parameters ---------- configuration : dict configuration schema : dict JSON schema args : argparse.NameSpace parsed command line arguments Returns ------- configuration : dict updated configuration after applying the plugin """ # Set values from the command line for name, path in self.arguments.items(): value = getattr(args, name.replace('-', '_')) if value is not None: util.set_value(configuration, path, value) return configuration @staticmethod def load_plugins(): """ Load all availabe plugins. Returns ------- plugin_cls : dict mapping from plugin names to plugin classes """ plugin_cls = {} for entry_point in pkg_resources.iter_entry_points('docker_interface.plugins'): cls = entry_point.load() assert cls.COMMANDS is not None, \ "plugin '%s' does not define its commands" % entry_point.name assert cls.ORDER is not None, \ "plugin '%s' does not define its priority" % entry_point.name plugin_cls[entry_point.name] = cls return plugin_cls def cleanup(self): """ Tear down the plugin and clean up any resources used. Inheriting plugins should implement this method to add additional functionality. """ pass class ValidationPlugin(Plugin): """ Validate the configuration document. """ COMMANDS = 'all' ORDER = 990 def apply(self, configuration, schema, args): super(ValidationPlugin, self).apply(configuration, schema, args) validator = jsonschema.validators.validator_for(schema)(schema) errors = list(validator.iter_errors(configuration)) if errors: # pragma: no cover for error in errors: self.logger.fatal(error.message) raise ValueError("failed to validate configuration") return configuration class ExecutePlugin(Plugin): """ Base class for plugins that execute shell commands. Inheriting classes should define the method :code:`build_command` which takes a configuration document as its only argument. """ def build_command(self, configuration): """ Construct a command and return its parts. Parameters ---------- configuration : dict configuration Returns ------- args : list sequence of command line arguments """ raise NotImplementedError def apply(self, configuration, schema, args): super(ExecutePlugin, self).apply(configuration, schema, args) parts = self.build_command(configuration) if parts: configuration['status-code'] = self.execute_command(parts, configuration['dry-run']) else: configuration['status-code'] = 0 return configuration def execute_command(self, parts, dry_run): """ Execute a command. Parameters ---------- parts : list Sequence of strings constituting a command. dry_run : bool Whether to just log the command instead of executing it. Returns ------- status : int Status code of the executed command or 0 if `dry_run` is `True`. """ if dry_run: self.logger.info("dry-run command '%s'", " ".join(map(str, parts))) return 0 else: # pragma: no cover self.logger.debug("executing command '%s'", " ".join(map(str, parts))) status_code = os.spawnvpe(os.P_WAIT, parts[0], parts, os.environ) if status_code: self.logger.warning("command '%s' returned status code %d", " ".join(map(str, parts)), status_code) return status_code class BasePlugin(Plugin): """ Load or create a default configuration and set up logging. """ SCHEMA = { "title": "Declarative Docker Interface (DI) definition.", "$schema": "http://json-schema.org/draft-04/schema", "additionalProperties": False, "required": ["workspace", "docker"], "properties": { "workspace": { "type": "string", "description": "Path defining the DI workspace (absolute or relative to the URI of this document). All subsequent path definitions must be absolute or relative to the `workspace`." }, "docker": { "type": "string", "description": "Name of the docker CLI.", "default": "docker" }, "log-level": { "type": "string", "enum": ["debug", "info", "warning", "error", "critical", "fatal"], "default": "info" }, "dry-run": { "type": "boolean", "description": "Whether to just construct the docker command.", "default": False }, "status-code": { "type": "integer", "description": "status code returned by docker" }, "plugins": { "oneOf": [ { "type": "array", "description": "Enable the listed plugins and disable all plugins not listed.", "items": { "type": "string" } }, { "type": "object", "properties": { "enable": { "type": "array", "description": "Enable the listed plugins.", "items": { "type": "string" } }, "disable": { "type": "array", "description": "Disable the listed plugins.", "items": { "type": "string" } } }, "additionalProperties": False } ] } } } def add_arguments(self, parser): parser.add_argument('--file', '-f', help='Configuration file.', default='di.yml') self.add_argument(parser, '/workspace') self.add_argument(parser, '/docker') self.add_argument(parser, '/log-level') self.add_argument(parser, '/dry-run') parser.add_argument('command', help='Docker interface command to execute.', choices=['run', 'build']) def apply(self, configuration, schema, args): # Load the configuration if configuration is None and os.path.isfile(args.file): filename = os.path.abspath(args.file) with open(filename) as fp: # pylint: disable=invalid-name configuration = yaml.safe_load(fp) self.logger.debug("loaded configuration from '%s'", filename) dirname = os.path.dirname(filename) configuration['workspace'] = os.path.join(dirname, configuration.get('workspace', '.')) elif configuration is None: raise FileNotFoundError( "missing configuration; could not find configuration file '%s'" % args.file) configuration = super(BasePlugin, self).apply(configuration, schema, args) logging.basicConfig(level=configuration.get('log-level', 'info').upper()) return configuration class SubstitutionPlugin(Plugin): """ Substitute variables in strings. String values in the configuration document may * reference other parts of the configuration document using :code:`#{path}`, where :code:`path` may be an absolute or relative path in the document. * reference a variable using :code:`${path}`, where :code:`path` is assumed to be an absolute path in the :code:`VARIABLES` class attribute of the plugin. By default, the plugin provides environment variables using the :code:`env` prefix. For example, a value could reference the user name on the host using :code:`${env/USER}`. Other plugins can provide variables for substitution by extending the :code:`VARIABLES` class attribute and should do so using a unique prefix. """ REF_PATTERN = re.compile(r'#\{(?P<path>.*?)\}') VAR_PATTERN = re.compile(r'\$\{(?P<path>.*?)\}') COMMANDS = 'all' ORDER = 980 VARIABLES = { 'env': dict(os.environ) } @classmethod def substitute_variables(cls, configuration, value, ref): """ Substitute variables in `value` from `configuration` where any path reference is relative to `ref`. Parameters ---------- configuration : dict configuration (required to resolve intra-document references) value : value to resolve substitutions for ref : str path to `value` in the `configuration` Returns ------- value : value after substitution """ if isinstance(value, str): # Substitute all intra-document references while True: match = cls.REF_PATTERN.search(value) if match is None: break path = os.path.join(os.path.dirname(ref), match.group('path')) try: value = value.replace( match.group(0), str(util.get_value(configuration, path))) except KeyError: raise KeyError(path) # Substitute all variable references while True: match = cls.VAR_PATTERN.search(value) if match is None: break value = value.replace( match.group(0), str(util.get_value(cls.VARIABLES, match.group('path'), '/'))) return value def apply(self, configuration, schema, args): super(SubstitutionPlugin, self).apply(configuration, schema, args) return util.apply(configuration, ft.partial(self.substitute_variables, configuration)) class WorkspaceMountPlugin(Plugin): """ Mount the workspace inside the container. """ SCHEMA = { "properties": { "run": { "properties": { "workspace-dir": { "type": "string", "description": "Path at which to mount the workspace in the container.", "default": "/workspace" }, "workdir": { "type": "string", "default": "#{workspace-dir}" } }, "additionalProperties": False } }, "additionalProperties": False } COMMANDS = ['run'] ORDER = 500 def add_arguments(self, parser): self.add_argument(parser, '/run/workspace-dir') self.add_argument(parser, '/run/workdir') def apply(self, configuration, schema, args): super(WorkspaceMountPlugin, self).apply(configuration, schema, args) configuration['run'].setdefault('mount', []).append({ 'type': 'bind', 'source': '#{/workspace}', 'destination': util.get_value(configuration, '/run/workspace-dir') }) return configuration class HomeDirPlugin(Plugin): """ Mount a home directory placed in the current directory. """ ORDER = 520 COMMANDS = ['run'] def apply(self, configuration, schema, args): super(HomeDirPlugin, self).apply(configuration, schema, args) configuration['run'].setdefault('mount', []).append({ 'destination': '#{/run/env/HOME}', 'source': '#{/workspace}/.di/home', 'type': 'bind', }) configuration['run'].setdefault('env', {}).setdefault('HOME', '/${user/name}') return configuration