From 274d883acb82d900abee9c451e1503777f7d02b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jake=20Buchholz=20G=C3=B6kt=C3=BCrk?= Date: Sun, 28 Nov 2021 23:04:28 +0000 Subject: [PATCH] alpine-cloud-images, part three --- alpine.pkr.hcl | 22 +- alpine.py | 3 + build | 18 +- cloud_helper.py | 32 +-- clouds/__init__.py | 13 +- clouds/aws.py | 152 ++++++------- clouds/identity_broker_client.py | 6 +- clouds/interfaces/adapter.py | 6 +- configs/alpine.conf | 54 +++-- configs/arch/x86_64.conf | 3 + configs/bootstrap/cloudinit.conf | 2 - configs/bootstrap/tiny.conf | 5 +- configs/cloud/aws.conf | 51 ++--- configs/cloud/oci.conf | 2 - configs/firmware/bios.conf | 8 +- configs/firmware/uefi.conf | 4 +- configs/{configs.conf => images.conf} | 0 configs/version/3.15.conf | 9 + configs/version/base/1.conf | 2 - configs/version/base/2.conf | 1 + configs/version/base/3.conf | 3 +- configs/version/base/4.conf | 8 + configs/version/edge.conf | 6 +- gen_releases.py | 100 +++++++++ image_configs.py | 201 +++++++++++------- overlays/testing/configs/alpine-testing.conf | 39 ++++ overlays/testing/configs/images.conf | 1 + .../testing/configs/testing/cloudinit.conf | 9 + overlays/testing/configs/testing/oci.conf | 4 + overlays/testing/scripts/setup-cloudinit | 27 +++ scripts/setup | 33 ++- 31 files changed, 534 insertions(+), 290 deletions(-) delete mode 100644 configs/bootstrap/cloudinit.conf delete mode 100644 configs/cloud/oci.conf rename configs/{configs.conf => images.conf} (100%) create mode 100644 configs/version/3.15.conf create mode 100644 configs/version/base/4.conf create mode 100755 gen_releases.py create mode 100644 overlays/testing/configs/alpine-testing.conf create mode 120000 overlays/testing/configs/images.conf create mode 100644 overlays/testing/configs/testing/cloudinit.conf create mode 100644 overlays/testing/configs/testing/oci.conf create mode 100755 overlays/testing/scripts/setup-cloudinit diff --git a/alpine.pkr.hcl b/alpine.pkr.hcl index 3c93c5e..e55f28b 100644 --- a/alpine.pkr.hcl +++ b/alpine.pkr.hcl @@ -28,15 +28,9 @@ locals { # randomly generated password password = uuidv4() - # all build configs - all_configs = yamldecode(file("work/configs.yaml")) - - # load the build actions to be taken - actions = yamldecode(file("work/actions.yaml")) - # resolve actionable build configs - configs = { for b, acfg in local.actions: - b => merge(local.all_configs[b], acfg) if length(acfg.actions) > 0 + configs = { for b, cfg in yamldecode(file("work/images.yaml")): + b => cfg if contains(keys(cfg), "actions") } } @@ -99,10 +93,10 @@ build { boot_wait = var.qemu.boot_wait[B.value.arch] # results - output_directory = B.value.image.dir - disk_size = B.value.image.size - format = B.value.image.format - vm_name = B.value.image.file + output_directory = "work/images/${B.value.cloud}/${B.value.image_key}" + disk_size = B.value.size + format = B.value.local_format + vm_name = "image.${B.value.local_format}" } } @@ -150,16 +144,18 @@ build { environment_vars = [ "DEBUG=${var.DEBUG}", "ARCH=${B.value.arch}", + "BOOTLOADER=${B.value.bootloader}", "BOOTSTRAP=${B.value.bootstrap}", "BUILD_NAME=${B.value.name}", "BUILD_REVISION=${B.value.revision}", "CLOUD=${B.value.cloud}", "END_OF_LIFE=${B.value.end_of_life}", "FIRMWARE=${B.value.firmware}", - "IMAGE_LOGIN=${B.value.image.login}", + "IMAGE_LOGIN=${B.value.login}", "INITFS_FEATURES=${B.value.initfs_features}", "KERNEL_MODULES=${B.value.kernel_modules}", "KERNEL_OPTIONS=${B.value.kernel_options}", + "MOTD=${B.value.motd}", "PACKAGES_ADD=${B.value.packages.add}", "PACKAGES_DEL=${B.value.packages.del}", "PACKAGES_NOSCRIPTS=${B.value.packages.noscripts}", diff --git a/alpine.py b/alpine.py index ebf99ac..46b10f1 100644 --- a/alpine.py +++ b/alpine.py @@ -8,6 +8,8 @@ from urllib.request import urlopen CDN_URL = 'https://dl-cdn.alpinelinux.org/alpine' +# TODO: also get EOL from authoritative source + def get_version_release(alpine_version): apk_ver = get_apk_version(alpine_version, 'main', 'x86_64', 'alpine-base') release = apk_ver.split('-')[0] @@ -16,6 +18,7 @@ def get_version_release(alpine_version): # TODO? maybe download and parse APKINDEX instead? +# also check out https://dl-cdn.alpinelinux.org/alpine/v3.15/releases/x86_64/latest-releases.yaml def get_apk_version(alpine_version, repo, arch, apk): repo_url = f"{CDN_URL}/{alpine_version}/{repo}/{arch}" apks_re = re.compile(f'"{apk}-(\\d.*)\\.apk"') diff --git a/build b/build index 86df6ea..6f13f5c 100755 --- a/build +++ b/build @@ -13,7 +13,6 @@ if not os.path.exists('work'): import venv PIP_LIBS = [ - 'pip', 'mergedeep', 'pyhocon', 'python-dateutil', @@ -21,6 +20,7 @@ if not os.path.exists('work'): ] print('Work environment does not exist, creating...', file=sys.stderr) venv.create('work', with_pip=True) + subprocess.run(['work/bin/pip', 'install', '-U', 'pip', 'wheel']) subprocess.run(['work/bin/pip', 'install', '-U', *PIP_LIBS]) # Re-execute using the right virtual environment, if necessary. @@ -109,6 +109,7 @@ def is_same_dir_symlink(x): return x_link == os.path.basename(x_link) +# TODO: revisit this to improve --custom overlay implementation def install_overlay(overlay): log.info("Installing '%s' overlay in work environment", overlay) dest_dir = os.path.join('work', overlay) @@ -123,7 +124,7 @@ def install_overlay(overlay): else: rel_x = os.path.relpath(src_x, dest_dir) - # TODO: only images.conf symlink can be overridden + # TODO: only images.conf symlink can be overridden, in reality if os.path.islink(dest_x): # only same-dir symlinks can be overridden if not is_same_dir_symlink(dest_x): @@ -230,7 +231,7 @@ log.debug(args) # set up credential provider, if we're going to use it if args.use_broker: - clouds.set_credential_provider() + clouds.set_credential_provider(debug=args.debug) ### Setup Configs @@ -256,7 +257,7 @@ if args.step == 'configs': ### What needs doing? -if not image_configs.determine_actions( +if not image_configs.refresh_state( 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) @@ -307,4 +308,11 @@ if p.returncode != 0: log.info('Packer Completed') -# TODO? collect artifacts? +# update final state in work/images.yaml +image_configs.refresh_state( + step='final', + only=args.only, + skip=args.skip +) + +log.info('Build Finished') diff --git a/cloud_helper.py b/cloud_helper.py index 3aaeaa0..d568254 100755 --- a/cloud_helper.py +++ b/cloud_helper.py @@ -42,28 +42,6 @@ ACTIONS = ['import', 'publish'] LOGFORMAT = '%(name)s - %(levelname)s - %(message)s' -### Functions - -# TODO? be more specific with args? -# import image config's local image to cloud -def import_image(ic): - imported = clouds.import_image(ic) - # write imported metadata - imported_yaml = Path(os.path.join(ic.local_dir, 'imported.yaml')) - yaml.dump(imported, imported_yaml) - - -# TODO? be more specific with args? -# publish image config's imported image to target regions with expected permissions -def publish_image(ic): - published = clouds.publish_image(ic) - # ensure image work directory exists - os.makedirs(ic.local_dir, exist_ok=True) - # write published metadata - published_yaml = Path(os.path.join(ic.local_dir, 'published.yaml')) - yaml.dump(published, published_yaml) - - ### Command Line & Logging parser = argparse.ArgumentParser(description=NOTE) @@ -85,7 +63,7 @@ log.debug(args) # set up credential provider, if we're going to use it if args.use_broker: - clouds.set_credential_provider() + clouds.set_credential_provider(debug=args.debug) # load build configs configs = ImageConfigManager( @@ -95,13 +73,15 @@ configs = ImageConfigManager( ) yaml = YAML() -yaml.default_flow_style = False +yaml.explicit_start = True for image_key in args.image_keys: image_config = configs.get(image_key) if args.action == 'import': - import_image(image_config) + clouds.import_image(image_config) elif args.action == 'publish': - publish_image(image_config) + os.makedirs(image_config.local_dir, exist_ok=True) + artifacts = clouds.publish_image(image_config) + yaml.dump(artifacts, Path(image_config.local_dir) / 'artifacts.yaml') diff --git a/clouds/__init__.py b/clouds/__init__.py index f35e94d..cd23d33 100644 --- a/clouds/__init__.py +++ b/clouds/__init__.py @@ -16,9 +16,9 @@ register(aws) # , oci, azure, gcp) # using a credential provider is optional, set across all adapters -def set_credential_provider(): +def set_credential_provider(debug=False): from .identity_broker_client import IdentityBrokerClient - cred_provider = IdentityBrokerClient() + cred_provider = IdentityBrokerClient(debug=debug) for adapter in ADAPTERS.values(): adapter.cred_provider = cred_provider @@ -26,15 +26,18 @@ def set_credential_provider(): ### forward to the correct adapter def latest_build_image(config): - return ADAPTERS[config.cloud].latest_build_image(config.name) + return ADAPTERS[config.cloud].latest_build_image( + config.project, + config.image_key + ) def import_image(config): return ADAPTERS[config.cloud].import_image(config) -def remove_image(config): - return ADAPTERS[config.cloud].remove_image(config.remote_image['id']) +def remove_image(config, image_id): + return ADAPTERS[config.cloud].remove_image(image_id) def publish_image(config): diff --git a/clouds/aws.py b/clouds/aws.py index 53808f0..51b5fc9 100644 --- a/clouds/aws.py +++ b/clouds/aws.py @@ -2,20 +2,22 @@ # vim: ts=4 et: import logging +import hashlib import os -import random -import string -import sys import time from datetime import datetime from subprocess import Popen, PIPE, run from .interfaces.adapter import CloudAdapterInterface -from image_configs import Tags +from image_configs import Tags, DictObj class AWSCloudAdapter(CloudAdapterInterface): + IMAGE_INFO = [ + 'revision', 'imported', 'import_id', 'import_region', 'published', + 'end_of_life', + ] CRED_MAP = { 'access_key': 'aws_access_key_id', 'secret_key': 'aws_secret_access_key', @@ -54,7 +56,7 @@ class AWSCloudAdapter(CloudAdapterInterface): return self._sessions[region] - # TODO: property? + @property def regions(self): if self.cred_provider: return self.cred_provider.get_regions(self.cloud) @@ -62,7 +64,7 @@ class AWSCloudAdapter(CloudAdapterInterface): # list of all subscribed regions return {r['RegionName']: True for r in self.session().client('ec2').describe_regions()['Regions']} - # TODO: property? + @property def default_region(self): if self.cred_provider: return self.cred_provider.get_default_region(self.cloud) @@ -79,82 +81,63 @@ class AWSCloudAdapter(CloudAdapterInterface): # return dict suitable to use for session() return {self.CRED_MAP[k]: v for k, v in creds.items() if k in self.CRED_MAP} - def _get_images_with_tags(self, tags={}, region=None): + def _get_images_with_tags(self, project, image_key, tags={}, region=None): ec2r = self.session(region).resource('ec2') req = {'Owners': ['self'], 'Filters': []} + tags |= { + 'project': project, + 'image_key': image_key, + } for k, v in tags.items(): req['Filters'].append({'Name': f"tag:{k}", 'Values': [str(v)]}) return sorted( ec2r.images.filter(**req), key=lambda k: k.creation_date, reverse=True) - def _aws_tags(self, b_tags): - # convert dict to [{'Key': k, 'Value': v}, ...] - a_tags = [] - for k, v in b_tags.items(): - # add extra Name tag - if k == 'name': - a_tags += [{'Key': 'Name', 'Value': str(v)}] - - a_tags += [{'Key': k, 'Value': str(v)}] - - return a_tags - - # cloud-agnostic necessary info about an ec2.Image + # necessary cloud-agnostic image info def _image_info(self, i): tags = Tags(from_list=i.tags) - del tags.Name - # TODO? realm/partition? - return { - 'id': i.image_id, - 'region': i.meta.client.meta.region_name, - 'tags': dict(tags) - # TODO? narrow down to these? - # imported = i.tags.imported - # published = i.tags.published - # revision = i.tags.build_revision - # source_id = i.image_id, - # source_region = i.meta.client.meta.region_name, - } + return DictObj({k: tags.get(k, None) for k in self.IMAGE_INFO}) # get the latest imported image for a given build name - def latest_build_image(self, build_name): - images = self._get_images_with_tags(tags={'build_name': build_name}) + def latest_build_image(self, project, image_key): + images = self._get_images_with_tags( + project=project, + image_key=image_key, + ) if images: # first one is the latest return self._image_info(images[0]) return None - ## TODO: rework these next two as a Tags class - # import an image # NOTE: requires 'vmimport' role with read/write of .* and its objects def import_image(self, ic): log = logging.getLogger('import') image_path = ic.local_path - image_aws = image_path.replace(ic.local_format, 'vhd') + image_aws = ic.local_dir / 'image.vhd' + name = ic.image_name description = ic.image_description - session = self.session() - s3r = session.resource('s3') - ec2c = session.client('ec2') - ec2r = session.resource('ec2') - # convert QCOW2 to VHD log.info('Converting %s to VHD format', image_path) p = Popen(self.CONVERT_CMD + (image_path, image_aws), stdout=PIPE, stdin=PIPE, encoding='utf8') out, err = p.communicate() if p.returncode: log.error('Unable to convert %s to VHD format (%s)', image_path, p.returncode) + log.error('EXIT: %d', p.returncode) log.error('STDOUT:\n%s', out) log.error('STDERR:\n%s', err) - sys.exit(p.returncode) + raise RuntimeError - bucket_name = 'alpine-cloud-images.' + ''.join( - random.SystemRandom().choice(string.ascii_lowercase + string.digits) - for _ in range(40)) - s3_key = os.path.basename(image_aws) + session = self.session() + s3r = session.resource('s3') + ec2c = session.client('ec2') + ec2r = session.resource('ec2') + + bucket_name = 'alpine-cloud-images.' + hashlib.sha1(os.urandom(40)).hexdigest() + s3_key = name + '.vhd' bucket = s3r.Bucket(bucket_name) log.info('Creating S3 bucket %s', bucket.name) @@ -166,7 +149,7 @@ class AWSCloudAdapter(CloudAdapterInterface): try: log.info('Uploading %s to %s', image_aws, s3_url) - bucket.upload_file(image_aws, s3_key) + bucket.upload_file(str(image_aws), s3_key) # import snapshot from S3 log.info('Importing EC2 snapshot from %s', s3_url) @@ -187,7 +170,7 @@ class AWSCloudAdapter(CloudAdapterInterface): if task_detail['Status'] not in ['pending', 'active', 'completed']: msg = f"Bad EC2 snapshot import: {task_detail['Status']} - {task_detail['StatusMessage']}" log.error(msg) - raise(RuntimeError, msg) + raise RuntimeError(msg) if task_detail['Status'] == 'completed': snapshot_id = task_detail['SnapshotId'] @@ -246,8 +229,8 @@ class AWSCloudAdapter(CloudAdapterInterface): # tag image (adds imported tag) log.info('Tagging EC2 AMI %s', image_id) tags.imported = datetime.utcnow().isoformat() - tags.source_id = image_id - tags.source_region = ec2c.meta.region_name + tags.import_id = image_id + tags.import_region = ec2c.meta.region_name image.create_tags(Tags=tags.as_list()) except Exception: log.error('Unable to tag image:', exc_info=True) @@ -263,7 +246,6 @@ class AWSCloudAdapter(CloudAdapterInterface): log = logging.getLogger('build') ec2r = self.session().resource('ec2') image = ec2r.Image(image_id) - # TODO? protect against removing a published image? snapshot_id = image.block_device_mappings[0]['Ebs']['SnapshotId'] snapshot = ec2r.Snapshot(snapshot_id) log.info('Deregistering %s', image_id) @@ -271,35 +253,29 @@ class AWSCloudAdapter(CloudAdapterInterface): log.info('Deleting %s', snapshot_id) snapshot.delete() - # TODO: this should be standardized and work with cred_provider - def _get_all_regions(self): - ec2c = self.session().client('ec2') - res = ec2c.describe_regions(AllRegions=True) - return { - r['RegionName']: r['OptInStatus'] != 'not-opted-in' - for r in res['Regions'] - } - # publish an image def publish_image(self, ic): log = logging.getLogger('publish') - source_image = self.latest_build_image(ic.name) + source_image = self.latest_build_image( + ic.project, + ic.image_key, + ) if not source_image: - log.error('No source image for %s', ic.name) - sys.exit(1) + log.error('No source image for %s', ic.image_key) + raise RuntimeError('Missing source imamge') - source_id = source_image['id'] - log.info('Publishing source: %s, %s', source_image['region'], source_id) + source_id = source_image.import_id + source_region = source_image.import_region + log.info('Publishing source: %s/%s', source_region, source_id) source = self.session().resource('ec2').Image(source_id) source_tags = Tags(from_list=source.tags) - publish = ic.publish # sort out published image access permissions perms = {'groups': [], 'users': []} - if 'PUBLIC' in publish['access'] and publish['access']['PUBLIC']: + if ic.access.get('PUBLIC', None): perms['groups'] = ['all'] else: - for k, v in publish['access'].items(): + for k, v in ic.access.items(): if v: log.debug('users: %s', k) perms['users'].append(str(k)) @@ -307,14 +283,13 @@ class AWSCloudAdapter(CloudAdapterInterface): log.debug('perms: %s', perms) # resolve destination regions - regions = self.regions() - if 'ALL' in publish['regions'] and publish['regions']['ALL']: + regions = self.regions + if ic.regions.pop('ALL', None): log.info('Publishing to ALL available regions') else: # clear ALL out of the way if it's still there - publish['regions'].pop('ALL', None) - # TODO: politely warn/skip unknown regions in b.aws.regions - regions = {r: regions[r] for r in publish['regions']} + ic.regions.pop('ALL', None) + regions = {r: regions[r] for r in ic.regions} publishing = {} for r in regions.keys(): @@ -324,10 +299,9 @@ class AWSCloudAdapter(CloudAdapterInterface): images = self._get_images_with_tags( region=r, - tags={ - 'build_name': ic.name, - 'build_revision': ic.revision - } + project=ic.project, + image_key=ic.image_key, + tags={'revision': ic.revision} ) if images: image = images[0] @@ -338,8 +312,8 @@ class AWSCloudAdapter(CloudAdapterInterface): res = ec2c.copy_image( Description=source.description, Name=source.name, - SourceImageId=source.id, - SourceRegion=source_image['region'], + SourceImageId=source_id, + SourceRegion=source_region, ) except Exception: log.warning('Skipping %s, unable to copy image:', r, exc_info=True) @@ -351,11 +325,11 @@ class AWSCloudAdapter(CloudAdapterInterface): publishing[r] = image - published = {} + artifacts = {} copy_wait = 180 - while len(published) < len(publishing): + while len(artifacts) < len(publishing): for r, image in publishing.items(): - if r not in published: + if r not in artifacts: image.reload() if image.state == 'available': # tag image @@ -395,22 +369,22 @@ class AWSCloudAdapter(CloudAdapterInterface): log.info('%s: Setting EOL deprecation time on %s', r, image.id) ec2c.enable_image_deprecation( ImageId=image.id, - DeprecateAt=f"{source_image['tags']['end_of_life']}T23:59:59Z" + DeprecateAt=f"{source_image.end_of_life}T23:59:59Z" ) - published[r] = self._image_info(image) + artifacts[r] = image.id if image.state == 'failed': log.error('%s: %s - %s - %s', r, image.id, image.state, image.state_reason) - published[r] = None + artifacts[r] = None - remaining = len(publishing) - len(published) + remaining = len(publishing) - len(artifacts) if remaining > 0: log.info('Waiting %ds for %d images to complete', copy_wait, remaining) time.sleep(copy_wait) copy_wait = 30 - return published + return artifacts def register(cloud, cred_provider=None): diff --git a/clouds/identity_broker_client.py b/clouds/identity_broker_client.py index d47a21c..0465d82 100644 --- a/clouds/identity_broker_client.py +++ b/clouds/identity_broker_client.py @@ -59,6 +59,7 @@ class IdentityBrokerClient: return True def _get(self, path): + self._logger.debug("request: %s", path) if not self._is_cache_valid(path): while True: # to handle rate limits try: @@ -95,6 +96,7 @@ class IdentityBrokerClient: self._cache[path] = json.load(res) break + self._logger.debug("response: %s", self._cache[path]) return self._cache[path] def get_credentials_url(self, vendor): @@ -117,7 +119,6 @@ class IdentityBrokerClient: if region['default']: self._default_region[vendor] = region['name'] - out[None] = region['credentials_url'] return out @@ -128,4 +129,7 @@ class IdentityBrokerClient: return self._default_region.get(vendor) def get_credentials(self, vendor, region=None): + if not region: + region = self.get_default_region(vendor) + return self._get(self.get_regions(vendor)[region]) diff --git a/clouds/interfaces/adapter.py b/clouds/interfaces/adapter.py index d0b5b07..77618b2 100644 --- a/clouds/interfaces/adapter.py +++ b/clouds/interfaces/adapter.py @@ -13,9 +13,11 @@ class CloudAdapterInterface: def sdk(self): raise NotImplementedError + @property def regions(self): raise NotImplementedError + @property def default_region(self): raise NotImplementedError @@ -25,11 +27,9 @@ class CloudAdapterInterface: def session(self, region=None): raise NotImplementedError - def latest_build_image(self, build_name): + def latest_build_image(self, project, image_key): raise NotImplementedError - # TODO: be more specific about what gets passed into these - def import_image(self, config): raise NotImplementedError diff --git a/configs/alpine.conf b/configs/alpine.conf index 9bde530..6180074 100644 --- a/configs/alpine.conf +++ b/configs/alpine.conf @@ -1,24 +1,45 @@ # vim: ts=2 et: +# NOTE: If you are using alpine-cloud-images to build public cloud images +# for something/someone other than Alpine Linux, you *MUST* override +# *AT LEAST* the 'project' setting with a unique identifier string value +# via a "config overlay" to avoid image import and publishing collisions. + +project = https://alpinelinux.org/cloud + # all build configs start with these Default { + project = ${project} + # image name/description components name = [ alpine ] - description = [ "Alpine Linux {release}-r{revision}" ] + description = [ Alpine Linux ] + + motd { + welcome = "Welcome to Alpine!" + wiki = \ + "The Alpine Wiki contains a large amount of how-to guides and general\n"\ + "information about administrating Alpine systems.\n"\ + "See ." + version_notes = "Release Notes:\n"\ + "* " + release_notes = "* = true - # ... - } - regions { - ALL = true - # alternately... - # = true - # ... - } - } -} +kernel_modules.ena = true +initfs_features.ena = true + +access.PUBLIC = true +regions.ALL = true WHEN { - # Arch aarch64 { - aws.arch = arm64 + # new AWS aarch64 default... + kernel_modules.gpio_pl061 = true + initfs_features.gpio_pl061 = true + WHEN { + "3.14 3.13 3.12" { + # ...but not supported for older versions + kernel_modules.gpio_pl061 = false + initfs_features.gpio_pl061 = false + } + } } - x86_64 { - aws.arch = x86_64 - } - - # Firmware - bios { - aws.boot_mode = legacy-bios - } - uefi { - aws.boot_mode = uefi - } -} +} \ No newline at end of file diff --git a/configs/cloud/oci.conf b/configs/cloud/oci.conf deleted file mode 100644 index 17a83c2..0000000 --- a/configs/cloud/oci.conf +++ /dev/null @@ -1,2 +0,0 @@ -# vim: ts=2 et: -builder = qemu \ No newline at end of file diff --git a/configs/firmware/bios.conf b/configs/firmware/bios.conf index ca37692..c1d9602 100644 --- a/configs/firmware/bios.conf +++ b/configs/firmware/bios.conf @@ -1,8 +1,6 @@ # vim: ts=2 et: name = [bios] -packages { - syslinux = --no-scripts -} - -qemu.firmware = null \ No newline at end of file +bootloader = extlinux +packages.syslinux = --no-scripts +qemu.firmware = null \ No newline at end of file diff --git a/configs/firmware/uefi.conf b/configs/firmware/uefi.conf index b37c25b..fe23a45 100644 --- a/configs/firmware/uefi.conf +++ b/configs/firmware/uefi.conf @@ -1,8 +1,10 @@ # vim: ts=2 et: name = [uefi] +bootloader = grub-efi packages { - grub-efi = --no-scripts + grub-efi = --no-scripts + dosfstools = true } WHEN { diff --git a/configs/configs.conf b/configs/images.conf similarity index 100% rename from configs/configs.conf rename to configs/images.conf diff --git a/configs/version/3.15.conf b/configs/version/3.15.conf new file mode 100644 index 0000000..f7f92f2 --- /dev/null +++ b/configs/version/3.15.conf @@ -0,0 +1,9 @@ +# vim: ts=2 et: + +include required("base/3.conf") + +end_of_life = "2023-11-01" + +motd { + sudo_deprecated = "NOTE: 'sudo' has been deprecated, please use 'doas' instead." +} \ No newline at end of file diff --git a/configs/version/base/1.conf b/configs/version/base/1.conf index 628fe85..ac3cbc3 100644 --- a/configs/version/base/1.conf +++ b/configs/version/base/1.conf @@ -52,7 +52,6 @@ kernel_modules { usb-storage = true ext4 = true nvme = true - ena = true } kernel_options { @@ -62,5 +61,4 @@ kernel_options { initfs_features { nvme = true - ena = true } diff --git a/configs/version/base/2.conf b/configs/version/base/2.conf index 644485b..e1a8f43 100644 --- a/configs/version/base/2.conf +++ b/configs/version/base/2.conf @@ -5,6 +5,7 @@ include required("1.conf") packages { # drop old alpine-mirrors alpine-mirrors = null + # use iproute2-minimal instead of full iproute2 iproute2 = null iproute2-minimal = true diff --git a/configs/version/base/3.conf b/configs/version/base/3.conf index 3aba9b8..4373266 100644 --- a/configs/version/base/3.conf +++ b/configs/version/base/3.conf @@ -3,7 +3,6 @@ include required("2.conf") packages { - # doas replaces sudo - sudo = null + # doas will officially replace sudo in 3.16 doas = true } diff --git a/configs/version/base/4.conf b/configs/version/base/4.conf new file mode 100644 index 0000000..6f2e978 --- /dev/null +++ b/configs/version/base/4.conf @@ -0,0 +1,8 @@ +# vim: ts=2 et: + +include required("3.conf") + +packages { + # doas officially replaces sudo in 3.16 + sudo = false +} diff --git a/configs/version/edge.conf b/configs/version/edge.conf index 4818ada..885a4bf 100644 --- a/configs/version/edge.conf +++ b/configs/version/edge.conf @@ -1,6 +1,10 @@ # vim: ts=2 et: -include required("base/3.conf") +include required("base/4.conf") + +motd { + sudo_removed = "NOTE: 'sudo' is no longer installed by default, please use 'doas' instead." +} # clear out inherited repos repos = null diff --git a/gen_releases.py b/gen_releases.py new file mode 100755 index 0000000..cd038b5 --- /dev/null +++ b/gen_releases.py @@ -0,0 +1,100 @@ +#!/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 textwrap + +NOTE = textwrap.dedent(""" + This script's output is meant to be compatible with alpine-ec2-ami's + releases.yaml, in order to bridge the gap until https://alpinelinux.org/cloud + can be updated to be generated from another source, or dynamically calls an + published image metadata service. This script should only be run after + the main 'build' script has been used successfully to publish all images. + """) + +sys.pycache_prefix = 'work/__pycache__' + +if not os.path.exists('work'): + print('FATAL: Work directory does not exist.', file=sys.stderr) + print(NOTE, file=sys.stderr) + exit(1) + +# 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 logging + +from collections import defaultdict +from ruamel.yaml import YAML + +import clouds +from image_configs import ImageConfigManager + + +### Constants & Variables + +LOGFORMAT = '%(name)s - %(levelname)s - %(message)s' + + +### Functions + +# allows us to set values deep within an object that might not be fully defined +def dictfactory(): + return defaultdict(dictfactory) + + +# undo dictfactory() objects to normal objects +def undictfactory(o): + if isinstance(o, defaultdict): + o = {k: undictfactory(v) for k, v in o.items()} + return o + + +### Command Line & Logging + +parser = argparse.ArgumentParser(description=NOTE) +parser.add_argument( + '--use-broker', action='store_true', + help='use the identity broker to get credentials') +parser.add_argument('--debug', action='store_true', help='enable debug output') +args = parser.parse_args() + +log = logging.getLogger('gen_releases') +log.setLevel(logging.DEBUG if args.debug else logging.INFO) +console = logging.StreamHandler(sys.stderr) +console.setFormatter(logging.Formatter(LOGFORMAT)) +log.addHandler(console) +log.debug(args) + +# set up credential provider, if we're going to use it +if args.use_broker: + clouds.set_credential_provider() + +# load build configs +configs = ImageConfigManager( + conf_path='work/configs/images.conf', + yaml_path='work/images.yaml', + log='gen_releases' +) +# make sure images.yaml is up-to-date with reality +configs.refresh_state('final') + +yaml = YAML() + +releases = dictfactory() +for i_key, i_cfg in configs.get().items(): + release = i_cfg.version if i_cfg.version == 'edge' else i_cfg.release + releases[release][i_key][i_cfg.tags.name] = dict(i_cfg.tags) | { + 'creation_date': i_cfg.published, + 'artifacts': i_cfg.artifacts, + } + +yaml.dump(undictfactory(releases), sys.stdout) diff --git a/image_configs.py b/image_configs.py index b697c35..84efa02 100644 --- a/image_configs.py +++ b/image_configs.py @@ -3,7 +3,6 @@ import itertools import logging import mergedeep -import os import pyhocon import shutil @@ -30,7 +29,6 @@ class ImageConfigManager(): self.yaml = YAML() self.yaml.register_class(ImageConfig) - self.yaml.default_flow_style = False self.yaml.explicit_start = True # hide !ImageConfig tag from Packer self.yaml.representer.org_represent_mapping = self.yaml.representer.represent_mapping @@ -50,8 +48,7 @@ class ImageConfigManager(): # load already-resolved YAML configs, restoring ImageConfig objects def _load_yaml(self): - # TODO: no warning if we're being called from cloud_helper.py - self.log.warning('Loading existing %s', self.yaml_path) + self.log.info('Loading existing %s', self.yaml_path) for key, config in self.yaml.load(self.yaml_path).items(): self._configs[key] = ImageConfig(key, config) @@ -74,18 +71,29 @@ class ImageConfigManager(): # set version releases for v, vcfg in cfg.Dimensions.version.items(): # version keys are quoted to protect dots - self.set_version_release(v.strip('"'), vcfg) + self._set_version_release(v.strip('"'), vcfg) dimensions = list(cfg.Dimensions.keys()) self.log.debug('dimensions: %s', dimensions) for dim_keys in (itertools.product(*cfg['Dimensions'].values())): - image_key = '-'.join(dim_keys).replace('"', '') + config_key = '-'.join(dim_keys).replace('"', '') # dict of dimension -> dimension_key dim_map = dict(zip(dimensions, dim_keys)) + + # replace version with release, and make image_key from that release = cfg.Dimensions.version[dim_map['version']].release - image_config = ImageConfig(image_key, {'release': release} | dim_map) + (rel_map := dim_map.copy())['version'] = release + image_key = '-'.join(rel_map.values()) + + image_config = ImageConfig( + config_key, + { + 'image_key': image_key, + 'release': release + } | dim_map + ) # merge in the Default config image_config._merge(cfg.Default) @@ -93,12 +101,19 @@ class ImageConfigManager(): # merge in each dimension key's configs for dim, dim_key in dim_map.items(): dim_cfg = deepcopy(cfg.Dimensions[dim][dim_key]) + exclude = dim_cfg.pop('EXCLUDE', None) if exclude and set(exclude) & set(dim_keys): - self.log.debug('%s SKIPPED, %s excludes %s', image_key, dim_key, exclude) + self.log.debug('%s SKIPPED, %s excludes %s', config_key, dim_key, exclude) skip = True break + if eol := dim_cfg.get('end_of_life', None): + if self.now > datetime.fromisoformat(eol): + self.log.warning('%s SKIPPED, %s end_of_life %s', config_key, dim_key, eol) + skip = True + break + image_config._merge(dim_cfg) # now that we're done with ConfigTree/dim_cfg, remove " from dim_keys @@ -122,40 +137,41 @@ class ImageConfigManager(): image_config.qemu['iso_url'] = self.iso_url_format.format(arch=image_config.arch) # we've resolved everything, add tags attribute to config - self._configs[image_key] = image_config + self._configs[config_key] = image_config self._save_yaml() # set current version release - def set_version_release(self, v, c): + def _set_version_release(self, v, c): if v == 'edge': c.put('release', self.now.strftime('%Y%m%d')) c.put('end_of_life', self.tomorrow.strftime('%F')) else: c.put('release', get_version_release(f"v{v}")['release']) - # release is also appended to build name array + # release is also appended to name & description arrays c.put('name', [c.release]) + c.put('description', [c.release]) # update current config status - def determine_actions(self, step, only, skip, revise): - self.log.info('Determining Actions') + def refresh_state(self, step, only=[], skip=[], revise=False): + self.log.info('Refreshing State') has_actions = False for ic in self._configs.values(): # clear away any previous actions if hasattr(ic, 'actions'): delattr(ic, 'actions') - dim_keys = set(ic.image_key.split('-')) + dim_keys = set(ic.config_key.split('-')) if only and len(set(only) & dim_keys) != len(only): - self.log.debug("%s SKIPPED, doesn't match --only", ic.image_key) + self.log.debug("%s SKIPPED, doesn't match --only", ic.config_key) continue if skip and len(set(skip) & dim_keys) > 0: - self.log.debug('%s SKIPPED, matches --skip', ic.image_key) + self.log.debug('%s SKIPPED, matches --skip', ic.config_key) continue - ic.determine_actions(step, revise) + ic.refresh_state(step, revise) if not has_actions and len(ic.actions): has_actions = True @@ -166,8 +182,8 @@ class ImageConfigManager(): class ImageConfig(): - def __init__(self, image_key, obj={}): - self.image_key = str(image_key) + def __init__(self, config_key, obj={}): + self.config_key = str(config_key) tags = obj.pop('tags', None) self.__dict__ |= self._deep_dict(obj) # ensure tag values are str() when loading @@ -176,15 +192,19 @@ class ImageConfig(): @property def local_dir(self): - return os.path.join('work/images', self.name) + return Path('work/images') / self.cloud / self.image_key @property def local_path(self): - return os.path.join(self.local_dir, 'image.' + self.local_format) + return self.local_dir / ('image.' + self.local_format) + + @property + def published_yaml(self): + return self.local_dir / 'published.yaml' @property def image_name(self): - return '-r'.join([self.name, str(self.revision)]) + return self.name.format(**self.__dict__) @property def image_description(self): @@ -196,19 +216,20 @@ class ImageConfig(): t = { 'arch': self.arch, 'bootstrap': self.bootstrap, - 'build_name': self.name, - 'build_revision': self.revision, 'cloud': self.cloud, 'description': self.image_description, 'end_of_life': self.end_of_life, 'firmware': self.firmware, + 'image_key': self.image_key, 'name': self.image_name, + 'project': self.project, 'release': self.release, + 'revision': self.revision, 'version': self.version } # stuff that might not be there yet - for k in ['imported', 'published', 'source_id', 'source_region']: - if k in self.__dict__: + for k in ['imported', 'import_id', 'import_region', 'published']: + if self.__dict__.get(k, None): t[k] = self.__dict__[k] return Tags(t) @@ -246,6 +267,7 @@ class ImageConfig(): # stringify arrays self.name = '-'.join(self.name) self.description = ' '.join(self.description) + self._resolve_motd() self._stringify_repos() self._stringify_packages() self._stringify_services() @@ -253,6 +275,26 @@ class ImageConfig(): self._stringify_dict_keys('kernel_options', ' ') self._stringify_dict_keys('initfs_features', ' ') + def _resolve_motd(self): + # merge version/release notes, as apporpriate + if self.motd.get('version_notes', None) and self.motd.get('release_notes', None): + if self.version == 'edge': + # edge is, by definition, not released + self.motd.pop('version_notes', None) + self.motd.pop('release_notes', None) + + elif self.release == self.version + '.0': + # no point in showing the same URL twice + self.motd.pop('release_notes') + + else: + # combine version and release notes + self.motd['release_notes'] = self.motd.pop('version_notes') + '\n' + \ + self.motd['release_notes'] + + # TODO: be rid of null values + self.motd = '\n\n'.join(self.motd.values()).format(**self.__dict__) + def _stringify_repos(self): # stringify repos map # : # @ enabled @@ -323,13 +365,11 @@ class ImageConfig(): for m, v in self.__dict__[d].items() ))) - # TODO? determine_current_state() - def determine_actions(self, step, revise): + def refresh_state(self, step, revise=False): log = logging.getLogger('build') - self.revision = 0 - # TODO: be more specific about our parameters - self.remote_image = clouds.latest_build_image(self) actions = {} + revision = 0 + remote_image = clouds.latest_build_image(self) # enable actions based on the specified step if step in ['local', 'import', 'publish']: @@ -343,50 +383,83 @@ class ImageConfig(): actions['publish'] = True if revise: - if os.path.exists(self.local_path): + if self.local_path.exists(): # remove previously built local image artifacts log.warning('Removing existing local image dir %s', self.local_dir) shutil.rmtree(self.local_dir) - if self.remote_image and 'published' in self.remote_image['tags']: - log.warning('Bumping build revision for %s', self.name) - self.revision = int(self.remote_image['tags']['build_revision']) + 1 + if remote_image and remote_image.published: + log.warning('Bumping image revision for %s', self.image_key) + revision = int(remote_image.revision) + 1 - elif self.remote_image and 'imported' in self.remote_image['tags']: + elif remote_image and remote_image.imported: # remove existing imported (but unpublished) image - log.warning('Removing unpublished remote image %s', self.remote_image['id']) - # TODO: be more specific? - clouds.remove_image(self) + log.warning('Removing unpublished remote image %s', remote_image.import_id) + clouds.remove_image(self, remote_image.import_id) - self.remote_image = None + remote_image = None - elif self.remote_image and 'imported' in self.remote_image['tags']: - # already imported, don't build/import again - log.warning('Already imported, skipping build/import') - actions.pop('build', None) - actions.pop('import', None) + elif remote_image: + if remote_image.imported: + # already imported, don't build/import again + log.info('%s - already imported', self.image_key) + actions.pop('build', None) + actions.pop('import', None) - if os.path.exists(self.local_path): - log.warning('Already built, skipping build') + if remote_image.published: + # NOTE: re-publishing can update perms or push to new regions + log.info('%s - already published', self.image_key) + + if self.local_path.exists(): # local image's already built, don't rebuild + log.info('%s - already locally built', self.image_key) actions.pop('build', None) - # set at time of import, carries forward when published - if self.remote_image: - self.end_of_life = self.remote_image['tags']['end_of_life'] - self.revision = self.remote_image['tags']['build_revision'] + # merge remote_image data into image state + if remote_image: + self.__dict__ |= dict(remote_image) else: - # default to tomorrow's date if unset - if 'end_of_life' not in self.__dict__: - tomorrow = datetime.utcnow() + timedelta(days=1) - self.end_of_life = tomorrow.strftime('%F') + self.__dict__ |= { + 'revision': revision, + 'imported': None, + 'import_id': None, + 'import_region': None, + 'published': None, + } + self.end_of_life = self.__dict__.pop( + 'end_of_life', + # EOL is tomorrow, if otherwise unset + (datetime.utcnow() + timedelta(days=1)).strftime('%F') + ) + + # update artifacts, if we've got 'em + artifacts_yaml = self.local_dir / 'artifacts.yaml' + if artifacts_yaml.exists(): + yaml = YAML() + self.artifacts = yaml.load(artifacts_yaml) + else: + self.artifacts = None self.actions = list(actions) - log.info('%s/%s-r%s = %s', self.cloud, self.name, self.revision, self.actions) + log.info('%s/%s = %s', self.cloud, self.image_name, self.actions) + + self.state_updated = datetime.utcnow().isoformat() -class Tags(dict): +class DictObj(dict): + + def __getattr__(self, key): + return self[key] + + def __setattr__(self, key, value): + self[key] = value + + def __delattr__(self, key): + del self[key] + + +class Tags(DictObj): def __init__(self, d={}, from_list=None, key_name='Key', value_name='Value'): for key, value in d.items(): @@ -395,23 +468,9 @@ class Tags(dict): if from_list: self.from_list(from_list, key_name, value_name) - def __getattr__(self, key): - return self[key] - def __setattr__(self, key, value): self[key] = str(value) - def __delattr__(self, key): - del self[key] - - def pop(self, key, default): - value = default - if key in self: - value = self[key] - del self[key] - - return value - def as_list(self, key_name='Key', value_name='Value'): return [{key_name: k, value_name: v} for k, v in self.items()] diff --git a/overlays/testing/configs/alpine-testing.conf b/overlays/testing/configs/alpine-testing.conf new file mode 100644 index 0000000..9b68e5e --- /dev/null +++ b/overlays/testing/configs/alpine-testing.conf @@ -0,0 +1,39 @@ +# vim: ts=2 et: + +# Overlay for testing alpine-cloud-images + +# start with the production alpine config +include required("alpine.conf") + +# override specific things... + +project = alpine-cloud-images__test + +Default { + # unset before resetting + name = null + name = [ test ] + description = null + description = [ Alpine Test ] +} + +Dimensions { + bootstrap { + # not quite working yet + #cloudinit { include required("testing/cloudinit.conf") } + } + cloud { + # adapters need to be written + #oci { include required("testing/oci.conf") } + #gcp { include required("testing/gcp.conf") } + #azure { include required("testing/azure.conf") } + } +} + +# test in private, and only in a couple regions +Mandatory.access.PUBLIC = false +Mandatory.regions = { + ALL = false + us-west-2 = true + us-east-1 = true +} \ No newline at end of file diff --git a/overlays/testing/configs/images.conf b/overlays/testing/configs/images.conf new file mode 120000 index 0000000..cc0f93d --- /dev/null +++ b/overlays/testing/configs/images.conf @@ -0,0 +1 @@ +alpine-testing.conf \ No newline at end of file diff --git a/overlays/testing/configs/testing/cloudinit.conf b/overlays/testing/configs/testing/cloudinit.conf new file mode 100644 index 0000000..0659a1e --- /dev/null +++ b/overlays/testing/configs/testing/cloudinit.conf @@ -0,0 +1,9 @@ +# vim: ts=2 et: +name = [cloudinit] + +packages { + cloud-init = true + openssh-server-pam = true +} +scripts = [ setup-cloudinit ] +script_dirs = [ setup-cloudinit.d ] \ No newline at end of file diff --git a/overlays/testing/configs/testing/oci.conf b/overlays/testing/configs/testing/oci.conf new file mode 100644 index 0000000..0e80256 --- /dev/null +++ b/overlays/testing/configs/testing/oci.conf @@ -0,0 +1,4 @@ +# vim: ts=2 et: +builder = qemu + +# TBD \ No newline at end of file diff --git a/overlays/testing/scripts/setup-cloudinit b/overlays/testing/scripts/setup-cloudinit new file mode 100755 index 0000000..9407e57 --- /dev/null +++ b/overlays/testing/scripts/setup-cloudinit @@ -0,0 +1,27 @@ +#!/bin/sh -eu +# vim: ts=4 et: + +[ -z "$DEBUG" ] || [ "$DEBUG" = 0 ] || set -x + +TARGET=/mnt +#SETUP=/tmp/setup-cloudinit.d + +die() { + printf '\033[1;7;31m FATAL: %s \033[0m\n' "$@" >&2 # bold reversed red + exit 1 +} +einfo() { + printf '\n\033[1;7;36m> %s <\033[0m\n' "$@" >&2 # bold reversed cyan +} + +einfo "Installing up cloud-init bootstrap components..." + +# This adds the init scripts at the correct boot phases +chroot "$TARGET" /sbin/setup-cloud-init + +# cloud-init locks our user by default which means alpine can't login from +# SSH. This seems like a bug in cloud-init that should be fixed but we can +# hack around it for now here. +if [ -f "$TARGET"/etc/cloud/cloud.cfg ]; then + sed -i '/lock_passwd:/s/True/False/' "$TARGET"/etc/cloud/cloud.cfg +fi diff --git a/scripts/setup b/scripts/setup index 6093b59..1eefb59 100755 --- a/scripts/setup +++ b/scripts/setup @@ -51,7 +51,7 @@ make_filesystem() { unit MiB print root_dev="${DEVICE}2" - /usr/sbin/mkfs.fat -n EFI "${DEVICE}1" + mkfs.fat -n EFI "${DEVICE}1" fi mkfs.ext4 -O ^64bit -L / "$root_dev" @@ -69,9 +69,12 @@ install_base() { mkdir -p "$TARGET/etc/apk" echo "$REPOS" > "$TARGET/etc/apk/repositories" cp -a /etc/apk/keys "$TARGET/etc/apk" + # shellcheck disable=SC2086 apk --root "$TARGET" --initdb --no-cache add $PACKAGES_ADD + # shellcheck disable=SC2086 [ -z "$PACKAGES_NOSCRIPTS" ] || \ apk --root "$TARGET" --no-cache --no-scripts add $PACKAGES_NOSCRIPTS + # shellcheck disable=SC2086 [ -z "$PACKAGES_DEL" ] || \ apk --root "$TARGET" --no-cache del $PACKAGES_DEL } @@ -89,10 +92,28 @@ install_bootloader() { einfo "Installing Bootloader" # create initfs + + # shellcheck disable=SC2046 + kernel=$(basename $(find "$TARGET/lib/modules/"* -maxdepth 0)) + + # ensure features can be found by mkinitfs + for FEATURE in $INITFS_FEATURES; do + # already taken care of? + [ -f "$TARGET/etc/mkinitfs/features.d/$FEATURE.modules" ] || \ + [ -f "$TARGET/etc/mkinitfs/features.d/$FEATURE.files" ] && continue + # find the kernel module directory + module=$(chroot "$TARGET" /sbin/modinfo -k "$kernel" -n "$FEATURE") + [ -z "$module" ] && die "initfs_feature '$FEATURE' kernel module not found" + # replace everything after .ko with a * + echo "$module" | cut -d/ -f5- | sed -e 's/\.ko.*/.ko*/' \ + > "$TARGET/etc/mkinitfs/features.d/$FEATURE.modules" + done + + # TODO? this appends INITFS_FEATURES, we may want to allow removal someday? sed -Ei "s/^features=\"([^\"]+)\"/features=\"\1 $INITFS_FEATURES\"/" \ "$TARGET/etc/mkinitfs/mkinitfs.conf" - # shellcheck disable=SC2046 - chroot "$TARGET" /sbin/mkinitfs $(basename $(find "$TARGET/lib/modules/"* -maxdepth 0)) + + chroot "$TARGET" /sbin/mkinitfs "$kernel" if [ "$FIRMWARE" = uefi ]; then install_grub_efi @@ -174,6 +195,7 @@ configure_system() { fi # explicitly lock the root account + chroot "$TARGET" /bin/sh -c "/bin/echo 'root:*' | /usr/sbin/chpasswd -e" chroot "$TARGET" /usr/bin/passwd -l root # set up image user @@ -181,7 +203,7 @@ configure_system() { chroot "$TARGET" /usr/sbin/addgroup "$user" chroot "$TARGET" /usr/sbin/adduser -h "/home/$user" -s /bin/sh -G "$user" -D "$user" chroot "$TARGET" /usr/sbin/addgroup "$user" wheel - chroot "$TARGET" /usr/bin/passwd -u "$user" + chroot "$TARGET" /bin/sh -c "echo '$user:*' | /usr/sbin/chpasswd -e" # modify PS1s in /etc/profile to add user sed -Ei \ @@ -190,6 +212,9 @@ configure_system() { -e "s/( PS1=')(%m:)/\\1%n@\\2/" \ "$TARGET"/etc/profile + # write /etc/motd + echo "$MOTD" > "$TARGET"/etc/motd + setup_services }