Thcipriani has uploaded a new change for review.

  https://gerrit.wikimedia.org/r/240292

Change subject: Add config deployment
......................................................................

Add config deployment

What happens on tin
---
1. Looks for environment-specific `./scap/config-files.yaml` with target
files in the format:

/path/to/target:
    template: env-specific-template.yaml.j2
    remote_vars: /optional/remote/variable/file.yaml

2. Looks for environment-specific `./scap/vars.yaml` that includes
variables used to render the template. These variables will be overridden
by any conflicting variables in the file specified by `remote_vars`

3. Variables from any environment-specific `vars.yaml` file are combined
with variables from the root `vars.yaml` file.

4. A json file is created at `[repo]/.git/config-files/[revision].json`
that contains the final path to any environment-specific templates as
well as a final list of combined variables.

What happens on targets
---

1. Download the file from `tin/[repo]/.git/config-files/[revision].json`

2. Loop through the config files, and render each template using the
variables from the downloaded json file and the variables from the (now)
local remove_vars file

3. Links rendered file (in `[repo]/.git/config-files/[path]`) to final
location

Bug: T109512
Change-Id: I3a7c116aa90a0cc3e470931bfd7423634f075b19
---
M scap/config.py
M scap/main.py
M scap/tasks.py
A scap/template.py
M scap/utils.py
5 files changed, 258 insertions(+), 14 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/mediawiki/tools/scap 
refs/changes/92/240292/1

diff --git a/scap/config.py b/scap/config.py
index b7a7d81..34d8f26 100644
--- a/scap/config.py
+++ b/scap/config.py
@@ -37,6 +37,7 @@
     'git_server': 'tin.eqiad.wmnet',
     'git_scheme': 'http',
     'git_submodules': False,
+    'config_deploy': False,
 }
 
 
diff --git a/scap/main.py b/scap/main.py
index e707533..f2aab49 100644
--- a/scap/main.py
+++ b/scap/main.py
@@ -7,17 +7,22 @@
 """
 import argparse
 import errno
+import json
 import multiprocessing
 import netifaces
 import os
 import psutil
+import requests
 import subprocess
+import yaml
 
 from . import cli
 from . import log
 from . import ssh
 from . import tasks
+from . import template
 from . import utils
+
 from datetime import datetime
 
 
@@ -578,7 +583,7 @@
 
 class DeployLocal(cli.Application):
     """Deploy service code via git"""
-    STAGES = ['fetch', 'promote', 'check']
+    STAGES = ['fetch', 'config_deploy', 'promote', 'check']
     EX_STAGES = ['rollback']
 
     rev = None
@@ -611,6 +616,15 @@
 
         self.user = self.config['git_repo_user']
 
+        # only supports http from tin for the moment
+        scheme = 'http'
+        repo = self.config['git_repo']
+        server = self.config['git_server']
+
+        url = os.path.normpath('{0}/{1}'.format(server, repo))
+
+        self.server_url = "{0}://{1}".format(scheme, url)
+
         getattr(self, self.arguments.stage)()
 
     def fetch(self):
@@ -625,23 +639,17 @@
 
         logger = self.get_logger()
 
-        repo = self.config['git_repo']
-        server = self.config['git_server']
         has_submodules = self.config['git_submodules']
 
         # create deployment directories if they don't already exist
         for d in [self.cache_dir, self.revs_dir]:
             utils.mkdir_p(d, logger=logger)
 
-        # only supports http from tin for the moment
-        scheme = 'http'
-
-        url = os.path.normpath('{0}/{1}'.format(server, repo))
-        url = "{0}://{1}/.git".format(scheme, url)
-        self.get_logger().debug('Fetching from: {}'.format(url))
+        git_remote = os.path.join(self.server_url, '.git')
+        self.get_logger().debug('Fetching from: {}'.format(git_remote))
 
         # clone/fetch from the repo to the cache directory
-        tasks.git_fetch(self.cache_dir, url, user=self.user)
+        tasks.git_fetch(self.cache_dir, git_remote, user=self.user)
 
         # clone/fetch from the local cache directory to the revision directory
         tasks.git_fetch(self.rev_dir, self.cache_dir, user=self.user)
@@ -654,6 +662,61 @@
         # link the .in-progress flag to the rev directory
         self._link_rev_dir(self.progress_flag)
 
+    def config_deploy(self):
+        """Renders config files
+
+        Grabs the current config json file from the deploy git server, and
+        renders the final template inside the current revision's
+        `.git/config-files` directory
+        """
+        logger = self.get_logger()
+        if not self.config['config_deploy']:
+            return
+
+        deploy_dir = self.config['deploy_dir']
+        config_url = os.path.join(self.server_url, '.git', 'config-files',
+            '{}.json'.format(self.rev))
+
+        logger.debug('Get config json: {}'.format(config_url))
+        r = requests.get(config_url)
+        if r.status_code != requests.codes.ok:
+            raise IOError(errno.ENOENT, 'Config file not found', config_url)
+
+        config_files = r.json()
+        overrides = config_files.get('override_vars', {})
+
+        # `revs/[revision]/.git/config-files` directory to render config_files
+        source_basepath = os.path.join(self.rev_dir, '.git', 'config-files')
+        logger.debug('Source basepath: {}'.format(source_basepath))
+
+        for config_file in config_files['files']:
+            name = config_file['template']
+            if os.path.commonprefix([name, deploy_dir]) == deploy_dir:
+                name = os.path.relpath(name, deploy_dir)
+
+            tmpl = template.Template(
+                name=name,
+                location='{}://{}'.format(
+                    self.config['git_scheme'],
+                    self.config['git_server'],
+                ),
+                var_file=config_file.get('remote_vars', None),
+                overrides=overrides
+            )
+
+            filename = config_file['name']
+            if filename.startswith('/'):
+                filename = filename[1:]
+
+            utils.mkdir_p(os.path.join(
+                source_basepath, os.path.dirname(filename)))
+
+            source = os.path.join(source_basepath, filename)
+            logger.debug('Rendering config_file: {}'.format(source))
+
+            with open(source, 'w') as f:
+                f.write(tmpl.render())
+
     def promote(self):
         """Promote the current deployment.
 
