Skip to content

Commit aef1c59

Browse files
iavclaude
andcommitted
feat(extensions): add netboot extension for full TFTP+NFS boot
Hooks: - extension_prepare_config: validate variables, compute defaults for NETBOOT_TFTP_PREFIX / NETBOOT_NFS_PATH (shared by LINUXFAMILY/BOARD/BRANCH/RELEASE, or per-host when NETBOOT_HOSTNAME is set), normalize NETBOOT_CLIENT_MAC to PXELINUX 01-<mac> form, fail fast on bad ROOTFS_COMPRESSION/ROOTFS_EXPORT_DIR combinations. - custom_kernel_config: enable ROOT_NFS, NFS_FS, NFS_V3, IP_PNP, IP_PNP_DHCP so root=/dev/nfs ip=dhcp works without an initrd. - post_customize_image: drop armbian-resize-filesystem.service (meaningless on NFS root) and /root/.not_logged_in_yet (the armbian-firstlogin interactive wizard blocks bring-up when there is no interactive console). armbian-firstrun.service stays — it only regenerates SSH host keys. - host_pre_docker_launch: append a bind-mount for ROOTFS_EXPORT_DIR to DOCKER_EXTRA_ARGS when the directory lives outside ${SRC}, using the hook's documented mechanism. - pre_umount_final_image: assemble the TFTP tree (Image/zImage, dtb/, uInitrd), write pxelinux.cfg/{default.example | 01-<mac>} with the right FDT/FDTDIR line and explicit INITRD directive when uInitrd is present, expose a netboot_artifacts_ready hook for userpatches. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 71655d8 commit aef1c59

1 file changed

Lines changed: 219 additions & 0 deletions

File tree

