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)