diff --git a/roles/bootstrap/tasks/_desktop.yml b/roles/bootstrap/tasks/_desktop.yml index c635261..43d1054 100644 --- a/roles/bootstrap/tasks/_desktop.yml +++ b/roles/bootstrap/tasks/_desktop.yml @@ -8,14 +8,37 @@ _de: "{{ system_cfg.features.desktop.environment }}" _family_pkgs: "{{ bootstrap_desktop_packages[os_family] | default({}) }}" _de_config: "{{ _family_pkgs[_de] | default({}) }}" + _base: "{{ bootstrap_desktop_base_packages[os_family] | default([]) }}" + _requested_groups: "{{ system_cfg.features.desktop.groups | default([]) }}" + _group_pkgs: >- + {{ + _requested_groups + | select('in', desktop_package_groups) + | map('extract', desktop_package_groups) + | map(attribute=os_family, default=[]) + | list + | sum(start=[]) + }} ansible.builtin.set_fact: - _desktop_groups: "{{ _de_config.groups | default([]) }}" - _desktop_packages: "{{ _de_config.packages | default([]) }}" + # GNOME ships under different dnf environment groups: Fedora uses + # workstation-product-environment, enterprise RHEL/Rocky/Alma use + # graphical-server-environment ("Server with GUI"). + _desktop_groups: >- + {{ ['graphical-server-environment'] + if (_de == 'gnome' and os_family == 'RedHat' and os != 'fedora') + else (_de_config.groups | default([])) }} + _desktop_packages: >- + {{ + ((_de_config.packages | default([])) + _base + _group_pkgs) + | reject('equalto', '') + | unique + | list + }} - name: Validate desktop environment is supported ansible.builtin.assert: that: - - (_desktop_groups | length > 0) or (_desktop_packages | length > 0) + - system_cfg.features.desktop.environment in (bootstrap_desktop_packages[os_family] | default({})) fail_msg: >- Desktop environment '{{ system_cfg.features.desktop.environment }}' is not defined for os_family '{{ os_family }}'. @@ -25,7 +48,7 @@ - name: Install desktop package groups when: _desktop_groups | length > 0 ansible.builtin.command: >- - {{ chroot_command }} dnf --releasever={{ os_version }} + {{ chroot_command }} dnf --releasever={{ os_version_major }} --setopt=install_weak_deps=False group install -y {{ _desktop_groups | join(' ') }} register: _desktop_group_result changed_when: _desktop_group_result.rc == 0 @@ -35,14 +58,12 @@ vars: _install_commands: RedHat: >- - {{ chroot_command }} dnf --releasever={{ os_version }} + {{ chroot_command }} dnf --releasever={{ os_version_major }} --setopt=install_weak_deps=False install -y {{ _desktop_packages | join(' ') }} Debian: >- - {{ chroot_command }} apt install -y {{ _desktop_packages | join(' ') }} + {{ chroot_command }} apt install -y --install-recommends {{ _desktop_packages | join(' ') }} Archlinux: >- pacstrap /mnt {{ _desktop_packages | join(' ') }} - Suse: >- - {{ chroot_command }} zypper install -y {{ _desktop_packages | join(' ') }} ansible.builtin.command: "{{ _install_commands[os_family] }}" register: _desktop_pkg_result changed_when: _desktop_pkg_result.rc == 0 diff --git a/roles/bootstrap/vars/desktop.yml b/roles/bootstrap/vars/desktop.yml index 6d4fbd2..66e6de8 100644 --- a/roles/bootstrap/vars/desktop.yml +++ b/roles/bootstrap/vars/desktop.yml @@ -1,7 +1,10 @@ --- # Per-family desktop environment package definitions. -# Keyed by os_family -> environment -> groups (dnf groupinstall) / packages. -# Kept intentionally minimal: base DE + essential tools, no full suites. +# Keyed by os_family -> environment -> groups (dnf group install) / packages. +# Wayland only: gnome, kde, sway, hyprland. No X11/xorg-server, no X11-only DEs. +# DE sets carry the session bits + the DE-specific xdg portal backend; the +# shared base below (fonts/audio/portal core/power/viewer apps) is layered on +# top for every DE via bootstrap_desktop_base_packages. bootstrap_desktop_packages: RedHat: gnome: @@ -24,13 +27,6 @@ bootstrap_desktop_packages: - xdg-user-dirs - xdg-desktop-portal-kde - bluez - - pipewire - - wireplumber - xfce: - groups: - - xfce-desktop-environment - packages: - - lightdm Debian: gnome: groups: [] @@ -53,15 +49,6 @@ bootstrap_desktop_packages: - xdg-user-dirs - xdg-desktop-portal-kde - bluez - - pipewire - - wireplumber - xfce: - groups: [] - packages: - - xfce4 - - xfce4-goodies - - lightdm - - xdg-user-dirs Archlinux: gnome: groups: [] @@ -84,15 +71,6 @@ bootstrap_desktop_packages: - xdg-user-dirs - xdg-desktop-portal-kde - bluez - - pipewire - - wireplumber - xfce: - groups: [] - packages: - - xfce4 - - xfce4-goodies - - lightdm - - xdg-user-dirs sway: groups: [] packages: @@ -100,12 +78,13 @@ bootstrap_desktop_packages: - waybar - foot - wofi + - nautilus - greetd + - greetd-tuigreet - xdg-user-dirs - xdg-desktop-portal-wlr + - polkit-gnome - bluez - - pipewire - - wireplumber hyprland: groups: [] packages: @@ -113,37 +92,80 @@ bootstrap_desktop_packages: - kitty - wofi - waybar - - ly + - nautilus + - greetd + - greetd-tuigreet - xdg-user-dirs - xdg-desktop-portal-hyprland - polkit-kde-agent - qt5-wayland - qt6-wayland - bluez - - pipewire - - wireplumber - Suse: - gnome: - groups: [] - packages: - - patterns-gnome-gnome_basic - - gdm - - xdg-user-dirs - kde: - groups: [] - packages: - - patterns-kde-kde_plasma - - sddm - - xdg-user-dirs -# Display manager auto-detection from desktop environment. -bootstrap_desktop_dm_map: - gnome: gdm - kde: sddm - xfce: lightdm - sway: greetd - hyprland: ly@tty2 - cinnamon: lightdm - mate: lightdm - lxqt: sddm - budgie: gdm +# Shared desktop base, installed for EVERY DE whenever desktop.enabled. +# Fonts (noto + emoji + one nerd font), audio stack (pipewire + wireplumber + +# pipewire-pulse), xdg portal core, power-profiles-daemon, and viewer-only base +# apps (browser, PDF/image/video viewers). DE metas (gnome/plasma) bundle their +# own file manager + settings, so no file manager is added here - the wlroots +# DE sets above carry their own (nautilus). +bootstrap_desktop_base_packages: + RedHat: + - google-noto-sans-fonts + - google-noto-emoji-fonts + - fira-code-fonts + - pipewire + - wireplumber + - pipewire-pulseaudio + - xdg-desktop-portal + - power-profiles-daemon + - firefox + - evince + - eog + - mpv + Debian: + - fonts-noto + - fonts-noto-color-emoji + - fonts-firacode + - pipewire + - wireplumber + - pipewire-pulse + - xdg-desktop-portal + - power-profiles-daemon + - firefox-esr + - evince + - eog + - mpv + Archlinux: + - noto-fonts + - noto-fonts-emoji + - ttf-nerd-fonts-symbols + - pipewire + - wireplumber + - pipewire-pulse + - xdg-desktop-portal + - power-profiles-daemon + - firefox + - evince + - loupe + - mpv + +# Opt-in package groups, selected per host via features.desktop.groups (a list +# of group names). _desktop.yml installs the union of the requested groups' +# packages. Empty selection by default. +desktop_package_groups: + dev: + RedHat: + - git + - "@development-tools" + - neovim + - python3-pip + Debian: + - git + - build-essential + - neovim + - python3-pip + Archlinux: + - git + - base-devel + - neovim + - python-pip diff --git a/roles/configuration/tasks/services.yml b/roles/configuration/tasks/services.yml index d5c61ca..440b162 100644 --- a/roles/configuration/tasks/services.yml +++ b/roles/configuration/tasks/services.yml @@ -1,13 +1,34 @@ --- -- name: Enable systemd services - when: _configuration_platform.init_system == 'systemd' +- name: Resolve desktop facts + when: system_cfg.features.desktop.enabled | bool vars: + _autologin: "{{ system_cfg.features.desktop.autologin | default(false) }}" + ansible.builtin.set_fact: _desktop_dm: >- {{ system_cfg.features.desktop.display_manager if (system_cfg.features.desktop.display_manager | length > 0) else (configuration_desktop_dm_map[system_cfg.features.desktop.environment] | default('')) }} + _desktop_session: "{{ system_cfg.features.desktop.session | default('') }}" + # Explicit session wins, else the per-environment command. Single source of + # truth for the greetd assert, the config gate, and the template. + _greetd_session: >- + {{ + system_cfg.features.desktop.session + if (system_cfg.features.desktop.session | default('') | length > 0) + else (configuration_desktop_session_cmd_map[system_cfg.features.desktop.environment] | default('')) + }} + _desktop_autologin_user: >- + {{ + _autologin + if (_autologin | string | lower not in ['', 'false'] and _autologin in system_cfg.users) + else '' + }} + +- name: Enable systemd services + when: _configuration_platform.init_system == 'systemd' + vars: configuration_systemd_services: >- {{ ['NetworkManager'] @@ -15,7 +36,6 @@ + (['ufw'] if system_cfg.features.firewall.backend == 'ufw' and system_cfg.features.firewall.enabled | bool else []) + ([_configuration_platform.ssh_service] if system_cfg.features.ssh.enabled | bool else []) + (['logrotate', 'systemd-timesyncd'] if os == 'archlinux' else []) - + ([_desktop_dm] if system_cfg.features.desktop.enabled | bool and _desktop_dm | length > 0 else []) + (['bluetooth'] if system_cfg.features.desktop.enabled | bool else []) }} ansible.builtin.command: "{{ chroot_command }} systemctl enable {{ item }}" @@ -27,6 +47,21 @@ and 'No such file or directory' not in (configuration_enable_service_result.stderr | default('')) and 'does not exist' not in (configuration_enable_service_result.stderr | default('')) +- name: Enable display manager for selected desktop + when: + - _configuration_platform.init_system == 'systemd' + - system_cfg.features.desktop.enabled | bool + - _desktop_dm | length > 0 + ansible.builtin.command: "{{ chroot_command }} systemctl enable {{ _desktop_dm }}" + register: configuration_enable_dm_result + changed_when: configuration_enable_dm_result.rc == 0 + # Unlike optional services above, a missing/unenabled DM is fatal: chroot + # systemctl can exit 0 while only warning on stderr, so check both. + failed_when: >- + configuration_enable_dm_result.rc != 0 + or 'No such file or directory' in (configuration_enable_dm_result.stderr | default('')) + or 'does not exist' in (configuration_enable_dm_result.stderr | default('')) + - name: Activate UFW firewall when: - system_cfg.features.firewall.backend == 'ufw' @@ -44,66 +79,108 @@ register: _desktop_target_result changed_when: _desktop_target_result.rc == 0 -- name: Enable OpenRC services - when: _configuration_platform.init_system == 'openrc' - vars: - configuration_openrc_services: >- - {{ - ['networking'] - + (['sshd'] if system_cfg.features.ssh.enabled | bool else []) - + ([system_cfg.features.firewall.backend] if system_cfg.features.firewall.enabled | bool else []) - }} +- name: Enable PipeWire user services globally + when: + - _configuration_platform.init_system == 'systemd' + - system_cfg.features.desktop.enabled | bool + ansible.builtin.command: "{{ chroot_command }} systemctl --global enable {{ item }}" + loop: "{{ configuration_desktop_audio_units }}" + register: _desktop_audio_result + changed_when: _desktop_audio_result.rc == 0 + failed_when: >- + _desktop_audio_result.rc != 0 + and 'No such file or directory' not in (_desktop_audio_result.stderr | default('')) + and 'does not exist' not in (_desktop_audio_result.stderr | default('')) + +- name: Assert greetd has a real session command to launch + when: + - system_cfg.features.desktop.enabled | bool + - _desktop_dm == 'greetd' + ansible.builtin.assert: + that: + - _greetd_session | length > 0 + - not (_greetd_session | trim | regex_search('\\.desktop$')) + fail_msg: >- + greetd needs an executable session command, but the resolved command for desktop + environment '{{ system_cfg.features.desktop.environment }}' is + '{{ _greetd_session }}'. greetd suits wlroots compositors (sway, hyprland) that + launch from a plain command; kde/gnome ship a '.desktop' session and should use + their own display manager (sddm, gdm). Set features.desktop.session to an + executable, or pick a different display manager. + +- name: Generate greetd configuration + when: + - _configuration_platform.init_system == 'systemd' + - system_cfg.features.desktop.enabled | bool + - _desktop_dm == 'greetd' + - _greetd_session | length > 0 block: - - name: Ensure OpenRC runlevel directory exists + - name: Ensure greetd config directory exists ansible.builtin.file: - path: /mnt/etc/runlevels/default + path: /mnt/etc/greetd state: directory mode: "0755" - - name: Check OpenRC init scripts - ansible.builtin.stat: - path: "/mnt/etc/init.d/{{ item }}" - loop: "{{ configuration_openrc_services }}" - register: configuration_openrc_service_stats + - name: Write greetd config.toml + ansible.builtin.template: + src: greetd-config.toml.j2 + dest: /mnt/etc/greetd/config.toml + mode: "0644" - - name: Enable OpenRC services - ansible.builtin.file: - src: "/mnt/etc/init.d/{{ item.item }}" - dest: "/mnt/etc/runlevels/default/{{ item.item }}" - state: link - loop: "{{ configuration_openrc_service_stats.results }}" - loop_control: - label: "{{ item.item }}" - when: item.stat.exists - -- name: Enable runit services - when: _configuration_platform.init_system == 'runit' +- name: Configure GDM autologin + when: + - _configuration_platform.init_system == 'systemd' + - system_cfg.features.desktop.enabled | bool + - _desktop_dm == 'gdm' + - _desktop_autologin_user | length > 0 vars: - configuration_runit_services: >- - {{ - ['dhcpcd'] - + (['sshd'] if system_cfg.features.ssh.enabled | bool else []) - + ([system_cfg.features.firewall.backend] if system_cfg.features.firewall.enabled | bool else []) - }} + # Debian's gdm3 reads /etc/gdm3/daemon.conf; RedHat/Arch GDM read + # /etc/gdm/custom.conf. The keys are identical, only the path differs. + _gdm_dir: "/mnt/etc/{{ 'gdm3' if os_family == 'Debian' else 'gdm' }}" + _gdm_conf: "{{ 'daemon.conf' if os_family == 'Debian' else 'custom.conf' }}" block: - - name: Ensure runit service directory exists + - name: Ensure GDM config directory exists ansible.builtin.file: - path: /mnt/var/service + path: "{{ _gdm_dir }}" state: directory mode: "0755" - - name: Check runit service definitions - ansible.builtin.stat: - path: "/mnt/etc/sv/{{ item }}" - loop: "{{ configuration_runit_services }}" - register: configuration_runit_service_stats + - name: Write GDM autologin config + ansible.builtin.template: + src: gdm-custom.conf.j2 + dest: "{{ _gdm_dir }}/{{ _gdm_conf }}" + mode: "0644" - - name: Enable runit services +- name: Configure SDDM autologin + when: + - _configuration_platform.init_system == 'systemd' + - system_cfg.features.desktop.enabled | bool + - _desktop_dm == 'sddm' + - _desktop_autologin_user | length > 0 + block: + - name: Ensure SDDM config directory exists ansible.builtin.file: - src: "/mnt/etc/sv/{{ item.item }}" - dest: "/mnt/var/service/{{ item.item }}" - state: link - loop: "{{ configuration_runit_service_stats.results }}" - loop_control: - label: "{{ item.item }}" - when: item.stat.exists + path: /mnt/etc/sddm.conf.d + state: directory + mode: "0755" + + # Plasma 6 ships the Wayland session as plasma.desktop; Plasma 5 ships it as + # plasmawayland.desktop (plasma.desktop is the X11 session there). Pick the + # installed Wayland session so autologin never lands on X11. + - name: Discover installed KDE Wayland sessions + ansible.builtin.find: + paths: /mnt/usr/share/wayland-sessions + patterns: "plasma.desktop,plasmawayland.desktop" + register: _kde_wayland_sessions + + - name: Resolve the KDE Wayland session file + ansible.builtin.set_fact: + _sddm_session: >- + {%- set names = _kde_wayland_sessions.files | map(attribute='path') | map('basename') | list -%} + {{ 'plasma.desktop' if 'plasma.desktop' in names else (names | first | default('')) }} + + - name: Write SDDM autologin drop-in + ansible.builtin.template: + src: sddm-autologin.conf.j2 + dest: /mnt/etc/sddm.conf.d/10-autologin.conf + mode: "0644" diff --git a/roles/configuration/templates/gdm-custom.conf.j2 b/roles/configuration/templates/gdm-custom.conf.j2 new file mode 100644 index 0000000..b827a72 --- /dev/null +++ b/roles/configuration/templates/gdm-custom.conf.j2 @@ -0,0 +1,4 @@ +[daemon] +WaylandEnable=true +AutomaticLoginEnable=true +AutomaticLogin={{ _desktop_autologin_user }} diff --git a/roles/configuration/templates/greetd-config.toml.j2 b/roles/configuration/templates/greetd-config.toml.j2 new file mode 100644 index 0000000..1794a47 --- /dev/null +++ b/roles/configuration/templates/greetd-config.toml.j2 @@ -0,0 +1,12 @@ +[terminal] +vt = 1 + +[default_session] +command = "tuigreet --time --remember --cmd {{ _greetd_session }}" +user = "greeter" +{% if _desktop_autologin_user | length > 0 %} + +[initial_session] +command = "{{ _greetd_session }}" +user = "{{ _desktop_autologin_user }}" +{% endif %} diff --git a/roles/configuration/templates/sddm-autologin.conf.j2 b/roles/configuration/templates/sddm-autologin.conf.j2 new file mode 100644 index 0000000..1fa35cb --- /dev/null +++ b/roles/configuration/templates/sddm-autologin.conf.j2 @@ -0,0 +1,6 @@ +{% set _session = _desktop_session if (_desktop_session | length > 0) else _sddm_session %} +[Autologin] +User={{ _desktop_autologin_user }} +{% if _session | length > 0 %} +Session={{ _session }} +{% endif %} diff --git a/roles/configuration/vars/main.yml b/roles/configuration/vars/main.yml index 2199eb6..ebd4048 100644 --- a/roles/configuration/vars/main.yml +++ b/roles/configuration/vars/main.yml @@ -35,45 +35,24 @@ configuration_platform_config: grub_mkconfig_prefix: grub-mkconfig locale_gen: true init_system: systemd - Suse: - user_group: wheel - sudo_group: "%wheel" - ssh_service: sshd - efi_loader: grubx64.efi - grub_install: true - initramfs_cmd: "/usr/bin/dracut --regenerate-all --force" - grub_mkconfig_prefix: grub-mkconfig - locale_gen: true - init_system: systemd - Alpine: - user_group: wheel - sudo_group: "%wheel" - ssh_service: sshd - efi_loader: grubx64.efi - grub_install: true - initramfs_cmd: "" - grub_mkconfig_prefix: grub-mkconfig - locale_gen: false - init_system: openrc - Void: - user_group: wheel - sudo_group: "%wheel" - ssh_service: sshd - efi_loader: grubx64.efi - grub_install: true - initramfs_cmd: "" - grub_mkconfig_prefix: grub-mkconfig - locale_gen: false - init_system: runit # Display manager auto-detection from desktop environment name. configuration_desktop_dm_map: gnome: gdm kde: sddm - xfce: lightdm sway: greetd - hyprland: ly@tty2 - cinnamon: lightdm - mate: lightdm - lxqt: sddm - budgie: gdm + hyprland: greetd + +# Per-environment session command for greetd-launched compositors (sway/hyprland): +# the executable greetd starts. kde/gnome use a display manager (sddm/gdm) whose +# Wayland session is resolved separately, so they are not in this map. +configuration_desktop_session_cmd_map: + sway: sway + hyprland: Hyprland + +# PipeWire user units enabled globally when a desktop is installed. +# pipewire/pipewire-pulse are socket-activated; wireplumber ships no socket. +configuration_desktop_audio_units: + - pipewire.socket + - pipewire-pulse.socket + - wireplumber.service diff --git a/roles/global_defaults/tasks/_normalize_system.yml b/roles/global_defaults/tasks/_normalize_system.yml index 541cd9b..d70f1ee 100644 --- a/roles/global_defaults/tasks/_normalize_system.yml +++ b/roles/global_defaults/tasks/_normalize_system.yml @@ -28,21 +28,41 @@ memory: "{{ [system_raw.memory | default(0) | int, 0] | max }}" balloon: "{{ [system_raw.balloon | default(0) | int, 0] | max }}" # --- Network --- - # Flat fields (bridge, ip, etc.) and interfaces[] are mutually exclusive. - # When interfaces[] is set, flat fields are populated from the first - # interface in the "Populate primary network fields" task below. - # When only flat fields are set, a synthetic interfaces[] entry is built. + # Flat fields (bridge, ip, etc.) and interfaces[] express the same primary NIC. + # When only flat fields are set, a synthetic interfaces[] entry is built below. + # When interfaces[] is set, the flat ip/prefix/gateway are backfilled from + # interfaces[0] so consumers reading the flat fields (e.g. the post-reboot + # reconnect block) still work. network: - bridge: "{{ system_raw.network.bridge | default('') | string }}" + bridge: >- + {{ + (system_raw.network.bridge | default('') | string) + if (system_raw.network.bridge | default('') | string | length) > 0 + else (system_raw.network.interfaces[0].bridge | default('') | string + if (system_raw.network.interfaces | default([]) | length) > 0 else '') + }} vlan: "{{ system_raw.network.vlan | default('') | string }}" - ip: "{{ system_raw.network.ip | default('') | string }}" + ip: >- + {{ + (system_raw.network.ip | default('') | string) + if (system_raw.network.ip | default('') | string | length) > 0 + else (system_raw.network.interfaces[0].ip | default('') | string + if (system_raw.network.interfaces | default([]) | length) > 0 else '') + }} prefix: >- {{ (system_raw.network.prefix | int | string) if (system_raw.network.prefix | default('') | string | length) > 0 - else '' + else (system_raw.network.interfaces[0].prefix | default('') | string + if (system_raw.network.interfaces | default([]) | length) > 0 else '') + }} + gateway: >- + {{ + (system_raw.network.gateway | default('') | string) + if (system_raw.network.gateway | default('') | string | length) > 0 + else (system_raw.network.interfaces[0].gateway | default('') | string + if (system_raw.network.interfaces | default([]) | length) > 0 else '') }} - gateway: "{{ system_raw.network.gateway | default('') | string }}" dns: servers: "{{ system_raw.network.dns.servers | default([]) }}" search: "{{ system_raw.network.dns.search | default([]) }}" @@ -148,6 +168,9 @@ enabled: "{{ system_raw.features.desktop.enabled | bool }}" environment: "{{ system_raw.features.desktop.environment | default('') | string | lower }}" display_manager: "{{ system_raw.features.desktop.display_manager | default('') | string | lower }}" + autologin: "{{ system_raw.features.desktop.autologin | default(false) }}" + session: "{{ system_raw.features.desktop.session | default('') | string }}" + groups: "{{ system_raw.features.desktop.groups | default([]) }}" secure_boot: enabled: "{{ system_raw.features.secure_boot.enabled | bool }}" method: "{{ system_raw.features.secure_boot.method | default('') | string | lower }}" @@ -169,7 +192,12 @@ else (system_raw.features.firmware.microcode | bool) }} gpu: - enabled: "{{ system_raw.features.gpu.enabled | bool }}" + enabled: >- + {{ + (system_raw.features.desktop.enabled | bool) + if (system_raw.features.gpu.enabled | string | lower) == 'auto' + else (system_raw.features.gpu.enabled | bool) + }} nvidia_driver: "{{ system_raw.features.gpu.nvidia_driver | default('auto') | string | lower }}" peripherals: enabled: >- diff --git a/roles/global_defaults/tasks/validation.yml b/roles/global_defaults/tasks/validation.yml index 021a0a5..037d4ba 100644 --- a/roles/global_defaults/tasks/validation.yml +++ b/roles/global_defaults/tasks/validation.yml @@ -140,7 +140,7 @@ os in ["ubuntu", "ubuntu-lts"] and (os_version | default('') | string | length) == 0 ) or ( - os in ["alpine", "archlinux", "opensuse", "void"] + os == "archlinux" ) fail_msg: "Invalid os/version specified. Please check README.md for supported values." quiet: true @@ -252,6 +252,49 @@ peripherals.webcam in [auto|true|false]; hardware.profile must be a dict. quiet: true +- name: Validate desktop environment + when: system_cfg.features.desktop.enabled | bool + ansible.builtin.assert: + that: + - system_cfg.features.desktop.environment in ["gnome", "kde", "sway", "hyprland"] + - >- + system_cfg.features.desktop.environment not in ["sway", "hyprland"] + or os_family_map[os] | default('') == "Archlinux" + - >- + system_cfg.features.desktop.display_manager | default('') | length == 0 + or system_cfg.features.desktop.display_manager in ["gdm", "sddm", "greetd"] + - >- + system_cfg.features.desktop.display_manager | default('') != "greetd" + or system_cfg.features.desktop.environment in ["sway", "hyprland"] + - >- + system_cfg.features.desktop.environment != "gnome" + or system_cfg.features.desktop.display_manager | default('') in ["", "gdm"] + - >- + system_cfg.features.desktop.environment != "kde" + or system_cfg.features.desktop.display_manager | default('') in ["", "sddm"] + fail_msg: >- + Invalid desktop config: environment '{{ system_cfg.features.desktop.environment }}' + for os_family '{{ os_family_map[os] | default('Unknown') }}', + display_manager '{{ system_cfg.features.desktop.display_manager | default('') }}'. + gnome and kde are available on all families; sway and hyprland are Archlinux only. + display_manager must be empty (auto) or match the environment's native DM: + gnome->gdm, kde->sddm, sway/hyprland->greetd. Only that DM's package is + installed, so a mismatched override fails at enable time. + quiet: true + +- name: Validate desktop autologin + when: system_cfg.features.desktop.enabled | bool + vars: + _autologin: "{{ system_cfg.features.desktop.autologin | default(false) }}" + ansible.builtin.assert: + that: + - _autologin is boolean and not _autologin or (_autologin is string and _autologin | length > 0 and _autologin in system_cfg.users) + fail_msg: >- + desktop.autologin must be false or a username string present in + system.users; got '{{ _autologin }}'. Bool true is not accepted - the + resolver matches the value against system.users by name. + quiet: true + - name: Validate virtual system sizing when: system_cfg.type == "virtual" ansible.builtin.assert: @@ -262,7 +305,7 @@ - (system_cfg.disks[0].size | float) > 0 - (system_cfg.disks[0].size | float) >= 20 # Btrfs minimum disk: swap_size + 5.5 GiB overhead (subvolumes + metadata). - # Swap sizing: memory < 16 GiB → max(memory_GiB, 2); memory >= 16 GiB → memory/2. + # Swap sizing: memory < 16 GiB -> max(memory_GiB, 2); memory >= 16 GiB -> memory/2. - >- system_cfg.filesystem != "btrfs" or (