extensions/netboot/netboot.sh

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
#
2+
# SPDX-License-Identifier: GPL-2.0
3+
# Copyright (c) 2026 Igor Velkov
4+
# This file is a part of the Armbian Build Framework https://github.com/armbian/build/
5+
#
6+
# Netboot: produce kernel + DTB + extlinux.conf + rootfs.tgz for TFTP/NFS root
7+
# boot without local storage. See Developer-Guide_Netboot.md for server setup
8+
# (tftpd-hpa + nfs-kernel-server + router DHCP options) and for the
9+
# `netboot_artifacts_ready` hook used to auto-deploy artifacts to a server.
10+
#
11+
# Variables:
12+
# NETBOOT_SERVER IP of TFTP/NFS server. If empty, nfsroot= uses
13+
# ${serverip} (filled by U-Boot from DHCP siaddr).
14+
# NETBOOT_TFTP_PREFIX Path prefix inside TFTP root. Default:
15+
# armbian/${LINUXFAMILY}/${BOARD}/${BRANCH}-${RELEASE}
16+
# NETBOOT_NFS_PATH Absolute NFS path of rootfs on the server.
17+
# Default depends on NETBOOT_HOSTNAME — see below.
18+
# NETBOOT_HOSTNAME Per-host deployment. When set, default NFS path
19+
# becomes /srv/netboot/rootfs/hosts/<hostname>
20+
# (each machine owns a full writable rootfs copy).
21+
# When empty, shared/${LINUXFAMILY}/${BOARD}/... is used.
22+
# NETBOOT_CLIENT_MAC Client MAC (aa:bb:cc:dd:ee:ff or aa-bb-cc-dd-ee-ff).
23+
# When set, PXE config is written as `01-<mac>`
24+
# (PXELINUX per-MAC override) instead of `default`;
25+
# multiple boards can then coexist on one TFTP root.
26+
#
27+
# Hook:
28+
# netboot_artifacts_ready Called after all artifacts are staged. Exposed
29+
# context: NETBOOT_TFTP_OUT, NETBOOT_TFTP_PREFIX,
30+
# NETBOOT_NFS_PATH, NETBOOT_PXE_FILE,
31+
# NETBOOT_ROOTFS_ARCHIVE (may be empty if
32+
# ROOTFS_COMPRESSION=none), plus BOARD/LINUXFAMILY/
33+
# BRANCH/RELEASE. Use it from userpatches to rsync
34+
# to a netboot server, unpack the rootfs archive,
35+
# etc. For builder-as-NFS-server workflows prefer
36+
# ROOTFS_EXPORT_DIR to skip the archive step.
37+
38+
function extension_prepare_config__netboot_defaults_and_validate() {
39+
declare -g NETBOOT_SERVER="${NETBOOT_SERVER:-}"
40+
declare -g NETBOOT_HOSTNAME="${NETBOOT_HOSTNAME:-}"
41+
declare -g NETBOOT_CLIENT_MAC="${NETBOOT_CLIENT_MAC:-}"
42+
declare -g NETBOOT_TFTP_PREFIX="${NETBOOT_TFTP_PREFIX:-armbian/${LINUXFAMILY}/${BOARD}/${BRANCH}-${RELEASE}}"
43+
44+
if [[ -n "${NETBOOT_HOSTNAME}" ]]; then
45+
declare -g NETBOOT_NFS_PATH="${NETBOOT_NFS_PATH:-/srv/netboot/rootfs/hosts/${NETBOOT_HOSTNAME}}"
46+
else
47+
declare -g NETBOOT_NFS_PATH="${NETBOOT_NFS_PATH:-/srv/netboot/rootfs/shared/${LINUXFAMILY}/${BOARD}/${BRANCH}-${RELEASE}}"
48+
fi
49+
50+
if [[ -n "${NETBOOT_CLIENT_MAC}" ]]; then
51+
declare -g NETBOOT_CLIENT_MAC_NORMALIZED="${NETBOOT_CLIENT_MAC//:/-}"
52+
NETBOOT_CLIENT_MAC_NORMALIZED="${NETBOOT_CLIENT_MAC_NORMALIZED,,}"
53+
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
54+
exit_with_error "${EXTENSION}: NETBOOT_CLIENT_MAC must look like aa:bb:cc:dd:ee:ff (got '${NETBOOT_CLIENT_MAC}')"
55+
fi
56+
fi
57+
58+
# Fail-fast on bad ROOTFS_COMPRESSION/ROOTFS_EXPORT_DIR combos before debootstrap,
59+
# not hours later in create_image_from_sdcard_rootfs.
60+
case "${ROOTFS_COMPRESSION:-gzip}" in
61+
gzip | zstd | none) ;;
62+
*) exit_with_error "${EXTENSION}: unknown ROOTFS_COMPRESSION: '${ROOTFS_COMPRESSION}' (expected: gzip|zstd|none)" ;;
63+
esac
64+
if [[ "${ROOTFS_COMPRESSION:-gzip}" == "none" && -z "${ROOTFS_EXPORT_DIR}" ]]; then
65+
exit_with_error "${EXTENSION}: ROOTFS_COMPRESSION=none requires ROOTFS_EXPORT_DIR (otherwise nothing is produced)"
66+
fi
67+
}
68+
69+
# Ensure NFS-root client support is built into the kernel.
70+
function custom_kernel_config__netboot_enable_nfs_root() {
71+
opts_y+=("ROOT_NFS" "NFS_FS" "NFS_V3" "IP_PNP" "IP_PNP_DHCP")
72+
}
73+
74+
# armbian-resize-filesystem tries to grow the root fs on first boot via resize2fs.
75+
# On an NFS-mounted root that's always meaningless (and would error) — strip the
76+
# systemd enablement symlink so the unit never runs.
77+
function post_customize_image__netboot_disable_resize_filesystem() {
78+
[[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0
79+
display_alert "${EXTENSION}: disabling armbian-resize-filesystem.service" "meaningless on NFS root" "info"
80+
run_host_command_logged find "${SDCARD}/etc/systemd/system/" \
81+
-name "armbian-resize-filesystem.service" -type l -delete
82+
}
83+
84+
# /etc/profile.d/armbian-check-first-login.sh launches the armbian-firstlogin
85+
# whiptail wizard (root password → user → locale …) when /root/.not_logged_in_yet
86+
# exists. On netboot there is often no interactive console on first boot, so the
87+
# wizard blocks the whole bring-up. Drop the trigger flag; default root/1234 login
88+
# keeps working, and armbian-firstrun.service still regenerates SSH host keys.
89+
function post_customize_image__netboot_skip_firstlogin_wizard() {
90+
[[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0
91+
[[ -f "${SDCARD}/root/.not_logged_in_yet" ]] || return 0
92+
display_alert "${EXTENSION}: skipping armbian-firstlogin wizard" "no interactive console assumed on netboot" "info"
93+
run_host_command_logged rm -f "${SDCARD}/root/.not_logged_in_yet"
94+
}
95+
96+
# ROOTFS_EXPORT_DIR must be visible inside the build container at the same path the
97+
# in-container rsync writes to — otherwise data lands in the container's private
98+
# filesystem and disappears on umount. Two cases:
99+
# 1) Path already under ${SRC}: core already bind-mounts ${SRC} at
100+
# ${DOCKER_ARMBIAN_TARGET_PATH} (/armbian) inside the container, so the data
101+
# path IS host-visible — but the env var still holds the host path, which
102+
# does not exist in the container. Translate the env var to the container
103+
# path so rsync writes into the bind-mounted volume.
104+
# 2) Path outside ${SRC}: add an explicit bind-mount at the same path.
105+
function host_pre_docker_launch__netboot_mount_export_dir() {
106+
[[ -z "${ROOTFS_EXPORT_DIR}" ]] && return 0
107+
if [[ "${ROOTFS_EXPORT_DIR}" == "${SRC}"* ]]; then
108+
declare container_export_dir="${DOCKER_ARMBIAN_TARGET_PATH:-/armbian}${ROOTFS_EXPORT_DIR#"${SRC}"}"
109+
display_alert "${EXTENSION}: translating ROOTFS_EXPORT_DIR for container" "${ROOTFS_EXPORT_DIR} -> ${container_export_dir}" "info"
110+
mkdir -p "${ROOTFS_EXPORT_DIR}"
111+
DOCKER_EXTRA_ARGS+=("--env" "ROOTFS_EXPORT_DIR=${container_export_dir}")
112+
return 0
113+
fi
114+
mkdir -p "${ROOTFS_EXPORT_DIR}"
115+
display_alert "${EXTENSION}: bind-mounting ROOTFS_EXPORT_DIR into container" "${ROOTFS_EXPORT_DIR}" "info"
116+
DOCKER_EXTRA_ARGS+=("--mount" "type=bind,source=${ROOTFS_EXPORT_DIR},target=${ROOTFS_EXPORT_DIR}")
117+
}
118+
119+
function pre_umount_final_image__900_collect_netboot_artifacts() {
120+
[[ "${ROOTFS_TYPE}" == "nfs-root" ]] || return 0
121+
122+
# shellcheck disable=SC2154 # ${version} is a readonly global set in create_image_from_sdcard_rootfs
123+
declare tftp_out="${DEST}/images/${version}-netboot-tftp"
124+
declare tftp_prefix_dir="${tftp_out}/${NETBOOT_TFTP_PREFIX}"
125+
declare pxe_dir="${tftp_out}/pxelinux.cfg"
126+
run_host_command_logged mkdir -pv "${tftp_prefix_dir}/dtb" "${pxe_dir}"
127+
128+
# Kernel image: arm64 uses Image, armv7 uses zImage. Preserve source basename
129+
# so U-Boot `booti`/`bootz` still picks the right path via image header.
130+
declare kernel_src="" kernel_name=""
131+
if [[ -f "${MOUNT}/boot/Image" ]]; then
132+
kernel_src="${MOUNT}/boot/Image"
133+
kernel_name="Image"
134+
elif [[ -f "${MOUNT}/boot/zImage" ]]; then
135+
kernel_src="${MOUNT}/boot/zImage"
136+
kernel_name="zImage"
137+
elif [[ -f "${MOUNT}/boot/vmlinuz-${IMAGE_INSTALLED_KERNEL_VERSION}" ]]; then
138+
kernel_src="${MOUNT}/boot/vmlinuz-${IMAGE_INSTALLED_KERNEL_VERSION}"
139+
# vmlinuz-* is a generic bzImage/Image; prefer Image for arm64, zImage otherwise
140+
[[ "${ARCH}" == "arm64" ]] && kernel_name="Image" || kernel_name="zImage"
141+
fi
142+
[[ -n "${kernel_src}" ]] || exit_with_error "${EXTENSION}: kernel image not found under ${MOUNT}/boot"
143+
run_host_command_logged cp -v "${kernel_src}" "${tftp_prefix_dir}/${kernel_name}"
144+
145+
if [[ -d "${MOUNT}/boot/dtb" ]]; then
146+
run_host_command_logged cp -a "${MOUNT}/boot/dtb/." "${tftp_prefix_dir}/dtb/"
147+
fi
148+
149+
declare initrd_line=""
150+
if [[ -f "${MOUNT}/boot/uInitrd" ]]; then
151+
run_host_command_logged cp -v "${MOUNT}/boot/uInitrd" "${tftp_prefix_dir}/uInitrd"
152+
initrd_line="INITRD ${NETBOOT_TFTP_PREFIX}/uInitrd"
153+
fi
154+
155+
# When NETBOOT_SERVER is empty, leave ${serverip} literal in nfsroot= so
156+
# U-Boot expands it at `pxe boot` time from DHCP siaddr (path 2).
157+
declare nfsroot_server="${NETBOOT_SERVER:-\${serverip\}}"
158+
159+
# Intentionally no `console=` in APPEND: hardcoding a baud (e.g. 115200)
160+
# breaks boards like helios64 which run at 1500000. Kernel resolves console
161+
# from DTB `/chosen/stdout-path`; `earlycon` keeps the early output.
162+
163+
# BOOT_FDT_FILE is not set for every board (e.g. helios64) — U-Boot then
164+
# resolves DTB via its own ${fdtfile} env. FDTDIR handles both cases.
165+
declare fdt_line
166+
if [[ -n "${BOOT_FDT_FILE}" && "${BOOT_FDT_FILE}" != "none" ]]; then
167+
fdt_line="FDT ${NETBOOT_TFTP_PREFIX}/dtb/${BOOT_FDT_FILE}"
168+
else
169+
fdt_line="FDTDIR ${NETBOOT_TFTP_PREFIX}/dtb"
170+
fi
171+
172+
# Per-MAC override wins over `default` in U-Boot `pxe get`. Multiple boards
173+
# can share one TFTP root with distinct `01-<mac>` files.
174+
declare pxe_file
175+
if [[ -n "${NETBOOT_CLIENT_MAC_NORMALIZED}" ]]; then
176+
pxe_file="01-${NETBOOT_CLIENT_MAC_NORMALIZED}"
177+
else
178+
pxe_file="default.example"
179+
fi
180+
181+
cat > "${pxe_dir}/${pxe_file}" <<- EXTLINUX_CONF
182+
# Generated by ${EXTENSION} for ${BOARD} ${BRANCH} ${RELEASE}
183+
# Target NFS path: ${NETBOOT_NFS_PATH}
184+
DEFAULT armbian
185+
TIMEOUT 30
186+
PROMPT 0
187+
188+
LABEL armbian
189+
MENU LABEL Armbian ${BOARD} ${BRANCH} ${RELEASE} (netboot)
190+
KERNEL ${NETBOOT_TFTP_PREFIX}/${kernel_name}
191+
${fdt_line}${initrd_line:+
192+
${initrd_line}}
193+
APPEND root=/dev/nfs nfsroot=${nfsroot_server}:${NETBOOT_NFS_PATH},tcp,v3 ip=dhcp rw rootwait earlycon loglevel=7 panic=10
194+
EXTLINUX_CONF
195+
196+
display_alert "${EXTENSION}: artifacts ready" "${tftp_out}" "info"
197+
display_alert "${EXTENSION}: TFTP payload" "${NETBOOT_TFTP_PREFIX}/ (${kernel_name}, dtb/, uInitrd)" "info"
198+
display_alert "${EXTENSION}: PXE config" "pxelinux.cfg/${pxe_file}" "info"
199+
display_alert "${EXTENSION}: target NFS path" "${NETBOOT_NFS_PATH}" "info"
200+
201+
# Expose context to the deploy hook. rootfs.tgz is built by the NFS ROOTFS_TYPE
202+
# path earlier in the pipeline; its path follows the same ${version} naming.
203+
declare -g NETBOOT_TFTP_OUT="${tftp_out}"
204+
declare -g NETBOOT_PXE_FILE="${pxe_file}"
205+
# ROOTFS_ARCHIVE_PATH is set by create_image_from_sdcard_rootfs after the archive
206+
# is produced (honours ROOTFS_COMPRESSION=gzip|zstd). Empty when ROOTFS_COMPRESSION=none.
207+
declare -g NETBOOT_ROOTFS_ARCHIVE="${ROOTFS_ARCHIVE_PATH:-}"
208+
209+
call_extension_method "netboot_artifacts_ready" <<- 'NETBOOT_HOOK_DOC'
210+
*called after netboot TFTP tree and rootfs are staged*
211+
Implementations can rsync ${NETBOOT_TFTP_OUT} to a TFTP server, extract
212+
${NETBOOT_ROOTFS_ARCHIVE} into ${NETBOOT_NFS_PATH} on an NFS server, etc.
213+
When the build host IS the NFS server, prefer ROOTFS_EXPORT_DIR (skips
214+
the archive step and writes straight into the export path).
215+
Exposed context: NETBOOT_TFTP_OUT, NETBOOT_TFTP_PREFIX, NETBOOT_PXE_FILE,
216+
NETBOOT_NFS_PATH, NETBOOT_ROOTFS_ARCHIVE (may be empty), NETBOOT_HOSTNAME,
217+
NETBOOT_CLIENT_MAC, plus BOARD, LINUXFAMILY, BRANCH, RELEASE.
218+
NETBOOT_HOOK_DOC
219+
}

0 commit comments

Comments
 (0)