ftl/common/cache_runner.py (150 lines of code) (raw):

# Copyright 2018 Google Inc. All Rights Reserved. # # 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 httplib2 import json import logging import requests import subprocess import sys from containerregistry.client import docker_name from containerregistry.client import docker_creds from containerregistry.client.v2_2 import docker_image from containerregistry.client.v2_2 import docker_session from containerregistry.transport import transport_pool from ftl.common import ftl_util from ftl.common import constants from ftl.php import layer_builder as php_builder from ftl.python import layer_builder as python_builder PHP = 'php' PYTHON = 'python' LANGUAGES = [PHP, PYTHON] LANGUAGE_CACHES = { PHP: constants.PHP_CACHE_NAMESPACE, PYTHON: constants.PYTHON_CACHE_NAMESPACE } MAPPING_BUCKET = 'ftl-global-cache' MAPPING_FILE = '{language}-mapping.json' LOCAL_MAPPING_FILE = '/workspace/' + MAPPING_FILE def main(): logging.getLogger().setLevel(logging.INFO) parser = argparse.ArgumentParser() parser.add_argument( '--packages', action='store', dest='packages', nargs='*', required=True, type=str, help='') parser.add_argument( '--language', '-l', action='store', dest='language', required=True, help='', choices=LANGUAGES) args = parser.parse_args() runner = CacheRunner(args.packages, args.language) runner.populate_cache() class CacheRunner(object): def __init__(self, packages, language): self._packages = packages self._language = language _cache = LANGUAGE_CACHES[language] self._cache_name = constants.GLOBAL_CACHE_REGISTRY + '/' + _cache self._reg = docker_name.Registry('gcr.io', strict=False) self._creds = docker_creds.DefaultKeychain.Resolve(self._reg) self._transport = transport_pool.Http(httplib2.Http, size=2) self._cache = docker_name.Tag(self._cache_name, strict=False) # retrieve mappings when initializing runner self._mappings = self.read_mappings() logging.info('existing mapping: %s' % self._mappings) def _tag(self, tag): return docker_name.Tag(self._cache_name + ':' + tag) def populate_cache(self): existing_entries = self.retrieve_cache_entries() # Determine which images exist in the cache that should not be there, # and remove them self.remove_old_entries(existing_entries) # Populate cache with new entries self.populate_cache_entries(existing_entries) # Finally, write back the new key mapping to the filesystem # to be copied to GCS self.write_mapping_to_workspace() def read_mappings(self): """read cache_key -> package tuple mappings from GCS config file return map of key to package,which we'll use as lookup when pushing images""" r = requests.get( 'https://www.googleapis.com/storage/v1' '/b/{bucket}/o/{file}?alt=media'.format( bucket=MAPPING_BUCKET, file=MAPPING_FILE.format(language=self._language))) if not r.ok: logging.error('Error retrieving mapping: %s' % r.text) try: return json.loads(r.text) except ValueError: # no mapping found: create new one return {} def retrieve_cache_entries(self): # returns all images stored in the cache currently with docker_image.FromRegistry(self._cache, self._creds, self._transport) as session: entries = set(tag.rstrip() for tag in session.tags() if tag) logging.info('existing entries in cache: %s' % entries) return entries def remove_old_entries(self, existing_entries): # for each existing entry in the mapping, # if it isn't in the package list, remove it for entry in existing_entries: entry_info = self._mappings.get(entry, '') if entry_info and entry_info not in self._packages: logging.info( 'removing entry {0} from cache'.format(entry_info)) self._remove_entry(entry) def _remove_entry(self, entry): # delete entry from mapping and cache docker_session.Delete(self._tag(entry), self._creds, self._transport) del self._mappings[entry] def populate_cache_entries(self, existing_entries): # for each package, either verify it is already in the cache, # or build the image and push it to the cache for package in self._packages: if package: try: name = None version = None if self._language == PHP: name, version = package.split(':') elif self._language == PYTHON: name, version = package.split('==') if name not in existing_entries: # builder._pip_install() expects the double equals # on the version self._build_image_and_push(name, '==' + version) except ValueError: logging.error( 'Encountered malformed package: {0}'.format(package)) def _build_image_and_push(self, package_name, package_version): logging.info('building package {name}, version {version}'.format( name=package_name, version=package_version)) image = None builder = None if self._language == PHP: builder = php_builder.PhaseTwoLayerBuilder( pkg_descriptor=(package_name, package_version)) elif self._language == PYTHON: interpreter_builder = python_builder.InterpreterLayerBuilder() interpreter_builder.BuildLayer() builder = python_builder.PipfileLayerBuilder( pkg_descriptor=(package_name, package_version), wheel_dir=self._setup_pip_and_wheel(), dep_img_lyr=interpreter_builder) if not builder: logging.error('Could not find builder for language {0}'.format( self._language)) sys.exit(1) builder.BuildLayer() # since we only have one layer, just use the builder's image # itself as the final image image = builder._img # TODO(nkubala): we should refactor AppendLayersIntoImage to not # have to set a base image # image = ftl_util.AppendLayersIntoImage([builder]) key = builder.GetCacheKey() tag = self._tag(key) with docker_session.Push( tag, self._creds, self._transport, threads=2) as session: session.upload(image) self._mappings['%s:%s' % (package_name, package_version)] = key def write_mapping_to_workspace(self): with open(MAPPING_FILE.format(language=self._language), 'w') as f: json.dump(self._mappings, f) def _setup_pip_and_wheel(self): cmd = [constants.PIP_DEFAULT_CMD] cmd.extend(['install', '--upgrade', 'pip']) subprocess.check_call(cmd) cmd = [constants.PIP_DEFAULT_CMD] cmd.extend(['install', 'wheel']) subprocess.check_call(cmd) return ftl_util.gen_tmp_dir(constants.WHEEL_DIR) if __name__ == '__main__': main()