diff --git a/config/templates/nfs-boot.cmd.template b/config/templates/nfs-boot.cmd.template index f512ff234495..db54d58ced5b 100644 --- a/config/templates/nfs-boot.cmd.template +++ b/config/templates/nfs-boot.cmd.template @@ -7,27 +7,33 @@ setenv net_setup "ip=dhcp" # for static configuration see documentation -# https://github.com/torvalds/linux/blob/master/Documentation/admin-guide/nfs/nfsroot.rst +# https://www.kernel.org/doc/Documentation/admin-guide/nfs/nfsroot.rst # setenv net_setup "ip=::::::::" -# you may need to add extra kernel arguments specific to your device -setenv bootargs "console=tty1 console=ttyS0,115200 root=/dev/nfs ${net_setup} rw rootflags=noatime disp.screen0_output_mode=1920x1080p60 panic=10 consoleblank=0 enforcing=0 loglevel=6" +# No hardcoded `console=`: kernels resolve the console from the DTB's +# /chosen/stdout-path. Hardcoding a baud rate here breaks boards whose +# UART runs at non-115200 speeds (e.g. helios64 @ 1500000). +setenv bootargs "root=/dev/nfs ${net_setup} rw rootwait earlycon panic=10 loglevel=6" -if test -n ${nfs_root}; then +if test -n "${nfs_root}"; then setenv bootargs "${bootargs} nfsroot=${nfs_root}" fi -if ext4load mmc 0 0x00000000 .next || fatload mmc 0 0x00000000 .next; then - ext4load mmc 0 ${fdt_addr_r} /dtb/${fdtfile} || fatload mmc 0 ${fdt_addr_r} /dtb/${fdtfile} - ext4load mmc 0 ${kernel_addr_r} zImage || fatload mmc 0 ${kernel_addr_r} zImage - ext4load mmc 0 ${ramdisk_addr_r} uInitrd || fatload mmc 0 ${ramdisk_addr_r} uInitrd || setenv ramdisk_addr_r "-" +# Load kernel + DTB + initrd from the active distro boot partition. +# devtype/devnum/distro_bootpart are set by U-Boot's bootflow scanner +# before this script runs. +setenv boot_dev "${devtype} ${devnum}:${distro_bootpart}" + +load ${boot_dev} ${fdt_addr_r} dtb/${fdtfile} +load ${boot_dev} ${ramdisk_addr_r} uInitrd || setenv ramdisk_addr_r "-" + +# Prefer arm64 flat Image (booti); fall back to compressed zImage (bootz). +if load ${boot_dev} ${kernel_addr_r} Image; then + booti ${kernel_addr_r} ${ramdisk_addr_r} ${fdt_addr_r} +else + load ${boot_dev} ${kernel_addr_r} zImage setenv fdt_high ffffffff bootz ${kernel_addr_r} ${ramdisk_addr_r} ${fdt_addr_r} -else - ext4load mmc 0 ${fdt_addr_r} script.bin || fatload mmc 0 ${fdt_addr_r} script.bin - ext4load mmc 0 ${kernel_addr_r} zImage || fatload mmc 0 ${kernel_addr_r} zImage - ext4load mmc 0 ${ramdisk_addr_r} uInitrd || fatload mmc 0 ${ramdisk_addr_r} uInitrd || setenv ramdisk_addr_r "-" - bootz ${kernel_addr_r} ${ramdisk_addr_r} fi # Recompile with: diff --git a/extensions/netboot/README.md b/extensions/netboot/README.md new file mode 100644 index 000000000000..b08cc950f465 --- /dev/null +++ b/extensions/netboot/README.md @@ -0,0 +1,567 @@ +# Armbian `netboot` extension + +Produces a full network-boot payload for a single-board computer: kernel +image, DTB, optional initrd, PXE/extlinux config, and an NFS-exportable +rootfs. The target board boots with no local storage involved in the +early boot path — U-Boot fetches kernel+DTB over TFTP, then mounts root +over NFS. + +For the short overview + variable reference see the companion page in +[armbian-doc](https://github.com/armbian/documentation) (`Developer-Guide_Netboot.md`). +This README holds the long-form guide: upstream constraints, server +setup, network configuration, troubleshooting, end-to-end examples. + +## Table of contents + +- [Why this exists (hybrid-NFS vs full netboot)](#why-this-exists) +- [Build-time variables](#build-time-variables) +- [Upstream constraint: U-Boot does not support proxyDHCP](#upstream-constraint-u-boot-does-not-support-proxydhcp) +- [Server side: TFTP + NFS](#server-side-tftp--nfs) +- [Network side: DHCP options 66/67](#network-side-dhcp-options-6667) +- [Builder-as-NFS-server single-step workflow](#builder-as-nfs-server-single-step-workflow) +- [Multi-board / multi-host deployments](#multi-board--multi-host-deployments) +- [First boot and `armbian-firstrun`](#first-boot-and-armbian-firstrun) +- [End-to-end example: helios64](#end-to-end-example-helios64) +- [Troubleshooting](#troubleshooting) + +## Why this exists + +`ROOTFS_TYPE=nfs` alone produces a hybrid image: kernel and DTB still +live on a local boot partition (SD/eMMC), only `/` comes over NFS. +`ROOTFS_TYPE=nfs-root` takes it further — kernel, DTB and PXE config +are also staged for TFTP, and the only thing the target needs to boot +is a network with working DHCP+TFTP+NFS. Selecting `nfs-root` is the +single switch that turns this extension on; it is auto-enabled from +the core `ROOTFS_TYPE` dispatch, no separate `ENABLE_EXTENSIONS` flag +is needed. + +Use cases: + +- Read-only / tamper-evident workstations and kiosks. +- SBC clusters where one machine owns the storage and N workers pull + their rootfs over NFS. +- Development loops where `edit → build → reboot target` should not + involve flashing or SD-card swaps. +- Boards with damaged or missing eMMC/SD but a working Ethernet PHY. + +Local storage (NVMe, USB, swap partition, data disks) can still be +mounted at runtime — the extension only arranges for the *early* boot +to come over the wire. + +## Build-time variables + +All variables are optional. The only required step is +`ROOTFS_TYPE=nfs-root`; defaults give you a single shared rootfs +per `BOARD × BRANCH × RELEASE` and a `pxelinux.cfg/default.example` +file to copy into place. + +| Variable | Default | Purpose | +|---|---|---| +| `NETBOOT_SERVER` | _(empty)_ | IP of the TFTP/NFS server. When empty, `nfsroot=` keeps the literal `${serverip}` placeholder so U-Boot expands it at `pxe boot` time from DHCP `siaddr`. Set this when the build should bake a fixed server IP into the PXE config. | +| `NETBOOT_TFTP_PREFIX` | `armbian/${LINUXFAMILY}/${BOARD}/${BRANCH}-${RELEASE}` | Path prefix inside TFTP root. One board can share one TFTP root with many other boards because each lives under its own prefix. | +| `NETBOOT_NFS_PATH` | see below | Absolute NFS path of the rootfs on the server. The APPEND line uses exactly this string for `nfsroot=...`. | +| `NETBOOT_HOSTNAME` | _(empty)_ | Per-host deployment. When set, the default `NETBOOT_NFS_PATH` becomes `/srv/netboot/rootfs/hosts/` — each machine owns its own writable rootfs copy. When empty, the default is `/srv/netboot/rootfs/shared/${LINUXFAMILY}/${BOARD}/${BRANCH}-${RELEASE}` (one image, potentially reused by identical boards). | +| `NETBOOT_CLIENT_MAC` | _(empty)_ | Client MAC (`aa:bb:cc:dd:ee:ff` or `aa-bb-cc-dd-ee-ff`). When set, the PXE config is written as `pxelinux.cfg/01-` (the PXELINUX per-MAC override) instead of `default.example`; multiple boards then coexist on one TFTP root, each picking its own extlinux. | +| `ROOTFS_COMPRESSION` | `gzip` | Format of the rootfs archive produced by `create_image_from_sdcard_rootfs`. `gzip` → `.tar.gz`, `zstd` → `.tar.zst`, `none` → no archive at all. The `none` case requires `ROOTFS_EXPORT_DIR`. | +| `ROOTFS_EXPORT_DIR` | _(empty)_ | Absolute path. When set, the rootfs tree is rsync'd directly into this directory in addition to (or instead of) the archive. Primary use: builder host is also the NFS server — single-step `build → boot` loop with no tar/unpack/rsync step. If this path is outside `${SRC}`, the extension bind-mounts it into the build container so the same path works on host and inside Docker. | + +### Hook: `netboot_artifacts_ready` + +Called from `pre_umount_final_image__900_collect_netboot_artifacts` +after all artifacts are staged. Exposed context: + +| Variable | Meaning | +|---|---| +| `NETBOOT_TFTP_OUT` | Absolute path of the staging directory (`output/images/-netboot-tftp`). | +| `NETBOOT_TFTP_PREFIX` | As above. | +| `NETBOOT_NFS_PATH` | As above. | +| `NETBOOT_PXE_FILE` | `default.example` or `01-` — the file that was written under `pxelinux.cfg/`. | +| `NETBOOT_ROOTFS_ARCHIVE` | Full path to the produced rootfs archive (empty when `ROOTFS_COMPRESSION=none`). | +| `NETBOOT_HOSTNAME`, `NETBOOT_CLIENT_MAC` | The sanitized values the extension used. | +| `BOARD`, `LINUXFAMILY`, `BRANCH`, `RELEASE` | Standard build variables. | + +Implement this hook in `userpatches/extensions/` to rsync the TFTP +tree to a netboot server, unpack the rootfs archive into the export +path, notify a monitoring system, etc. When the build host is the +NFS server, prefer `ROOTFS_EXPORT_DIR` — the hook then only needs to +handle the TFTP side. + +## Upstream constraint: U-Boot does not support proxyDHCP + +This is the single most important fact behind the server-side design. +Any tutorial that tells you to set up a "PXE proxy server" next to +your existing router DHCP will not work with U-Boot — it works with +BIOS/UEFI PXE ROMs but not with Das U-Boot's `bootp.c`. + +What the U-Boot source (`net/bootp.c`, current master) actually does: + +- Sends `vendor-class-identifier = U-Boot.armv8` (or `.armv7`). It + does **not** send `PXEClient`, so a proxyDHCP server that filters + on vendor class (the standard case for dnsmasq `dhcp-range=...,proxy`) + will not answer at all. +- Parses the **first** `DHCPOFFER` it sees. The state machine + immediately transitions `SELECTING → REQUESTING`: + ```c + dhcp_state = REQUESTING; + dhcp_send_request_packet(bp); + ``` + A second OFFER from a separate PXE server arriving moments later is + silently discarded. +- Takes `server-ip` (`siaddr`) from that first OFFER only: + `net_server_ip = ntohl(bp->bp_siaddr)`. If the router answered + first with `siaddr = router_ip`, U-Boot will TFTP from the router. +- Never talks to UDP/4011 (Boot Server Discovery), which is the + second phase of the PXE spec that a proxyDHCP flow depends on. + +**Consequence:** any scheme where the router hands out IPs and a +separate server is supposed to add PXE options is architecturally +incompatible with U-Boot without patching the client. The PXE +information (`siaddr`, `bootfile`) must come from the **same** DHCP +server that hands out the IP. + +Two supported workarounds: + +1. **Put DHCP options 66/67 on the main network DHCP server** (usually + the router). Works unmodified with upstream U-Boot. Documented + below. _This is the path the extension is designed around._ +2. Persist `serverip` in U-Boot environment via `env set serverip …; + env save`. This is per-board, brittle (`env` offset can be wiped + by an image flash), and not something the Armbian build can do for + you — but it's there if you absolutely cannot touch your DHCP. + +## Server side: TFTP + NFS + +The minimal production setup is `tftpd-hpa` + `nfs-kernel-server` on +one Linux host. **No DHCP runs on the server.** DHCP lives on the +network router (see next section). + +Directory layout: + +```text +/srv/netboot/ + tftp/ # TFTP root (= TFTP_DIRECTORY) + pxelinux.cfg/ + default # the file U-Boot pxe get downloads + 01-aa-bb-cc-dd-ee-ff # per-MAC override (lowercase, dashes) + armbian/ + //-/ + Image + uInitrd # optional + dtb/ + rockchip/rk3399-kobol-helios64.dtb + rockchip/overlay/*.dtbo + rootfs/ + shared///-/ + etc/ bin/ usr/ ... + hosts// + etc/ bin/ usr/ ... +``` + +### `/etc/default/tftpd-hpa` + +```sh +TFTP_USERNAME="tftp" +TFTP_DIRECTORY="/srv/netboot/tftp" +TFTP_ADDRESS=":69" +TFTP_OPTIONS="--secure --ipv4" +``` + +`--secure` chroots the daemon into `TFTP_DIRECTORY`; `--ipv4` avoids +IPv6 bind conflicts on dual-stack hosts. + +### `/etc/exports` + +```text +/srv/netboot/rootfs *(ro,sync,no_subtree_check,no_root_squash,crossmnt,fsid=0) +/srv/netboot/rootfs/shared *(rw,sync,no_subtree_check,no_root_squash) +/srv/netboot/rootfs/hosts *(rw,sync,no_subtree_check,no_root_squash) +``` + +`no_root_squash` is required so the NFS client can write files owned +by UID 0. `crossmnt`/`fsid=0` makes the top-level a pseudo-root so +clients can mount `shared/...` and `hosts/...` paths directly without +needing the top export. + +### systemd + +```sh +systemctl enable --now tftpd-hpa nfs-kernel-server +exportfs -ra +``` + +Firewall: UDP/69 (TFTP), TCP/2049 (NFS), plus whatever `rpc.mountd` +and `rpc.statd` bind to if you're using NFSv3 — pin them via +`/etc/default/nfs-kernel-server` and `/etc/default/nfs-common` for a +predictable firewall rule. + +## Network side: DHCP options 66/67 + +This is the only section that changes on the *network* side — the +main DHCP server (usually a router) needs to announce two options for +PXE clients: + +- **Option 66** (`tftp-server-name`) — IP or hostname of the TFTP + server. This ends up as `siaddr`/`serverip` in U-Boot. +- **Option 67** (`bootfile-name`) — the filename U-Boot asks for first + via `pxe get`. This must be **`default`**, not `pxelinux.cfg/default` + (see gotcha below). + +### OpenWRT (UCI / dnsmasq as DHCP) + +```sh +uci set dhcp.@dnsmasq[0].dhcp_boot='default,,' +uci commit dhcp +/etc/init.d/dnsmasq restart +``` + +Example for a server reachable as `m1` / `192.168.1.125`: + +```sh +uci set dhcp.@dnsmasq[0].dhcp_boot='default,m1,192.168.1.125' +uci commit dhcp +/etc/init.d/dnsmasq restart +``` + +The three fields are `bootfile,servername,siaddr`. `servername` is +informational (populates the `sname` DHCP field); `siaddr` is what +U-Boot actually uses. + +**LuCI has no UI for `dhcp_boot`.** The "DHCP-Options" field in +*Network → DHCP and DNS → Advanced Settings* is a different mechanism +(`list dhcp_option`) and cannot express option 66/67 cleanly. The only +way to see / change `dhcp_boot` is via UCI/SSH or by reading +`/etc/config/dhcp` directly. + +### The `bootfile=default` gotcha + +Set the bootfile to `default`, **not** `pxelinux.cfg/default`. U-Boot's +`pxe get` treats the bootfile path as a *directory*, extracts the +directory component as `bootdir`, and then its internal +`get_pxelinux_path()` prefixes `pxelinux.cfg/` again. So: + +- bootfile = `default` → bootdir = `""` → requests become + `pxelinux.cfg/01-`, `pxelinux.cfg/`, `pxelinux.cfg/default` + — correct paths, `tftpd-hpa` finds them. +- bootfile = `pxelinux.cfg/default` → bootdir = `pxelinux.cfg/` → + requests become `pxelinux.cfg/pxelinux.cfg/01-`, and so on — + doubled prefix, `tftpd-hpa` returns file-not-found for everything. + +### Other DHCP servers + +The same two options translate directly: + +- **isc-dhcp-server**: `next-server ; filename "default";` inside + the relevant subnet or host stanza. +- **dnsmasq standalone**: `dhcp-boot=default,,` in + `dnsmasq.conf`. +- **Mikrotik/RouterOS**: `/ip dhcp-server network set [find] boot-file-name=default next-server=`. +- **EdgeOS / VyOS**: `set service dhcp-server shared-network-name + subnet bootfile-name default` and + `bootfile-server `. +- **Windows DHCP Server**: Scope Options → 066 (Boot Server Host Name) + + 067 (Bootfile Name), value `default`. + +## Builder-as-NFS-server single-step workflow + +When the machine building the image is also the NFS server for the +target, you can skip the archive entirely: build straight into the +export directory. + +```sh +./compile.sh build \ + BOARD=helios64 BRANCH=edge RELEASE=resolute \ + BUILD_MINIMAL=yes \ + ROOTFS_TYPE=nfs-root \ + NETBOOT_SERVER=192.168.1.125 \ + NETBOOT_HOSTNAME=helios64-a \ + NETBOOT_CLIENT_MAC=aa:bb:cc:dd:ee:ff \ + ROOTFS_COMPRESSION=none \ + ROOTFS_EXPORT_DIR=/srv/netboot/rootfs/hosts/helios64-a +``` + +What happens: + +- `ROOTFS_COMPRESSION=none` skips the tar/gzip step. No `*.tar.gz` + appears under `output/images/`. +- `ROOTFS_EXPORT_DIR` is populated via `rsync -aHWh`, preserving + permissions, xattrs, hardlinks. +- Because the path is outside `${SRC}`, the extension bind-mounts it + into the build container via + `host_pre_docker_launch__netboot_mount_export_dir` — the same build + command works on bare-metal and in Docker. +- `pre_umount_final_image__900_collect_netboot_artifacts` still + produces the TFTP tree at + `output/images/-netboot-tftp/armbian///-/` + — you rsync that into your TFTP root as usual. + +Requirements: + +- The export directory must be writable by the build process (root in + most setups — `compile.sh` escalates via sudo). +- Disk budget: roughly 1.5 GB per `BUILD_MINIMAL` rootfs, more for + desktop images. Multiply by the number of `hosts/` + directories. +- `ROOTFS_COMPRESSION=none` without `ROOTFS_EXPORT_DIR` is rejected + early (in `extension_prepare_config`) — otherwise nothing would be + produced at all. + +When this workflow does **not** fit: + +- Builder and NFS server are different machines with no shared mount. + Use `ROOTFS_COMPRESSION=gzip|zstd` and rsync/ssh the archive via a + `netboot_artifacts_ready` hook (or by hand). +- Two parallel builds targeting the same `ROOTFS_EXPORT_DIR` — rsync + will clobber each other. Use distinct directories (a per-host + layout already gives you that). + +## Multi-board / multi-host deployments + +Armbian does not have a universal image. The smallest unit is +`BOARD × BRANCH × RELEASE`, and even among boards in the same SoC +family the BSP (`armbian-bsp-cli-*`) is per-board. Plan sharing +accordingly: + +| Share | x86 ↔ arm64 | rockchip64 ↔ meson64 | helios64 ↔ rock5b | +|---|---|---|---| +| Kernel image | impossible (different arch) | different kernel packages | different kernels | +| DTB | x86 doesn't use DTBs | different DTB trees | different DTBs | +| rootfs binaries | impossible | technically compatible | technically compatible | +| `armbian-bsp-cli-*` | per-board | per-board | per-board | + +Practically, the maximum rootfs sharing is **N physical boards of +identical model**, and even that has caveats (see "identical boards" +below). + +Supported patterns: + +1. **One board, one image.** Default: `shared///-/`, + `pxelinux.cfg/default` points at it. +2. **N different boards (different models).** Each in its own + `shared///...`; each board's U-Boot requests + `01-` first, so per-MAC PXE configs are the routing mechanism. + Build each with a different `NETBOOT_CLIENT_MAC`. +3. **N identical boards, per-host rootfs.** `NETBOOT_HOSTNAME=` + → rootfs lives at `hosts//`. Each board gets its own copy; + there are no shared-write conflicts (`/var/log`, `/etc/machine-id`, + `/etc/ssh/ssh_host_*_key`, etc.). Build once per host, deploy each + to its own directory. +4. **N identical boards, one rootfs (advanced).** Technically possible + with a read-only rootfs + tmpfs/overlay over the writable paths, + but the Armbian build itself does not set this up for you — the + produced rootfs assumes single-host ownership. If you need this, + layer a `userpatches/customize-image.sh` that moves `/var`, `/etc` + and `/home` onto tmpfs/overlay mounts in `/etc/fstab`, and use + `ro` instead of `rw` in the NFS export + APPEND. + +Explicit non-goals: + +- One rootfs shared between architectures (x86 + arm64). +- One rootfs shared between SoC families (rockchip64 + meson64 would + conflict on the BSP and often on kernel ABI). +- "Generic Linux netboot" — that's the job of Debian/Ubuntu + netinstall images, not Armbian. + +## First boot and `armbian-firstrun` + +Armbian has two "first boot" mechanisms that matter for netboot. They +are often confused: + +| Name | Unit / script | What it does | Netboot treatment | +|---|---|---|---| +| `armbian-firstrun.service` | systemd | Regenerates SSH host keys, calls helper scripts. Non-interactive. | **Kept.** Harmless on NFS root. | +| `armbian-firstlogin` (wizard) | `/etc/profile.d/armbian-check-first-login.sh` on first shell login | Runs the whiptail wizard: root password → create user → timezone → locale. **Interactive. Blocks the login shell until finished.** | **Suppressed.** The extension deletes `/root/.not_logged_in_yet` during `post_customize_image`, which is the trigger flag the profile script looks for. The wizard is then skipped permanently; default `root/1234` login continues to work. | + +The extension also drops the `armbian-resize-filesystem.service` +enablement symlink — that unit calls `resize2fs` on the root block +device, which does not exist on an NFS root and errors out. + +**If you want the wizard-set values** (user account, timezone, +locale), bake them into the image at build time: + +```sh +# userpatches/customize-image.sh +useradd -m -s /bin/bash -G sudo,netdev alice +echo 'alice:hunter2' | chpasswd +mkdir -p /home/alice/.ssh +cat > /home/alice/.ssh/authorized_keys <<'KEY' +ssh-ed25519 AAAAC3... alice@laptop +KEY +chown -R alice:alice /home/alice/.ssh +chmod 700 /home/alice/.ssh +chmod 600 /home/alice/.ssh/authorized_keys + +ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime +echo 'LANG=en_US.UTF-8' > /etc/default/locale +``` + +This gives you the same result as running the wizard, without the +interactive hang on first boot. Armbian-firstrun still runs and gives +the image a unique set of SSH host keys per boot. + +## End-to-end example: helios64 + +Target: Helios64 (`rockchip64/helios64`, `edge`/`resolute`, +`ttyS2@1500000`). Builder and NFS server are the same Linux host at +`192.168.1.125`, reachable as `m1`. DHCP is OpenWRT at `192.168.1.1`. + +### 1. Server + +```sh +apt install tftpd-hpa nfs-kernel-server +cat > /etc/default/tftpd-hpa <<'EOF' +TFTP_USERNAME="tftp" +TFTP_DIRECTORY="/srv/netboot/tftp" +TFTP_ADDRESS=":69" +TFTP_OPTIONS="--secure --ipv4" +EOF +mkdir -p /srv/netboot/{tftp/pxelinux.cfg,rootfs/shared,rootfs/hosts} +cat >> /etc/exports <<'EOF' +/srv/netboot/rootfs *(ro,sync,no_subtree_check,no_root_squash,crossmnt,fsid=0) +/srv/netboot/rootfs/shared *(rw,sync,no_subtree_check,no_root_squash) +/srv/netboot/rootfs/hosts *(rw,sync,no_subtree_check,no_root_squash) +EOF +systemctl enable --now tftpd-hpa nfs-kernel-server +exportfs -ra +``` + +### 2. Router (OpenWRT) + +```sh +ssh root@192.168.1.1 \ + "uci set dhcp.@dnsmasq[0].dhcp_boot='default,m1,192.168.1.125'; \ + uci commit dhcp; /etc/init.d/dnsmasq restart" +``` + +### 3. Build (single-step, builder = NFS server) + +```sh +./compile.sh build \ + BOARD=helios64 BRANCH=edge RELEASE=resolute \ + BUILD_MINIMAL=yes \ + ROOTFS_TYPE=nfs-root \ + NETBOOT_SERVER=192.168.1.125 \ + ROOTFS_COMPRESSION=none \ + ROOTFS_EXPORT_DIR=/srv/netboot/rootfs/shared/rockchip64/helios64/edge-resolute +``` + +### 4. Drop the TFTP tree into place + +```sh +rsync -a output/images/*-netboot-tftp/ /srv/netboot/tftp/ +# -> /srv/netboot/tftp/pxelinux.cfg/default.example +# -> /srv/netboot/tftp/armbian/rockchip64/helios64/edge-resolute/Image +# -> /srv/netboot/tftp/armbian/rockchip64/helios64/edge-resolute/dtb/rockchip/... +mv /srv/netboot/tftp/pxelinux.cfg/default.example \ + /srv/netboot/tftp/pxelinux.cfg/default +``` + +(`default.example` → `default` is deliberate: the build does not +overwrite a live `default` file automatically — you opt in by the +rename.) + +### 5. Boot + +Pull the SD/eMMC out of the Helios64 (or rearrange `boot_targets` so +`pxe` sits before `mmc*`), power it on, and watch the U-Boot console +(`ttyS2 @ 1500000`): + +```text +Scanning bootdev 'ethernet@fe300000.bootdev': + DHCP client bound to address 192.168.1.130 + *** Unhandled DHCP Option in OFFER: 54 + filename: default + Retrieving file: pxelinux.cfg/01- + Retrieving file: pxelinux.cfg/ + Retrieving file: pxelinux.cfg/default + Config file 'pxelinux.cfg/default' found + Retrieving file: armbian/rockchip64/helios64/edge-resolute/Image + Retrieving file: armbian/rockchip64/helios64/edge-resolute/dtb/rockchip/rk3399-kobol-helios64.dtb + append: root=/dev/nfs nfsroot=192.168.1.125:/srv/netboot/rootfs/shared/...,tcp,v3 ip=dhcp rw rootwait earlycon loglevel=7 panic=10 + Starting kernel ... +... +IP-Config: Guessing netmask 255.255.255.0 +IP-Config: Complete: + device=eth0, hwaddr=..., ipaddr=192.168.1.130, mask=255.255.255.0, gw=192.168.1.1 + host=192.168.1.130, domain=, nis-domain=(none) + bootserver=192.168.1.125, rootserver=192.168.1.125, rootpath= +VFS: Mounted root (nfs filesystem) readonly on device 0:17. +... +[ OK ] Reached target graphical.target - Graphical Interface. + +helios64 login: +``` + +Default credentials are `root` / `1234` (the wizard was suppressed at +build time, so the shell is usable immediately). The +`armbian-firstrun.service` line means SSH host keys have been +regenerated on this boot — they'll persist in the NFS rootfs. + +## Troubleshooting + +**`TFTP from server 192.168.1.1` instead of `.125`** — the router is +providing `siaddr` (its own IP). DHCP option 66/67 are not being sent, +or are being sent without `siaddr`. Check `uci show dhcp | grep boot` +on OpenWRT; on other DHCP servers check the equivalent next-server +setting. Also confirm U-Boot is reading the **first** OFFER: it does +not merge multiple OFFERs, so a proxyDHCP server will not help here. + +**`Retrieving file: C0A8012C.img` followed by `ABORT`** — U-Boot +received the OFFER with no bootfile, fell back to requesting a file +named after its own IP in hex (`C0A8012C` = `192.168.1.44`). Fix: set +option 67 (`bootfile`) on the DHCP server to `default`. + +**`pxelinux.cfg/pxelinux.cfg/…` in the tftpd-hpa log** — the DHCP +bootfile is `pxelinux.cfg/default` (or anything with a slash). U-Boot +extracts the directory and re-prefixes `pxelinux.cfg/` itself. Set +bootfile to the bare filename `default`. + +**`VFS: Unable to mount root fs via NFS`** — several causes, check in +order: + +- Kernel was built without `CONFIG_ROOT_NFS`/`CONFIG_IP_PNP_DHCP`. + The extension's `custom_kernel_config__netboot_enable_nfs_root` + hook turns these on; make sure you didn't override it. `zcat + /proc/config.gz | grep -E 'ROOT_NFS|IP_PNP'` on a known-good + image. +- `/etc/exports` path mismatch vs what's in `nfsroot=`. `showmount + -e ` and compare byte-for-byte. +- Server firewall is blocking TCP/2049 (or the randomized mountd + port for NFSv3). Pin mountd and open the port. +- `nfsroot=` is using a hostname the client can't resolve yet (DNS + isn't up during early mount). Use an IP, not a hostname — the + extension does this by default when `NETBOOT_SERVER` is set. + +**`MODULE FAILURE` from initramfs** — the kernel is loading an +initramfs and trying to run `/init` that doesn't understand NFS root. +Either drop the initrd from the PXE config (the extension copies +`uInitrd` only if one exists) or rebuild the initramfs with NFS +support (`update-initramfs -u` with `MODULES=most` in +`/etc/initramfs-tools/initramfs.conf`). + +**Board boots from SD/eMMC instead of netboot.** The default +`boot_targets` on most Armbian boards puts local storage first +(`mmc1 mmc0 scsi0 usb0 pxe dhcp`). `pxe`/`dhcp` only trigger when no +local bootflow is found. Either physically remove the local media or +re-order `boot_targets` in U-Boot env: + +```text +=> env set boot_targets "pxe dhcp mmc1 mmc0 scsi0 usb0" +=> env save +``` + +Note that `env save` is per-board and can be wiped by the next U-Boot +flash. + +**Wrong baud rate on serial console.** The extension intentionally +does **not** put `console=…` in the kernel APPEND line. Hardcoding a +baud (e.g. 115200) breaks boards like Helios64 that run at +`1500000`. The kernel resolves the console from DT +`/chosen/stdout-path`; `earlycon` is still passed so you see early +boot output. If you see *no* console output at all, check that the +board's U-Boot `bootargs` template isn't overriding APPEND. + +**`armbian-firstlogin` whiptail wizard still appears.** Rebuild. +The extension only drops `/root/.not_logged_in_yet` during +`post_customize_image`, so images built before you enabled the +extension will still carry the flag. Alternatively, `ssh root@ +rm -f /root/.not_logged_in_yet` on the deployed rootfs. diff --git a/extensions/netboot/netboot.sh b/extensions/netboot/netboot.sh new file mode 100644 index 000000000000..b22e06d456d6 --- /dev/null +++ b/extensions/netboot/netboot.sh @@ -0,0 +1,219 @@ +# +# SPDX-License-Identifier: GPL-2.0 +# Copyright (c) 2026 Igor Velkov +# This file is a part of the Armbian Build Framework https://github.com/armbian/build/ +# +# Netboot: produce kernel + DTB + extlinux.conf + rootfs.tgz for TFTP/NFS root +# boot without local storage. See Developer-Guide_Netboot.md for server setup +# (tftpd-hpa + nfs-kernel-server + router DHCP options) and for the +# `netboot_artifacts_ready` hook used to auto-deploy artifacts to a server. +# +# Variables: +# NETBOOT_SERVER IP of TFTP/NFS server. If empty, nfsroot= uses +# ${serverip} (filled by U-Boot from DHCP siaddr). +# NETBOOT_TFTP_PREFIX Path prefix inside TFTP root. Default: +# armbian/${LINUXFAMILY}/${BOARD}/${BRANCH}-${RELEASE} +# NETBOOT_NFS_PATH Absolute NFS path of rootfs on the server. +# Default depends on NETBOOT_HOSTNAME — see below. +# NETBOOT_HOSTNAME Per-host deployment. When set, default NFS path +# becomes /srv/netboot/rootfs/hosts/ +# (each machine owns a full writable rootfs copy). +# When empty, shared/${LINUXFAMILY}/${BOARD}/... is used. +# NETBOOT_CLIENT_MAC Client MAC (aa:bb:cc:dd:ee:ff or aa-bb-cc-dd-ee-ff). +# When set, PXE config is written as `01-` +# (PXELINUX per-MAC override) instead of `default`; +# multiple boards can then coexist on one TFTP root. +# +# Hook: +# netboot_artifacts_ready Called after all artifacts are staged. Exposed +# context: NETBOOT_TFTP_OUT, NETBOOT_TFTP_PREFIX, +# NETBOOT_NFS_PATH, NETBOOT_PXE_FILE, +# NETBOOT_ROOTFS_ARCHIVE (may be empty if +# ROOTFS_COMPRESSION=none), plus BOARD/LINUXFAMILY/ +# BRANCH/RELEASE. Use it from userpatches to rsync +# to a netboot server, unpack the rootfs archive, +# etc. For builder-as-NFS-server workflows prefer +# ROOTFS_EXPORT_DIR to skip the archive step. + +function extension_prepare_config__netboot_defaults_and_validate() { + declare -g NETBOOT_SERVER="${NETBOOT_SERVER:-}" + declare -g NETBOOT_HOSTNAME="${NETBOOT_HOSTNAME:-}" + declare -g NETBOOT_CLIENT_MAC="${NETBOOT_CLIENT_MAC:-}" + declare -g NETBOOT_TFTP_PREFIX="${NETBOOT_TFTP_PREFIX:-armbian/${LINUXFAMILY}/${BOARD}/${BRANCH}-${RELEASE}}" + + if [[ -n "${NETBOOT_HOSTNAME}" ]]; then + declare -g NETBOOT_NFS_PATH="${NETBOOT_NFS_PATH:-/srv/netboot/rootfs/hosts/${NETBOOT_HOSTNAME}}" + else + declare -g NETBOOT_NFS_PATH="${NETBOOT_NFS_PATH:-/srv/netboot/rootfs/shared/${LINUXFAMILY}/${BOARD}/${BRANCH}-${RELEASE}}" + fi + + if [[ -n "${NETBOOT_CLIENT_MAC}" ]]; then + declare -g NETBOOT_CLIENT_MAC_NORMALIZED="${NETBOOT_CLIENT_MAC//:/-}" + NETBOOT_CLIENT_MAC_NORMALIZED="${NETBOOT_CLIENT_MAC_NORMALIZED,,}" + if [[ ! "${NETBOOT_CLIENT_MAC_NORMALIZED}" =~ ^[0-9a-f]{2}-[0-9a-f]{2}-[0-9a-f]{2}-[0-9a-f]{2}-[0-9a-f]{2}-[0-9a-f]{2}$ ]]; then + exit_with_error "${EXTENSION}: NETBOOT_CLIENT_MAC must look like aa:bb:cc:dd:ee:ff (got '${NETBOOT_CLIENT_MAC}')" + fi + fi + + # Fail-fast on bad ROOTFS_COMPRESSION/ROOTFS_EXPORT_DIR combos before debootstrap, + # not hours later in create_image_from_sdcard_rootfs. + case "${ROOTFS_COMPRESSION:-gzip}" in + gzip | zstd | none) ;; + *) exit_with_error "${EXTENSION}: unknown ROOTFS_COMPRESSION: '${ROOTFS_COMPRESSION}' (expected: gzip|zstd|none)" ;; + esac + if [[ "${ROOTFS_COMPRESSION:-gzip}" == "none" && -z "${ROOTFS_EXPORT_DIR}" ]]; then + exit_with_error "${EXTENSION}: ROOTFS_COMPRESSION=none requires ROOTFS_EXPORT_DIR (otherwise nothing is produced)" + fi +} + +# Ensure NFS-root client support is built into the kernel. +function custom_kernel_config__netboot_enable_nfs_root() { + opts_y+=("ROOT_NFS" "NFS_FS" "NFS_V3" "IP_PNP" "IP_PNP_DHCP") +} + +# armbian-resize-filesystem tries to grow the root fs on first boot via resize2fs. +# On an NFS-mounted root that's always meaningless (and would error) — strip the +# systemd enablement symlink so the unit never runs. +function post_customize_image__netboot_disable_resize_filesystem() { + [[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0 + display_alert "${EXTENSION}: disabling armbian-resize-filesystem.service" "meaningless on NFS root" "info" + run_host_command_logged find "${SDCARD}/etc/systemd/system/" \ + -name "armbian-resize-filesystem.service" -type l -delete +} + +# /etc/profile.d/armbian-check-first-login.sh launches the armbian-firstlogin +# whiptail wizard (root password → user → locale …) when /root/.not_logged_in_yet +# exists. On netboot there is often no interactive console on first boot, so the +# wizard blocks the whole bring-up. Drop the trigger flag; default root/1234 login +# keeps working, and armbian-firstrun.service still regenerates SSH host keys. +function post_customize_image__netboot_skip_firstlogin_wizard() { + [[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0 + [[ -f "${SDCARD}/root/.not_logged_in_yet" ]] || return 0 + display_alert "${EXTENSION}: skipping armbian-firstlogin wizard" "no interactive console assumed on netboot" "info" + run_host_command_logged rm -f "${SDCARD}/root/.not_logged_in_yet" +} + +# ROOTFS_EXPORT_DIR must be visible inside the build container at the same path the +# in-container rsync writes to — otherwise data lands in the container's private +# filesystem and disappears on umount. Two cases: +# 1) Path already under ${SRC}: core already bind-mounts ${SRC} at +# ${DOCKER_ARMBIAN_TARGET_PATH} (/armbian) inside the container, so the data +# path IS host-visible — but the env var still holds the host path, which +# does not exist in the container. Translate the env var to the container +# path so rsync writes into the bind-mounted volume. +# 2) Path outside ${SRC}: add an explicit bind-mount at the same path. +function host_pre_docker_launch__netboot_mount_export_dir() { + [[ -z "${ROOTFS_EXPORT_DIR}" ]] && return 0 + if [[ "${ROOTFS_EXPORT_DIR}" == "${SRC}"* ]]; then + declare container_export_dir="${DOCKER_ARMBIAN_TARGET_PATH:-/armbian}${ROOTFS_EXPORT_DIR#"${SRC}"}" + display_alert "${EXTENSION}: translating ROOTFS_EXPORT_DIR for container" "${ROOTFS_EXPORT_DIR} -> ${container_export_dir}" "info" + mkdir -p "${ROOTFS_EXPORT_DIR}" + DOCKER_EXTRA_ARGS+=("--env" "ROOTFS_EXPORT_DIR=${container_export_dir}") + return 0 + fi + mkdir -p "${ROOTFS_EXPORT_DIR}" + display_alert "${EXTENSION}: bind-mounting ROOTFS_EXPORT_DIR into container" "${ROOTFS_EXPORT_DIR}" "info" + DOCKER_EXTRA_ARGS+=("--mount" "type=bind,source=${ROOTFS_EXPORT_DIR},target=${ROOTFS_EXPORT_DIR}") +} + +function pre_umount_final_image__900_collect_netboot_artifacts() { + [[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0 + + # shellcheck disable=SC2154 # ${version} is a readonly global set in create_image_from_sdcard_rootfs + declare tftp_out="${DEST}/images/${version}-netboot-tftp" + declare tftp_prefix_dir="${tftp_out}/${NETBOOT_TFTP_PREFIX}" + declare pxe_dir="${tftp_out}/pxelinux.cfg" + run_host_command_logged mkdir -pv "${tftp_prefix_dir}/dtb" "${pxe_dir}" + + # Kernel image: arm64 uses Image, armv7 uses zImage. Preserve source basename + # so U-Boot `booti`/`bootz` still picks the right path via image header. + declare kernel_src="" kernel_name="" + if [[ -f "${MOUNT}/boot/Image" ]]; then + kernel_src="${MOUNT}/boot/Image" + kernel_name="Image" + elif [[ -f "${MOUNT}/boot/zImage" ]]; then + kernel_src="${MOUNT}/boot/zImage" + kernel_name="zImage" + elif [[ -f "${MOUNT}/boot/vmlinuz-${IMAGE_INSTALLED_KERNEL_VERSION}" ]]; then + kernel_src="${MOUNT}/boot/vmlinuz-${IMAGE_INSTALLED_KERNEL_VERSION}" + # vmlinuz-* is a generic bzImage/Image; prefer Image for arm64, zImage otherwise + [[ "${ARCH}" == "arm64" ]] && kernel_name="Image" || kernel_name="zImage" + fi + [[ -n "${kernel_src}" ]] || exit_with_error "${EXTENSION}: kernel image not found under ${MOUNT}/boot" + run_host_command_logged cp -v "${kernel_src}" "${tftp_prefix_dir}/${kernel_name}" + + if [[ -d "${MOUNT}/boot/dtb" ]]; then + run_host_command_logged cp -a "${MOUNT}/boot/dtb/." "${tftp_prefix_dir}/dtb/" + fi + + declare initrd_line="" + if [[ -f "${MOUNT}/boot/uInitrd" ]]; then + run_host_command_logged cp -v "${MOUNT}/boot/uInitrd" "${tftp_prefix_dir}/uInitrd" + initrd_line="INITRD ${NETBOOT_TFTP_PREFIX}/uInitrd" + fi + + # When NETBOOT_SERVER is empty, leave ${serverip} literal in nfsroot= so + # U-Boot expands it at `pxe boot` time from DHCP siaddr (path 2). + declare nfsroot_server="${NETBOOT_SERVER:-\${serverip\}}" + + # Intentionally no `console=` in APPEND: hardcoding a baud (e.g. 115200) + # breaks boards like helios64 which run at 1500000. Kernel resolves console + # from DTB `/chosen/stdout-path`; `earlycon` keeps the early output. + + # BOOT_FDT_FILE is not set for every board (e.g. helios64) — U-Boot then + # resolves DTB via its own ${fdtfile} env. FDTDIR handles both cases. + declare fdt_line + if [[ -n "${BOOT_FDT_FILE}" && "${BOOT_FDT_FILE}" != "none" ]]; then + fdt_line="FDT ${NETBOOT_TFTP_PREFIX}/dtb/${BOOT_FDT_FILE}" + else + fdt_line="FDTDIR ${NETBOOT_TFTP_PREFIX}/dtb" + fi + + # Per-MAC override wins over `default` in U-Boot `pxe get`. Multiple boards + # can share one TFTP root with distinct `01-` files. + declare pxe_file + if [[ -n "${NETBOOT_CLIENT_MAC_NORMALIZED}" ]]; then + pxe_file="01-${NETBOOT_CLIENT_MAC_NORMALIZED}" + else + pxe_file="default.example" + fi + + cat > "${pxe_dir}/${pxe_file}" <<- EXTLINUX_CONF + # Generated by ${EXTENSION} for ${BOARD} ${BRANCH} ${RELEASE} + # Target NFS path: ${NETBOOT_NFS_PATH} + DEFAULT armbian + TIMEOUT 30 + PROMPT 0 + + LABEL armbian + MENU LABEL Armbian ${BOARD} ${BRANCH} ${RELEASE} (netboot) + KERNEL ${NETBOOT_TFTP_PREFIX}/${kernel_name} + ${fdt_line}${initrd_line:+ + ${initrd_line}} + APPEND root=/dev/nfs nfsroot=${nfsroot_server}:${NETBOOT_NFS_PATH},tcp,v3 ip=dhcp rw rootwait earlycon loglevel=7 panic=10 + EXTLINUX_CONF + + display_alert "${EXTENSION}: artifacts ready" "${tftp_out}" "info" + display_alert "${EXTENSION}: TFTP payload" "${NETBOOT_TFTP_PREFIX}/ (${kernel_name}, dtb/, uInitrd)" "info" + display_alert "${EXTENSION}: PXE config" "pxelinux.cfg/${pxe_file}" "info" + display_alert "${EXTENSION}: target NFS path" "${NETBOOT_NFS_PATH}" "info" + + # Expose context to the deploy hook. rootfs.tgz is built by the NFS ROOTFS_TYPE + # path earlier in the pipeline; its path follows the same ${version} naming. + declare -g NETBOOT_TFTP_OUT="${tftp_out}" + declare -g NETBOOT_PXE_FILE="${pxe_file}" + # ROOTFS_ARCHIVE_PATH is set by create_image_from_sdcard_rootfs after the archive + # is produced (honours ROOTFS_COMPRESSION=gzip|zstd). Empty when ROOTFS_COMPRESSION=none. + declare -g NETBOOT_ROOTFS_ARCHIVE="${ROOTFS_ARCHIVE_PATH:-}" + + call_extension_method "netboot_artifacts_ready" <<- 'NETBOOT_HOOK_DOC' + *called after netboot TFTP tree and rootfs are staged* + Implementations can rsync ${NETBOOT_TFTP_OUT} to a TFTP server, extract + ${NETBOOT_ROOTFS_ARCHIVE} into ${NETBOOT_NFS_PATH} on an NFS server, etc. + When the build host IS the NFS server, prefer ROOTFS_EXPORT_DIR (skips + the archive step and writes straight into the export path). + Exposed context: NETBOOT_TFTP_OUT, NETBOOT_TFTP_PREFIX, NETBOOT_PXE_FILE, + NETBOOT_NFS_PATH, NETBOOT_ROOTFS_ARCHIVE (may be empty), NETBOOT_HOSTNAME, + NETBOOT_CLIENT_MAC, plus BOARD, LINUXFAMILY, BRANCH, RELEASE. + NETBOOT_HOOK_DOC +} diff --git a/lib/functions/configuration/main-config.sh b/lib/functions/configuration/main-config.sh index 3b276d07fbd3..42e9de0c8bb4 100644 --- a/lib/functions/configuration/main-config.sh +++ b/lib/functions/configuration/main-config.sh @@ -138,6 +138,13 @@ function do_main_configuration() { nfs) FIXED_IMAGE_SIZE=256 # small SD card with kernel, boot script and .dtb/.bin files ;; + nfs-root) + # Full netboot: no local storage in the early boot path at all. Kernel, + # DTB and extlinux go to TFTP, rootfs is mounted over NFS. The netboot + # extension owns artifact staging and PXE config generation. + FIXED_IMAGE_SIZE=256 + enable_extension "netboot" + ;; f2fs) enable_extension "fs-f2fs-support" # Fixed image size is in 1M dd blocks (MiB) @@ -160,8 +167,10 @@ function do_main_configuration() { ;; esac - # Check if the filesystem type is supported by the build host - if [[ $CONFIG_DEFS_ONLY != yes ]]; then # don't waste time if only gathering config defs + # Check if the filesystem type is supported by the build host. + # Skipped for nfs/nfs-root: those produce a rootfs tarball and/or export tree; + # host-side filesystem support is irrelevant. + if [[ $CONFIG_DEFS_ONLY != yes && $ROOTFS_TYPE != nfs && $ROOTFS_TYPE != nfs-root ]]; then check_filesystem_compatibility_on_host fi diff --git a/lib/functions/image/partitioning.sh b/lib/functions/image/partitioning.sh index dfe29741691a..f7d2fb694cd6 100644 --- a/lib/functions/image/partitioning.sh +++ b/lib/functions/image/partitioning.sh @@ -20,7 +20,7 @@ function prepare_partitions() { # possible partition combinations # /boot: none, ext4, ext2, fat (BOOTFS_TYPE) - # root: ext4, btrfs, f2fs, nilfs2, nfs (ROOTFS_TYPE) + # root: ext4, btrfs, f2fs, nilfs2, nfs, nfs-root (ROOTFS_TYPE) # declare makes local variables by default if used inside a function # NOTE: mountopts string should always start with comma if not empty @@ -121,7 +121,7 @@ function prepare_partitions() { BOOTSIZE=0 fi # Check if we need root partition - [[ $ROOTFS_TYPE != nfs ]] && + [[ $ROOTFS_TYPE != nfs && $ROOTFS_TYPE != nfs-root ]] && local rootpart=$((next++)) display_alert "calculated rootpart" "rootpart: ${rootpart}" "debug" @@ -144,7 +144,7 @@ function prepare_partitions() { display_alert "Using user-defined image size" "$FIXED_IMAGE_SIZE MiB" "info" sdsize=$FIXED_IMAGE_SIZE # basic sanity check - if [[ $ROOTFS_TYPE != nfs && $ROOTFS_TYPE != btrfs && $sdsize -lt $rootfs_size ]]; then + if [[ $ROOTFS_TYPE != nfs && $ROOTFS_TYPE != nfs-root && $ROOTFS_TYPE != btrfs && $sdsize -lt $rootfs_size ]]; then exit_with_error "User defined image size is too small" "$sdsize <= $rootfs_size" fi else diff --git a/lib/functions/image/rootfs-to-image.sh b/lib/functions/image/rootfs-to-image.sh index 51903cec259f..40133ddb62b8 100644 --- a/lib/functions/image/rootfs-to-image.sh +++ b/lib/functions/image/rootfs-to-image.sh @@ -19,7 +19,7 @@ function calculate_image_version() { calculated_image_version="${vendor_version_prelude}${BOARD^}_${RELEASE}_${BRANCH}_${kernel_version_for_image}${DESKTOP_ENVIRONMENT:+_$DESKTOP_ENVIRONMENT}${EXTRA_IMAGE_SUFFIX}" [[ $BUILD_DESKTOP == yes ]] && calculated_image_version=${calculated_image_version}_desktop [[ $BUILD_MINIMAL == yes ]] && calculated_image_version=${calculated_image_version}_minimal - [[ $ROOTFS_TYPE == nfs ]] && calculated_image_version=${calculated_image_version}_nfsboot + [[ $ROOTFS_TYPE == nfs || $ROOTFS_TYPE == nfs-root ]] && calculated_image_version=${calculated_image_version}_nfsboot display_alert "Calculated image version" "${calculated_image_version}" "debug" } @@ -40,7 +40,7 @@ function create_image_from_sdcard_rootfs() { if [[ ${INCLUDE_HOME_DIR:-no} == yes ]]; then exclude_home=""; fi # nilfs2 fs does not have extended attributes support, and have to be ignored on copy if [[ $ROOTFS_TYPE == nilfs2 ]]; then rsync_ea=""; fi - if [[ $ROOTFS_TYPE != nfs ]]; then + if [[ $ROOTFS_TYPE != nfs && $ROOTFS_TYPE != nfs-root ]]; then display_alert "Copying files via rsync to" "/ (MOUNT root)" run_host_command_logged rsync -aHWh $rsync_ea \ --exclude="/boot" \ @@ -52,12 +52,53 @@ function create_image_from_sdcard_rootfs() { $exclude_home \ --info=progress0,stats1 $SDCARD/ $MOUNT/ else - display_alert "Creating rootfs archive" "rootfs.tgz" "info" - tar cp --xattrs --directory=$SDCARD/ --exclude='./boot/*' --exclude='./dev/*' --exclude='./proc/*' --exclude='./run/*' --exclude='./tmp/*' \ - --exclude='./sys/*' $exclude_home . | - pv -p -b -r -s "$(du -sb "$SDCARD"/ | cut -f1)" \ - -N "$(logging_echo_prefix_for_pv "create_rootfs_archive") rootfs.tgz" | - gzip -c > "$DEST/images/${version}-rootfs.tgz" + # ROOTFS_COMPRESSION: gzip (default, .tar.gz) | zstd (.tar.zst) | none (skip archive) + declare rootfs_compression="${ROOTFS_COMPRESSION:-gzip}" + declare archive_ext="" archive_filter="" + case "${rootfs_compression}" in + gzip) archive_ext="tar.gz"; archive_filter="gzip -c" ;; + zstd) archive_ext="tar.zst"; archive_filter="zstd -T0 -c" ;; + none) ;; + *) exit_with_error "Unknown ROOTFS_COMPRESSION: '${rootfs_compression}' (expected: gzip|zstd|none)" ;; + esac + if [[ "${rootfs_compression}" == "none" && -z "${ROOTFS_EXPORT_DIR}" ]]; then + exit_with_error "ROOTFS_COMPRESSION=none requires ROOTFS_EXPORT_DIR (otherwise nothing is produced)" + fi + + declare -g ROOTFS_ARCHIVE_PATH="" + if [[ "${rootfs_compression}" != "none" ]]; then + ROOTFS_ARCHIVE_PATH="${DEST}/images/${version}-rootfs.${archive_ext}" + display_alert "Creating rootfs archive" "${version}-rootfs.${archive_ext}" "info" + # Subshell with pipefail so failures in tar/pv propagate (otherwise the + # exit code of the final compressor stage hides truncation mid-archive). + ( + set -o pipefail + tar cp --xattrs --directory=$SDCARD/ --exclude='./boot/*' --exclude='./dev/*' --exclude='./proc/*' --exclude='./run/*' --exclude='./tmp/*' \ + --exclude='./sys/*' $exclude_home . | + pv -p -b -r -s "$(du -sb "$SDCARD"/ | cut -f1)" \ + -N "$(logging_echo_prefix_for_pv "create_rootfs_archive") rootfs.${archive_ext}" | + ${archive_filter} > "${ROOTFS_ARCHIVE_PATH}" + ) + fi + + # ROOTFS_EXPORT_DIR: when set, also rsync rootfs tree into this directory. + # Useful when the build host is the NFS server, or has the NFS export mounted, + # so netboot deployment is a single build step with no unpack/transport phase. + if [[ -n "${ROOTFS_EXPORT_DIR}" ]]; then + display_alert "Exporting rootfs tree" "${ROOTFS_EXPORT_DIR}" "info" + run_host_command_logged mkdir -pv "${ROOTFS_EXPORT_DIR}" + # --delete so files removed from the source rootfs don't survive in a + # reused export tree (otherwise the NFS root silently drifts from the image). + run_host_command_logged rsync -aHWh --delete $rsync_ea \ + --exclude="/boot/*" \ + --exclude="/dev/*" \ + --exclude="/proc/*" \ + --exclude="/run/*" \ + --exclude="/tmp/*" \ + --exclude="/sys/*" \ + $exclude_home \ + --info=progress0,stats1 "$SDCARD/" "${ROOTFS_EXPORT_DIR}/" + fi fi # stage: rsync /boot