@@ -664,6 +727,7 @@
         service = self.config.get('service_name', None)
 
         self._link_rev_dir(self.cur_link)
+        self._link_config_files()
 
         if service is not None:
             tasks.restart_service(service, user=self.config['git_repo_user'])
@@ -724,6 +788,22 @@
         self.promote()
         self._remove_progress_link()
 
+    def _link_config_files(self):
+        """Links rendered config files to their final destination"""
+        logger = self.get_logger()
+
+        config_base = os.path.join(self.cur_link, '.git', 'config-files')
+        logger.debug('Linking config files at: {}'.format(config_base))
+
+        for dir_path, _, conf_files in os.walk(config_base):
+            for conf_file in conf_files:
+                full_path = os.path.normpath(
+                    '{}/{}'.format(dir_path, conf_file))
+
+                rel_path = os.path.relpath(full_path, config_base)
+                final_path = os.path.join('/', rel_path)
+                tasks.move_symlink(full_path, final_path, user=self.user)
+
     def _link_rev_dir(self, symlink_path):
         tasks.move_symlink(self.rev_dir, symlink_path, user=self.user)
 
@@ -751,6 +831,7 @@
         'git_submodules',
         'service_name',
         'service_port',
+        'config_deploy',
     ]
 
     repo = None
@@ -811,6 +892,8 @@
                 tasks.git_update_deploy_head(deploy_info, location=cwd)
                 tasks.git_tag_repo(deploy_info, location=cwd)
 
+                self.config_deploy_setup(commit)
+
                 self.config['git_rev'] = commit
 
                 # Run git update-server-info because git repo is a dumb
@@ -825,6 +908,80 @@
 
         return 0
 
+    def config_deploy_setup(self, commit):
+        """Generate environment-specific config file and variable template list
+
+        Builds a json file that contains:
+        #. A list of file objects containing template files to be deployed
+        #. An object containing variables specificed in the
+        environment-specific `vars.yaml` file and inheriting from the
+        `vars.yaml` file
+        """
+        logger = self.get_logger()
+
+        if not self.config['config_deploy']:
+            return
+
+        logger.debug('Deploy config: True')
+        path_root = os.path.join(self.config['git_deploy_dir'], self.repo)
+        scap_path = os.path.join(path_root, 'scap')
+
+        cfg_file = utils.get_env_specific_filename(
+            os.path.join(scap_path, 'config-files.yaml'),
+            self.arguments.environment
+        )
+
+        logger.debug('Config deploy file: {}'.format(cfg_file))
+        if not os.path.isfile(cfg_file):
+            return
+
+        config_file_path = os.path.join(path_root, '.git', 'config-files')
+        utils.mkdir_p(config_file_path)
+        tmp_cfg_file = os.path.join(config_file_path, '{}.json'.format(commit))
+        tmp_cfg = {}
+
+        with open(cfg_file, 'r') as cf:
+            config_files = yaml.load(cf.read())
+
+        tmp_cfg['files'] = []
+        # Get an environment specific template
+        for config_file in config_files:
+            f = {}
+            f['name'] = config_file
+            template_name = config_files[config_file]['template']
+            template = utils.get_env_specific_filename(
+                os.path.join(scap_path, 'templates', template_name))
+            f['template'] = os.path.relpath(
+                template, self.config['git_deploy_dir'])
+
+            # Remote var file is optional
+            if config_files[config_file].get('remote_vars', None):
+                f['remote_vars'] = config_files[config_file]['remote_vars']
+
+            tmp_cfg['files'].append(f)
+
+        tmp_cfg['override_vars'] = {}
+
+        # Build vars to override remote
+        vars_files = [
+            utils.get_env_specific_filename(
+                'vars.yaml',
+                self.arguments.environment
+            ),
+            os.path.join(os.getcwd(), 'scap/vars.yaml')
+        ]
+
+        for vars_file in vars_files:
+            try:
+                with open(vars_file, 'r') as vf:
+                    tmp_cfg['override_vars'].update(yaml.load(vf.read()))
+            except IOError:
+                pass  # don't worry if a vars.yaml doesn't exist
+
+        with open(tmp_cfg_file, 'w') as tc:
+            json.dump(tmp_cfg, tc)
+            logger.debug('Wrote config deploy file: {}'.format(tmp_cfg_file))
+
     def execute_rollback(self, stage):
         prompt = "Stage '{}' failed. Perform rollback?".format(stage)
 
