On 11/13/25 11:44, Alyssa Ross wrote: > Demi Marie Obenour writes: > >> Include a new 'update' command to update the system. This works as >> follows: >> >> 1. Take a global, system-wide lock. >> 2. Create a BTRFS subvolume for the sys.updates VM to write the updates. >> 3. Bind-mount this subvolume into the VM's shared directory. >> 4. Start sys.appvm-updates to get the updates. >> 5. Wait for the VM to shut down. >> 6. Take a BTRFS snapshot of the subvolume. >> 7. Call syncfs() to flush all of the data on the subvolume. >> 8. Inspect the contents of the subvolume. >> Check that everything is a regular file and that the names are reasonable. >> Check that SHA256SUMS and SHA256SUMS.gpg are present. > > Not any more. Will fix. >> 9. Call systemd-sysupdate to run the actual update. >> >> sys.appvm-updates uses host-provided information to fetch the update. >> This allows editing files on the host to change the update URL and >> signing key. >> >> Signed-off-by: Demi Marie Obenour >> --- >> host/rootfs/Makefile | 2 + >> host/rootfs/default.nix | 28 ++++++- >> host/rootfs/file-list.mk | 4 + >> host/rootfs/image/etc/fstab | 1 + >> .../image/etc/sysupdate.d/50-verity.transfer | 20 +++++ >> host/rootfs/image/etc/sysupdate.d/60-root.transfer | 20 +++++ >> .../image/etc/sysupdate.d/70-kernel.transfer | 20 +++++ >> host/rootfs/image/usr/bin/update | 89 ++++++++++++++++++++++ >> host/rootfs/os-release.in | 13 ++++ >> host/rootfs/os-release.in.license | 2 + >> host/rootfs/updatevm-url-env | 3 + >> host/rootfs/vm-sysupdate.d/50-verity.transfer | 18 +++++ >> host/rootfs/vm-sysupdate.d/60-root.transfer | 18 +++++ >> host/rootfs/vm-sysupdate.d/70-kernel.transfer | 18 +++++ >> lib/config.default.nix | 2 + >> lib/config.nix | 11 ++- >> lib/fake-update-signing-key.gpg | 1 + >> lib/fake-update-signing-key.gpg.license | 2 + >> release/live/default.nix | 4 +- >> release/live/shell.nix | 3 +- >> vm/app/updates.nix | 37 +++++++++ >> 21 files changed, 309 insertions(+), 7 deletions(-) > >> diff --git a/host/rootfs/default.nix b/host/rootfs/default.nix >> index b574b8ddf5858867156507429a55b7f537e3c485..0a7638f8d78cf36592c2721d059bc867b04f233c 100644 >> --- a/host/rootfs/default.nix >> +++ b/host/rootfs/default.nix >> @@ -5,6 +5,7 @@ >> import ../../lib/call-package.nix ( >> { callSpectrumPackage, spectrum-build-tools, src >> , pkgsMusl, pkgsStatic, linux_latest >> +, config >> }: >> pkgsStatic.callPackage ( >> >> @@ -13,6 +14,7 @@ pkgsStatic.callPackage ( >> , busybox, cloud-hypervisor, cryptsetup, dbus, execline, inkscape >> , iproute2, inotify-tools, jq, mdevd, s6, s6-linux-init, socat >> , util-linuxMinimal, virtiofsd, xorg, xdg-desktop-portal-spectrum-host >> +, btrfs-progs >> }: >> >> let >> @@ -36,6 +38,7 @@ let >> cloud-hypervisor cryptsetup dbus execline inotify-tools iproute2 >> jq mdevd s6 s6-linux-init s6-rc socat spectrum-host-tools >> virtiofsd xdg-desktop-portal-spectrum-host >> + btrfs-progs > > Let's keep this sorted. Will fix. >> @@ -79,11 +82,24 @@ let >> appvm-firefox = callSpectrumPackage ../../vm/app/firefox.nix {}; >> appvm-foot = callSpectrumPackage ../../vm/app/foot.nix {}; >> appvm-gnome-text-editor = callSpectrumPackage ../../vm/app/gnome-text-editor.nix {}; >> + appvm-updates = callSpectrumPackage ../../vm/app/updates.nix {}; > > I think appvm-sysupdate or appvm-systemd-sysupdate would be clearer. Will fix. >> }; >> >> packagesSysroot = runCommand "packages-sysroot" { >> depsBuildBuild = [ inkscape ]; >> nativeBuildInputs = [ xorg.lndir ]; >> + env = { >> + VERSION = config.version; >> + UPDATE_URL = config.update-url; >> + }; >> + src = fileset.toSource { >> + root = ./.; >> + fileset = fileset.intersection src (fileset.unions [ >> + ./vm-sysupdate.d >> + ./os-release.in >> + ./updatevm-url-env >> + ]); >> + }; >> } '' >> mkdir -p $out/usr/bin $out/usr/share/dbus-1/services \ >> $out/usr/share/icons/hicolor/20x20/apps >> @@ -95,8 +111,7 @@ let >> done >> >> # If systemd-pull is missing systemd-sysupdate will fail with a >> - # very confusing error message. If systemd-sysupdate doesn't work, >> - # users will not be able to receive an update that fixes the problem. >> + # very confusing error message. >> for i in sysupdate pull; do >> if ! cat -- "$out/usr/lib/systemd/systemd-$i" > /dev/null; then >> echo "link to systemd-$i didn't get installed" >&2 >> @@ -118,6 +133,14 @@ let >> ln -st $out/usr/share/dbus-1/services \ >> ${pkgsGui.xdg-desktop-portal-gtk}/share/dbus-1/services/org.freedesktop.impl.portal.desktop.gtk.service >> >> + mkdir -p -- "$out/etc/updatevm/sysupdate.d" >> + substitute "$src/os-release.in" "$out/etc/os-release" --subst-var VERSION >> + for d in "$src/vm-sysupdate.d"/*.transfer; do >> + result_file=''${d#"$src/vm-sysupdate.d/"} >> + substitute "$d" "$out/etc/updatevm/sysupdate.d/$result_file" --subst-var UPDATE_URL >> + done >> + substitute "$src/updatevm-url-env" "$out/etc/updatevm/url-env" --subst-var UPDATE_URL >> + > > I think it would make more sense to do these at the Make layer. It > handles other generated files, so I don't see why it can't handle these > too, and then if I add something to os-release I don't have to rebuild > any Nix stuff. Will fix. >> diff --git a/host/rootfs/image/etc/fstab b/host/rootfs/image/etc/fstab >> index 6a82ecc85090a37b13603b29f74ca6e554a28c33..78cec99f29dda993ad97048771097121a0e42622 100644 >> --- a/host/rootfs/image/etc/fstab >> +++ b/host/rootfs/image/etc/fstab >> @@ -4,3 +4,4 @@ proc /proc proc defaults 0 0 >> devpts /dev/pts devpts defaults,gid=4,mode=620 0 0 >> tmpfs /dev/shm tmpfs defaults 0 0 >> sysfs /sys sysfs defaults 0 0 >> +tmpfs /tmp tmpfs defaults,mode=0700 0 0 > > Is this used? No. >> diff --git a/host/rootfs/image/usr/bin/update b/host/rootfs/image/usr/bin/update >> new file mode 100755 >> index 0000000000000000000000000000000000000000..cbbf8ad8634a7771a0a5f7d6586ee88cdc0672a8 >> --- /dev/null >> +++ b/host/rootfs/image/usr/bin/update >> @@ -0,0 +1,89 @@ >> +#!/bin/execlineb -WS1 >> +# SPDX-License-Identifier: EUPL-1.2+ >> +# SPDX-FileCopyrightText: 2025 Demi Marie Obenour >> + >> +# Steps: >> +# >> +# 1. Take a global, system-wide lock. >> +# 2. Create a BTRFS subvolume for the sys.updates VM to write the updates. >> +# 3. Bind-mount this subvolume into the VM's shared directory. >> +# 4. Start sys.updates to get the updates. >> +# 5. Wait for the VM to shut down. >> +# 6. Take a BTRFS snapshot of the subvolume. >> +# 7. Call syncfs() to flush all of the data on the subvolume. >> +# 8. Inspect the contents of the subvolume. >> +# Check that everything is a regular file and that the names are reasonable. >> +# Check that SHA256SUMS and SHA256SUMS.gpg are present. > > Not any more. Will fix in v3. >> +# 9. Call systemd-sysupdate to run the actual update. >> + >> +if { mkdir -p -m 0700 /run/updater } >> +s6-setlock /run/update-lock >> +foreground { redirfd -w 2 /dev/null rmdir -- $1 } >> +if { umask 0077 mkdir -p -- $1 } >> +cd $1 >> +foreground { >> + # If this exists already that is okay. >> + foreground { redirfd -w 2 /dev/null btrfs subvolume create -- shared } >> + > > Wouldn't it break if there's already stuff in it? No, it works fine in this case. I checked :). > I'd do > > foreground { redirfd -w 2 /dev/null btrfs subvolume delete -- shared } > if { btrfs subvolume create -- shared } > > and then you know you've got an empty subvolume. An empty subvolume isn't good: it means that systemd-sysupdate will redownload an update even when it isn't needed. >> + # Snapshot directory may have files or directories with untrusted names. >> + # Redirect its output to /dev/null to avoid printing them to the console. >> + ifelse -n { redirfd -w 2 /dev/null rm -rf -- snapshot } { >> + foreground { redirfd -w 2 echo "Cannot remove snapshot directory" } >> + exit 1 >> + } > > Why not btrfs subvolume delete? It's faster and won't print names. It doesn't distinguish "subvolume doesn't exist" from "problem deleting subvolume". A better solution is to call `rm -f` if `btrfs subvolume delete` failed. That ignores "does not exist" errors, but not other errors. >> + >> + backtick -E update_vm_id_ { >> + backtick -E id_path { readlink /run/vm/by-name/sys.appvm-updates } >> + basename -- $id_path >> + } >> + >> + multisubstitute { >> + define fsdir /run/vm/by-id/${update_vm_id_}/fs >> + define update_vm_id ${update_vm_id_} > > Why? Avoiding serial substitution. >> + define svcdir /run/service/vmm/instance/${update_vm_id_} > > Can also use /run/vm/by-name/sys.appvm-updates/fs and > /run/vm/by-name/sys.appvm-updates/service if you prefer, although you > need to look up the ID for vm-start anyway currently. I have a patch for that coming up. >> + } >> + >> + # $fsdir is read-only to the guest, but read-write to the host. >> + # Directories bind-mounted into it are read-write to the guest. >> + # See etc/s6-linux-init/run-image/service/vhost-user-fs/template/run >> + # for details. >> + >> + # Set up /etc with what the VM needs. The VM will overlay this >> + # on its own /etc. >> + if { rm -rf -- ${fsdir}/etc } >> + if { umask 022 mkdir -p -- ${fsdir}/updates ${fsdir}/etc/systemd } >> + if { cp -R -- /etc/updatevm/sysupdate.d /etc/updatevm/url-env ${fsdir}/etc } >> + if { cp -- /etc/systemd/import-pubring.gpg ${fsdir}/etc/systemd } > > Why copy rather than bind mount? Target does not exist and I didn't want to bind-mount all of /etc/systemd. >> + >> + # If the directory is already mounted, unmount it. This prevents a >> + # confusing error from mount. >> + foreground { redirfd -w 2 /dev/null umount -- ${fsdir}/updates } >> + >> + # Share the update directory with the VM. >> + if { mount --bind -- shared ${fsdir}/updates } >> + >> + # Start the update VM. >> + if { vm-start $update_vm_id } >> + >> + # Wait for the VM to exit. >> + if { s6-svwait -D ${svcdir} } >> + > > It might be more robust to use a transient VM, like we use for > AppImages, so that nothing can restart it. Transient VMs are still > developing though, so it's also fine to say we'll do it this way for now > and adapt it later. This would also save all the filesystem resetting > you're needing to do here. The path to the update directory is user-provided. It's not from the VM's persistent storage. >> diff --git a/host/rootfs/os-release.in.license b/host/rootfs/os-release.in.license >> new file mode 100644 >> index 0000000000000000000000000000000000000000..c4a0586a407fe14c3e0855749a7524ac3871dda4 >> --- /dev/null >> +++ b/host/rootfs/os-release.in.license >> @@ -0,0 +1,2 @@ >> +SPDX-License-Identifier: CC0-1.0 >> +SPDX-FileCopyrightText: 2025 Demi Marie Obenour > > os-release files can have comments, so no need for a separate license > file here. > >> diff --git a/lib/config.nix b/lib/config.nix >> index 01bcfa2bb2d5c412e212f5a60d9032e89c8a7442..5b6b95013734202b7e2e01d5ffce313080658006 100644 >> --- a/lib/config.nix >> +++ b/lib/config.nix >> @@ -1,5 +1,6 @@ >> -# SPDX-FileCopyrightText: 2023 Alyssa Ross >> # SPDX-License-Identifier: MIT >> +# SPDX-FileCopyrightText: 2024 Alyssa Ross >> +# SPDX-FileCopyrightText: 2025 Demi Marie Obenour > > Why have I changed from 2023 to 2024? Mistake 🙂 >> >> let >> customConfigPath = builtins.tryEval ; >> @@ -17,5 +18,11 @@ let >> callConfig = config: if builtins.typeOf config == "lambda" then config { >> inherit default; >> } else config; >> + finalConfig = default // callConfig config; >> in >> - default // callConfig config; >> + finalConfig // { >> + update-signing-key = builtins.path { >> + name = "signing-key"; >> + path = finalConfig.update-signing-key; >> + }; >> + } > > What does this do? This ensures that the Nix store path doesn't depend on the name of the update signing key, only its contents. >> diff --git a/lib/fake-update-signing-key.gpg b/lib/fake-update-signing-key.gpg >> new file mode 100644 >> index 0000000000000000000000000000000000000000..b4c15467614ee15deef02af05f4c6554a1f7a013 >> --- /dev/null >> +++ b/lib/fake-update-signing-key.gpg >> @@ -0,0 +1 @@ >> +NOT A VALID KEY - UPDATES WILL NOT WORK >> diff --git a/lib/fake-update-signing-key.gpg.license b/lib/fake-update-signing-key.gpg.license >> new file mode 100644 >> index 0000000000000000000000000000000000000000..c4a0586a407fe14c3e0855749a7524ac3871dda4 >> --- /dev/null >> +++ b/lib/fake-update-signing-key.gpg.license >> @@ -0,0 +1,2 @@ >> +SPDX-License-Identifier: CC0-1.0 >> +SPDX-FileCopyrightText: 2025 Demi Marie Obenour > > Given it's not a valid key anyway might as well just put this in the file. > >> diff --git a/release/live/default.nix b/release/live/default.nix >> index dc649732ffa46a998a4a66360aa8ff7ef6bccae0..581420da9acf855d4b3d9ececc1ef406f742fd75 100644 >> --- a/release/live/default.nix >> +++ b/release/live/default.nix >> @@ -7,7 +7,7 @@ import ../../lib/call-package.nix ( >> { callSpectrumPackage, spectrum-build-tools, rootfs, src >> , lib, pkgsStatic, stdenvNoCC >> , cryptsetup, dosfstools, jq, mtools, util-linux >> -, systemdUkify, version, efi >> +, systemdUkify, config, efi >> }: >> >> let >> @@ -49,7 +49,7 @@ stdenv.mkDerivation { >> SYSTEMD_BOOT_EFI = "${efi.systemd}/lib/systemd/boot/efi/systemd-boot${efiArch}.efi"; >> EFI_IMAGE = efi; >> EFINAME = "BOOT${toUpper efiArch}.EFI"; >> - VERSION = version; >> + VERSION = config.version; >> }; >> >> buildFlags = [ "dest=$(out)" ]; > > Maybe this should be squashed into an earlier patch? Correct. >> diff --git a/vm/app/updates.nix b/vm/app/updates.nix >> new file mode 100644 >> index 0000000000000000000000000000000000000000..d2c1e5fcb35b37c7ed8a173f19b97894a36a7f0c >> --- /dev/null >> +++ b/vm/app/updates.nix >> @@ -0,0 +1,37 @@ >> +# SPDX-License-Identifier: MIT >> +# SPDX-FileCopyrightText: 2023 Alyssa Ross >> +# SPDX-FileCopyrightText: 2025 Demi Marie Obenour >> + >> +import ../../lib/call-package.nix ( >> +{ callSpectrumPackage, config, curl, lib, src >> +, runCommand, systemd, writeScript >> +}: >> + >> +let >> + update-url = config.update-url; >> + mountpoint = "/run/virtiofs/virtiofs0"; >> + sysupdate-path = "${systemd}/lib/systemd/systemd-sysupdate"; >> + runner = writeScript "update-run-script" >> + '' >> + #!/usr/bin/execlineb -P >> + if { mount -toverlay -olowerdir=${mountpoint}/etc:/etc -- overlay /etc } >> + envfile ${mountpoint}/etc/url-env > > Seems like overkill to use an envfile for a single URL? It is indeed overkill, but I'm not aware of a simpler option. There is backtick + cat but that's two programs rather than one. >> + importas -i update_url UPDATE_URL >> + if { ${sysupdate-path} update } >> + if { ${curl}/bin/curl -L --proto =http,https >> + -o ${mountpoint}/updates/SHA256SUMS.gpg ''${update_url}/SHA256SUMS.gpg } >> + # systemd-sysupdate recently went from needing SHA256SUMS.gpg to SHA256SUMS.sha256.asc. >> + # I (Demi) have no need if this is intentional or a bug. I also have no idea if this >> + # behavior will stay unchanged in the future. Therefore, create both files and let >> + # systemd-sysupdate ignore the one it isn't interested in. >> + if { ln -f ${mountpoint}/updates/SHA256SUMS.gpg ${mountpoint}/updates/SHA256SUMS.sha256.asc } > > Would be good to figure out why that happened. If we add a comment like > this it's very unlikely to ever get cleaned up. https://github.com/systemd/systemd/issues/39273 >> + ${curl}/bin/curl -L --proto =http,https >> + -o ${mountpoint}/updates/SHA256SUMS ''${update_url}/SHA256SUMS >> + ''; >> +in >> + >> +callSpectrumPackage ../make-vm.nix {} { >> + providers.net = [ "sys.netvm" ]; >> + type = "nix"; >> + run = "${runner}"; > > Might as well inline this. I chose to keep it separate to improve readability. >> +}) (_: {}) >> >> -- >> 2.51.2 -- Sincerely, Demi Marie Obenour (she/her/hers)