feat(deploy): USB-bootable douro image (disk-image-douro-usb) #114

Merged
padreug merged 1 commit from feat/douro-usb into dev 2026-09-29 20:51:29 +00:00
3 changed files with 105 additions and 75 deletions

View file

@ -33,9 +33,19 @@ Each ATM model has two flake outputs:
| `nixosConfigurations.<model>-installed` | installed | full GPT + systemd-boot install, ext4 root, supports `nixos-rebuild switch` | | `nixosConfigurations.<model>-installed` | installed | full GPT + systemd-boot install, ext4 root, supports `nixos-rebuild switch` |
| `packages.x86_64-linux.iso-<model>` | ISO | ISO image of the live variant | | `packages.x86_64-linux.iso-<model>` | ISO | ISO image of the live variant |
| `packages.x86_64-linux.disk-image-<model>` | raw image | dd-able full disk image of the installed variant | | `packages.x86_64-linux.disk-image-<model>` | raw image | dd-able full disk image of the installed variant |
| `nixosConfigurations.<model>-usb` | installed, on a stick | `<model>-installed` hardened to run from a USB stick: `nixos-usb`/`ESP-USB` labels, `nofail` `/boot`, no partition growing, auto-upgrade off (`batm3`, `douro` only) |
| `packages.x86_64-linux.disk-image-<model>-usb` | raw image | dd-able image of the `-usb` variant — flash, plug in, boot; no installer step |
Models: `douro`, `tejo`, `sintra`, `batm3`. Models: `douro`, `tejo`, `sintra`, `batm3`.
**Run-from-USB deployments** (`batm3` today, `douro` since the LNbits cutover)
skip the Alpine + dd-to-internal-disk procedure below entirely: the stick *is*
the system. Flash `disk-image-<model>-usb` with balenaEtcher (it verifies the
write — a truncated or bad copy fails stage-1 fsck on first boot) onto a stick
of 16 GB or more, plug it in, power on. Updates go in-place against the
`<model>-usb` config (`nix copy` the toplevel + `switch-to-configuration`),
which keeps pairing and `/var/lib/bitspire`.
```bash ```bash
# Build a Sintra disk image # Build a Sintra disk image
nix build .#disk-image-sintra nix build .#disk-image-sintra

View file

@ -20,12 +20,24 @@
initrd.availableKernelModules = [ initrd.availableKernelModules = [
"xhci_pci" "xhci_pci"
"ahci" "ahci"
# USB mass-storage: required to boot the dd'd image from a USB stick
# (stage-1 must bind the flash drive as a SCSI disk so
# /dev/disk/by-label/* appears). Harmless on the internal install.
#
# NOTE: deliberately NO "uas" here. Many USB sticks/bridges advertise
# UAS but drop off the bus ("device offline error, dev sdb") under
# sustained write load. Blacklisting uas below forces the slower-but-
# reliable usb-storage (Bulk-Only Transport) path. SATA/mSATA installs
# don't use uas anyway. (Same hardening as batm3.nix.)
"usb_storage" "usb_storage"
"sd_mod" "sd_mod"
"sdhci_pci" "sdhci_pci"
"i915" "i915"
]; ];
# Keep the USB flash drive off the flaky UAS driver (see note above).
blacklistedKernelModules = [ "uas" ];
kernelModules = [ kernelModules = [
"kvm-intel" "kvm-intel"
"i2c-dev" "i2c-dev"
@ -37,6 +49,9 @@
"vt.handoff=7" # Bay Trail: preserve BIOS display init "vt.handoff=7" # Bay Trail: preserve BIOS display init
"quiet" "quiet"
"splash" "splash"
# Disable USB autosuspend so the boot medium (and kiosk peripherals)
# aren't power-suspended mid-I/O — another cause of "device offline".
"usbcore.autosuspend=-1"
]; ];
}; };

155
flake.nix
View file

