diff --git a/autoinstall-schema.json b/autoinstall-schema.json index 5e27b33b..0e89c79c 100644 --- a/autoinstall-schema.json +++ b/autoinstall-schema.json @@ -260,6 +260,18 @@ } ] }, + "ubuntu-advantage": { + "type": "object", + "properties": { + "token": { + "type": "string", + "minLength": 24, + "maxLength": 30, + "pattern": "^C[1-9A-HJ-NP-Za-km-z]+$", + "description": "A valid token starts with a C and is followed by 23 to 29 Base58 characters.\nSee https://pkg.go.dev/github.com/btcsuite/btcutil/base58#CheckEncode" + } + } + }, "proxy": { "type": [ "string", diff --git a/examples/autoinstall.yaml b/examples/autoinstall.yaml index 51aff9e2..4685f3b9 100644 --- a/examples/autoinstall.yaml +++ b/examples/autoinstall.yaml @@ -42,6 +42,9 @@ snaps: channel: 3.2/stable updates: all timezone: Pacific/Guam +ubuntu-advantage: + # Token that passes the basic format checking but is invalid (i.e. contains more than 16 bytes of random data) + token: C1NWcZTHLteJXGVMM6YhvHDpGrhyy7 storage: config: - {type: disk, ptable: gpt, path: /dev/vdb, wipe: superblock, preserve: false, grub_device: true, id: disk-1} diff --git a/scripts/kvm-test.py b/scripts/kvm-test.py index 253b5211..3a2c9034 100755 --- a/scripts/kvm-test.py +++ b/scripts/kvm-test.py @@ -2,7 +2,7 @@ '''kvm-test - boot a kvm with a test iso, possibly building that test iso first -kvm-test --build -q --install -o --boot +kvm-test -q --install -o --boot slimy build, install, overwrite existing image if it exists, and boot the result after install @@ -15,20 +15,23 @@ import copy import crypt import os import random +import shlex import socket +import subprocess import sys import tempfile import yaml cfg = ''' +default_mem: '8G' iso: basedir: /srv/iso release: edge: jammy/subiquity-edge/jammy-live-server-subiquity-edge-amd64.iso canary: jammy/jammy-desktop-canary-amd64.iso jammy: jammy/jammy-live-server-amd64.iso - desktop: impish/jammy-desktop-amd64.iso + desktop: jammy/jammy-desktop-amd64.iso impish: impish/ubuntu-21.10-live-server-amd64.iso hirsute: hirsute/ubuntu-21.04-live-server-amd64.iso groovy: groovy/ubuntu-20.10-live-server-amd64.iso @@ -37,9 +40,6 @@ iso: default: edge ''' -sys_memory = '8G' - - def salted_crypt(plaintext_password): # match subiquity documentation salt = '$6$exDY1mhS4KUYCE/2' @@ -51,6 +51,7 @@ class Context: self.config = self.load_config() self.args = args self.release = args.release + self.default_mem = self.config.get('default_mem', '8G') if not self.release: self.release = self.config["iso"]["default"] iso = self.config["iso"] @@ -60,10 +61,8 @@ class Context: except KeyError: pass self.curdir = os.getcwd() - # self.iso = f'{self.curdir}/{self.release}-test.iso' self.iso = f'/tmp/kvm-test/{self.release}-test.iso' self.hostname = f'{self.release}-test' - # self.target = f'{self.curdir}/{self.hostname}.img' self.target = f'/tmp/kvm-test/{self.hostname}.img' self.password = salted_crypt('ubuntu') self.cloudconfig = f'''\ @@ -80,9 +79,6 @@ autoinstall: password: "{self.password}" username: ubuntu ''' - # refresh-installer: - # update: yes - # channel: candidate def merge(self, a, b): '''Take a pair of dictionaries, and provide the merged result. @@ -137,8 +133,8 @@ parser.add_argument('--basesnap', default=None, action='store', parser.add_argument('--snap', default=None, action='store', help='inject this snap into the ISO') parser.add_argument('-B', '--bios', action='store_true', default=False, - help='boot in BIOS mode') -parser.add_argument('-c', '--channel', default=False, action='store', + help='boot in BIOS mode (default mode is UEFI)') +parser.add_argument('-c', '--channel', action='store', help='build iso with snap from channel') parser.add_argument('-d', '--disksize', default='12G', action='store', help='size of disk to create (12G default)') @@ -157,14 +153,15 @@ parser.add_argument('-s', '--serial', default=False, action='store_true', help='attach to serial console') parser.add_argument('-S', '--sound', default=False, action='store_true', help='enable sound') -parser.add_argument('-t', '--this', action='store', - help='use this iso') +parser.add_argument('--iso', action='store', help='use this iso') parser.add_argument('-u', '--update', action='store', help='subiquity-channel argument') +parser.add_argument('-m', '--memory', action='store', + help='memory for VM') parser.add_argument('--save', action='store_true', help='preserve built snap') parser.add_argument('--reuse', action='store_true', - help='reuse previously saved snap') + help='reuse previously saved snap. Implies --save') parser.add_argument('--build', default=False, action='store_true', help='build test iso') parser.add_argument('--install', default=False, action='store_true', @@ -174,44 +171,36 @@ parser.add_argument('--boot', default=False, action='store_true', help='boot test image') -def waitstatus_to_exitcode(waitstatus): - '''If the process exited normally (if WIFEXITED(status) is true), return - the process exit status (return WEXITSTATUS(status)): result greater - than or equal to 0. - - If the process was terminated by a signal (if WIFSIGNALED(status) is - true), return -signum where signum is the number of the signal that - caused the process to terminate (return -WTERMSIG(status)): result less - than 0. - - Otherwise, raise a ValueError.''' - - # This function is for python 3.9 compat - - if 'waitstatus_to_exitcode' in dir(os): - return os.waitstatus_to_exitcode(waitstatus) - if os.WIFEXITED(waitstatus): - return os.WEXITSTATUS(waitstatus) - if os.WIFSIGNALED(waitstatus): - return -os.WTERMSIG(waitstatus) - - raise ValueError +def parse_args(): + ctx = Context(parser.parse_args()) + if ctx.args.quick or ctx.args.basesnap or ctx.args.snap \ + or ctx.args.channel or ctx.args.reuse: + ctx.args.build = True + if ctx.args.reuse: + ctx.args.save = True + return ctx -class SubProcessFailure(Exception): - pass +def run(cmd): + if isinstance(cmd, str): + cmd_str = cmd + cmd_array = shlex.split(cmd) + else: + cmd_str = shlex.join(cmd) + cmd_array = cmd + # semi-simulate "bash -x" + print(f'+ {cmd_str}', file=sys.stderr) + subprocess.run(cmd_array, check=True) -def run(cmds): - for cmd in [line.strip() for line in cmds.splitlines()]: - if len(cmd) < 1: - continue - # semi-simulate "bash -x" - print(f'+ {cmd}') - # FIXME subprocess check - ec = waitstatus_to_exitcode(os.system(cmd)) - if ec != 0: - raise SubProcessFailure(f'command [{cmd}] returned [{ec}]') +def assert_exists(path): + if not os.path.exists(path): + raise Exception(f'Expected file {path} not found') + + +def remove_if_exists(path): + if os.path.exists(path): + os.remove(path) @contextlib.contextmanager @@ -219,7 +208,7 @@ def delete_later(path): try: yield path finally: - os.remove(path) + remove_if_exists(path) @contextlib.contextmanager @@ -236,16 +225,17 @@ def mounter(src, dest): run(f'sudo umount {dest}') +def livefs_edit(ctx, *args): + run(['sudo', 'PYTHONPATH=$LIVEFS_EDITOR', 'python3', '-m', 'livefs_edit', + ctx.baseiso, ctx.iso, *args]) + + def build(ctx): - run('sudo -v') - run(f'rm -f {ctx.iso}') + remove_if_exists(ctx.iso) project = os.path.basename(os.getcwd()) snap_manager = noop if ctx.args.save else delete_later - # FIXME generalize to - # PYTHONPATH=$LIVEFS_EDITOR python3 -m livefs_edit $OLD_ISO $NEW_ISO - # --inject-snap $SUBIQUITY_SNAP_PATH - if project.startswith('subiquity'): + if project == 'subiquity': if ctx.args.quick: run(f'sudo ./scripts/quick-test-this-branch.sh {ctx.baseiso} \ {ctx.iso}') @@ -259,38 +249,27 @@ def build(ctx): run(f'sudo ./scripts/inject-subiquity-snap.sh {ctx.baseiso} \ {ctx.args.snap} {ctx.iso}') elif ctx.args.channel: - run(f'sudo PYTHONPATH=$LIVEFS_EDITOR python3 -m livefs_edit \ - {ctx.baseiso} {ctx.iso} \ - --add-snap-from-store subiquity {ctx.args.channel}') + livefs_edit(ctx, '--add-snap-from-store', 'core20', 'stable', + '--add-snap-from-store', 'subiquity', + ctx.args.channel) else: with snap_manager('subiquity_test.snap') as snap: if not ctx.args.reuse: - run(f''' - snapcraft clean --use-lxd - snapcraft snap --use-lxd --output {snap} - ''') - run(f''' - test -f {snap} - sudo PYTHONPATH=$LIVEFS_EDITOR python3 -m livefs_edit \ - {ctx.baseiso} {ctx.iso} \ - --add-snap-from-store core20 stable \ - --inject-snap {snap} - ''') - # sudo ./scripts/inject-subiquity-snap.sh {ctx.baseiso} \ - # {snap} {ctx.iso} + run('snapcraft clean --use-lxd') + run(f'snapcraft snap --use-lxd --output {snap}') + assert_exists(snap) + livefs_edit(ctx, '--add-snap-from-store', 'core20', 'stable', + '--inject-snap', snap) elif project == 'ubuntu-desktop-installer': with snap_manager('udi_test.snap') as snap: - run(f''' - snapcraft clean --use-lxd - snapcraft snap --use-lxd --output {snap} - test -f {snap} - sudo ./scripts/inject-snap {ctx.baseiso} \ - {ctx.iso} {snap} - ''') + run('snapcraft clean --use-lxd') + run(f'snapcraft snap --use-lxd --output {snap}') + assert_exists(snap) + run(f'sudo ./scripts/inject-snap {ctx.baseiso} {ctx.iso} {snap}') else: raise Exception(f'do not know how to build {project}') - run(f'test -f {ctx.iso}') + assert_exists(ctx.iso) def write(dest, data): @@ -315,15 +294,12 @@ def drive(path, format='qcow2'): kwargs = [] serial = None cparam = 'writethrough' - # if cache == False: cparam = 'none' - # serial doesn't work.. - # serial = str(int(random.random() * 100000000)).zfill(8) - kwargs += [f'file={path}'] - kwargs += [f'format={format}'] - kwargs += [f'cache={cparam}'] - kwargs += ['if=virtio'] + kwargs.append(f'file={path}') + kwargs.append(f'format={format}') + kwargs.append(f'cache={cparam}') + kwargs.append('if=virtio') if serial: - kwargs += [f'serial={serial}'] + kwargs.append(f'serial={serial}') return '-drive ' + ','.join(kwargs) @@ -346,15 +322,15 @@ class PortFinder: def nets(ctx): ports = PortFinder() - ret = [] if ctx.args.nets > 0: + ret = [] for _ in range(ctx.args.nets): port = ports.get() - ret += ['-nic', - 'user,model=virtio-net-pci,' + - f'hostfwd=tcp::{port}-:22'] # ,restrict=on'] + ret.extend(('-nic', + 'user,model=virtio-net-pci,' + + f'hostfwd=tcp::{port}-:22')) else: - ret += ['-nic', 'none'] + ret = ['-nic', 'none'] return ret @@ -362,12 +338,12 @@ def bios(ctx): ret = [] # https://help.ubuntu.com/community/UEFI if not ctx.args.bios: - ret += ['-bios', '/usr/share/qemu/OVMF.fd'] + ret = ['-bios', '/usr/share/qemu/OVMF.fd'] return ret def memory(ctx): - return ['-m', str(sys_memory)] + return ['-m', ctx.args.memory or ctx.default_mem] def kvm_common(ctx): @@ -381,35 +357,6 @@ def kvm_common(ctx): return ret -def grub_get_extra_args(mntdir): - # The inject-snap process of livefs-edit adds a new layer squash - # that must be in place, or the injected snap isn't there. - # We don't want to hardcode the current value, because that layer - # isn't there if a base iso is being used. - # Parse the args out of grub.cfg and include them with ours. - cfgpath = f'{mntdir}/boot/grub/grub.cfg' - ret = [] - try: - with open(cfgpath, 'r') as fp: - for line in fp.readlines(): - chunks = line.strip().split('\t') - # ['linux', '/casper/vmlinuz ---'] - # layerfs-path=a.b.c.d.e.squashfs - if not chunks or not chunks[0] == 'linux': - continue - subchunks = chunks[1].split(' ') - if not subchunks or not subchunks[0] == '/casper/vmlinuz': - continue - for sc in subchunks[1:]: - if sc != '---': - ret.append(sc) - break - # breakpoint() - except FileNotFoundError: - pass - return ret - - def get_initrd(mntdir): for initrd in ('initrd', 'initrd.lz', 'initrd.lz4'): path = f'{mntdir}/casper/{initrd}' @@ -422,8 +369,8 @@ def install(ctx): if os.path.exists(ctx.target): if ctx.args.overwrite: os.remove(ctx.target) - - run('sudo -v') + else: + raise Exception('refusing to overwrite existing image') with tempfile.TemporaryDirectory() as tempdir: mntdir = f'{tempdir}/mnt' @@ -432,47 +379,43 @@ def install(ctx): kvm = kvm_common(ctx) - if ctx.args.this: - iso = ctx.args.this + if ctx.args.iso: + iso = ctx.args.iso elif ctx.args.base: iso = ctx.baseiso else: iso = ctx.iso - kvm += ['-cdrom', iso] + kvm.extend(('-cdrom', iso)) if ctx.args.serial: - kvm += ['-nographic'] - appends += ['console=ttyS0'] + kvm.append('-nographic') + appends.append('console=ttyS0') if ctx.args.autoinstall or ctx.args.autoinstall_file: if ctx.args.autoinstall_file: ctx.cloudconfig = ctx.args.autoinstall_file.read() - kvm += [drive(create_seed(ctx.cloudconfig, tempdir), 'raw')] - appends += ['autoinstall'] + kvm.append(drive(create_seed(ctx.cloudconfig, tempdir), 'raw')) + appends.append('autoinstall') if ctx.args.update: - appends += ['subiquity-channel=candidate'] + appends.append('subiquity-channel=' + ctx.args.update) - kvm += [drive(ctx.target)] + kvm.append(drive(ctx.target)) if not os.path.exists(ctx.target) or ctx.args.overwrite: run(f'qemu-img create -f qcow2 {ctx.target} {ctx.args.disksize}') - # drive2 = f'{ctx.curdir}/drive2.img' - - # appends += ['subiquity-channel=edge'] - - with mounter(iso, mntdir): - if len(appends) > 0: - appends += grub_get_extra_args(mntdir) + if len(appends) > 0: + with mounter(iso, mntdir): # if we're passing kernel args, we need to manually specify # kernel / initrd - kvm += ['-kernel', f'{mntdir}/casper/vmlinuz'] - kvm += ['-initrd', get_initrd(mntdir)] + kvm.extend(('-kernel', f'{mntdir}/casper/vmlinuz')) + kvm.extend(('-initrd', get_initrd(mntdir))) toappend = ' '.join(appends) - kvm += ['-append', f'"{toappend}"'] - - run(' '.join(kvm)) + kvm.extend(('-append', f'"{toappend}"')) + run(kvm) + else: + run(kvm) def boot(ctx): @@ -481,21 +424,17 @@ def boot(ctx): target = ctx.args.img kvm = kvm_common(ctx) - kvm += [drive(target)] - run(' '.join(kvm)) + kvm.append(target) + run(kvm) -def help(ctx): +def help(): parser.print_usage() sys.exit(1) -def cloud(ctx): - print(ctx.cloudconfig) - - try: - ctx = Context(parser.parse_args()) + ctx = parse_args() except TypeError: help() diff --git a/scripts/runtests.sh b/scripts/runtests.sh index 1bafbd00..a295b49a 100755 --- a/scripts/runtests.sh +++ b/scripts/runtests.sh @@ -28,19 +28,30 @@ validate () { netplan generate --root .subiquity elif [ "${mode}" = "system_setup" ]; then setup_mode="$2" + launcher_cmds=".subiquity/run/launcher-command" echo "system setup validation for $setup_mode" - echo "checking launcher-status" - [ -d ".subiquity/run/subiquity/" ] || (echo "run/subiquity/ dir not created for status"; exit 1) - [ -e ".subiquity/run/subiquity/launcher-status" ] || (echo "run/subiquity/launcher-status not created"; exit 1) + echo "checking ${launcher_cmds}" + if [ ! -f ${launcher_cmds} ]; then + echo "Expected launcher commands to be written to the file." + exit 1 + elif [ -z "$(grep action ${launcher_cmds})" ] && [ "${setup_mode}" != "autoinstall-no-shutdown" ]; then + echo "Expected action to be set in launcher commands." + exit 1 + elif [ -z "$(grep defaultUid ${launcher_cmds})" ] && [ "${setup_mode}" != "answers-reconf" ]; then + echo "Expected defaultUid to be set in launcher commands." + exit 1 + else + cat ${launcher_cmds} + fi expected_status="reboot" if [ "${setup_mode}" = "autoinstall-full" ]; then expected_status="shutdown" elif [ "${setup_mode}" = "autoinstall-no-shutdown" ]; then - expected_status="complete" + expected_status="" fi - result_status="$(cat .subiquity/run/subiquity/launcher-status)" + result_status="$(cat ${launcher_cmds} | grep action | cut -d = -f 2)" if [ "${result_status}" != "${expected_status}" ]; then - echo "incorrect run/subiquity/launcher-status: expect ${expected_status}, got ${result_status}" + echo "incorrect ${launcher_cmds}: expect ${expected_status}, got ${result_status}" exit 1 fi echo "checking generated config" @@ -52,7 +63,7 @@ validate () { for file in system_setup/tests/golden/${setup_mode}/*.conf; do filename=$(basename ${file}) conf_filepath=".subiquity/etc/${filename}" - diff -Nup "${file}" "${conf_filepath}" || exit 1 + diff -NBup "${file}" "${conf_filepath}" || exit 1 done if [ "${setup_mode}" != "answers-reconf" ]; then echo "checking user created" @@ -78,8 +89,9 @@ validate () { echo "user not assigned with the expected group sudo" exit 1 fi - lang="$(grep -Eo 'LANG="([^.@ _]+)' .subiquity/etc/default/locale | cut -d \" -f 2)" - if [ -z "$( ls .subiquity/var/cache/apt/archives/) | grep $lang" ] ; then + # Extract value of the LANG variable from etc/default/locale (with or without quotes) + lang="$(grep -Eo 'LANG=([^.@ _]+)' .subiquity/etc/default/locale | cut -d= -f 2- | cut -d\" -f 2-)" + if ! ls .subiquity/var/cache/apt/archives/ | grep --fixed-strings --quiet -- "$lang"; then echo "expected $lang language packs in directory var/cache/apt/archives/" exit 1 fi @@ -161,6 +173,7 @@ python3 scripts/check-yaml-fields.py .subiquity/var/log/installer/subiquity-curt python3 scripts/check-yaml-fields.py <(python3 scripts/check-yaml-fields.py .subiquity/etc/cloud/cloud.cfg.d/99-installer.cfg datasource.None.userdata_raw) \ locale='"en_GB.UTF-8"' \ timezone='"Pacific/Guam"' \ + ubuntu_advantage.token='"C1NWcZTHLteJXGVMM6YhvHDpGrhyy7"' \ 'snap.commands=[snap install --channel=3.2/stable etcd]' grep -q 'finish: subiquity/Install/install/postinstall/install_package1: SUCCESS: installing package1' \ .subiquity/subiquity-server-debug.log diff --git a/scripts/test-this-branch.sh b/scripts/test-this-branch.sh index 3cb98440..f272732f 100755 --- a/scripts/test-this-branch.sh +++ b/scripts/test-this-branch.sh @@ -8,7 +8,8 @@ sudo apt install -y zsync xorriso isolinux snapcraft snap --output subiquity_test.snap urlbase=http://cdimage.ubuntu.com/ubuntu-server/daily-live/current -isoname=$(distro-info -d)-live-server-$(dpkg --print-architecture).iso +distroname=$(distro-info -d) +isoname="${distroname}"-live-server-$(dpkg --print-architecture).iso zsync ${urlbase}/${isoname}.zsync sudo ./scripts/inject-subiquity-snap.sh ${isoname} subiquity_test.snap custom.iso diff --git a/subiquity/client/controllers/filesystem.py b/subiquity/client/controllers/filesystem.py index 86ee4e17..d80f8413 100644 --- a/subiquity/client/controllers/filesystem.py +++ b/subiquity/client/controllers/filesystem.py @@ -217,7 +217,7 @@ class FilesystemController(SubiquityTuiController, FilesystemManipulator): if self.model.bootloader == Bootloader.PREP: self.supports_resilient_boot = False else: - release = lsb_release()['release'] + release = lsb_release(dry_run=self.app.opts.dry_run)['release'] self.supports_resilient_boot = release >= '20.04' self.ui.set_body(FilesystemView(self.model, self)) diff --git a/subiquity/client/controllers/ubuntu_advantage.py b/subiquity/client/controllers/ubuntu_advantage.py index 067f7741..f7e09bda 100644 --- a/subiquity/client/controllers/ubuntu_advantage.py +++ b/subiquity/client/controllers/ubuntu_advantage.py @@ -16,7 +16,6 @@ """ import logging -from typing import Optional from subiquitycore.async_helpers import schedule_task @@ -56,11 +55,9 @@ class UbuntuAdvantageController(SubiquityTuiController): async def make_ui(self) -> UbuntuAdvantageView: """ Generate the UI, based on the data provided by the model. """ - path_lsb_release: Optional[str] = None - if self.app.opts.dry_run: - # In dry-run mode, always pretend to be on LTS - path_lsb_release = "examples/lsb-release-focal" - if "LTS" not in lsb_release(path_lsb_release)["description"]: + + dry_run: bool = self.app.opts.dry_run + if "LTS" not in lsb_release(dry_run=dry_run)["description"]: await self.endpoint.skip.POST() raise Skip("Not running LTS version") diff --git a/subiquity/server/apt.py b/subiquity/server/apt.py index c6bfc563..eb0df309 100644 --- a/subiquity/server/apt.py +++ b/subiquity/server/apt.py @@ -207,7 +207,7 @@ class AptConfigurer: if os.path.exists(proxy_path): os.unlink(proxy_path) - codename = lsb_release()['codename'] + codename = lsb_release(dry_run=self.app.opts.dry_run)['codename'] write_file( self.install_tree.p('etc/apt/sources.list'), diff --git a/subiquity/server/controllers/filesystem.py b/subiquity/server/controllers/filesystem.py index 68c87322..cdd81b98 100644 --- a/subiquity/server/controllers/filesystem.py +++ b/subiquity/server/controllers/filesystem.py @@ -448,7 +448,7 @@ class FilesystemController(SubiquityController, FilesystemManipulator): if self.model.bootloader == Bootloader.PREP: self.supports_resilient_boot = False else: - release = lsb_release()['release'] + release = lsb_release(dry_run=self.app.opts.dry_run)['release'] self.supports_resilient_boot = release >= '20.04' self._start_task = schedule_task(self._start()) diff --git a/subiquity/server/controllers/kernel.py b/subiquity/server/controllers/kernel.py index 0f766c4b..f6d7a8f9 100644 --- a/subiquity/server/controllers/kernel.py +++ b/subiquity/server/controllers/kernel.py @@ -70,8 +70,10 @@ class KernelController(NonInteractiveController): # Should check this package exists really but # that's a bit tricky until we get cleverer about # the apt config in general. + dry_run: bool = self.app.opts.dry_run package = 'linux-{flavor}-{release}'.format( - flavor=flavor, release=lsb_release()['release']) + flavor=flavor, + release=lsb_release(dry_run=dry_run)['release']) self.model.metapkg_name = package def make_autoinstall(self): diff --git a/subiquity/server/controllers/ubuntu_advantage.py b/subiquity/server/controllers/ubuntu_advantage.py index 025c10aa..c91cec47 100644 --- a/subiquity/server/controllers/ubuntu_advantage.py +++ b/subiquity/server/controllers/ubuntu_advantage.py @@ -22,6 +22,10 @@ from subiquity.server.controller import SubiquityController log = logging.getLogger("subiquity.server.controllers.ubuntu_advantage") +TOKEN_DESC = """\ +A valid token starts with a C and is followed by 23 to 29 Base58 characters. +See https://pkg.go.dev/github.com/btcsuite/btcutil/base58#CheckEncode""" + class UbuntuAdvantageController(SubiquityController): """ Represent the server-side Ubuntu Advantage controller. """ @@ -29,6 +33,33 @@ class UbuntuAdvantageController(SubiquityController): endpoint = API.ubuntu_advantage model_name = "ubuntu_advantage" + autoinstall_key = "ubuntu-advantage" + autoinstall_schema = { + "type": "object", + "properties": { + "token": { + "type": "string", + "minLength": 24, + "maxLength": 30, + "pattern": "^C[1-9A-HJ-NP-Za-km-z]+$", + "description": TOKEN_DESC, + }, + }, + } + + def load_autoinstall_data(self, data: dict) -> None: + """ Load autoinstall data and update the model. """ + if data is None: + return + self.model.token = data.get("token", "") + + def make_autoinstall(self) -> dict: + """ Return a dictionary that can be used as an autoinstall snippet for + Ubuntu Advantage. + """ + return { + "token": self.model.token + } def serialize(self) -> str: """ Save the current state of the model so it can be loaded later. diff --git a/subiquity/ui/views/help.py b/subiquity/ui/views/help.py index 10809bbe..2e5fdc5b 100644 --- a/subiquity/ui/views/help.py +++ b/subiquity/ui/views/help.py @@ -441,7 +441,7 @@ class HelpMenu(PopUpLauncher): self.app.add_global_overlay(stretchy) def about(self, sender=None): - info = lsb_release() + info = lsb_release(dry_run=self.app.opts.dry_run) if 'LTS' in info['description']: template = _(ABOUT_INSTALLER_LTS) else: diff --git a/subiquitycore/lsb_release.py b/subiquitycore/lsb_release.py index e8bc0ed7..1a08feba 100644 --- a/subiquitycore/lsb_release.py +++ b/subiquitycore/lsb_release.py @@ -2,13 +2,17 @@ import shlex LSB_RELEASE_FILE = "/etc/lsb-release" +LSB_RELEASE_EXAMPLE = "examples/lsb-release-focal" -def lsb_release(path=None): +def lsb_release(path=None, dry_run: bool = False): """return a dictionary of values from /etc/lsb-release. keys are lower case with DISTRIB_ prefix removed.""" + if dry_run and path is not None: + raise ValueError("Both dry_run and path are specified.") + if path is None: - path = LSB_RELEASE_FILE + path = LSB_RELEASE_EXAMPLE if dry_run else LSB_RELEASE_FILE ret = {} try: diff --git a/subiquitycore/tests/test_lsb_release.py b/subiquitycore/tests/test_lsb_release.py new file mode 100644 index 00000000..f49983f8 --- /dev/null +++ b/subiquitycore/tests/test_lsb_release.py @@ -0,0 +1,58 @@ +# Copyright 2021 Canonical, Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import unittest +from unittest.mock import patch, mock_open + +from subiquitycore.lsb_release import lsb_release + + +class TestLSBRelease(unittest.TestCase): + def setUp(self): + self.target = "subiquitycore.lsb_release.open" + + def test_lsb_release(self): + lsb_str = """ +DISTRIB_ID=Ubuntu +DISTRIB_RELEASE=21.10 +DISTRIB_CODENAME=impish +DISTRIB_DESCRIPTION="Ubuntu 21.10" + """ + + with patch(self.target, mock_open(read_data=lsb_str)) as patched: + distro = lsb_release(path="dummy") + patched.assert_called_once_with("dummy", "r") + self.assertEqual(distro["id"], "Ubuntu") + self.assertEqual(distro["release"], "21.10") + self.assertEqual(distro["codename"], "impish") + self.assertEqual(distro["description"], "Ubuntu 21.10") + + def test_lsb_release_inexistent(self): + with patch(self.target, side_effect=FileNotFoundError): + self.assertEqual(lsb_release("/inexistent"), {}) + + def test_lsb_release_default(self): + with patch(self.target, side_effect=FileNotFoundError) as patched: + lsb_release(path=None) + patched.assert_called_once_with("/etc/lsb-release", "r") + + def test_lsb_release_dry_run(self): + with patch(self.target, side_effect=FileNotFoundError) as patched: + lsb_release(dry_run=True) + patched.assert_called_once_with("examples/lsb-release-focal", "r") + + def test_lsb_release_mutually_exclusive(self): + with self.assertRaises(ValueError): + lsb_release(path="dummy", dry_run=True) diff --git a/system_setup/common/wsl_conf.py b/system_setup/common/wsl_conf.py index 8724d945..3d1e6244 100644 --- a/system_setup/common/wsl_conf.py +++ b/system_setup/common/wsl_conf.py @@ -111,7 +111,7 @@ def default_loader(is_advanced=False): return data -def wsl_config_update(config_class, root_dir, default_user=None): +def wsl_config_update(config_class, root_dir): """ This update the configuration file for the given class. @@ -163,11 +163,6 @@ def wsl_config_update(config_class, root_dir, default_user=None): config.add_section(config_section) config[config_section][config_setting] = config_value - if config_type == "wsl" and default_user is not None: - if "user" not in config: - config.add_section("user") - config["user"]["default"] = default_user - # sort config in ascii order for section in config._sections: config._sections[section] = \ diff --git a/system_setup/server/controllers/configure.py b/system_setup/server/controllers/configure.py index b10e7662..dfeab9e5 100644 --- a/system_setup/server/controllers/configure.py +++ b/system_setup/server/controllers/configure.py @@ -32,6 +32,7 @@ log = logging.getLogger("system_setup.server.controllers.configure") class ConfigureController(SubiquityController): + default_uid = 0 def __init__(self, app): super().__init__(app) @@ -265,6 +266,81 @@ class ConfigureController(SubiquityController): log.error("Failed to run locale activation commands.") return + def __query_uid(self, etc_dir, username): + """ Finds the UID of username in etc_dir/passwd file. """ + uid = None + with open(os.path.join(etc_dir, "passwd")) as f: + for line in f: + tokens = line.split(":") + if username == tokens[0]: + if len(tokens) != 7: + raise Exception("Invalid passwd entry") + + uid = int(tokens[2]) + break + + return uid + + async def _create_user(self, root_dir): + """ Helper method to create the user from the identity model + and store it's UID. """ + wsl_id = self.model.identity.user + username = wsl_id.username + create_user_base = [] + assign_grp_base = [] + usergroups_list = get_users_and_groups() + if self.app.opts.dry_run: + log.debug("creating a mock-up env for user %s", username) + # creating folders and files for dryrun + etc_dir = os.path.join(root_dir, "etc") + os.makedirs(etc_dir, exist_ok=True) + home_dir = os.path.join(root_dir, "home") + os.makedirs(home_dir, exist_ok=True) + pseudo_files = ["passwd", "shadow", "gshadow", "group", + "subgid", "subuid"] + for file in pseudo_files: + filepath = os.path.join(etc_dir, file) + open(filepath, "a").close() + + # mimic groupadd + group_id = 1000 + for group in usergroups_list: + group_filepath = os.path.join(etc_dir, "group") + gshadow_filepath = os.path.join(etc_dir, "gshadow") + shutil.copy(group_filepath, + "{}-".format(group_filepath)) + with open(group_filepath, "a") as group_file: + group_file.write("{}:x:{}:\n".format(group, group_id)) + group_id += 1 + shutil.copy(gshadow_filepath, + "{}-".format(gshadow_filepath)) + with open(gshadow_filepath, "a") as gshadow_file: + gshadow_file.write("{}:!::\n".format(group)) + + create_user_base = ["-P", root_dir] + assign_grp_base = ["-P", root_dir] + + create_user_cmd = ["useradd"] + create_user_base + \ + ["-m", "-s", "/bin/bash", "-c", wsl_id.realname, + "-p", wsl_id.password, username] + assign_grp_cmd = ["usermod"] + assign_grp_base + \ + ["-a", "-G", ",".join(usergroups_list), username] + + create_user_proc = await arun_command(create_user_cmd) + if create_user_proc.returncode != 0: + raise Exception("Failed to create user %s: %s" + % (username, create_user_proc.stderr)) + log.debug("created user %s", username) + + self.default_uid = self.__query_uid(etc_dir, username) + if self.default_uid is None: + log.error("Could not retrieve %s UID", username) + + assign_grp_proc = await arun_command(assign_grp_cmd) + if assign_grp_proc.returncode != 0: + raise Exception(("Failed to assign group to user %s: %s") + % (username, assign_grp_proc.stderr)) + @with_context( description="final system configuration", level="INFO", childlevel="DEBUG") @@ -287,68 +363,12 @@ class ConfigureController(SubiquityController): self.app.update_state(ApplicationState.POST_RUNNING) - dryrun = self.app.opts.dry_run variant = self.app.variant root_dir = self.model.root - username = None + if variant == "wsl_setup": - wsl_id = self.model.identity.user - username = wsl_id.username - create_user_base = [] - assign_grp_base = [] - usergroups_list = get_users_and_groups() + await self._create_user(root_dir) lang = self.model.locale.selected_language - if dryrun: - log.debug("creating a mock-up env for user %s", username) - # creating folders and files for dryrun - etc_dir = os.path.join(root_dir, "etc") - os.makedirs(etc_dir, exist_ok=True) - home_dir = os.path.join(root_dir, "home") - os.makedirs(home_dir, exist_ok=True) - pseudo_files = ["passwd", "shadow", "gshadow", "group", - "subgid", "subuid"] - for file in pseudo_files: - filepath = os.path.join(etc_dir, file) - open(filepath, "a").close() - - # mimic groupadd - group_id = 1000 - for group in usergroups_list: - group_filepath = os.path.join(etc_dir, "group") - gshadow_filepath = os.path.join(etc_dir, "gshadow") - shutil.copy(group_filepath, - "{}-".format(group_filepath)) - with open(group_filepath, "a") as group_file: - group_file.write("{}:x:{}:\n". - format(group, group_id)) - group_id += 1 - shutil.copy(gshadow_filepath, - "{}-".format(gshadow_filepath)) - with open(gshadow_filepath, "a") as gshadow_file: - gshadow_file.write("{}:!::\n".format(group)) - - create_user_base = ["-P", root_dir] - assign_grp_base = ["-P", root_dir] - - create_user_cmd = ["useradd"] + create_user_base + \ - ["-m", "-s", "/bin/bash", - "-c", wsl_id.realname, - "-p", wsl_id.password, username] - assign_grp_cmd = ["usermod"] + assign_grp_base + \ - ["-a", "-G", ",".join(usergroups_list), - username] - - create_user_proc = await arun_command(create_user_cmd) - if create_user_proc.returncode != 0: - raise Exception("Failed to create user %s: %s" - % (username, create_user_proc.stderr)) - log.debug("created user %s", username) - - assign_grp_proc = await arun_command(assign_grp_cmd) - if assign_grp_proc.returncode != 0: - raise Exception(("Failed to assign group to user %s: %s") - % (username, assign_grp_proc.stderr)) - await self.apply_locale(lang) else: @@ -361,8 +381,7 @@ class ConfigureController(SubiquityController): wsl_config_update(self.model.wslconfadvanced.wslconfadvanced, root_dir) - wsl_config_update(self.model.wslconfbase.wslconfbase, root_dir, - default_user=username) + wsl_config_update(self.model.wslconfbase.wslconfbase, root_dir) self.app.update_state(ApplicationState.DONE) @@ -375,3 +394,7 @@ class ConfigureController(SubiquityController): def stop_uu(self): # This is a no-op to allow Shutdown controller to depend on this one pass + + # Allows passing the recently created user UID to the Shutdown controller. + def get_default_uid(self): + return self.default_uid diff --git a/system_setup/server/controllers/shutdown.py b/system_setup/server/controllers/shutdown.py index a9966ef0..e7280438 100644 --- a/system_setup/server/controllers/shutdown.py +++ b/system_setup/server/controllers/shutdown.py @@ -50,18 +50,24 @@ class SetupShutdownController(ShutdownController): @with_context(description='mode={self.mode.name}') def shutdown(self, context): self.shuttingdown_event.set() - launcher_status = "complete" + comments = ["# This file was auto generated by system-setup.", + "# Don't edit it. It will be overwritten at next run."] + launcher_status = [] if self.mode == ShutdownMode.REBOOT: - log.debug("rebooting") - launcher_status = "reboot" + log.debug("Setting launcher for reboot") + launcher_status += ["action=reboot"] elif self.mode == ShutdownMode.POWEROFF: - log.debug("Shutting down") - launcher_status = "shutdown" + log.debug("Setting launcher for shut down") + launcher_status += ["action=shutdown"] + + default_uid = self.app.controllers.Install.get_default_uid() + if default_uid is not None and default_uid != 0: + launcher_status += ["defaultUid={}".format(default_uid)] + + if len(launcher_status) > 0: + status_file = os.path.join(self.root_dir, "run/launcher-command") + with open(status_file, "w+") as f: + f.write("\n".join(comments + launcher_status)) - subiquity_rundir = os.path.join(self.root_dir, "run", "subiquity") - os.makedirs(subiquity_rundir, exist_ok=True) - lau_status_file = os.path.join(subiquity_rundir, "launcher-status") - with open(lau_status_file, "w+") as f: - f.write(launcher_status) self.app.exit() diff --git a/system_setup/tests/golden/answers/wsl.conf b/system_setup/tests/golden/answers/wsl.conf index 442a8b52..a110cfe1 100644 --- a/system_setup/tests/golden/answers/wsl.conf +++ b/system_setup/tests/golden/answers/wsl.conf @@ -5,7 +5,3 @@ root = /custom_mnt_path [network] generatehosts = false generateresolvconf = false - -[user] -default = ubuntu - diff --git a/system_setup/tests/golden/autoinstall-full/wsl.conf b/system_setup/tests/golden/autoinstall-full/wsl.conf index 39ef73cf..22dce10d 100644 --- a/system_setup/tests/golden/autoinstall-full/wsl.conf +++ b/system_setup/tests/golden/autoinstall-full/wsl.conf @@ -11,7 +11,3 @@ enabled = false [network] generatehosts = false generateresolvconf = false - -[user] -default = ubuntu - diff --git a/system_setup/tests/golden/autoinstall/wsl.conf b/system_setup/tests/golden/autoinstall/wsl.conf index 442a8b52..a110cfe1 100644 --- a/system_setup/tests/golden/autoinstall/wsl.conf +++ b/system_setup/tests/golden/autoinstall/wsl.conf @@ -5,7 +5,3 @@ root = /custom_mnt_path [network] generatehosts = false generateresolvconf = false - -[user] -default = ubuntu -