diff --git a/.github/workflows/update-images.yml b/.github/workflows/update-images.yml new file mode 100644 index 00000000..1057c49c --- /dev/null +++ b/.github/workflows/update-images.yml @@ -0,0 +1,28 @@ +name: update-images +on: + workflow_dispatch: + schedule: + - cron: "0 5 * * 1" # runs weekly every Monday at 05:00 UTC + +jobs: + bump-images: + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repo" + uses: "actions/checkout@v6" + - name: "Install the Nix package manager" + uses: "cachix/install-nix-action@master" + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + - name: "Refresh container test cloud image pins" + run: tools/update-images.py + - name: "Create Pull Request" + uses: "peter-evans/create-pull-request@v8" + with: + branch: "auto_update_images" + title: "Bump container test cloud image pins" + body: | + Automatically refreshed the cloud image URLs and sha256 hashes in + `lib/container-test-driver/images.json` by running + `tools/update-images.py`. + commit-message: "chore(container-tests): bump cloud image pins" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 251aa898..b82921b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,21 +46,59 @@ Before creating a new issue, please [search existing issues](https://github.com/ ## Adding New Distributions -System Manager officially supports Ubuntu and NixOS. To add support for another distribution: +System Manager officially supports Ubuntu, Debian, and NixOS. +Promoting a new distribution to officially-supported status means it is exercised by CI on every PR and a regression in it blocks the build. -1. Initialize a new flake with distribution checks disabled: - ```sh - nix run 'github:numtide/system-manager' -- init --flake --allow-any-distro - ``` +### Trying a distribution informally -2. Switch to the new configuration: - ```sh - nix run 'github:numtide/system-manager' -- switch --flake '.' - ``` +If you just want to run System Manager on an untested distribution without contributing it back, initialize a flake and disable the OS check by setting `system-manager.allowAnyDistro = true` in your configuration module: + +```nix +{ + config.system-manager.allowAnyDistro = true; +} +``` + +Then iterate with `nix run 'github:numtide/system-manager' -- switch --flake '.'` and debug any errors using the FAQ, GitHub issues, or a discussion. +Once the distribution is stable for your use case, consider upstreaming it via the steps below. + +### Adding official support + +Adding a distribution touches four areas: the OS allow-list, the container test driver, the VM test driver, and the documentation. + +**1. Add the distribution ID to the OS allow-list.** +Edit `nix/modules/default.nix` and append the `/etc/os-release` `ID` value to the `supportedIds` list inside the `osVersion` pre-activation assertion. +The check is bypassed when users set `system-manager.allowAnyDistro = true`, but the allow-list is what controls the default. + +**2. Add a container test entry.** +Container tests live under `testFlake/container-tests/` and are parameterized over every distribution declared in `lib/container-test-driver/distros.nix`. +Adding a new entry there causes all existing tests to automatically generate a `container--*` variant via `forEachDistro`. + +The entry must supply `systems`, a `rootfs` derivation built by `lib.container-test-driver.make-rootfs.buildRootfs`, and a `maskableService` (a systemd unit that test scripts may mask, typically `unattended-upgrades.service` or equivalent). + +`buildRootfs` accepts three upstream image formats via `cloudImgFormat`, and the right choice depends on what the distribution publishes: + +- `"tar"` (default) consumes a flat rootfs tarball such as Ubuntu's `*-server-cloudimg-amd64-root.tar.xz`. This is the simplest path, has no architecture restrictions, and should be preferred whenever the distribution ships a rootfs tarball. +- `"disk-tarball"` consumes a `.tar.xz` that wraps a raw disk image, such as Debian's `*-genericcloud-*.tar.xz`. It unpacks the outer tarball, locates the root partition with `sfdisk -J` + `jq`, extracts it with `dd`, and dumps the ext4 filesystem into `$out` via `debugfs -R "rdump / $out"`. All required tools (`util-linux`, `e2fsprogs`, `jq`) are cross-architecture in nixpkgs, so this works on both `x86_64-linux` and `aarch64-linux`. `excludePatterns` are applied as a post-extraction prune pass rather than as tar `--exclude` flags. Note: this currently assumes the root filesystem is ext4; a btrfs-backed rootfs (such as Fedora Workstation) would need a `btrfs restore`-based variant added alongside. +- `"qcow2"` extracts the rootfs from a qcow2 cloud disk image using `guestfish tar-out`. It pulls in `libguestfs-with-appliance`, whose `libguestfs-appliance` subpackage is marked `meta.platforms = [ "i686-linux" "x86_64-linux" ]` in nixpkgs, so entries using this format must restrict `systems` to `x86_64-linux`. Use only as a last resort, when the distribution publishes neither a rootfs tarball nor a disk-in-tarball variant. + +Pin a specific dated build directory upstream rather than `latest/` and obtain the SHA256 with `nix-prefetch-url`. URL and hash go in `lib/container-test-driver/images.json`; `distros.nix` reads them automatically. + +Reuse the existing `excludePatterns` (which strip container-incompatible systemd units) and `extraDirs` (per-package-manager directories like `var/lib/apt/lists/partial`) as a starting point and trim or extend them based on the first build. + +**3. Add a VM test entry.** +VM tests live under `testFlake/vm-tests/` and iterate over distributions exposed by `nix-vm-test`. +Edit the `distros` attrset in `testFlake/vm-tests/default.nix` to add a key matching the `nix-vm-test` distribution name (`ubuntu`, `debian`, `fedora`, `rocky`). +Each entry takes a `filter` predicate that selects which versions to exercise — use it to skip versions you do not want in the matrix. +If `nix-vm-test` does not yet support the distribution, support must be added there first. -3. Debug any errors that occur. Refer to the FAQ, GitHub issues, or open a discussion for help. +**4. Run the test matrix and triage failures.** +Build the new check attributes via `nix build .#checks.x86_64-linux.container--*` and `vm--*-*` and triage any failures. +Prefer fixing tests to be distribution-agnostic over skipping them. -4. Once stable, the distribution can be added to the `supportedIds` list in the [system-manager module](./nix/modules/default.nix). +**5. Update documentation.** +The user-facing platform statement lives in `docs/site/reference/supported-platforms.md`, with secondary mentions in `README.md`, `docs/site/how-to/install.md`, `docs/site/tutorials/getting-started.md`, and `docs/site/how-to/test-configuration.md`. +Mention the new distribution alongside the existing supported ones. ## Creating an Ad-Hoc Release diff --git a/README.md b/README.md index 23f61bb2..3a315797 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ You can find the [full documentation here](https://system-manager.net/main/). ## Quick Example to Get Started -We will assume you're using a non-NixOS distribution (such as Ubuntu) and you have Nix already installed, with flakes enabled. +We will assume you're using a non-NixOS distribution (such as Ubuntu, Debian, or Fedora) and you have Nix already installed, with flakes enabled. System Manager has an "init" subcommand that can build a set of starting files for you. By default, it places the files in `~/.config/system-manager`. You can run this init subcommand by typing: @@ -214,7 +214,7 @@ Then re-run System Manager and your changes will take effect; now you should hav ## Supported Systems -System Manager is currently only supported on NixOS and Ubuntu. However, it can be used on other distributions by enabling the following: +System Manager is currently supported on NixOS, Ubuntu, Debian, and Fedora. However, it can be used on other distributions by enabling the following: ```nix { diff --git a/crates/system-manager-engine/src/activate/users.rs b/crates/system-manager-engine/src/activate/users.rs index b1107915..ca0512ab 100644 --- a/crates/system-manager-engine/src/activate/users.rs +++ b/crates/system-manager-engine/src/activate/users.rs @@ -60,14 +60,22 @@ pub fn lock_managed_users() -> Result<()> { } /// Resolves a base shell path (after prefix stripping) to an existing FHS location. +/// +/// For nologin, always prefer `/usr/sbin/nologin` regardless of whether the +/// original base path also happens to resolve: on merged-usr distributions +/// (e.g. fedora) `/bin` is a symlink to `/usr/bin`, so a base of +/// `/bin/nologin` would `exists()`-check true via `/usr/bin/nologin` and we +/// would restore the wrong path, leaving `/etc/passwd` with `/bin/nologin` +/// instead of the `/usr/sbin/nologin` every distro ships. fn resolve_shell(base: &str) -> Result<&str> { - if Path::new(base).exists() { - return Ok(base); - } - match base { - "/bin/nologin" | "/sbin/nologin" => { - for fallback in ["/usr/sbin/nologin", "/usr/bin/nologin", "/bin/nologin"] { + "/bin/nologin" | "/sbin/nologin" | "/usr/sbin/nologin" | "/usr/bin/nologin" => { + for fallback in [ + "/usr/sbin/nologin", + "/usr/bin/nologin", + "/sbin/nologin", + "/bin/nologin", + ] { if Path::new(fallback).exists() { return Ok(fallback); } @@ -75,6 +83,9 @@ fn resolve_shell(base: &str) -> Result<&str> { anyhow::bail!("No valid nologin shell found for base path '{}'", base); } _ => { + if Path::new(base).exists() { + return Ok(base); + } for fallback in ["/bin/sh", "/usr/bin/sh"] { if Path::new(fallback).exists() { return Ok(fallback); diff --git a/docs/site/how-to/install.md b/docs/site/how-to/install.md index 5e780cb8..4cc72ea1 100644 --- a/docs/site/how-to/install.md +++ b/docs/site/how-to/install.md @@ -4,7 +4,7 @@ To use System Manager, you need: -* **A Linux machine.** We've tested System Manager with Ubuntu both as standalone and under Windows Subsystem for Linux (WSL). +* **A Linux machine.** We've tested System Manager with Ubuntu (both as standalone and under Windows Subsystem for Linux) Debian 13 (trixie), and Fedora 43. * **At least 12GB Disk Space.** However, we recommend at least 16GB, as you will be very tight for space with under 16GB. (This is primarily due to Nix; if you're using System Manager to configure, for example, small servers on the Cloud, 8GB simply won't be enough.) * **Nix installed system-wide** with flakes enabled. (System Manager doesn't work with a per-user installation of Nix) diff --git a/docs/site/how-to/test-configuration.md b/docs/site/how-to/test-configuration.md index 67b6dc3b..643e63e8 100644 --- a/docs/site/how-to/test-configuration.md +++ b/docs/site/how-to/test-configuration.md @@ -166,7 +166,7 @@ Test "Verify application files" (0.3s) ```python start_all() -# Wait for Ubuntu systemd to be ready +# Wait for the distro's systemd to be ready machine.wait_for_unit("multi-user.target") # Activate system-manager configuration (displays full activation output) @@ -196,7 +196,7 @@ with subtest("Verify service is responding"): The test framework: -1. Builds an Ubuntu 24.04 container image with the nix-installer binary included +1. Builds a container image (Ubuntu 22.04, Ubuntu 24.04, Debian 13, or Fedora 43) with the nix-installer binary included 2. Starts the container using systemd-nspawn within the Nix build sandbox 3. Installs Nix via nix-installer at container startup (multi-user mode with daemon) 4. Copies your system-manager profile closure into the container via `nix copy` diff --git a/docs/site/reference/supported-platforms.md b/docs/site/reference/supported-platforms.md index 90bbbc56..c68dba91 100644 --- a/docs/site/reference/supported-platforms.md +++ b/docs/site/reference/supported-platforms.md @@ -9,8 +9,8 @@ System Manager runs on Linux systems that use systemd for service management. | Ubuntu 22.04+ | Tested | Primary development platform | | Ubuntu on WSL2 | Tested | Windows Subsystem for Linux | | NixOS | Tested | Works alongside existing NixOS configuration | -| Debian | Community | Should work; similar to Ubuntu | -| Fedora | Community | Should work; uses systemd | +| Debian 13 (trixie) | Tested | Container and VM tests | +| Fedora 43 | Tested | Container and VM tests without SELinux support | | Arch Linux | Community | Should work; uses systemd | ## Requirements @@ -29,7 +29,7 @@ System Manager runs on Linux systems that use systemd for service management. ## Platform detection System Manager checks the platform at activation time using a pre-activation assertion that reads `/etc/os-release`. -By default, it only allows activation on Ubuntu and NixOS. +By default, it only allows activation on Ubuntu, Debian, Fedora, and NixOS. ### Enabling other distributions @@ -44,7 +44,7 @@ To allow System Manager to run on untested distributions, set the `system-manage ``` This disables the OS check entirely. -There is no option to selectively allow specific distributions; the check is either on (default, allowing only Ubuntu and NixOS) or off. +There is no option to selectively allow specific distributions; the check is either on (default, allowing only Ubuntu, Debian, Fedora, and NixOS) or off. ## Limitations diff --git a/docs/site/tutorials/getting-started.md b/docs/site/tutorials/getting-started.md index 58ea9412..97021e5c 100644 --- a/docs/site/tutorials/getting-started.md +++ b/docs/site/tutorials/getting-started.md @@ -2,7 +2,7 @@ If you've heard of NixOS, you've probably heard that it lets you define your entire system in configuration files and then reproduce that system anywhere with a single command. System Manager brings that same declarative model to other Linux distributions*, with no reinstalling, no switching operating systems, and no special prerequisites beyond having Nix installed. -*Presently, System Manager is only tested on Ubuntu, and is limited to only Linux distributions based on systemd. +*Presently, System Manager is tested on Ubuntu, Debian, and Fedora, and is limited to only Linux distributions based on systemd. # Initializing Your System diff --git a/lib/container-test-driver/distros.nix b/lib/container-test-driver/distros.nix index 64ee831e..3719dd4d 100644 --- a/lib/container-test-driver/distros.nix +++ b/lib/container-test-driver/distros.nix @@ -4,6 +4,16 @@ }: let makeRootfs = import ./make-rootfs.nix { inherit pkgs system; }; + images = builtins.fromJSON (builtins.readFile ./images.json); + + fetchCloudImg = + distroName: + let + entry = images.${distroName}.${system} or (throw "Unsupported system for ${distroName}: ${system}"); + in + builtins.fetchurl { + inherit (entry) url sha256; + }; ubuntuExcludePatterns = [ "etc/systemd/system/network-online.target.wants/*" @@ -23,25 +33,10 @@ let in { ubuntu-22_04 = { - systems = [ - "x86_64-linux" - "aarch64-linux" - ]; + systems = builtins.attrNames images.ubuntu-22_04; rootfs = makeRootfs.buildRootfs { name = "ubuntu-22_04"; - cloudImg = - if system == "x86_64-linux" then - builtins.fetchurl { - url = "https://cloud-images.ubuntu.com/releases/jammy/release-20260227/ubuntu-22.04-server-cloudimg-amd64-root.tar.xz"; - sha256 = "05gw1sspv9d4m5yazc8105yc2vr3y9xkwnwilnzn774w9nivwib3"; - } - else if system == "aarch64-linux" then - builtins.fetchurl { - url = "https://cloud-images.ubuntu.com/releases/jammy/release-20260227/ubuntu-22.04-server-cloudimg-arm64-root.tar.xz"; - sha256 = "1aya4ainn5289bhczbx97dxv7ck8ng3kmz8yiicz8ynvyfg6mvrq"; - } - else - throw "Unsupported system: ${system}"; + cloudImg = fetchCloudImg "ubuntu-22_04"; excludePatterns = ubuntuExcludePatterns; extraDirs = [ "var/lib/apt/lists/partial" ]; }; @@ -49,25 +44,56 @@ in }; ubuntu-24_04 = { - systems = [ - "x86_64-linux" - "aarch64-linux" - ]; + systems = builtins.attrNames images.ubuntu-24_04; rootfs = makeRootfs.buildRootfs { name = "ubuntu-24_04"; - cloudImg = - if system == "x86_64-linux" then - builtins.fetchurl { - url = "https://cloud-images.ubuntu.com/releases/noble/release-20251026/ubuntu-24.04-server-cloudimg-amd64-root.tar.xz"; - sha256 = "0y3d55f5qy7bxm3mfmnxzpmwp88d7iiszc57z5b9npc6xgwi28np"; - } - else if system == "aarch64-linux" then - builtins.fetchurl { - url = "https://cloud-images.ubuntu.com/releases/noble/release-20251026/ubuntu-24.04-server-cloudimg-arm64-root.tar.xz"; - sha256 = "1l4l0llfffspzgnmwhax0fcnjn8ih8n4azhfaghng2hh1xvr4a17"; - } - else - throw "Unsupported system: ${system}"; + cloudImg = fetchCloudImg "ubuntu-24_04"; + excludePatterns = ubuntuExcludePatterns; + extraDirs = [ "var/lib/apt/lists/partial" ]; + }; + maskableService = "unattended-upgrades.service"; + }; + + fedora-43 = { + # x86_64-linux only: the qcow2 extraction path uses libguestfs-with-appliance, + # whose appliance subpackage is not available on aarch64 in nixpkgs. + systems = [ "x86_64-linux" ]; + rootfs = makeRootfs.buildRootfs { + name = "fedora-43"; + cloudImgFormat = "qcow2"; + cloudImg = builtins.fetchurl { + url = "https://dl.fedoraproject.org/pub/fedora/linux/releases/43/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-43-1.6.x86_64.qcow2"; + sha256 = "0bxbr2kf6ija4rg36mnspg5qbk0767bn44133zfdilkwm7478rc4"; + }; + excludePatterns = ubuntuExcludePatterns ++ [ + # systemd-firstboot blocks boot waiting for interactive configuration + "usr/lib/systemd/system/systemd-firstboot.service" + # auditd/audit-rules fail inside nspawn (no audit netlink) + "usr/lib/systemd/system/auditd.service" + "usr/lib/systemd/system/audit-rules.service" + # NetworkManager-wait-online blocks multi-user.target inside nspawn + "usr/lib/systemd/system/NetworkManager-wait-online.service" + ]; + extraDirs = [ + "var/cache/dnf" + "var/lib/dnf" + ]; + # Pre-seed firstboot markers so systemd considers the container already configured. + extraSetup = '' + : > $out/etc/machine-id + echo 'LANG=C.UTF-8' > $out/etc/locale.conf + echo 'KEYMAP=us' > $out/etc/vconsole.conf + ''; + }; + maskableService = "dnf-automatic.timer"; + }; + + debian-13 = { + systems = builtins.attrNames images.debian-13; + rootfs = makeRootfs.buildRootfs { + name = "debian-13"; + cloudImgFormat = "disk-tarball"; + cloudImg = fetchCloudImg "debian-13"; excludePatterns = ubuntuExcludePatterns; extraDirs = [ "var/lib/apt/lists/partial" ]; }; diff --git a/lib/container-test-driver/images.json b/lib/container-test-driver/images.json new file mode 100644 index 00000000..b98b7837 --- /dev/null +++ b/lib/container-test-driver/images.json @@ -0,0 +1,32 @@ +{ + "debian-13": { + "aarch64-linux": { + "sha256": "02jhbgc97kapbdnk8ny6ql12g9xvi34jpirp5kxzgcz1b5wabwg4", + "url": "https://cloud.debian.org/images/cloud/trixie/20260413-2447/debian-13-genericcloud-arm64-20260413-2447.tar.xz" + }, + "x86_64-linux": { + "sha256": "049a06pb4bqpmy6qp3jgzblrwim9i492153mcglm53jngh48r5p5", + "url": "https://cloud.debian.org/images/cloud/trixie/20260413-2447/debian-13-genericcloud-amd64-20260413-2447.tar.xz" + } + }, + "ubuntu-22_04": { + "aarch64-linux": { + "sha256": "0fg2jv8wi5cqlw0sl07xi3jmyyk1056pzybfqgpxs3nx7crhajlf", + "url": "https://cloud-images.ubuntu.com/releases/jammy/release-20260320/ubuntu-22.04-server-cloudimg-arm64-root.tar.xz" + }, + "x86_64-linux": { + "sha256": "1dafwd805bh4c243g7p16jixb52amh09p23ibx1lyj4k50d5yrad", + "url": "https://cloud-images.ubuntu.com/releases/jammy/release-20260320/ubuntu-22.04-server-cloudimg-amd64-root.tar.xz" + } + }, + "ubuntu-24_04": { + "aarch64-linux": { + "sha256": "07vjiwx0smm6hyahi8w9y54zyxbpczfaqh75ihvi4msmmhbwb0m6", + "url": "https://cloud-images.ubuntu.com/releases/noble/release-20260321/ubuntu-24.04-server-cloudimg-arm64-root.tar.xz" + }, + "x86_64-linux": { + "sha256": "0xyp9pin8m6ys0gxk6spq83dgdfl3malbvps7v78hrp7sjg3rp0k", + "url": "https://cloud-images.ubuntu.com/releases/noble/release-20260321/ubuntu-24.04-server-cloudimg-amd64-root.tar.xz" + } + } +} diff --git a/lib/container-test-driver/make-rootfs.nix b/lib/container-test-driver/make-rootfs.nix index 8f682705..6e2a89ed 100644 --- a/lib/container-test-driver/make-rootfs.nix +++ b/lib/container-test-driver/make-rootfs.nix @@ -10,6 +10,7 @@ in { name, cloudImg, + cloudImgFormat ? "tar", excludePatterns ? [ ], extraDirs ? [ ], extraSetup ? "", @@ -21,19 +22,74 @@ in map (p: "--exclude='${p}'") excludePatterns ); mkdirCommands = builtins.concatStringsSep "\n " (map (d: "mkdir -p $out/${d}") extraDirs); + excludePruneCommands = builtins.concatStringsSep "\n " ( + map (p: "rm -rf $out/${p}") excludePatterns + ); + extractCommand = + if cloudImgFormat == "tar" then + '' + tar --exclude='dev/*' \ + ${excludeArgs} \ + ${tarExtraFlags} \ + ${tarCompression}xf ${cloudImg} -C $out + '' + else if cloudImgFormat == "qcow2" then + '' + LIBGUESTFS_BACKEND=direct \ + guestfish --ro -a ${cloudImg} -i tar-out / - \ + | tar --exclude='dev/*' \ + ${excludeArgs} \ + -C $out -x + '' + else if cloudImgFormat == "disk-tarball" then + '' + set -euo pipefail + + workdir=$(mktemp -d) + tar -C "$workdir" -xf ${cloudImg} + rawimg=$(ls "$workdir"/*.raw | head -n1) + if [ -z "$rawimg" ]; then + echo "disk-tarball: no *.raw file inside ${cloudImg}" >&2 + exit 1 + fi + + # Pick the largest partition + read -r start size <<<"$(sfdisk -J "$rawimg" \ + | jq -r '.partitiontable.partitions | max_by(.size) | "\(.start) \(.size)"')" + dd if="$rawimg" of="$workdir/root.ext4" \ + bs=512 skip="$start" count="$size" status=none + + debugfs -R "rdump / $out" "$workdir/root.ext4" >/dev/null 2>&1 + + # debugfs rdump has no --exclude, so apply excludePatterns via a + # post-extraction prune pass. Also strip /dev/* to match the tar + # path (which uses tar --exclude='dev/*'). + rm -rf $out/dev/* + ${excludePruneCommands} + + rm -rf "$workdir" + '' + else + throw "buildRootfs: unsupported cloudImgFormat '${cloudImgFormat}' (expected 'tar', 'qcow2', or 'disk-tarball')"; + nativeBuildInputs = [ + pkgs.xz + ] + ++ pkgs.lib.optionals (cloudImgFormat == "qcow2") [ pkgs.libguestfs-with-appliance ] + ++ pkgs.lib.optionals (cloudImgFormat == "disk-tarball") [ + pkgs.util-linux + pkgs.e2fsprogs + pkgs.jq + ]; in pkgs.runCommand "rootfs-${name}" { - nativeBuildInputs = [ pkgs.xz ]; + inherit nativeBuildInputs; } '' mkdir -p $out # Extract cloud image, excluding container-incompatible services - tar --exclude='dev/*' \ - ${excludeArgs} \ - ${tarExtraFlags} \ - ${tarCompression}xf ${cloudImg} -C $out + ${extractCommand} # Ensure build user can modify all extracted files chmod -R u+rwX $out diff --git a/nix/modules/default.nix b/nix/modules/default.nix index b81db20d..fe4cd443 100644 --- a/nix/modules/default.nix +++ b/nix/modules/default.nix @@ -131,6 +131,25 @@ system-manager = { allowAnyDistro = lib.mkEnableOption "the usage of system-manager on untested distributions"; + targetDistro = lib.mkOption { + type = + with lib.types; + nullOr (enum [ + "ubuntu" + "debian" + "fedora" + "nixos" + ]); + default = null; + description = '' + The Linux distribution system-manager is being activated on. + When set, default users/groups are adjusted to match the + distribution's expectations (e.g. fedora ships `nobody` at + GID 65534 instead of `nogroup`, and `wheel` at GID 10 instead + of `uucp`). Leave `null` to apply the Debian/Ubuntu defaults. + ''; + }; + preActivationAssertions = lib.mkOption { type = with lib.types; @@ -204,6 +223,8 @@ supportedIds = [ "nixos" "ubuntu" + "debian" + "fedora" ]; in { diff --git a/nix/modules/upstream/nixpkgs/users-groups.nix b/nix/modules/upstream/nixpkgs/users-groups.nix index dcc16d91..afc01246 100644 --- a/nix/modules/upstream/nixpkgs/users-groups.nix +++ b/nix/modules/upstream/nixpkgs/users-groups.nix @@ -862,41 +862,62 @@ in uid = ids.uids.nobody; isSystemUser = true; description = "Unprivileged account (don't use!)"; - group = "nogroup"; + # Fedora ships gid 65534 as `nobody`, Debian/Ubuntu ship it as + # `nogroup`. Both names must work on both distros, but we set + # the primary group to match what the distro already has so + # userborn does not try to rename an existing group. + group = if config.system-manager.targetDistro == "fedora" then "nobody" else "nogroup"; }; }; - # GIDs are set to match Debian/Ubuntu defaults to avoid conflicts. - # NixOS uses different GIDs which conflict with existing system groups. + # GIDs are set to match the target distribution's defaults so + # userborn's mutable-users mode doesn't try to create groups that + # already exist at those GIDs under a different name. # - # To add a user to a group via extraGroups, the group must be declared here. - # For pre-existing system groups, declare them with the matching GID. - users.groups = { - root.gid = lib.mkDefault 0; - wheel.gid = lib.mkDefault 900; - sudo.gid = lib.mkDefault 27; - disk.gid = lib.mkDefault 6; - kmem.gid = lib.mkDefault 15; - tty.gid = lib.mkDefault 5; - floppy.gid = lib.mkDefault 25; - uucp.gid = lib.mkDefault 10; - lp.gid = lib.mkDefault 7; - cdrom.gid = lib.mkDefault 24; - tape.gid = lib.mkDefault 26; - audio.gid = lib.mkDefault 29; - video.gid = lib.mkDefault 44; - dialout.gid = lib.mkDefault 20; - nogroup.gid = lib.mkDefault 65534; - users.gid = lib.mkDefault 100; - nixbld.gid = lib.mkDefault ids.gids.nixbld; - utmp.gid = lib.mkDefault 43; - adm.gid = lib.mkDefault 4; - input.gid = lib.mkDefault 996; - kvm.gid = lib.mkDefault 994; - render.gid = lib.mkDefault 993; - sgx.gid = lib.mkDefault 995; - shadow.gid = lib.mkDefault 42; - }; + # To add a user to a group via extraGroups, the group must be + # declared here. For pre-existing system groups, declare them + # with the matching GID. + users.groups = + let + onFedora = config.system-manager.targetDistro == "fedora"; + in + { + root.gid = lib.mkDefault 0; + sudo.gid = lib.mkDefault 27; + disk.gid = lib.mkDefault 6; + tty.gid = lib.mkDefault 5; + lp.gid = lib.mkDefault 7; + keys.gid = lib.mkDefault 96; + users.gid = lib.mkDefault 100; + nixbld.gid = lib.mkDefault ids.gids.nixbld; + adm.gid = lib.mkDefault 4; + shadow.gid = lib.mkDefault 42; + + # Groups whose GID differs between Debian/Ubuntu and Fedora. + # Prefer the target distribution's numbering so mutable-mode + # userborn finds the group by name without GID collisions. + wheel.gid = lib.mkDefault (if onFedora then 10 else 900); + kmem.gid = lib.mkDefault (if onFedora then 9 else 15); + floppy.gid = lib.mkDefault (if onFedora then 19 else 25); + cdrom.gid = lib.mkDefault (if onFedora then 11 else 24); + tape.gid = lib.mkDefault (if onFedora then 33 else 26); + audio.gid = lib.mkDefault (if onFedora then 63 else 29); + video.gid = lib.mkDefault (if onFedora then 39 else 44); + dialout.gid = lib.mkDefault (if onFedora then 18 else 20); + utmp.gid = lib.mkDefault (if onFedora then 22 else 43); + } + // lib.optionalAttrs (!onFedora) { + # Debian/Ubuntu ship `nogroup` at 65534 and `uucp` at 10. + # Fedora ships `nobody` at 65534 and `wheel` at 10; declaring + # these here would race userborn against the existing groups. + nogroup.gid = lib.mkDefault 65534; + uucp.gid = lib.mkDefault 10; + } + // lib.optionalAttrs onFedora { + # Fedora's `nobody` group occupies 65534; declare it so the + # `nobody` user above can reference it by name. + nobody.gid = lib.mkDefault 65534; + }; systemd.services.linger-users = lib.mkIf ((length lingeringUsers) > 0) { wantedBy = [ "multi-user.target" ]; diff --git a/testFlake/flake.lock b/testFlake/flake.lock index c8ab1019..56db65d5 100644 --- a/testFlake/flake.lock +++ b/testFlake/flake.lock @@ -129,15 +129,16 @@ ] }, "locked": { - "lastModified": 1775586700, - "narHash": "sha256-D8iv3UHNS0DBt+Ry1lnjHig9XWL+T1j9VIE/p7mF8Bc=", + "lastModified": 1776289539, + "narHash": "sha256-Opze91tWTXeE+5hzChVZ9K5NMPt39uO+jfAOHn3DRJs=", "owner": "numtide", "repo": "nix-vm-test", - "rev": "c0325bd08f8897ca6d4e4cab7ac30e466e43cce0", + "rev": "dfa6ba3d23d79add9dcca93f6b7f7e8199214897", "type": "github" }, "original": { "owner": "numtide", + "ref": "feat/fedora-43", "repo": "nix-vm-test", "type": "github" } diff --git a/testFlake/flake.nix b/testFlake/flake.nix index d86837d8..39573a8a 100644 --- a/testFlake/flake.nix +++ b/testFlake/flake.nix @@ -11,7 +11,7 @@ system-manager-v1-1-0.url = "github:numtide/system-manager/v1.1.0"; nixpkgs.follows = "system-manager/nixpkgs"; nix-vm-test = { - url = "github:numtide/nix-vm-test"; + url = "github:numtide/nix-vm-test/feat/fedora-43"; inputs.nixpkgs.follows = "nixpkgs"; }; sops-nix.url = "github:Mic92/sops-nix"; diff --git a/testFlake/vm-tests/default.nix b/testFlake/vm-tests/default.nix index aa1bde5b..3089ebf5 100644 --- a/testFlake/vm-tests/default.nix +++ b/testFlake/vm-tests/default.nix @@ -7,143 +7,171 @@ }: let - forEachUbuntuImage = + distros = { + ubuntu = { + # Ubuntu 20.04 reaches end of life April 2025; drop support. + filter = v: v != "20_04"; + }; + debian = { + # Only Debian 13 (trixie) + filter = v: v == "13"; + }; + fedora = { + # Only Fedora 43 + filter = v: v == "43"; + }; + }; + + forEachImage = name: { modules, testScriptFunction, - extraPathsToRegister ? [ ], + extraPathsToRegister ? (_distroName: [ ]), projectTest ? test: test.sandboxed, }: let - ubuntu = nix-vm-test.ubuntu; - in - lib.listToAttrs ( - # Ubuntu 20.04 reaches end of life April 2025; drop support. - lib.flip map (lib.filter (v: v != "20_04") (lib.attrNames ubuntu.images)) ( - imageVersion: + mkToplevel = + distroName: + system-manager.lib.makeSystemConfig { + modules = modules ++ [ + ( + { lib, pkgs, ... }: + { + options.hostPkgs = lib.mkOption { + type = lib.types.raw; + readOnly = true; + }; + config = { + nixpkgs.hostPlatform = system; + hostPkgs = pkgs; + system-manager.targetDistro = distroName; + }; + } + ) + ]; + }; + mkTestForDistro = + distroName: distroConfig: let - toplevel = ( - system-manager.lib.makeSystemConfig { - modules = modules ++ [ - ( - { lib, pkgs, ... }: - { - options.hostPkgs = lib.mkOption { - type = lib.types.raw; - readOnly = true; - }; - config = { - nixpkgs.hostPlatform = system; - hostPkgs = pkgs; - }; - } - ) - ]; - } - ); - inherit (toplevel.config) hostPkgs; + distro = nix-vm-test.${distroName}; + versions = lib.filter distroConfig.filter (lib.attrNames distro.images); in - lib.nameValuePair "vm-ubuntu-${imageVersion}-${name}" ( - projectTest ( - ubuntu.${imageVersion} { - testScript = testScriptFunction { inherit toplevel hostPkgs; }; - extraPathsToRegister = extraPathsToRegister ++ [ - toplevel - ]; - sharedDirs = { }; - } - ) - ) - ) - ); + lib.listToAttrs ( + map ( + imageVersion: + let + toplevel = mkToplevel distroName; + inherit (toplevel.config) hostPkgs; + in + lib.nameValuePair "vm-${distroName}-${imageVersion}-${name}" ( + projectTest ( + distro.${imageVersion} { + testScript = testScriptFunction { inherit toplevel hostPkgs distroName; }; + extraPathsToRegister = extraPathsToRegister distroName ++ [ + toplevel + ]; + sharedDirs = { }; + } + ) + ) + ) versions + ); + in + lib.foldlAttrs ( + acc: distroName: distroConfig: + acc // mkTestForDistro distroName distroConfig + ) { } distros; - newConfig = system-manager.lib.makeSystemConfig { - modules = [ - ( - { lib, pkgs, ... }: - { - imports = [ sops-nix.nixosModules.sops ]; - config = { - nixpkgs.hostPlatform = system; + mkNewConfig = + distroName: + system-manager.lib.makeSystemConfig { + modules = [ + ( + { lib, pkgs, ... }: + { + imports = [ sops-nix.nixosModules.sops ]; + config = { + nixpkgs.hostPlatform = system; + system-manager.targetDistro = distroName; - services.nginx.enable = false; + services.nginx.enable = false; - environment = { - etc = { - foo_new = { - text = '' - This is just a test! - ''; + environment = { + etc = { + foo_new = { + text = '' + This is just a test! + ''; + }; }; + + systemPackages = [ + pkgs.fish + ]; }; - systemPackages = [ - pkgs.fish - ]; - }; + systemd.services = { + new-service = { + enable = true; + description = "new-service"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecReload = "${lib.getBin pkgs.coreutils}/bin/true"; + }; + wantedBy = [ + "system-manager.target" + "default.target" + ]; + script = '' + sleep 2 + ''; + }; + }; - systemd.services = { - new-service = { + nix = { enable = true; - description = "new-service"; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - ExecReload = "${lib.getBin pkgs.coreutils}/bin/true"; + settings = { + experimental-features = [ + "nix-command" + "flakes" + ]; + trusted-users = [ "zimbatm" ]; }; - wantedBy = [ - "system-manager.target" - "default.target" - ]; - script = '' - sleep 2 - ''; }; - }; - nix = { - enable = true; - settings = { - experimental-features = [ - "nix-command" - "flakes" + users.users.zimbatm = { + isNormalUser = true; + extraGroups = [ + "wheel" + "sudo" ]; - trusted-users = [ "zimbatm" ]; + initialPassword = "test123"; }; - }; - users.users.zimbatm = { - isNormalUser = true; - extraGroups = [ - "wheel" - "sudo" - ]; - initialPassword = "test123"; - }; - - sops = { - age.generateKey = false; - age.keyFile = "/run/age-keys.txt"; - defaultSopsFile = ../sops/secrets.yaml; - secrets.test = { }; - }; - systemd.services.sops-install-secrets = { - before = [ "sysinit-reactivation.target" ]; - requiredBy = [ "sysinit-reactivation.target" ]; + sops = { + age.generateKey = false; + age.keyFile = "/run/age-keys.txt"; + defaultSopsFile = ../sops/secrets.yaml; + secrets.test = { }; + }; + systemd.services.sops-install-secrets = { + before = [ "sysinit-reactivation.target" ]; + requiredBy = [ "sysinit-reactivation.target" ]; + }; }; - }; - } - ) - ]; - }; + } + ) + ]; + }; callTest = file: import file { inherit - forEachUbuntuImage - newConfig + forEachImage + mkNewConfig system-manager system lib diff --git a/testFlake/vm-tests/example.nix b/testFlake/vm-tests/example.nix index 1a6b95f4..08d028a7 100644 --- a/testFlake/vm-tests/example.nix +++ b/testFlake/vm-tests/example.nix @@ -1,20 +1,28 @@ { - forEachUbuntuImage, - newConfig, + forEachImage, + mkNewConfig, system-manager, ... }: -forEachUbuntuImage "example" { +forEachImage "example" { modules = [ ../../examples/example.nix ]; - extraPathsToRegister = [ - newConfig + extraPathsToRegister = distroName: [ + (mkNewConfig distroName) ../sops/age-keys.txt ]; testScriptFunction = - { toplevel, hostPkgs, ... }: + { + toplevel, + hostPkgs, + distroName, + ... + }: + let + newConfig = mkNewConfig distroName; + in #python '' # Start all machines in parallel diff --git a/testFlake/vm-tests/prepopulate.nix b/testFlake/vm-tests/prepopulate.nix index 266ebcda..0df0494d 100644 --- a/testFlake/vm-tests/prepopulate.nix +++ b/testFlake/vm-tests/prepopulate.nix @@ -1,17 +1,20 @@ { - forEachUbuntuImage, - newConfig, + forEachImage, + mkNewConfig, system-manager, ... }: -forEachUbuntuImage "prepopulate" { +forEachImage "prepopulate" { modules = [ ../../examples/example.nix ]; - extraPathsToRegister = [ newConfig ]; + extraPathsToRegister = distroName: [ (mkNewConfig distroName) ]; testScriptFunction = - { toplevel, ... }: + { toplevel, distroName, ... }: + let + newConfig = mkNewConfig distroName; + in '' # Start all machines in parallel start_all() diff --git a/testFlake/vm-tests/security-wrappers.nix b/testFlake/vm-tests/security-wrappers.nix index 223c5a14..3450829f 100644 --- a/testFlake/vm-tests/security-wrappers.nix +++ b/testFlake/vm-tests/security-wrappers.nix @@ -2,12 +2,12 @@ # This must run in a VM because setting SUID bits and file capabilities # requires privileges that the nix build sandbox seccomp filter blocks. { - forEachUbuntuImage, + forEachImage, system-manager, ... }: -forEachUbuntuImage "security-wrappers" { +forEachImage "security-wrappers" { modules = [ ( { pkgs, ... }: @@ -21,7 +21,7 @@ forEachUbuntuImage "security-wrappers" { } ) ]; - extraPathsToRegister = [ ]; + extraPathsToRegister = _distroName: [ ]; testScriptFunction = { toplevel, hostPkgs, ... }: '' diff --git a/testFlake/vm-tests/sudo-module.nix b/testFlake/vm-tests/sudo-module.nix index 0c9e7512..1afd4eba 100644 --- a/testFlake/vm-tests/sudo-module.nix +++ b/testFlake/vm-tests/sudo-module.nix @@ -1,12 +1,12 @@ # Test sudo module: sudoers generation, no Nix-built sudo in PATH/wrappers, # and host sudo works with the generated config. { - forEachUbuntuImage, + forEachImage, system-manager, ... }: -forEachUbuntuImage "sudo-module" { +forEachImage "sudo-module" { modules = [ ( { ... }: @@ -40,7 +40,7 @@ forEachUbuntuImage "sudo-module" { } ) ]; - extraPathsToRegister = [ ]; + extraPathsToRegister = _distroName: [ ]; testScriptFunction = { toplevel, hostPkgs, ... }: '' diff --git a/testFlake/vm-tests/sudo.nix b/testFlake/vm-tests/sudo.nix index 187de41c..f9fe0ae4 100644 --- a/testFlake/vm-tests/sudo.nix +++ b/testFlake/vm-tests/sudo.nix @@ -3,17 +3,17 @@ # 1. Running system-manager as non-root without --sudo fails # 2. Running system-manager with --sudo succeeds { - forEachUbuntuImage, + forEachImage, system-manager, system, ... }: -forEachUbuntuImage "sudo" { +forEachImage "sudo" { modules = [ ../../examples/example.nix ]; - extraPathsToRegister = [ + extraPathsToRegister = _distroName: [ system-manager.packages.x86_64-linux.default ]; testScriptFunction = diff --git a/testFlake/vm-tests/target-host.nix b/testFlake/vm-tests/target-host.nix index 4cada210..2a0a3f9f 100644 --- a/testFlake/vm-tests/target-host.nix +++ b/testFlake/vm-tests/target-host.nix @@ -2,17 +2,17 @@ # This test runs the engine directly via SSH from the host (test driver) to the VM # It tests that the engine can be invoked remotely, which is the core of --target-host { - forEachUbuntuImage, + forEachImage, system-manager, system, ... }: -forEachUbuntuImage "target-host" { +forEachImage "target-host" { modules = [ ../../examples/example.nix ]; - extraPathsToRegister = [ + extraPathsToRegister = _distroName: [ system-manager.packages.x86_64-linux.default ]; # Use driver instead of sandboxed since we need network access from the test script diff --git a/tools/update-images.py b/tools/update-images.py new file mode 100755 index 00000000..63f71e7f --- /dev/null +++ b/tools/update-images.py @@ -0,0 +1,140 @@ +#! /usr/bin/env nix-shell +#! nix-shell -i python3 -p python3 python3Packages.beautifulsoup4 python3Packages.requests nix-prefetch + +import json +import re +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +import requests +from bs4 import BeautifulSoup + +IMAGES_JSON = ( + Path(__file__).resolve().parent.parent + / "lib" + / "container-test-driver" + / "images.json" +) + +# distro -> upstream index URL (must end with "/") +UBUNTU_RELEASES = { + "ubuntu-22_04": "https://cloud-images.ubuntu.com/releases/jammy/", + "ubuntu-24_04": "https://cloud-images.ubuntu.com/releases/noble/", +} + +DEBIAN_RELEASES = { + "debian-13": "https://cloud.debian.org/images/cloud/trixie/", +} + + +def nix_hash(url: str) -> str: + print(f"[+] nix-prefetch-url {url}", file=sys.stderr) + result = subprocess.run( + ["nix-prefetch-url", "--type", "sha256", url], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def latest_dated_subdir(index_url: str, pattern: re.Pattern) -> str: + """Return the lexically newest dated subdirectory under index_url that matches pattern.""" + page = requests.get(index_url, timeout=30) + page.raise_for_status() + soup = BeautifulSoup(page.content, "html.parser") + candidates = [] + for link in soup.find_all("a"): + href = link.get("href", "") + m = pattern.match(href) + if m: + candidates.append((m.group("date"), href)) + if not candidates: + raise RuntimeError(f"no dated subdirectories matched at {index_url}") + candidates.sort(key=lambda kv: datetime.strptime(kv[0][:8], "%Y%m%d")) + return candidates[-1][1] + + +def ubuntu_rootfs(release: str, base_url: str) -> dict: + """Return { system: { url, sha256 } } for the latest release-* build under base_url.""" + pattern = re.compile(r"^release-(?P\d{8})/$") + latest = latest_dated_subdir(base_url, pattern) + build_url = f"{base_url}{latest}" + print(f"[+] {release}: {build_url}", file=sys.stderr) + + page = requests.get(build_url, timeout=30) + page.raise_for_status() + soup = BeautifulSoup(page.content, "html.parser") + + # Filenames look like ubuntu-22.04-server-cloudimg-amd64-root.tar.xz + rootfs_re = re.compile( + r"^.*-server-cloudimg-(?Pamd64|arm64)-root\.tar\.xz$" + ) + arch_to_system = {"amd64": "x86_64-linux", "arm64": "aarch64-linux"} + + out = {} + for link in soup.find_all("a"): + href = link.get("href", "") + m = rootfs_re.match(href) + if not m: + continue + system = arch_to_system[m.group("arch")] + url = f"{build_url}{href}" + out[system] = {"url": url, "sha256": nix_hash(url)} + if not out: + raise RuntimeError(f"no rootfs tarballs found under {build_url}") + return out + + +def debian_genericcloud(release: str, base_url: str) -> dict: + """Return { system: { url, sha256 } } for the latest dated debian build.""" + pattern = re.compile(r"^(?P\d{8}-\d{4})/$") + latest = latest_dated_subdir(base_url, pattern) + build_url = f"{base_url}{latest}" + print(f"[+] {release}: {build_url}", file=sys.stderr) + + page = requests.get(build_url, timeout=30) + page.raise_for_status() + soup = BeautifulSoup(page.content, "html.parser") + + # Filenames look like debian-13-genericcloud-amd64-20260413-2447.tar.xz + tarball_re = re.compile( + r"^.*-genericcloud-(?Pamd64|arm64)-\d{8}-\d{4}\.tar\.xz$" + ) + arch_to_system = {"amd64": "x86_64-linux", "arm64": "aarch64-linux"} + + out: dict[str, dict] = {} + seen_urls: set[str] = set() + for link in soup.find_all("a"): + href = link.get("href", "") + m = tarball_re.match(href) + if not m: + continue + url = f"{build_url}{href}" + if url in seen_urls: + continue + seen_urls.add(url) + system = arch_to_system[m.group("arch")] + out[system] = {"url": url, "sha256": nix_hash(url)} + if not out: + raise RuntimeError(f"no genericcloud tarballs found under {build_url}") + return out + + +def main() -> None: + images: dict[str, dict] = {} + + for release, url in UBUNTU_RELEASES.items(): + images[release] = ubuntu_rootfs(release, url) + + for release, url in DEBIAN_RELEASES.items(): + images[release] = debian_genericcloud(release, url) + + IMAGES_JSON.write_text(json.dumps(images, indent=2, sort_keys=True) + "\n") + print(f"[+] wrote {IMAGES_JSON}", file=sys.stderr) + + +if __name__ == "__main__": + main()