alpine-zdt-images/alpine-cloud-images/build

344 lines
9.8 KiB
Plaintext
Raw Normal View History

2021-11-23 06:09:18 +00:00
#!/usr/bin/env python3
# vim: ts=4 et:
# Ensure we're using the Python virtual env with our installed dependencies
import os
import sys
import subprocess
sys.pycache_prefix = 'work/__pycache__'
# Create the work environment if it doesn't exist.
if not os.path.exists('work'):
import venv
PIP_LIBS = [
'mergedeep',
'pyhocon',
'python-dateutil',
'ruamel.yaml',
]
print('Work environment does not exist, creating...', file=sys.stderr)
venv.create('work', with_pip=True)
2021-11-28 23:04:28 +00:00
subprocess.run(['work/bin/pip', 'install', '-U', 'pip', 'wheel'])
2021-11-23 06:09:18 +00:00
subprocess.run(['work/bin/pip', 'install', '-U', *PIP_LIBS])
# Re-execute using the right virtual environment, if necessary.
venv_args = [os.path.join('work', 'bin', 'python3')] + sys.argv
if os.path.join(os.getcwd(), venv_args[0]) != sys.executable:
print("Re-executing with work environment's Python...\n", file=sys.stderr)
os.execv(venv_args[0], venv_args)
# We're now in the right Python environment...
import argparse
import io
import logging
import shutil
import time
from glob import glob
2021-11-23 06:09:18 +00:00
from subprocess import Popen, PIPE
from urllib.request import urlopen
import clouds
from alpine import Alpine
2021-11-23 06:09:18 +00:00
from image_configs import ImageConfigManager
### Constants & Variables
STEPS = ['configs', 'state', 'rollback', 'local', 'upload', 'import', 'publish', 'release']
2021-11-23 06:09:18 +00:00
LOGFORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
WORK_CLEAN = {'bin', 'include', 'lib', 'pyvenv.cfg', '__pycache__'}
WORK_OVERLAYS = ['configs', 'scripts']
UEFI_FIRMWARE = {
'aarch64': {
'apk': 'aavmf',
'bin': 'usr/share/AAVMF/QEMU_EFI.fd',
},
'x86_64': {
'apk': 'ovmf',
'bin': 'usr/share/OVMF/OVMF.fd',
}
2021-11-23 06:09:18 +00:00
}
alpine = Alpine()
2021-11-23 06:09:18 +00:00
### Functions
# ensure list has unique values, preserving order
def unique_list(x):
d = {e: 1 for e in x}
return list(d.keys())
def remove_dupe_args():
class RemoveDupeArgs(argparse.Action):
def __call__(self, parser, args, values, option_string=None):
setattr(args, self.dest, unique_list(values))
return RemoveDupeArgs
def are_args_valid(checker):
class AreArgsValid(argparse.Action):
def __call__(self, parser, args, values, option_string=None):
# remove duplicates
values = unique_list(values)
for x in values:
if not checker(x):
parser.error(f"{option_string} value is not a {self.metavar}: {x}")
setattr(args, self.dest, values)
return AreArgsValid
def clean_work():
log.info('Cleaning work environment')
for x in (set(os.listdir('work')) - WORK_CLEAN):
x = os.path.join('work', x)
log.debug('removing %s', x)
if os.path.isdir(x) and not os.path.islink(x):
2021-11-23 06:09:18 +00:00
shutil.rmtree(x)
else:
os.unlink(x)
def is_images_conf(o, x):
if not all([
o == 'configs',
x.endswith('/images.conf'),
os.path.islink(x),
]):
2021-11-23 06:09:18 +00:00
return False
# must also link to file in the same directory
2021-11-23 06:09:18 +00:00
x_link = os.path.normpath(os.readlink(x))
return x_link == os.path.basename(x_link)
def install_overlay(overlay):
log.info("Installing '%s' overlay in work environment", overlay)
dest_dir = os.path.join('work', overlay)
os.makedirs(dest_dir, exist_ok=True)
for src in unique_list(['.'] + args.custom):
src_dir = os.path.join(src, overlay)
if not os.path.exists(src_dir):
log.debug('%s does not exist, skipping', src_dir)
continue
for x in glob(os.path.join(src_dir, '**'), recursive=True):
x = x.removeprefix(src_dir + '/')
2021-11-23 06:09:18 +00:00
src_x = os.path.join(src_dir, x)
dest_x = os.path.join(dest_dir, x)
if is_images_conf(overlay, src_x):
2021-11-23 06:09:18 +00:00
rel_x = os.readlink(src_x)
if os.path.islink(dest_x):
log.debug('overriding %s', dest_x)
os.unlink(dest_x)
log.debug('ln -s %s %s', rel_x, dest_x)
os.symlink(rel_x, dest_x)
continue
2021-11-23 06:09:18 +00:00
if os.path.isdir(src_x):
if not os.path.exists(dest_x):
log.debug('makedirs %s', dest_x)
os.makedirs(dest_x)
if os.path.isdir(dest_x):
continue
2021-11-23 06:09:18 +00:00
if os.path.exists(dest_x):
log.critical('Unallowable destination overwirte detected: %s', dest_x)
2021-11-23 06:09:18 +00:00
sys.exit(1)
log.debug('cp -p %s %s', src_x, dest_x)
shutil.copy(src_x, dest_x)
2021-11-23 06:09:18 +00:00
def install_overlays():
for overlay in WORK_OVERLAYS:
if not os.path.isdir(os.path.join('work', overlay)):
install_overlay(overlay)
else:
log.info("Using existing '%s' in work environment", overlay)
def install_qemu_firmware():
firm_dir = 'work/firmware'
if os.path.isdir(firm_dir):
log.info('Using existing UEFI firmware in work environment')
return
log.info('Installing UEFI firmware in work environment')
os.makedirs(firm_dir)
for arch, a_cfg in UEFI_FIRMWARE.items():
apk = a_cfg['apk']
bin = a_cfg['bin']
v = alpine.apk_version('community', arch, apk)
apk_url = f"{alpine.repo_url('community', arch)}/{apk}-{v}.apk"
data = urlopen(apk_url).read()
2021-11-23 06:09:18 +00:00
# Python tarfile library can't extract from APKs
tar_cmd = ['tar', '-zxf', '-', '-C', firm_dir, bin]
p = Popen(tar_cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
out, err = p.communicate(input=data)
if p.returncode:
log.critical('Unable to untar %s to get %s', apk_url, bin)
2021-11-23 06:09:18 +00:00
log.error('%s = %s', p.returncode, ' '.join(tar_cmd))
log.error('STDOUT:\n%s', out.decode('utf8'))
log.error('STDERR:\n%s', err.decode('utf8'))
sys.exit(1)
firm_bin = os.path.join(firm_dir, f"uefi-{arch}.bin")
os.symlink(bin, firm_bin)
2021-11-23 06:09:18 +00:00
### Command Line & Logging
parser = argparse.ArgumentParser(
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
# general options
parser.add_argument(
'--debug', action='store_true', help='enable debug output')
parser.add_argument(
'--clean', action='store_true', help='start with a clean work environment')
# config options
parser.add_argument(
'--custom', metavar='DIR', nargs='+', action=are_args_valid(os.path.isdir),
default=[], help='overlay custom directory in work environment')
# state options
2021-11-23 06:09:18 +00:00
parser.add_argument(
'--skip', metavar='KEY', nargs='+', action=remove_dupe_args(),
default=[], help='skip variants with dimension key(s)')
parser.add_argument(
'--only', metavar='KEY', nargs='+', action=remove_dupe_args(),
default=[], help='only variants with dimension key(s)')
parser.add_argument(
'--revise', action='store_true',
help='remove existing local/uploaded/imported image, or bump revision and '
' rebuild if published or released')
parser.add_argument(
'--use-broker', action='store_true',
help='use the identity broker to get credentials')
2021-11-23 06:09:18 +00:00
# packer options
parser.add_argument(
'--no-color', action='store_true', help='turn off Packer color output')
parser.add_argument(
'--parallel', metavar='N', type=int, default=1,
help='build N images in parallel')
parser.add_argument(
'--vars', metavar='FILE', nargs='+', action=are_args_valid(os.path.isfile),
default=[], help='supply Packer with -vars-file(s)')
# positional argument
2021-11-23 06:09:18 +00:00
parser.add_argument(
'step', choices=STEPS, help='build up to and including this step')
2021-11-23 06:09:18 +00:00
args = parser.parse_args()
log = logging.getLogger('build')
log.setLevel(logging.DEBUG if args.debug else logging.INFO)
console = logging.StreamHandler()
logfmt = logging.Formatter(LOGFORMAT, datefmt='%FT%TZ')
logfmt.converter = time.gmtime
console.setFormatter(logfmt)
log.addHandler(console)
log.debug(args)
if args.step == 'rollback':
log.warning('"rollback" step enables --revise option')
args.revise = True
2021-11-23 06:09:18 +00:00
# set up credential provider, if we're going to use it
if args.use_broker:
2021-11-28 23:04:28 +00:00
clouds.set_credential_provider(debug=args.debug)
2021-11-23 06:09:18 +00:00
### Setup Configs
latest = alpine.version_info()
2021-11-23 06:09:18 +00:00
log.info('Latest Alpine version %s and release %s', latest['version'], latest['release'])
if args.clean:
clean_work()
# install overlay(s) if missing
install_overlays()
image_configs = ImageConfigManager(
conf_path='work/configs/images.conf',
yaml_path='work/images.yaml',
log='build',
alpine=alpine,
2021-11-23 06:09:18 +00:00
)
log.info('Configuration Complete')
if args.step == 'configs':
sys.exit(0)
### What needs doing?
2021-11-28 23:04:28 +00:00
if not image_configs.refresh_state(
2021-11-23 06:09:18 +00:00
step=args.step, only=args.only, skip=args.skip, revise=args.revise):
log.info('No pending actions to take at this time.')
sys.exit(0)
if args.step == 'state' or args.step == 'rollback':
2021-11-23 06:09:18 +00:00
sys.exit(0)
# install firmware if missing
install_qemu_firmware()
### Build/Import/Publish with Packer
env = os.environ | {
'TZ': 'UTC',
'PACKER_CACHE_DIR': 'work/packer_cache'
}
packer_cmd = [
'packer', 'build', '-timestamp-ui',
'-parallel-builds', str(args.parallel)
]
if args.no_color:
packer_cmd.append('-color=false')
if args.use_broker:
packer_cmd += ['-var', 'USE_BROKER=1']
if args.debug:
# do not add '-debug', it will pause between steps
packer_cmd += ['-var', 'DEBUG=1']
for var_file in args.vars:
packer_cmd.append(f"-var-file={var_file}")
packer_cmd += ['.']
log.info('Executing Packer...')
log.debug(packer_cmd)
out = io.StringIO()
p = Popen(packer_cmd, stdout=PIPE, encoding='utf8', env=env)
while p.poll() is None:
text = p.stdout.readline()
out.write(text)
print(text, end="")
if p.returncode != 0:
log.critical('Packer Failure')
sys.exit(p.returncode)
log.info('Packer Completed')
2021-11-28 23:04:28 +00:00
# update final state in work/images.yaml
image_configs.refresh_state(
step='final',
only=args.only,
skip=args.skip
)
log.info('Build Finished')