tools/build/build.py (450 lines of code) (raw):

# Licensed to the Apache Software Foundation (ASF) under one or more # contributor license agreements. See the NOTICE file distributed with # this work for additional information regarding copyright ownership. # The ASF licenses this file to You 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 hashlib import os import shutil import subprocess import tempfile import requests from bs4 import BeautifulSoup from tenacity import retry, stop_after_attempt, wait_fixed DOCKER = shutil.which('docker') TAR = shutil.which('tar') GIT = shutil.which('git') GPG = shutil.which('gpg') if any([req is None for req in [DOCKER, TAR, GPG, GIT]]): raise OSError(f'Requirement(s) not found in PATH:\n' f' docker: {DOCKER if DOCKER is not None else "MISSING"}\n' f' tar: {TAR if TAR is not None else "MISSING"}\n' f' git: {GIT if GIT is not None else "MISSING"}\n' f' gpg: {GPG if GPG is not None else "MISSING"}') ASF_NEXUS_REPO = 'https://github.com/apache/sdap-nexus.git' ASF_INGESTER_REPO = 'https://github.com/apache/sdap-ingester.git' ASF_NEXUSPROTO_REPO = 'https://github.com/apache/sdap-nexusproto.git' ASF_NEXUSPROTO_BRANCH = 'develop' SKIP_KEYS = ['webapp', 'solr', 'solr-init', 'gi', 'cm'] def build_cmd( tag, context, dockerfile='', cache=True, proto=None, proto_repo=ASF_NEXUSPROTO_REPO, proto_branch=ASF_NEXUSPROTO_BRANCH ): command = [DOCKER, 'build', context] if dockerfile != '': command.extend(['-f', os.path.join(context, dockerfile)]) command.extend(['-t', tag]) if proto == 'git': command.extend([ '--build-arg', 'BUILD_NEXUSPROTO=true', '--build-arg', f'APACHE_NEXUSPROTO={proto_repo}', '--build-arg', f'APACHE_NEXUSPROTO_BRANCH={proto_branch}', ]) if not cache: command.append('--no-cache') return command @retry(stop=stop_after_attempt(2), wait=wait_fixed(2)) def run_subprocess(cmd, suppress_output=False, err_on_fail=True, **kwargs): if not kwargs.pop('dryrun', False): stdout = subprocess.DEVNULL if suppress_output else None p = subprocess.Popen(cmd, stdout=stdout, stderr=subprocess.STDOUT, **kwargs) p.wait() if err_on_fail and p.returncode != 0: raise OSError(f'Subprocess returned nonzero: {p.returncode}') else: cmd_str = ' '.join(cmd) if suppress_output: cmd_str += ' > /dev/null' print(cmd_str) def yes_no_prompt(prompt, default=True): do_continue = input(prompt).lower() while do_continue not in ['', 'y', 'n']: do_continue = input(prompt).lower() if do_continue == '': return default else: return do_continue == 'y' def choice_prompt(prompt: str, choices: list, default: str = None) -> str: assert len(choices) > 0 if len(choices) == 1: return choices[0] valid_choices = [str(i) for i in range(len(choices))] if default is not None: assert default in choices valid_choices.append('') print(prompt) choice = None while choice not in valid_choices: for i, c in enumerate(choices): print('[{:2d}] {}-> {}'.format(i, ''.ljust(10, '-'), c)) print() if default is None: choice = input('Selection: ') else: choice = input(f'Selection [{choices.index(default)}]: ') if default is not None and choice == '': return default else: return choices[int(choice)] def basic_prompt(prompt, default=None): if default is not None: prompt = f'{prompt} [{default}] : ' else: prompt += ': ' while True: response = input(prompt) if response == '' and default is not None: response = default if yes_no_prompt(f'Confirm: "{response}" [Y]/N '): return response def pull_source(dst_dir: tempfile.TemporaryDirectory, build: dict): ASF = 'ASF (dist.apache.org)' GHB = 'GitHub' LFS = 'Local Filesystem' source_location = choice_prompt( 'Where is the source you\'re building from stored?', [ASF, GHB, LFS], ASF ) if source_location == ASF: DEV = 'Dev area (release candidates)' REL = 'Most recent release area' ARC = 'Archive (full release history)' url_map = { DEV: 'https://dist.apache.org/repos/dist/dev/', REL: 'https://dist.apache.org/repos/dist/release/', ARC: 'https://archive.apache.org/dist/' } release_area = choice_prompt( 'Where is the release you\'re looking for?', [DEV, REL, ARC] ) url = url_map[release_area] + 'sdap/' response = requests.get(url) response.raise_for_status() soup = BeautifulSoup(response.text, 'html.parser') versions = [ node.text.rstrip('/') for node in soup.find_all('a') if node.get('href').rstrip('/') not in ['KEYS', '..'] ] # Extra filtering to remove some special values in archive HTML page versions = [ v for v in versions if v not in ['Parent Directory', 'Name', 'Last modified', 'Size', 'Description'] ] if len(versions) == 0: print('There is nothing in this area to build...') exit(0) version = choice_prompt( 'Choose a release/release candidate to build', versions, ) url = url + version + '/' response = requests.get(url) response.raise_for_status() soup = BeautifulSoup(response.text, 'html.parser') def remove_suffixes(s: str, suffixes): for suffix in suffixes: s = s.removesuffix(suffix) return s build_artifacts = list(set([ remove_suffixes(node.text, ['.sha512', '.asc']) for node in soup.find_all('a') if node.get('href').rstrip('/') not in ['KEYS', '..'] ])) build_artifacts = [ a for a in build_artifacts if a not in ['Parent Directory', 'Name', 'Last modified', 'Size', 'Description'] ] nexus_tarball = None ingester_tarball = None for artifact in build_artifacts: if '-nexus-' in artifact: if any([build['webapp'], build['solr'], build['solr-init']]): nexus_tarball = os.path.join(dst_dir.name, artifact) else: continue elif '-ingester-' in artifact: if any([build['cm'], build['gi']]): ingester_tarball = os.path.join(dst_dir.name, artifact) else: continue for ext in ['', '.sha512', '.asc']: filename = artifact + ext dst = os.path.join(dst_dir.name, filename) print(f'Downloading {url + artifact + ext}') response = requests.get(url + artifact + ext) response.raise_for_status() with open(dst, 'wb') as fp: fp.write(response.content) fp.flush() print(f'Verifying checksum for {artifact}') m = hashlib.sha512() m.update(open(os.path.join(dst_dir.name, artifact), 'rb').read()) if m.hexdigest() != open(os.path.join(dst_dir.name, artifact) + '.sha512', 'r').read().split(' ')[0]: raise ValueError('Bad checksum!') print(f'Verifying signature for {artifact}') try: run_subprocess( [GPG, '--verify', os.path.join(dst_dir.name, artifact) + '.asc', os.path.join(dst_dir.name, artifact)], True ) except: raise ValueError('Bad signature!') print('Extracting release source files...') if any([build['webapp'], build['solr'], build['solr-init']]): run_subprocess( [TAR, 'xvf', nexus_tarball, '-C', dst_dir.name], suppress_output=True ) shutil.move( os.path.join(dst_dir.name, 'Apache-SDAP', nexus_tarball.split('/')[-1].removesuffix('.tar.gz')), os.path.join(dst_dir.name, 'nexus') ) if any([build['cm'], build['gi']]): run_subprocess( [TAR, 'xvf', ingester_tarball, '-C', dst_dir.name], suppress_output=True ) shutil.move( os.path.join(dst_dir.name, 'Apache-SDAP', ingester_tarball.split('/')[-1].removesuffix('.tar.gz')), os.path.join(dst_dir.name, 'ingester') ) elif source_location == GHB: if any([build['webapp'], build['solr'], build['solr-init']]): if not yes_no_prompt('Will you be using a fork for the Nexus repository? Y/[N]: ', False): nexus_repo = ASF_NEXUS_REPO else: nexus_repo = basic_prompt('Enter Nexus fork URL') # TODO Maybe fetch list of branches? nexus_branch = basic_prompt('Enter Nexus branch to build') print(f'Cloning Nexus repo {nexus_repo} at {nexus_branch}') run_subprocess( [GIT, 'clone', '--branch', nexus_branch, nexus_repo], suppress_output=True, cwd=dst_dir.name ) shutil.move( os.path.join(dst_dir.name, 'sdap-nexus'), os.path.join(dst_dir.name, 'nexus') ) if any([build['cm'], build['gi']]): if not yes_no_prompt('Will you be using a fork for the Ingester repository? Y/[N]: ', False): ingester_repo = ASF_INGESTER_REPO else: ingester_repo = basic_prompt('Enter Ingester fork URL') # TODO Maybe fetch list of branches? ingester_branch = basic_prompt('Enter Ingester branch to build') print(f'Cloning Nexus repo {ingester_repo} at {ingester_branch}') run_subprocess( [GIT, 'clone', '--branch', ingester_branch, ingester_repo], suppress_output=True, cwd=dst_dir.name ) shutil.move( os.path.join(dst_dir.name, 'sdap-ingester'), os.path.join(dst_dir.name, 'ingester') ) else: print('NOTE: Building from local FS should only be done for testing purposes. Please use other sources for ' 'official release images (ASF) or anything pushed publicly for production or distribution outside of an ' 'official release (GitHub).') if any([build['webapp'], build['solr'], build['solr-init']]): path = basic_prompt('Enter path to Nexus repository') if not os.path.isdir(path): print(f'{path} either does not exist or is not a directory') exit(1) print(f'Copying Nexus {os.path.abspath(path)} -> {os.path.join(dst_dir.name, "nexus")}') shutil.copytree( path, os.path.join(dst_dir.name, 'nexus') ) if any([build['cm'], build['gi']]): path = basic_prompt('Enter path to Ingester repository') if not os.path.isdir(path): print(f'{path} either does not exist or is not a directory') exit(1) print(f'Copying Ingester {os.path.abspath(path)} -> {os.path.join(dst_dir.name, "nexus")}') shutil.copytree( path, os.path.join(dst_dir.name, 'ingester') ) def main(): parser = argparse.ArgumentParser( epilog="With the exception of the --skip-nexus and --skip-ingester options, the user will be " "prompted to set options at runtime." ) parser.add_argument( '-t', '--tag', dest='tag', help='Tag for built docker images', ) parser.add_argument( '--docker-registry', dest='registry', help='Docker registry to tag images with. Important if you want to push the images.' ) cache = parser.add_mutually_exclusive_group(required=False) cache.add_argument( '--no-cache', dest='cache', action='store_false', help='Don\'t use build cache' ) cache.add_argument( '--cache', dest='cache', action='store_true', help='Use build cache' ) push = parser.add_mutually_exclusive_group(required=False) push.add_argument( '--push', dest='push', action='store_true', help='Push images after building' ) push.add_argument( '--no-push', dest='push', action='store_false', help='Don\'t push images after building' ) parser.add_argument( '--dry-run', dest='dry', action='store_true', help="Don't execute build/push commands, but print them" ) parser.add_argument( '--skip-nexus', dest='skip_nexus', action='store_true', help='Don\'t build Nexus webapp, Solr cloud & Solr cloud init images' ) parser.add_argument( '--skip-ingester', dest='skip_ingester', action='store_true', help='Don\'t build Collection Manager & Granule Ingester images' ) parser.add_argument( '--skip', dest='skip', nargs='*', choices=['webapp', 'solr', 'solr-init', 'gi', 'cm'], help='List of individual images to not build', default=[], ) parser.add_argument( '--nexusproto', dest='proto_src', choices=['pip', 'git', None], default=None, help='Source for nexusproto build. \'pip\' to use the latest published version from PyPi; \'git\' to build ' 'from a git repo (see --nexusproto-repo and --nexusproto-branch). Omit to be prompted' ) parser.add_argument( '--nexusproto-repo', dest='proto_repo', default=None, help='Repository URL for nexusproto build. Omit to be prompted if --nexusproto=git' ) parser.add_argument( '--nexusproto-branch', dest='proto_branch', default=None, help='Repository branch name for nexusproto build. Omit to be prompted if --nexusproto=git' ) parser.set_defaults(cache=None, push=None) args = parser.parse_args() tag, registry, cache, push = args.tag, args.registry, args.cache, args.push proto, proto_repo, proto_branch = args.proto_src, args.proto_repo, args.proto_branch build = {key: key not in args.skip for key in SKIP_KEYS} if args.skip_ingester: build['cm'] = False build['gi'] = False if args.skip_nexus: build['webapp'] = False build['solr'] = False build['solr-init'] = False # TODO: Prompting is a bit cumbersome. Maybe do all prompts then ask for confirmation for all entries if tag is None: tag = basic_prompt('Enter the tag to use for built images') if registry is None: registry = basic_prompt('Enter Docker image registry') if cache is None: cache = yes_no_prompt('Use Docker build cache? [Y]/N: ') if push is None: push = yes_no_prompt('Push built images? [Y]/N: ') if proto is None: proto = 'git' if yes_no_prompt('Custom build nexusproto? Y/[N]: ', default=False) else 'pip' if proto == 'git': if proto_repo is None: proto_repo = basic_prompt('Enter nexusproto repository URL', default=ASF_NEXUSPROTO_REPO) if proto_branch is None: proto_branch = basic_prompt('Enter nexusproto repository branch', default=ASF_NEXUSPROTO_BRANCH) extract_dir = tempfile.TemporaryDirectory() pull_source(extract_dir, build) os.environ['DOCKER_DEFAULT_PLATFORM'] = 'linux/amd64' built_images = [] if any([build['cm'], build['gi']]): print('Building ingester images...') cm_tag = f'{registry}/sdap-collection-manager:{tag}' if build['cm']: run_subprocess(build_cmd( cm_tag, os.path.join(extract_dir.name, 'ingester'), dockerfile='collection_manager/docker/Dockerfile', cache=cache ), dryrun=args.dry) built_images.append(cm_tag) gi_tag = f'{registry}/sdap-granule-ingester:{tag}' if build['gi']: run_subprocess(build_cmd( gi_tag, os.path.join(extract_dir.name, 'ingester'), dockerfile='granule_ingester/docker/Dockerfile', cache=cache, proto=proto, proto_repo=proto_repo, proto_branch=proto_branch ), dryrun=args.dry) built_images.append(gi_tag) if any([build['webapp'], build['solr'], build['solr-init']]): solr_tag = f'{registry}/sdap-solr-cloud:{tag}' if build['solr']: run_subprocess(build_cmd( solr_tag, os.path.join(extract_dir.name, 'nexus/docker/solr'), cache=cache ), dryrun=args.dry) built_images.append(solr_tag) solr_init_tag = f'{registry}/sdap-solr-cloud-init:{tag}' if build['solr-init']: run_subprocess(build_cmd( solr_init_tag, os.path.join(extract_dir.name, 'nexus/docker/solr'), dockerfile='cloud-init/Dockerfile', cache=cache ), dryrun=args.dry) built_images.append(solr_init_tag) webapp_tag = f'{registry}/sdap-nexus-webapp:{tag}' if build['webapp']: run_subprocess(build_cmd( webapp_tag, os.path.join(extract_dir.name, 'nexus'), dockerfile='docker/nexus-webapp/Dockerfile', cache=cache, proto=proto, proto_repo=proto_repo, proto_branch=proto_branch ), dryrun=args.dry) built_images.append(webapp_tag) if not args.dry: print('Image builds completed') if push: for image in built_images: run_subprocess( [DOCKER, 'push', image], dryrun=args.dry ) print('done') if __name__ == '__main__': try: main() except KeyboardInterrupt: print('\nBuild cancelled by user') exit(0)