From 000ca76ddf8bf8ab6f4ace85947b28b41f6b10a7 Mon Sep 17 00:00:00 2001 From: John Pals <7024725+DigitalPals@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:16:03 +0200 Subject: [PATCH] Align installer defaults with ISO policy --- AGENTS.md | 24 +++ README.md | 18 ++- docs/installation-parity.md | 67 ++++++++ docs/releasing.md | 5 + image/README.md | 3 + image/installation_policy.py | 40 +++++ image/package | 1 + image/repair-installed | 1 + image/rootfs/usr/libexec/cybexos-config | 30 ++-- image/test_installation_parity.py | 193 ++++++++++++++++++++++++ install | 107 ++++++++----- inventory/group_vars/all.yml | 9 +- roles/dotfiles/tasks/main.yml | 7 + scripts/installer-defaults | 56 +++++++ scripts/migrate-config | 20 +-- tests/application-defaults.py | 5 +- tests/installation-parity.py | 10 ++ tests/release-update | 3 + tests/run | 1 + 19 files changed, 518 insertions(+), 82 deletions(-) create mode 100644 docs/installation-parity.md create mode 100644 image/installation_policy.py create mode 100644 image/test_installation_parity.py create mode 100755 scripts/installer-defaults create mode 100644 tests/installation-parity.py diff --git a/AGENTS.md b/AGENTS.md index 1df414bf..811d9a75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,29 @@ # Repository agent instructions +## Installation parity: ISO is authoritative + +- The current ISO installation defines the CybexOS product. Keep `./install`, + `./bootstrap`, and ISO installations equivalent for the same release, + hardware, and explicit user choices. Resolve differences by bringing the + checkout path into line with the ISO, not by changing the ISO to match an + older checkout default. +- Treat applications, desktop and personal defaults, authentication, enabled + services, firewall policy, hardware support, and recovery as one shared + installation contract. Reuse the shared policy, task files, templates, and + package selections; do not add independent installer defaults. +- Preserve explicit saved choices and user-owned data during installs, + reconfiguration, and updates. Matching a fresh installation never authorizes + repartitioning an existing Fedora system or resetting personal settings. + Automatic login must retain the ISO's verified-encryption requirement. +- Every change to the installed product must cover both installation paths in + the same change, with regression coverage that compares their outcomes. + Run the source and image parity checks before handoff. A passing fixture + suite must not be reported as a completed end-to-end installation test. +- Release comparisons must use artifacts from the same source revision. + When preparing a release, rebuild and qualify the ISO/RPM if its installed + payload changed; an older ISO or an older qualification report does not + validate the new release. + ## ISO testing location - Always place completed ISOs for testing in `/data/pxe/iso`. diff --git a/README.md b/README.md index dd3c069f..5c224500 100644 --- a/README.md +++ b/README.md @@ -80,14 +80,16 @@ cd CybexOS No inventory or configuration file needs to be edited first. The installer detects the current desktop user, home directory, hostname, timezone, locale, -and keyboard settings and offers them as defaults. All application groups -are selected by default, including in non-interactive installs; interactive -setup allows explicit opt-outs. Fastfetch is a required baseline package. -The installer explicitly asks whether to enable passwordless sudo, passwordless -local Polkit authorization, and encrypted-boot desktop autologin. The two passwordless choices -have no implicit answer. When Docker is selected, it also asks whether the -desktop user may run Docker without sudo; the default is no, because the -`docker` group grants root-equivalent control of the machine. +and keyboard settings and offers them as defaults. The ISO defines the shared +fresh-install policy: all application groups and personal dotfiles are enabled, +sudo and local Polkit require authentication, and Docker requires sudo. +Automatic login defaults on only when the complete root filesystem is verified +as encrypted. These defaults also apply in non-interactive installs; interactive +setup allows explicit opt-outs. Existing saved choices remain authoritative. +Fastfetch is a required baseline package. Passwordless sudo, local Polkit and +Docker access remain explicit opt-ins; the `docker` group grants root-equivalent +control of the machine. See [installation parity](docs/installation-parity.md) +for the shared contract and release checks. On an encrypted single-user installation, SDDM can open the desktop after the LUKS unlock and unlock GNOME Keyring with the briefly cached boot password. diff --git a/docs/installation-parity.md b/docs/installation-parity.md new file mode 100644 index 00000000..26824646 --- /dev/null +++ b/docs/installation-parity.md @@ -0,0 +1,67 @@ +# Installation parity + +The ISO defines CybexOS's installed product. A checkout installation and an ISO +installation of the same release, on equivalent hardware with the same user +choices, must receive the same desktop, applications, account defaults, +authentication policy, services, firewall, and hardware configuration. + +Fresh installations use these ISO defaults: + +| Choice | Default on both paths | +| --- | --- | +| Applications | Every standard application group enabled | +| Personal defaults | Enabled, including Fish, Kitty, Git/SSH and browser preferences | +| Sudo and local Polkit | Password required | +| Docker administrator access | Sudo required | +| Desktop automatic login | Enabled only after complete root encryption is verified | +| Additional local-network firewall ports | Disabled; LocalSend retains its shared ports | +| Machine identity | Preserve the identity already configured by Fedora/Anaconda | + +`inventory/group_vars/all.yml` is the common default input. +`image/installation_policy.py` defines the account policy used by both +`scripts/installer-defaults` and the packaged `cybexos-config` helper. The +checkout questionnaire starts from that policy instead of maintaining its own +booleans. It uses the ISO's boot-time encryption verifier, including every +Btrfs member. Unverifiable or mixed encrypted/plaintext storage disables +automatic login. Real installs run this read-only probe with administrator +access; `./install --check` never elevates or installs dependencies and cannot +enable autologin if its access is insufficient to verify encryption. + +Saved choices take precedence. Re-running or updating an installation must not +silently turn on personal defaults or passwordless access for an older account. +`--reconfigure` offers the saved boolean choices as its defaults. An explicit +identity change in the checkout questionnaire still applies that choice. +Neither parity nor a release update authorizes repartitioning an existing +Fedora installation or resetting user settings to match a clean account. +Fastfetch, Voxtype, Oh My Posh, MIME associations, and npm configuration are +seeded only when absent, as on the ISO. Managed Fish and Kitty fragments remain +updateable independently of those personal files. + +The checkout path installs onto existing Fedora and retains source-release +updates and uninstall; the ISO uses Anaconda for disk/account creation and RPM +delivery for desktop updates and repair. These mechanisms are distinct from the +installed policy. Comparing a development checkout with an older ISO is not a +parity test: both artifacts must come from the same revision, with completed +hardware setup and equivalent application choices. + +## Required checks for changes and releases + +Run `./tests/run` and `python3 -B image/check-source`. Both required CI jobs run +`image/test_installation_parity.py`. It executes the real checkout questionnaire +and non-interactive dry run, then compares the complete generated configuration +with ISO provisioning and target finalization for encrypted and plaintext +installations. It also checks saved opt-outs, existing personal files, mixed or +unverifiable Btrfs, and the policy module shipped in the repair payload. + +The existing image package, desktop payload, installed policy and user parity +tests cover package selections and shared task/template sources. Extend those +checks whenever a package, setting, service or hardware path changes; a new +feature cannot be added to only one installer's schema. + +Release changes must pass the source and image gates from the same Git revision +and the existing generic Fedora and ISO installation/upgrade qualification +gates. Build and qualify a new ISO/RPM when its payload changes. Retain the +revision and artifact digests in the qualification evidence. Fixture checks +compare the installation contract; they are not evidence that fresh physical +or VM installations have been performed. Do not use an older ISO qualification +as evidence for a newer checkout. diff --git a/docs/releasing.md b/docs/releasing.md index c486c8d9..df4ee9ad 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -46,6 +46,11 @@ repository. 1. Review `release-manifest.json`, `VERSION`, the Fedora release, configuration schema, minimum updater version, and all dependency pins. 2. Run `./tests/run` and `./tests/fedora-vm-convergence` locally when practical. + Run `python3 -B image/check-source` as well. Both source suites enforce the + [ISO-leading installation contract](installation-parity.md); changes to + applications, defaults or policy must cover both installation paths. Build + and qualify ISO/RPM artifacts from the same release revision rather than + reusing an older image as parity evidence. The source gate includes an N to N+1 ownership test that advances vendor runtime while requiring every user customization sentinel to remain byte-identical. diff --git a/image/README.md b/image/README.md index c2c334ae..d5c216cd 100644 --- a/image/README.md +++ b/image/README.md @@ -49,6 +49,9 @@ version references. Both deployment paths consume [the shared desktop contract](../assets/desktop-contract.json), wallpaper collection, existing Cybex Plymouth artwork, helpers, firewall zone and sysctl policy. Bluetooth visibility matches the workstation default. +The ISO is authoritative for both paths' installation defaults; the checkout +installer consumes the same [installation policy](installation_policy.py). +The [parity contract](../docs/installation-parity.md) describes its checks. The image contains the repository desktop and default applications; it does not export personal plugins (including the Omarchy plugin), credentials, monitor overrides or private launchers from the build machine. The private diff --git a/image/installation_policy.py b/image/installation_policy.py new file mode 100644 index 00000000..c839c0cd --- /dev/null +++ b/image/installation_policy.py @@ -0,0 +1,40 @@ +"""Fresh-install policy shared by the ISO and the checkout installer. + +The inventory supplies application and security defaults. Machine identity +and verified disk encryption are inputs, never properties of the build host. +Existing saved choices are applied separately and must not be reset here. +""" +import yaml + + +BOOLEANS = ('manage_system_identity', 'manage_personal_dotfiles', 'cleanup_legacy_xps_artifacts', + 'passwordless_wheel', 'passwordless_local_polkit', 'docker_sudoless', 'desktop_autologin', + 'start_optional_hardware_services', 'allow_insecure_sccache_transport', 'xps_2026_camera_enabled') +FEATURES = ('developer_tools', 'connected_widgets', 'proprietary_apps', 'tailscale', 'docker', 'podman', + 'steam', 'private_hooks', 'apple_display', 'source_builds', 'local_network_services') + + +def defaults(inventory, *, fresh_account=True, encrypted=False): + values = yaml.safe_load(inventory.read_text()) + if not isinstance(values, dict) or not isinstance(values.get('features'), dict): + raise ValueError('Installation defaults must contain the feature contract') + features = values['features'] + if set(features) != set(FEATURES): + raise ValueError('Installation feature keys must match both installer schemas') + for key in FEATURES: + if type(features.get(key)) is not bool: + raise ValueError(f'features.{key} must have a boolean installation default') + for key in BOOLEANS: + # The inventory retains the legacy gdm_autologin template for direct + # Ansible callers; fresh installers use verified encryption instead. + if key != 'desktop_autologin' and type(values.get(key)) is not bool: + raise ValueError(f'{key} must have a boolean installation default') + result = {key: values.get(key) is True for key in BOOLEANS} + # Match the ISO: installed identity remains owned by Fedora/Anaconda; + # a fresh account receives personal defaults and encrypted-root autologin. + # Repairing an older installation must never opt it into either choice. + result['manage_system_identity'] = False + result['manage_personal_dotfiles'] = fresh_account and values.get('manage_personal_dotfiles') is True + result['desktop_autologin'] = fresh_account and encrypted is True + result['features'] = {key: features[key] for key in FEATURES} + return result diff --git a/image/package b/image/package index 3ffa5e8f..465aef3b 100755 --- a/image/package +++ b/image/package @@ -165,6 +165,7 @@ def main(): prepare_provision(ROOT, payload) copy("image/library/cybexos_managed_file.py", "usr/share/cybexos/lib/managed_files.py") copy("image/release_metadata.py", "usr/share/cybexos/lib/release_metadata.py") + copy("image/installation_policy.py", "usr/share/cybexos/lib/installation_policy.py") copy("roles/apps/files/cybexos-repository-policy", "usr/libexec/cybexos-repository-policy", True) # The workstation's font family mappings; roles/apps/tasks/fonts.yml # installs the same file on checkout deployments. diff --git a/image/repair-installed b/image/repair-installed index 4c5aaa92..8360b259 100755 --- a/image/repair-installed +++ b/image/repair-installed @@ -38,6 +38,7 @@ def prepare(payload): shutil.copyfile(ROOT / source, target) target.chmod(0o755 if executable else 0o644) + copy('image/installation_policy.py', 'usr/share/cybexos/lib/installation_policy.py') for relative in ('usr/libexec/cybexos-configure-installed', 'usr/libexec/cybexos-config', 'usr/bin/cybex', 'usr/bin/hyprland-quickshell', 'usr/lib/systemd/user/hyprland-session.target', diff --git a/image/rootfs/usr/libexec/cybexos-config b/image/rootfs/usr/libexec/cybexos-config index ee45963d..aca30f48 100755 --- a/image/rootfs/usr/libexec/cybexos-config +++ b/image/rootfs/usr/libexec/cybexos-config @@ -19,6 +19,13 @@ import tempfile import yaml +# Source fixtures must exercise this checkout, even on a machine with an older +# packaged policy installed. The installed helper reads its RPM-owned library. +source_image = Path(__file__).resolve().parent.parent.parent.parent +sys.path.insert(0, str(source_image) if (source_image / 'installation_policy.py').is_file() + else '/usr/share/cybexos/lib') +from installation_policy import BOOLEANS, FEATURES, defaults as installation_defaults # noqa: E402 + CONFIG = Path('/etc/cybexos/config.yml') INVENTORY = Path('/usr/share/cybexos/provision/inventory/group_vars/all.yml') @@ -26,11 +33,6 @@ APPLY = '/usr/libexec/cybexos-configure-installed' # Key order and quoting follow ./install's generated file. STRINGS = ('primary_user', 'primary_group', 'primary_home', 'machine_hostname', 'machine_timezone', 'machine_locale', 'regional_locale', 'machine_keyboard_layout', 'machine_keyboard_variant') -BOOLEANS = ('manage_system_identity', 'manage_personal_dotfiles', 'cleanup_legacy_xps_artifacts', - 'passwordless_wheel', 'passwordless_local_polkit', 'docker_sudoless', 'desktop_autologin', - 'start_optional_hardware_services', 'allow_insecure_sccache_transport', 'xps_2026_camera_enabled') -FEATURES = ('developer_tools', 'connected_widgets', 'proprietary_apps', 'tailscale', 'docker', 'podman', - 'steam', 'private_hooks', 'apple_display', 'source_builds', 'local_network_services') PATTERNS = { 'machine_hostname': r'[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}', 'machine_timezone': r'[A-Za-z0-9_+-]+(?:/[A-Za-z0-9_+-]+)*', @@ -125,24 +127,12 @@ def detect(root=Path('/'), entries=None, group=group_name, inventory=INVENTORY, account = candidates[0] if account is None: return None - defaults = yaml.safe_load(inventory.read_text()) or {} - features = defaults.get('features') or {} values = {'config_schema_version': 1, 'primary_user': account.pw_name, 'primary_group': group(account.pw_gid), 'primary_home': account.pw_dir, **identity(root)} - # Unset, templated or non-boolean defaults fall back to the stricter choice. - values.update({key: defaults.get(key) is True for key in BOOLEANS}) - # A live ISO cannot inherit the build host's sudo choice. The guided - # installer applies a confirmed opt-in after provisioning; Advanced has - # no such prompt and keeps the password-required default. - if fresh_account: - values['passwordless_wheel'] = False - # Anaconda and the OS settings own identity after an ISO installation; a - # later checkout run must not reset them to this installation-time record. - values['manage_system_identity'] = False - # Only a fresh installation account opts in without being asked. - values['manage_personal_dotfiles'] = fresh_account + values.update(installation_defaults(inventory, fresh_account=fresh_account)) + # Only the target helper's verified decision (or a saved login policy) + # can enable automatic login; the build host is never an input. values['desktop_autologin'] = login.get('user') == account.pw_name and login.get('autologin') is True - values['features'] = {key: features.get(key) is True for key in FEATURES} return values diff --git a/image/test_installation_parity.py b/image/test_installation_parity.py new file mode 100644 index 00000000..cdb92ec8 --- /dev/null +++ b/image/test_installation_parity.py @@ -0,0 +1,193 @@ +"""Compare real checkout configuration with the ISO's installed policy.""" +import json +import os +from pathlib import Path +import shlex +import subprocess +import unittest + +import yaml + +from test_reconfigure import Fixture, ROOT, config, target + + +class InstallationParity(Fixture): + def setUp(self): + super().setUp() + self.home = self.root / 'home/alice' + self.home.mkdir(parents=True) + self.bin = self.root / 'bin' + self.bin.mkdir() + self.installer = self.root / 'install' + self.installer.write_text((ROOT / 'install').read_text().replace( + 'repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)', + 'repo_dir=' + shlex.quote(str(ROOT)), + ).replace('[[ -r /etc/fedora-release ]]', 'true')) + for name, body in { + 'getent': f"printf '%s\\n' 'alice:x:1001:1001::{self.home}:/bin/bash'", + 'id': "printf '%s\\n' alice", + 'hostnamectl': "printf '%s\\n' studio", + 'timedatectl': "printf '%s\\n' Europe/Amsterdam", + 'locale': "printf '%s\\n' LANG=nl_NL.UTF-8", + 'localectl': 'case "$*" in *X11Layout*) echo us;; *X11Variant*) echo dvorak;; esac', + 'sudo': 'echo "--check must never elevate or install" >&2; exit 97', + }.items(): + self.command(name, body) + self.environment = dict(os.environ, HOME=str(self.home), SUDO_USER='alice', + CYBEXOS_CONFIG_FILE=str(self.path), + PATH=f"{self.bin}:{os.environ['PATH']}", PYTHONDONTWRITEBYTECODE='1') + + def command(self, name, body): + path = self.bin / name + path.write_text('#!/bin/sh\n' + body + '\n') + path.chmod(0o755) + + def encryption(self, encrypted, *, mixed=False, fail=False): + self.command('findmnt', "printf '%s\\n' " + shlex.quote(json.dumps({'filesystems': [ + {'source': '/dev/mapper/root[/root]', 'target': '/', 'fstype': 'btrfs'}]}))) + self.command('btrfs', 'exit 1' if fail else "cat <<'EOF'\n" + "Label: none uuid: 01234567-89ab-cdef-0123-456789abcdef\n" + "\tTotal devices 2 FS bytes used 4096\n" + "\tdevid 1 size 65536 used 4096 path /dev/mapper/first\n" + "\tdevid 2 size 65536 used 4096 path /dev/mapper/second\nEOF") + first = {'blockdevices': [{'type': 'crypt' if encrypted else 'part'}]} + second = {'blockdevices': [{'type': 'part' if mixed or not encrypted else 'crypt'}]} + self.command('lsblk', 'case "$*" in\n*first) printf "%s\\n" ' + shlex.quote(json.dumps(first)) + + ';;\n*) printf "%s\\n" ' + shlex.quote(json.dumps(second)) + ';;\nesac') + + def checkout(self, *extra, interactive=False, answers=''): + source = self.installer + if interactive: + # Replace terminal detection only; feed the actual questionnaire. + source = self.root / 'interactive-install' + source.write_text(self.installer.read_text().replace( + '[[ $non_interactive == false && ! -t 0 ]]', 'false')) + result = subprocess.run(['bash', str(source), '--check', + *([] if interactive else ['--non-interactive']), *extra], + env=self.environment, input=answers, text=True, capture_output=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stderr) + return yaml.safe_load(result.stdout[result.stdout.index('---\n'):]) + + def installed_iso(self, encrypted): + self.path.unlink(missing_ok=True) + self.generate() + (self.root / 'etc/passwd').write_text('alice:x:1001:1001::/home/alice:/usr/bin/fish\n') + (self.root / 'etc/shadow').write_text('root:!:1::::::\n') + target.finalize(self.root, {'account': {'username': 'alice', 'encrypted': encrypted}}, encrypted) + value = yaml.safe_load(self.path.read_text()) + self.path.unlink() + # Same account-scoped policy; the checkout fixture's existing home is + # under its disposable root instead of the host's real /home/alice. + value['primary_home'] = str(self.home) + return value + + def test_fresh_interactive_and_noninteractive_defaults_match_iso(self): + for encrypted in (False, True): + with self.subTest(encrypted=encrypted): + self.encryption(encrypted) + expected = self.installed_iso(encrypted) + self.assertEqual(self.checkout(), expected) + self.assertEqual(self.checkout(interactive=True, answers='\n' * 30), expected) + self.assertIs(expected['passwordless_wheel'], False) + self.assertIs(expected['manage_personal_dotfiles'], True) + self.assertIs(expected['desktop_autologin'], encrypted) + self.assertFalse((self.root / 'etc/sudoers.d/10-wheel-nopasswd').exists()) + self.assertFalse(self.path.exists()) + + def test_existing_personal_files_do_not_select_another_product_default(self): + self.encryption(True) + (self.home / '.gitconfig').write_text('[user]\nname = Personal\n') + value = self.checkout(interactive=True, answers='\n' * 30) + self.assertIs(value['manage_personal_dotfiles'], True) + self.assertEqual((self.home / '.gitconfig').read_text(), '[user]\nname = Personal\n') + + def test_mixed_or_unverifiable_btrfs_never_selects_autologin(self): + for options in ({'mixed': True}, {'fail': True}): + with self.subTest(options=options): + self.encryption(True, **options) + self.assertIs(self.checkout()['desktop_autologin'], False) + + def test_saved_choices_survive_reuse_and_reconfiguration_defaults(self): + self.encryption(True) + saved = self.checkout() + saved.update(manage_personal_dotfiles=False, desktop_autologin=False, passwordless_wheel=True) + saved['features'].update(steam=False, connected_widgets=False, podman=False, source_builds=False) + original = config.render(saved) + self.path.write_text(original) + self.assertEqual(self.checkout(), saved) + self.assertEqual(self.checkout('--reconfigure'), saved) + self.assertEqual(self.checkout('--reconfigure', interactive=True, answers='\n' * 30), saved) + self.assertEqual(self.path.read_text(), original) + + def test_source_personal_seeding_preserves_edits_like_iso_seeding(self): + from test_desktop_payload import INIT + selected = {'Install application configuration', 'Install Voxtype configuration', + 'Install Oh My Posh theme from the active configuration', + 'Install feature-aware MIME defaults', 'Configure npm user prefix'} + tasks = [task for task in yaml.safe_load((ROOT / 'roles/dotfiles/tasks/main.yml').read_text()) + if task.get('name') in selected] + self.assertEqual(len(tasks), len(selected)) + for task in tasks: + task['become'] = False + task.pop('notify', None) + options = task.get('ansible.builtin.copy') or task['ansible.builtin.template'] + if 'src' in options and not options['src'].startswith('{{ config_repo }}'): + directory = 'templates' if 'ansible.builtin.template' in task else 'files' + options['src'] = str(ROOT / 'roles/dotfiles' / directory) + '/' + options['src'] + paths = ('.config/fastfetch/config.jsonc', '.config/voxtype/config.toml', + '.config/oh-my-posh/EDM115-newline2.omp.json', '.config/mimeapps.list', '.npmrc') + for relative in paths: + (self.home / relative).parent.mkdir(parents=True, exist_ok=True) + inventory = yaml.safe_load((ROOT / 'inventory/group_vars/all.yml').read_text()) + playbook = self.root / 'personal-seed.yml' + playbook.write_text(yaml.safe_dump([{ + 'hosts': 'localhost', 'connection': 'local', 'gather_facts': False, + 'vars': {'primary_home': str(self.home), 'primary_user': 'fixture', + 'config_repo': str(ROOT), 'manage_personal_dotfiles': True, + 'features': inventory['features']}, 'tasks': tasks, + }])) + + def apply(): + result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(playbook)], + env=os.environ, text=True, capture_output=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + apply() + # Exercise the ISO's actual merge operation with the freshly rendered + # shared defaults, then edit both accounts before the next application. + seed = self.root / 'seed' + other = self.root / 'iso-account' + for relative in paths: + source = self.home / relative + (seed / relative).parent.mkdir(parents=True, exist_ok=True) + (seed / relative).write_bytes(source.read_bytes()) + INIT.seed_applications(seed, other) + for relative in paths: + self.assertEqual((self.home / relative).read_bytes(), (other / relative).read_bytes()) + for home in (self.home, other): + (home / relative).write_text('Personal configuration survives upgrades\n') + apply() + INIT.seed_applications(seed, other) + for relative in paths: + self.assertEqual((self.home / relative).read_text(), 'Personal configuration survives upgrades\n') + self.assertEqual((self.home / relative).read_bytes(), (other / relative).read_bytes()) + + def test_packaged_policy_is_the_same_source_used_by_checkout(self): + from installation_policy import defaults + import tempfile + from unittest.mock import patch + from test_installed_policy import repair + with tempfile.TemporaryDirectory() as directory: + payload = Path(directory) + repair.prepare(payload) + bundled = payload / 'usr/share/cybexos/lib/installation_policy.py' + self.assertEqual(bundled.read_bytes(), (ROOT / 'image/installation_policy.py').read_bytes()) + inventory = yaml.safe_load((ROOT / 'inventory/group_vars/all.yml').read_text()) + inventory['features']['new_unhandled_choice'] = True + with patch.object(Path, 'read_text', return_value=yaml.safe_dump(inventory)): + with self.assertRaisesRegex(ValueError, 'both installer schemas'): + defaults(Path('fixture')) + + +if __name__ == '__main__': + unittest.main() diff --git a/install b/install index f8fac5cd..f91ab4d2 100755 --- a/install +++ b/install @@ -51,6 +51,7 @@ done # transaction and configuration run, so take the same lock; fd 9 survives the # final exec, and the playbook keeps it until it exits. acquire_update_lock() { + [[ ${update_lock_acquired:-false} == true ]] && return local state_root=${XDG_STATE_HOME:-$HOME/.local/state}/cybexos/update local runtime_root=${XDG_RUNTIME_DIR:-$state_root/runtime} mkdir -p -- "$runtime_root" @@ -59,6 +60,7 @@ acquire_update_lock() { echo 'install: a CybexOS update is running; retry after it finishes (cybexos-update-run status).' >&2 exit 75 } + update_lock_acquired=true } # Ansible's workers detach from the terminal (setsid), and sudo's default @@ -153,6 +155,39 @@ valid_timezone() { [[ $1 != /* && $1 != *..* && -e /usr/share/zoneinfo/$1 ]]; } valid_keyboard_layout() { [[ $1 =~ ^[A-Za-z0-9_,-]+$ ]]; } valid_keyboard_variant() { [[ -z $1 || $1 =~ ^[A-Za-z0-9_,-]+$ ]]; } +# The ISO and checkout consume the same policy. Query it as root for real +# installs: Btrfs's complete-device encryption check requires that access. +# --check never elevates or installs dependencies and fails closed if the +# existing root's encryption cannot be verified with the available access. +if ! python3 -c 'import yaml' >/dev/null 2>&1; then + if [[ $check_only == true ]]; then + echo 'install: --check needs python3 and python3-pyyaml' >&2 + exit 1 + fi + acquire_update_lock + run_step 'Install installer prerequisites' /tmp/cybexos-bootstrap-ansible.log \ + sudo dnf -y install python3-pyyaml ansible-core +fi +defaults_command=(python3 -B "$repo_dir/scripts/installer-defaults") +[[ -r $config_path ]] && defaults_command+=(--config "$config_path") +if [[ $check_only == false ]]; then + sudo -n true 2>/dev/null || sudo -v + defaults_command=(sudo "${defaults_command[@]}") +fi +installation_defaults=$("${defaults_command[@]}") +declare root_encrypted manage_system_identity manage_personal_dotfiles cleanup_legacy_xps_artifacts +declare passwordless_wheel passwordless_local_polkit docker_sudoless desktop_autologin +declare start_optional_hardware_services allow_insecure_sccache_transport xps_2026_camera_enabled +declare developer_tools connected_widgets proprietary_apps tailscale docker podman steam +declare private_hooks apple_display source_builds local_network_services +while IFS='=' read -r key value; do + [[ $key =~ ^[a-z_][a-z0-9_]*$ && $value =~ ^(true|false)$ ]] || { + echo 'install: invalid shared installation policy' >&2 + exit 1 + } + printf -v "$key" '%s' "$value" +done <<<"$installation_defaults" + ask_text() { local variable=$1 prompt=$2 default=$3 validator=$4 answer while true; do @@ -193,19 +228,6 @@ if [[ $non_interactive == true ]]; then regional_locale=$detected_regional machine_keyboard_layout=$detected_keyboard_layout machine_keyboard_variant=$detected_keyboard_variant - developer_tools=true - steam=true - docker=true - podman=true - tailscale=true - connected_widgets=true - proprietary_apps=true - passwordless_wheel=true - passwordless_local_polkit=false - docker_sudoless=false - desktop_autologin=false - local_network_services=false - manage_personal_dotfiles=false else ui_header 'CybexOS setup' echo 'Detected values are offered in brackets. No inventory editing is required.' @@ -219,35 +241,48 @@ else ask_text machine_keyboard_layout 'Keyboard layout' "$detected_keyboard_layout" valid_keyboard_layout ask_text machine_keyboard_variant 'Keyboard variant (blank for default)' "$detected_keyboard_variant" valid_keyboard_variant echo - ask_bool developer_tools 'Install developer and Android tooling?' true - ask_bool steam 'Install gaming tools and Steam?' true - ask_bool docker 'Install and enable Docker?' true - ask_bool podman 'Install Podman and Distrobox?' true - ask_bool tailscale 'Install and enable Tailscale?' true - ask_bool connected_widgets 'Enable connected-service widgets?' true - ask_bool proprietary_apps 'Install Brave, 1Password, and ChatGPT packages?' true - ask_bool local_network_services 'Open feature-selected local-network firewall ports?' false + ask_bool developer_tools 'Install developer and Android tooling?' "$developer_tools" + ask_bool steam 'Install gaming tools and Steam?' "$steam" + ask_bool docker 'Install and enable Docker?' "$docker" + ask_bool podman 'Install Podman and Distrobox?' "$podman" + ask_bool tailscale 'Install and enable Tailscale?' "$tailscale" + ask_bool connected_widgets 'Enable connected-service widgets?' "$connected_widgets" + ask_bool proprietary_apps 'Install Brave, 1Password, and ChatGPT packages?' "$proprietary_apps" + ask_bool local_network_services 'Open feature-selected local-network firewall ports?' "$local_network_services" echo echo 'The following choices relax local authentication. Review each explicitly.' - ask_bool passwordless_wheel 'Allow the wheel group to use sudo without a password?' true - ask_bool passwordless_local_polkit 'Authorize active local wheel users through Polkit without a password?' - docker_sudoless=false + ask_bool passwordless_wheel 'Allow the wheel group to use sudo without a password?' "$passwordless_wheel" + ask_bool passwordless_local_polkit 'Authorize active local wheel users through Polkit without a password?' "$passwordless_local_polkit" if [[ $docker == true ]]; then - ask_bool docker_sudoless "Let $primary_user use Docker without sudo? (The docker group is equivalent to root access.)" false + ask_bool docker_sudoless "Let $primary_user use Docker without sudo? (The docker group is equivalent to root access.)" "$docker_sudoless" + fi + if [[ $root_encrypted == true ]]; then + ask_bool desktop_autologin 'Automatically log this user into Hyprland after disk unlock?' "$desktop_autologin" + else + echo 'Automatic login stays off because root encryption could not be verified.' fi - ask_bool desktop_autologin 'Automatically log this user into Hyprland when the root disk is encrypted?' false echo if [[ -e $selected_home/.gitconfig || -e $selected_home/.ssh/config \ || -e $selected_home/.config/fish/config.fish \ || -e $selected_home/.config/kitty/kitty.conf ]]; then ask_bool manage_personal_dotfiles \ - 'Install the opinionated shell/application dotfiles alongside existing configuration?' false + 'Install the opinionated shell/application dotfiles alongside existing configuration?' "$manage_personal_dotfiles" else ask_bool manage_personal_dotfiles \ - 'Install the opinionated shell and application dotfiles?' true + 'Install the opinionated shell and application dotfiles?' "$manage_personal_dotfiles" fi fi +[[ $docker == true ]] || docker_sudoless=false +[[ $podman == true ]] || source_builds=false +# Fedora keeps its existing identity unless the user explicitly changes it. +if [[ $machine_hostname != "$detected_hostname" || $machine_timezone != "$detected_timezone" \ + || $machine_locale != "$detected_locale" || $regional_locale != "$detected_regional" \ + || $machine_keyboard_layout != "$detected_keyboard_layout" \ + || $machine_keyboard_variant != "$detected_keyboard_variant" ]]; then + manage_system_identity=true +fi + primary_home=$(getent passwd "$primary_user" | cut -d: -f6) primary_group=$(id -gn "$primary_user") [[ -n $primary_home && -d $primary_home ]] || { @@ -270,16 +305,16 @@ machine_locale: $(yaml_string "$machine_locale") regional_locale: $(yaml_string "$regional_locale") machine_keyboard_layout: $(yaml_string "$machine_keyboard_layout") machine_keyboard_variant: $(yaml_string "$machine_keyboard_variant") -manage_system_identity: true +manage_system_identity: $manage_system_identity manage_personal_dotfiles: $manage_personal_dotfiles -cleanup_legacy_xps_artifacts: false +cleanup_legacy_xps_artifacts: $cleanup_legacy_xps_artifacts passwordless_wheel: $passwordless_wheel passwordless_local_polkit: $passwordless_local_polkit docker_sudoless: $docker_sudoless desktop_autologin: $desktop_autologin -start_optional_hardware_services: true -allow_insecure_sccache_transport: false -xps_2026_camera_enabled: true +start_optional_hardware_services: $start_optional_hardware_services +allow_insecure_sccache_transport: $allow_insecure_sccache_transport +xps_2026_camera_enabled: $xps_2026_camera_enabled features: developer_tools: $developer_tools connected_widgets: $connected_widgets @@ -288,9 +323,9 @@ features: docker: $docker podman: $podman steam: $steam - private_hooks: false - apple_display: false - source_builds: $podman + private_hooks: $private_hooks + apple_display: $apple_display + source_builds: $source_builds local_network_services: $local_network_services EOF diff --git a/inventory/group_vars/all.yml b/inventory/group_vars/all.yml index e8801e9b..a414abe9 100644 --- a/inventory/group_vars/all.yml +++ b/inventory/group_vars/all.yml @@ -1,8 +1,9 @@ --- # Opinionated product defaults. The interactive installer records machine-specific # answers in /etc/cybexos/config.yml and passes that file as extra vars. -# Direct developer runs use the current account. Wheel sudo is passwordless -# by product default; saved opt-outs remain authoritative. +# Direct developer runs use the current account. The ISO defines fresh-install +# policy: password-required sudo and the complete managed personal defaults. +# Explicit saved choices remain authoritative on every deployment path. fedora_release: "44" config_schema_version: 1 machine_hostname: "{{ ansible_facts.hostname | default('fedora') }}" @@ -12,7 +13,7 @@ regional_locale: en_US.UTF-8 machine_keyboard_layout: us machine_keyboard_variant: "" manage_system_identity: false -manage_personal_dotfiles: false +manage_personal_dotfiles: true cleanup_legacy_xps_artifacts: false cybexos_xps_2026: false plymouth_theme: cybex @@ -37,7 +38,7 @@ xps_2026_camera_enabled: true xps_2026_haptic_intensity: high # Explicitly accepted local-machine security tradeoffs. -passwordless_wheel: true +passwordless_wheel: false passwordless_local_polkit: false # Membership in the docker group grants root-equivalent control of the host, # so Docker requires sudo unless this is explicitly accepted. diff --git a/roles/dotfiles/tasks/main.yml b/roles/dotfiles/tasks/main.yml index 0bb61680..e2ae46e5 100644 --- a/roles/dotfiles/tasks/main.yml +++ b/roles/dotfiles/tasks/main.yml @@ -322,6 +322,8 @@ when: manage_personal_dotfiles | bool tags: [shell-defaults] +# Match the ISO's one-time user seed: these files become user-owned after +# first creation. Managed Fish/Kitty fragments remain separately updateable. - name: Install application configuration become: true become_user: "{{ primary_user }}" @@ -329,6 +331,7 @@ src: "{{ item.src }}" dest: "{{ primary_home }}/{{ item.dest }}" mode: "{{ item.mode | default('0644') }}" + force: false loop: - { src: fastfetch.jsonc, dest: .config/fastfetch/config.jsonc } when: manage_personal_dotfiles | bool @@ -343,6 +346,7 @@ src: voxtype.toml dest: "{{ primary_home }}/.config/voxtype/config.toml" mode: "0644" + force: false notify: Restart Voxtype after configuration updates when: manage_personal_dotfiles | bool @@ -353,6 +357,7 @@ src: "{{ config_repo }}/assets/EDM115-newline2.omp.json" dest: "{{ primary_home }}/.config/oh-my-posh/EDM115-newline2.omp.json" mode: "0644" + force: false when: manage_personal_dotfiles | bool - name: Configure environment variables @@ -436,6 +441,7 @@ src: mimeapps.list.j2 dest: "{{ primary_home }}/.config/mimeapps.list" mode: "0644" + force: false when: manage_personal_dotfiles | bool tags: [browser, mime-defaults] @@ -514,6 +520,7 @@ dest: "{{ primary_home }}/.npmrc" mode: "0644" content: "prefix=${HOME}/.npm-global\n" + force: false when: - manage_personal_dotfiles | bool - features.developer_tools | bool diff --git a/scripts/installer-defaults b/scripts/installer-defaults new file mode 100755 index 00000000..875d0c41 --- /dev/null +++ b/scripts/installer-defaults @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Read the ISO's fresh-install defaults without changing the machine.""" +import importlib.machinery +import importlib.util +import argparse +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'image')) +from installation_policy import defaults # noqa: E402 +import yaml # noqa: E402 + + +def encrypted_root(): + # The same complete-device check used at every ISO-installed boot. + loader = importlib.machinery.SourceFileLoader( + 'installer_login_policy', str(ROOT / 'roles/desktop/files/login/cybexos-login-prepare')) + spec = importlib.util.spec_from_loader(loader.name, loader) + policy = importlib.util.module_from_spec(spec) + loader.exec_module(policy) + return policy.encrypted_root() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--config', type=Path) + args = parser.parse_args() + encrypted = encrypted_root() + values = defaults(ROOT / 'inventory/group_vars/all.yml', encrypted=encrypted) + if args.config: + saved = yaml.safe_load(args.config.read_text()) + if not isinstance(saved, dict) or not isinstance(saved.get('features', {}), dict): + raise ValueError('Saved installation choices must be a mapping') + for destination, source in ((values, saved), (values['features'], saved.get('features', {}))): + for key in destination: + if key == 'features' or key not in source: + continue + if type(source[key]) is not bool: + raise ValueError(f'{key} must be a boolean') + destination[key] = source[key] + if 'desktop_autologin' not in saved and 'gdm_autologin' in saved: + if type(saved['gdm_autologin']) is not bool: + raise ValueError('gdm_autologin must be a boolean') + values['desktop_autologin'] = saved['gdm_autologin'] + # The ISO does not offer autologin on an unverified/plaintext root. + values['desktop_autologin'] = values['desktop_autologin'] and encrypted + print(f'root_encrypted={str(encrypted).lower()}') + # Only fixed policy keys and booleans cross into Bash; there is no eval. + for key, value in {**{key: value for key, value in values.items() if key != 'features'}, + **values['features']}.items(): + print(f'{key}={str(value).lower()}') + + +if __name__ == '__main__': + main() diff --git a/scripts/migrate-config b/scripts/migrate-config index 019b7775..8cb9f6a8 100755 --- a/scripts/migrate-config +++ b/scripts/migrate-config @@ -11,19 +11,8 @@ import yaml CURRENT_SCHEMA = 1 -FEATURE_DEFAULTS = { - "developer_tools": True, - "connected_widgets": True, - "proprietary_apps": True, - "tailscale": True, - "docker": True, - "podman": True, - "steam": True, - "private_hooks": False, - "apple_display": False, - "source_builds": True, - "local_network_services": False, -} +INVENTORY = Path(__file__).resolve().parents[1] / 'inventory/group_vars/all.yml' +FEATURE_DEFAULTS = yaml.safe_load(INVENTORY.read_text())['features'] BOOLEAN_KEYS = { "manage_system_identity", "manage_personal_dotfiles", @@ -91,6 +80,11 @@ def migrate(document: object) -> dict: # group without asking. A saved configuration without an answer revokes # that access; `cybex configure` can accept it explicitly. result.setdefault("docker_sudoless", False) + # Older source installs omitted this answer and inherited the old false + # default. Keep their files untouched; ISO-leading defaults are for fresh + # installs, not an implicit opt-in during a release migration. + result.setdefault("manage_personal_dotfiles", False) + result.setdefault("passwordless_wheel", False) for key in STRING_KEYS: if key in result and (not isinstance(result[key], str) or not result[key]): raise ValueError(f"{key} must be a non-empty string") diff --git a/tests/application-defaults.py b/tests/application-defaults.py index 97f76de3..e8342a77 100644 --- a/tests/application-defaults.py +++ b/tests/application-defaults.py @@ -166,6 +166,7 @@ def test_noninteractive_installer_selects_every_application(self): "hostnamectl": "printf '%s\\n' cybex-test", "timedatectl": "printf '%s\\n' UTC", "localectl": "exit 0", + "findmnt": "exit 1", "sudo": "echo 'sudo must not run during --check' >&2; exit 97", } for name, body in probes.items(): @@ -183,7 +184,9 @@ def test_noninteractive_installer_selects_every_application(self): for key in APPLICATIONS: self.assertIs(config["features"][key], True, key) self.assertFalse((home / "absent.yml").exists()) - self.assertIs(config["passwordless_wheel"], True) + self.assertIs(config["passwordless_wheel"], False) + self.assertIs(config["manage_personal_dotfiles"], True) + self.assertIs(config["manage_system_identity"], False) for key in ("passwordless_local_polkit", "docker_sudoless", "desktop_autologin"): self.assertIs(config[key], False) diff --git a/tests/installation-parity.py b/tests/installation-parity.py new file mode 100644 index 00000000..af41c1ac --- /dev/null +++ b/tests/installation-parity.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +"""Run the cross-installer contract in the required source gate as well.""" +from pathlib import Path +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'image')) + +if __name__ == '__main__': + unittest.main(module='test_installation_parity') diff --git a/tests/release-update b/tests/release-update index 51076736..6bcb6c84 100755 --- a/tests/release-update +++ b/tests/release-update @@ -26,6 +26,9 @@ chmod 0755 "$fixture/release/cybexos-1.2.3/install" \ "$fixture/release/cybexos-1.2.3/scripts/manage-agent-skills" cp "$repo_root/scripts/migrate-config" \ "$fixture/release/cybexos-1.2.3/scripts/migrate-config" +mkdir -p "$fixture/release/cybexos-1.2.3/inventory/group_vars" +cp "$repo_root/inventory/group_vars/all.yml" \ + "$fixture/release/cybexos-1.2.3/inventory/group_vars/all.yml" cp "$repo_root/scripts/semver" \ "$fixture/release/cybexos-1.2.3/scripts/semver" printf '1.2.3\n' >"$fixture/release/cybexos-1.2.3/VERSION" diff --git a/tests/run b/tests/run index e780b408..17040c70 100755 --- a/tests/run +++ b/tests/run @@ -469,6 +469,7 @@ python_fixtures=( shell-health repository-policy application-defaults + installation-parity login-policy session-launcher omawrite