from concurrent import futures import contextlib import json import logging import os import re import shutil import sys import tempfile import docker import git from fuel_ccp.common import jinja_utils from fuel_ccp import config from fuel_ccp.config import images BUILD_TIMEOUT = 2 ** 16 # in seconds CONF = config.CONF LOG = logging.getLogger(__name__) _SHUTDOWN = False def render_dockerfile(path, name, config): LOG.debug('%s: Rendering dockerfile', name) sources = set() parent = [] # Could've been None if we could use nonlocal render_files = [] def copy_sources(source_name, cont_dir): if source_name not in config['sources']: raise ValueError('No such source: %s' % source_name) sources.add(source_name) return 'COPY %s %s' % (source_name, cont_dir) def image_spec(image_name): if parent: raise RuntimeError('You can use image_spec only once in FROM line') parent.append(image_name) return images.image_spec(image_name, add_address=CONF.builder.push) def render(fname): if fname.endswith('.j2'): oname = fname[:-3] else: oname = fname + '.rendered' render_files.append({ 'src': fname, 'dest': oname }) return oname content = jinja_utils.jinja_render(path, config['render'], [copy_sources, image_spec, render]) return content, sources, render_files, parent[0] if parent else None def prepare_source(source_name, name, dest_dir, config): tmp_dir = os.path.join(dest_dir, source_name) git_url = config['sources'].get(source_name, {}).get('git_url') source_dir = config['sources'].get(source_name, {}).get('source_dir') if git_url: LOG.info('%s: Cloning repository "%s"', name, git_url) repo = git.Repo.clone_from(git_url, tmp_dir) ref = config['sources'][source_name]['git_ref'] LOG.info('%s: Changing reference to "%s"', name, ref) repo.git.checkout(ref) LOG.info('%s: Repository %s has been cloned', name, git_url) if source_dir: LOG.info('%s: Using local directory %s', name, source_dir) shutil.copytree(source_dir, tmp_dir) def create_rendered_dockerfile(dockerfile, tmp_path, config): src_dir = os.path.dirname(dockerfile['path']) dest_dir = os.path.join(tmp_path, dockerfile['name']) os.makedirs(dest_dir) dockerfilename = os.path.join(dest_dir, 'Dockerfile') with open(dockerfilename, 'w') as f: f.write(dockerfile['content']) for source_name in dockerfile['sources']: prepare_source(source_name, dockerfile['name'], dest_dir, config) for render_file in dockerfile['render_files']: fpath = os.path.join(src_dir, render_file['src']) opath = os.path.join(dest_dir, render_file['dest']) content = jinja_utils.jinja_render(fpath, config['render']) with open(opath, 'wb') as f: f.write(content.encode('utf-8')) for filename in os.listdir(src_dir): if 'Dockerfile' in filename: continue full_filename = os.path.join(src_dir, filename) if os.path.isfile(full_filename): shutil.copy(full_filename, dest_dir) elif os.path.isdir(full_filename): shutil.copytree(full_filename, os.path.join(dest_dir, filename)) return dockerfilename def find_dockerfiles(repository_name, match=True): dockerfiles = {} repository_dir = os.path.join(CONF.repositories.path, repository_name) for root, __, files in os.walk(repository_dir): if 'Dockerfile.j2' in files: path = os.path.join(root, 'Dockerfile.j2') else: continue name = os.path.basename(os.path.dirname(path)) spec = images.image_spec(name, add_address=CONF.builder.push) dockerfiles[name] = { 'name': name, 'full_name': spec, 'path': path, 'parent': None, 'children': [], 'match': match, 'build_result': None, 'push_result': None, 'content': None, 'sources': None, } if len(dockerfiles) == 0: msg = 'No dockerfile for %s found' if CONF.repositories.skip_empty: LOG.debug(msg, repository_name) else: LOG.error(msg, repository_name) sys.exit(1) return dockerfiles def render_dockerfiles(dockerfiles, config): for dockerfile in dockerfiles.values(): content, sources, render_files, parent = \ render_dockerfile(dockerfile['path'], dockerfile['name'], config) dockerfile['content'] = content dockerfile['sources'] = sources dockerfile['parent'] = parent dockerfile['render_files'] = render_files IMAGE_FULL_NAME_RE = r"((?P[\w:\.-]+)/){0,2}" \ "(?P[\w_-]+)" \ "(:(?P[\w_\.-]+))?" IMAGE_FULL_NAME_PATTERN = re.compile(IMAGE_FULL_NAME_RE) def connect_children(dockerfiles): orphan_children = [] for dockerfile in dockerfiles.values(): parent = dockerfile['parent'] if parent: if parent not in dockerfiles: orphan_children.append(dockerfile) else: dockerfiles[parent]['children'].append(dockerfile) dockerfile['parent'] = dockerfiles[parent] if orphan_children: orphan_str = ", ".join( "{name}[{parent}]".format(**d) for d in orphan_children) raise RuntimeError( "Could not find parents for the following images: {}".format( orphan_str)) def get_dockerfiles(match=False): dockerfiles = {} for repository_def in CONF.repositories.repos: dockerfiles.update( find_dockerfiles(repository_def['name'], match=match)) return dockerfiles def get_dockerfiles_tree(match=False, config=None): if config is None: config = _get_config() dockerfiles = get_dockerfiles(match) render_dockerfiles(dockerfiles, config) connect_children(dockerfiles) return dockerfiles def build_dockerfile(dc, dockerfile): LOG.info("%s: Starting image build", dockerfile['name']) for line in dc.build(rm=True, forcerm=True, nocache=CONF.builder.no_cache, tag=dockerfile['full_name'], path=os.path.dirname(dockerfile['path'])): if _SHUTDOWN: raise RuntimeError("Building '{}' was interrupted".format( dockerfile['name'] )) build_data = json.loads(line.decode("UTF-8")) if 'stream' in build_data: LOG.debug('%s: %s' % (dockerfile['name'], build_data['stream'].rstrip())) if 'errorDetail' in build_data: LOG.error('%s: %s' % (dockerfile['name'], build_data['errorDetail']['message'])) dockerfile['build_result'] = 'Failure' return dockerfile['build_result'] = 'Success' LOG.info("%s: Build succeeded", dockerfile['name']) def push_dockerfile(dc, dockerfile): if dockerfile['build_result'] == 'Failure': dockerfile['push_result'] = 'Failure' LOG.error("%s: Push will be skipped due to build failure", dockerfile['name']) return if CONF.registry.username and CONF.registry.password: dc.login(username=CONF.registry.username, password=CONF.registry.password, registry=CONF.registry.address) for line in dc.push(dockerfile['full_name'], stream=True, insecure_registry=CONF.registry.insecure): build_data = json.loads(line.decode("UTF-8")) status = build_data.get('status', '') if status: LOG.debug('%s: %s' % (dockerfile['name'], status)) if build_data.get('progress'): LOG.debug('%s: %s' % ( dockerfile['name'], build_data['progress'].rstrip())) if ('Layer already exists' in status and not dockerfile['push_result']): dockerfile['push_result'] = 'Exists' elif 'errorDetail' in build_data: LOG.error('%s: %s', dockerfile['name'], build_data['errorDetail']['message']) dockerfile['push_result'] = 'Failure' elif status == 'Pushed' or 'Mounted from' in status: dockerfile['push_result'] = 'Success' if dockerfile['push_result'] == 'Success': LOG.info("%s: Push into %s registry finished", dockerfile['name'], CONF.registry.address) elif dockerfile['push_result'] == 'Exists': LOG.info("%s: Already in %s registry", dockerfile['name'], CONF.registry.address) def process_dockerfile(dockerfile, tmp_dir, config, executor, future_list, ready_images): path = create_rendered_dockerfile(dockerfile, tmp_dir, config) dockerfile['path'] = path with contextlib.closing(docker.Client( timeout=CONF.registry.timeout)) as dc: build_dockerfile(dc, dockerfile) if CONF.builder.push and CONF.registry.address: push_dockerfile(dc, dockerfile) for child in dockerfile['children']: if child['match'] or (CONF.builder.keep_image_tree_consistency and child['name'] in ready_images): if dockerfile['build_result'] == 'Failure': LOG.error("%s: Build will be skipped due to parent image (%s) " "build failure", child['name'], dockerfile['name']) child['build_result'] = 'Failure' if CONF.builder.push: child['push_result'] = 'Failure' else: submit_dockerfile_processing(child, tmp_dir, config, executor, future_list, ready_images) def submit_dockerfile_processing(dockerfile, tmp_dir, config, executor, future_list, ready_images): future_list.append(executor.submit( process_dockerfile, dockerfile, tmp_dir, config, executor, future_list, ready_images )) def match_not_ready_base_dockerfiles(dockerfile, ready_images): while True: parent = dockerfile['parent'] if parent is None or parent['match'] or parent['name'] in ready_images: break parent['match'] = True dockerfile = parent def get_ready_image_names(): with contextlib.closing(docker.Client( timeout=CONF.registry.timeout)) as dc: ready_images = [] for image in dc.images(): if image["RepoTags"]: for repo_tag in image["RepoTags"]: matcher = IMAGE_FULL_NAME_PATTERN.match(repo_tag) if not matcher: continue ns = matcher.group("namespace") name = matcher.group("name") tag = matcher.group("tag") if CONF.images.namespace == ns and CONF.images.tag == tag: ready_images.append(name) return ready_images def match_dockerfiles_by_component(dockerfiles, component, ready_images=()): pattern = re.compile(re.escape(component)) matched_dockerfiles = list(filter(pattern.match, dockerfiles.keys())) if matched_dockerfiles: LOG.info("Component \"%s\" matches: %s", component, ", ".join(matched_dockerfiles)) else: raise RuntimeError("Component \"%s\" doesn't match any " "dockerfile" % component) for dockerfile in matched_dockerfiles: dockerfiles[dockerfile]['match'] = True if CONF.builder.build_base_images_if_not_exist: match_not_ready_base_dockerfiles( dockerfiles[dockerfile], ready_images) def wait_futures(future_list, skip_errors=False): while future_list: future = future_list[0] if future.done(): future_list.pop(0) continue try: # we need to use timeout because in this case python # thread wakes up time to time to check timeout and don't # block signal processing future.result(timeout=BUILD_TIMEOUT) except Exception as ex: if skip_errors: LOG.error(str(ex)) else: raise def _get_config(): cfg = {'render': dict(CONF.images._items())} if CONF.registry.address: cfg['render']['namespace'] = '%s/%s' % ( CONF.registry.address, cfg['render']['namespace']) cfg['render'].update(CONF.versions._items()) cfg['render']['url'] = CONF.url cfg['sources'] = CONF.sources return cfg def _get_summary(dockerfiles): LOG.info('#' * 50) LOG.info('Summary:') build_succeeded = [d['name'] for d in dockerfiles.values() if d['build_result'] == 'Success'] if build_succeeded: LOG.info('%d image(s) build succeeded: %s' % ( len(build_succeeded), ', '.join(build_succeeded))) build_failed = [d['name'] for d in dockerfiles.values() if d['build_result'] == 'Failure'] if build_failed: LOG.error('%d image(s) build failed: %s' % ( len(build_failed), ', '.join(build_failed))) push_succeeded = [d['name'] for d in dockerfiles.values() if d['push_result'] == 'Success'] if push_succeeded: LOG.info('%d image(s) push succeeded: %s' % ( len(push_succeeded), ', '.join(push_succeeded))) already_pushed = [d['name'] for d in dockerfiles.values() if d['push_result'] == 'Exists'] if already_pushed: LOG.info('%d image(s) already in registry: %s' % ( len(already_pushed), ', '.join(already_pushed))) push_failed = [d['name'] for d in dockerfiles.values() if d['push_result'] == 'Failure'] if push_failed: LOG.error('%d image(s) push failed: %s' % ( len(push_failed), ', '.join(push_failed))) LOG.info('#' * 50) if build_failed or push_failed: return False return True try: TemporaryDirectory = tempfile.TemporaryDirectory except AttributeError: # This is based on TemporaryDirectory class that appeared in Python 3.2 class TemporaryDirectory(object): def __init__(self, **kwargs): self.name = tempfile.mkdtemp(**kwargs) def __enter__(self): return self.name def __exit__(self, exc_type, exc_value, tb): shutil.rmtree(self.name) def build_components(components=None): with TemporaryDirectory() as tmp_dir: config = _get_config() match = not bool(components) dockerfiles = get_dockerfiles_tree(match, config) ready_images = get_ready_image_names() if components is not None: for component in components: match_dockerfiles_by_component(dockerfiles, component, ready_images) with futures.ThreadPoolExecutor(max_workers=CONF.builder.workers) as ( executor): future_list = [] try: for dockerfile in dockerfiles.values(): if dockerfile['match'] and ( dockerfile['parent'] is None or not dockerfile['parent']['match']): submit_dockerfile_processing( dockerfile, tmp_dir, config, executor, future_list, ready_images) wait_futures(future_list) except SystemExit: global _SHUTDOWN _SHUTDOWN = True for future in future_list: future.cancel() wait_futures(future_list, skip_errors=True) raise finally: build_succeeded = _get_summary(dockerfiles) if not build_succeeded: sys.exit(1)