diff --git a/scap/tasks.py b/scap/tasks.py
index 1dddd11..20e4394 100644
--- a/scap/tasks.py
+++ b/scap/tasks.py
@@ -703,11 +703,14 @@
 
 
 def move_symlink(source, dest, user='mwdeploy', logger=None):
-    common_path = os.path.commonprefix([source, dest])
-    rsource = os.path.relpath(source, common_path)
-    rdest = os.path.relpath(dest, common_path)
+    dest_dir = os.path.dirname(dest)
+    rsource = os.path.relpath(source, dest_dir)
+    rdest = os.path.relpath(dest, dest_dir)
 
-    with utils.cd(common_path):
+    # Make link target's parent directory if it doesn't exist
+    utils.mkdir_p(dest_dir, user=user, logger=logger)
+
+    with utils.cd(dest_dir):
         utils.sudo_check_call(user,
                               "ln -sfT '{}' '{}'".format(rsource, rdest),
                               logger)
diff --git a/scap/template.py b/scap/template.py
new file mode 100644
index 0000000..88bbd2b
--- /dev/null
+++ b/scap/template.py
@@ -0,0 +1,79 @@
+# -*- coding: utf-8 -*-
+"""
+    scap.template
+    ~~~~~~~~~~
+    Module for working with file templates
+"""
+
+import jinja2
+import requests
+import yaml
+
+
+class ScapTemplateLoader(jinja2.BaseLoader):
+    """Overrides the jinja2 baseloader with one that will fetch template
+    files from tin
+    """
+    def __init__(self, location):
+        self.location = location
+
+    def get_source(self, environment, template):
+        """Grab a template file from the staging server"""
+        uri = '{}/{}'.format(self.location, template)
+        template = self._get_remote_file(uri)
+        if not template:
+            raise jinja2.TemplateNotFound(template)
+
+        source, etag = template
+        uptodate = self._uptodate(uri, etag)
+        return source, uri, uptodate
+
+    def _get_remote_file(self, uri):
+        """Helper method to return etag and response body"""
+        r = requests.get(uri)
+        if r.status_code != requests.codes.ok:
+            return None
+        return r.text, r.headers.get('etag', '')
+
+    def _uptodate(self, uri, etag):
+        """Returns a functions that takes no arguments that will check if
+        a given template is up-to-date
+        """
+        def uptodate():
+            import requests
+            r = requests.get(uri)
+            if r.status_code == requests.codes.ok:
+                return etag == r.headers.get('etag', '')
+            # If we got a non-200 response, assume the template we have is fine
+            return True
+        return uptodate
+
+
+class Template(object):
+    """Adapter class that wraps jinja2 templates
+    """
+    def __init__(self, name, location, var_file=None, overrides=None):
+        loader = ScapTemplateLoader(location)
+        self._env = jinja2.Environment(loader=loader)
+        self._template = self._env.get_template(name)
+        self._overrides = overrides
+        self.var_file = var_file
+
+    def _get_file_vars(self):
+        if not self.var_file:
+            return {}
+
+        with open(self.var_file, 'r') as variables:
+            return yaml.load(variables.read())
+
+    def render(self):
+        """Renders the templates specified by `self.name` using the
+        variables sourced from the import yaml file specified by
+        `self.var_file`
+        """
+        template_vars = self._get_file_vars()
+        if self._overrides:
+            overrides = self._overrides
+            overrides.update(template_vars)
+            template_vars = overrides
+        return self._template.render(template_vars)
diff --git a/scap/utils.py b/scap/utils.py
index 77eccd2..0e8a95e 100644
--- a/scap/utils.py
+++ b/scap/utils.py
@@ -117,6 +117,10 @@
     base = os.path.dirname(path)
     filename = os.path.basename(path)
 
+    if base.endswith('/templates'):
+        base = os.path.dirname(base)
+        filename = os.path.join('templates', filename)
+
     env_filename = os.path.join(base, 'environments', env, filename)
 
     if os.path.isfile(env_filename):

-- 
To view, visit https://gerrit.wikimedia.org/r/240292
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: I3a7c116aa90a0cc3e470931bfd7423634f075b19
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/tools/scap
Gerrit-Branch: master
Gerrit-Owner: Thcipriani <[email protected]>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to