@ -349,6 +349,61 @@
}) })
]; ];
}; };
# Module that turns an <model>-installed config into the one a dd'd USB
# stick actually runs. Shared by every *-usb variant so the USB-boot
# hazards are solved once:
# - distinct fs labels (nixos-usb / ESP-USB) so stage-1 can't latch an
# internal drive that already holds a generic nixos/ESP-labelled install;
# - nofail /boot: the firmware already loaded the bootloader before Linux;
# without nofail a slow/late ESP-USB enumeration (BOT is slower than UAS)
# blows past systemd's 90s device-timeout into emergency mode with root
# locked — a dead end. nofail + short timeout lets the already-mounted
# root carry the boot; /boot mounts if/when it shows;
# - NO growPartition/autoResize: sfdisk rewriting the partition table on
# first boot is the single most bus-stressing write, and flaky USB
# bridges drop off the bus mid-rewrite (sfdisk wedges in D-state and
# ESP-USB vanishes with the device). Persistent state is a few MB and
# the image ships ~2GB free. The internal-disk images keep it;
# - autoUpgrade off: no scheduled nix-store churn or bootloader writes on
# the stick. Updates go in-place via `nix copy` + switch-to-configuration
# against the named <model>-usb config (preserves pairing + /var/lib).
usbBootModule = { lib, ... }: {
fileSystems."/".device = lib.mkForce "/dev/disk/by-label/nixos-usb";
fileSystems."/boot".device = lib.mkForce "/dev/disk/by-label/ESP-USB";
fileSystems."/boot".options = [ "nofail" "x-systemd.device-timeout=10s" ];
system.autoUpgrade.enable = lib.mkForce false;
};
# dd-able USB image of a <model>-usb config. make-disk-image gives the
# ext4 root the nixos-usb label directly (-L) but hardcodes the ESP FAT
# label to "ESP", so the volume is relabelled to ESP-USB afterwards —
# volume label only; bootloader files are untouched and UEFI loads
# /EFI/BOOT/BOOTX64.EFI regardless. Keeps systemd-boot: both the batm3
# and douro firmware UEFI-USB-boot fine via that removable fallback.
mkUsbDiskImage = machineModel: usbConfig:
let
baseImage = import (nixpkgs + "/nixos/lib/make-disk-image.nix") {
inherit pkgs lib;
config = usbConfig.config;
format = "raw";
partitionTableType = "efi";
diskSize = "auto";
label = "nixos-usb"; # ext4 root label (make-disk-image -L)
};
in
pkgs.runCommand "nixos-disk-image-${machineModel}-usb"
{ nativeBuildInputs = [ pkgs.parted pkgs.mtools ]; }
''
mkdir -p $out
cp --sparse=always ${baseImage}/nixos.img $out/nixos.img
chmod +w $out/nixos.img
espStart=$(parted -sm "$out/nixos.img" unit B print | awk -F: '$1==1 {gsub("B","",$2); print $2}')
echo "ESP partition starts at byte $espStart — relabelling to ESP-USB"
export MTOOLS_SKIP_CHECK=1
mlabel -i "$out/nixos.img@@$espStart" ::ESP-USB
printf 'verify ESP label: '; mlabel -i "$out/nixos.img@@$espStart" -s :: || true
'';
in in
{ {
# ── NixOS Configurations (top-level, not per-system) ────────── # ── NixOS Configurations (top-level, not per-system) ──────────
@ -381,35 +436,22 @@
sintra-installed = mkInstalledConfig "sintra" ./deploy/nixos/hardware/upboard.nix; sintra-installed = mkInstalledConfig "sintra" ./deploy/nixos/hardware/upboard.nix;
batm3-installed = mkInstalledConfig "batm3" ./deploy/nixos/hardware/batm3.nix; batm3-installed = mkInstalledConfig "batm3" ./deploy/nixos/hardware/batm3.nix;
# USB-bootable variant of batm3-installed. This is the config the # USB-bootable variants of <model>-installed (see usbBootModule for
# flashed USB stick actually runs — distinct fs labels so stage-1 can't # what changes). These are the configs a flashed stick actually runs.
# latch the internal drive, nofail /boot, no growPartition, autoUpgrade # Exposed as named configs (not just inline in the disk-image targets)
# off. Exposed as a named config (not just inline in the disk-image # so their system closures can be built here and deployed in-place with
# target) so its system closure can be built here and deployed in-place # `nix copy` + `switch-to-configuration` — updating the app on a running
# with `nix copy` + `switch-to-configuration` — updating the app on a # stick WITHOUT reflashing (preserves pairing + /var/lib state).
# running stick WITHOUT reflashing (preserves pairing + /var/lib state). # disk-image-<model>-usb builds its filesystem image from the same config.
# disk-image-batm3-usb builds its filesystem image from this same config.
batm3-usb = self.nixosConfigurations.batm3-installed.extendModules { batm3-usb = self.nixosConfigurations.batm3-installed.extendModules {
modules = [ modules = [ usbBootModule ];
({ lib, ... }: { };
fileSystems."/".device = lib.mkForce "/dev/disk/by-label/nixos-usb"; # douro: the production unit's internal drive is not NixOS, so the
fileSystems."/boot".device = lib.mkForce "/dev/disk/by-label/ESP-USB"; # label disambiguation is moot today, but the nofail /boot and the
# /boot must NOT be a hard boot dependency on the USB image. The # uas/autosuspend hardening in douro.nix are what make a stick a
# firmware already loaded the bootloader before Linux; without # reliable boot medium on the Bay Trail box. Same in-place update flow.
# nofail, a slow/late ESP-USB enumeration (BOT is slower than UAS) douro-usb = self.nixosConfigurations.douro-installed.extendModules {
# blows past systemd's 90s device-timeout into emergency mode with modules = [ usbBootModule ];
# root locked — a dead end. nofail + short timeout lets the
# already-mounted root carry the boot; /boot mounts if/when it shows.
fileSystems."/boot".options = [ "nofail" "x-systemd.device-timeout=10s" ];
# NO growPartition/autoResize: sfdisk rewriting the partition table
# on first boot is the single most bus-stressing write, and flaky
# USB bridges drop off the bus mid-rewrite (sfdisk wedges in D-state
# and ESP-USB vanishes with the device). Persistent state is a few
# MB and the image ships ~2GB free. The internal-SATA disk-image-
# batm3 keeps growPartition (a real AHCI SSD won't drop the bus).
system.autoUpgrade.enable = lib.mkForce false;
})
];
}; };
}; };
@ -543,53 +585,16 @@
printf 'verify ESP label: '; mlabel -i "$out/nixos.img@@$espStart" -s :: || true printf 'verify ESP label: '; mlabel -i "$out/nixos.img@@$espStart" -s :: || true
''; '';
# USB-bootable BATM3 TEST image with DISTINCT partition labels # USB-bootable images (see usbBootModule / mkUsbDiskImage in the let
# (nixos-usb / ESP-USB). The plain disk-image-batm3 reuses the generic # block). Flash with dd or balenaEtcher, boot the stick, done — no
# nixos/ESP labels, so a USB stick carrying it, booted on a batm3 whose # installer step. The plain disk-image-<model> reuses the generic
# internal SATA drive ALREADY holds a nixos/ESP-labelled install, makes # nixos/ESP labels, so a stick carrying it, booted on a machine whose
# stage-1's by-label/nixos resolve to the internal drive (larger fs, # internal drive ALREADY holds a nixos/ESP-labelled install, makes
# journal recovers) instead of the stick — the stage-2 init path baked # stage-1's by-label/nixos resolve to the internal drive instead of the
# into the USB's boot entry isn't on that root, so stage 1 aborts. # stick — the stage-2 init path baked into the USB's boot entry isn't on
# Distinct labels make stage-1 pick the stick unambiguously WITHOUT # that root, so stage 1 aborts. These variants can't hit that.
# touching the internal drive. Unlike disk-image-sintra-usb this keeps disk-image-batm3-usb = mkUsbDiskImage "batm3" self.nixosConfigurations.batm3-usb;
# systemd-boot: the batm3 firmware UEFI-USB-boots fine via the ESP's disk-image-douro-usb = mkUsbDiskImage "douro" self.nixosConfigurations.douro-usb;
# /EFI/BOOT/BOOTX64.EFI removable fallback, so no GRUB/hybrid-table
# change is needed — only the label disambiguation here plus the
# usb_storage/uas initrd modules (in batm3.nix). Does NOT grow to fill
# the stick (see the growPartition note below — sfdisk on first boot
# wedges flaky USB bridges); auto-upgrade off (test image, not a managed
# fleet member — also stops scheduled bootloader writes landing on the
# internal drive's ESP).
disk-image-batm3-usb =
let
# Filesystem image of the batm3-usb config (defined in
# nixosConfigurations). Same config that in-place deploys target, so
# a reflash and a `switch-to-configuration` converge on one system.
baseImage = import (nixpkgs + "/nixos/lib/make-disk-image.nix") {
inherit pkgs lib;
config = self.nixosConfigurations.batm3-usb.config;
format = "raw";
partitionTableType = "efi";
diskSize = "auto";
label = "nixos-usb"; # ext4 root label (make-disk-image -L)
};
in
pkgs.runCommand "nixos-disk-image-batm3-usb"
{ nativeBuildInputs = [ pkgs.parted pkgs.mtools ]; }
''
mkdir -p $out
cp --sparse=always ${baseImage}/nixos.img $out/nixos.img
chmod +w $out/nixos.img
# make-disk-image hardcodes the ESP FAT label to "ESP"; relabel the
# volume to ESP-USB so /boot (by-label/ESP-USB) can't resolve to an
# internal drive's ESP. Volume label only — bootloader files are
# untouched, and UEFI loads /EFI/BOOT/BOOTX64.EFI regardless.
espStart=$(parted -sm "$out/nixos.img" unit B print | awk -F: '$1==1 {gsub("B","",$2); print $2}')
echo "ESP partition starts at byte $espStart — relabelling to ESP-USB"
export MTOOLS_SKIP_CHECK=1
mlabel -i "$out/nixos.img@@$espStart" ::ESP-USB
printf 'verify ESP label: '; mlabel -i "$out/nixos.img@@$espStart" -s :: || true
'';
# Backwards compat # Backwards compat
iso = self.nixosConfigurations.douro.config.system.build.isoImage; iso = self.nixosConfigurations.douro.config.system.build.isoImage;