Mount the rootfs read-only on both images

The rootfs mounted read-write, so a slot ran with its own files open to
change, and the factory-slot mounts rode along on the release image.
Both images now carry the read-only-rootfs feature: the ro root line and
the rcS default, the volatile links made at rootfs time, a writable copy
of /var/lib at boot, a build failure for a post-install that needs the
machine, and the removal of shadow, base-passwd, update-rc.d and
update-alternatives.

What must last or change at run time is handled file by file:

- forgefirm-users renders the four account files from the record into
  /run/forgefirm/accounts and bind-mounts each copy over its /etc file
  (useradd and the rest are gone with shadow); a render writes through
  the mount, and the image's own files apply until the first render.
- forgefirm-banner bind-mounts a copy of /etc/issue and writes the
  address block through it.
- sshd keeps its host keys under /data/forgefirm/ssh, so the fingerprint
  survives updates; both sshd configs carry the same HostKey lines.
- forgefirm-logging passes logrotate a state file under /var/run
  (logrotate refuses to run without one).
- forgefirm-persist points the boot timestamp and the random seed at
  /data/forgefirm.

The dev image appends the /factory slot mounts, without nofail (busybox
mount hands it to the kernel, which rejects it). The rootfs command
entries lose their semicolons: on scarthgap the value is the task's
vardeps, split on whitespace, so "name;" left the function body out of
the signature and a changed body did not remake the rootfs; with the
bodies tracked, the dev image's DATETIME string needs a vardepsexclude.
release.sh gains the read-only gate (root ro, no /factory line,
ROOTFS_READ_ONLY=yes, host keys on /data). image.health checks the
mounts, the account binds, the banner bind, the host keys and the
dev-only /factory mounts.

Proven on the bench reference (dev image 20260909140901): / ro, /data
rw, /var/lib a tmpfs copy, the four account files and /etc/issue bound
from tmpfs, the host keys in /data/forgefirm/ssh, no "Read-only file
system" line in any log; forgectrl.auth and commission.account-login (a
temporary account rendered, logged in over HTTPS and removed again),
kernel.latch-locked-idle and motion.liveness-probe PASS; logrotate runs
with the volatile state. forgetest unit tests 335 OK; both images build
clean, and debugfs on the built rootfs shows every setting above.
This commit is contained in:
ScottW514
2026-09-09 11:02:10 -04:00
parent 2936890eaa
commit 8af8b197ee
14 changed files with 309 additions and 79 deletions
+52 -2
View File
@@ -106,7 +106,9 @@ def fds_of(pid):
description="The image that is running is the image the manifest describes, with the "
"kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
"the daemon ownership, "
"the init ordering, and the file modes the release depends on.")
"the init ordering, the file modes the release depends on, and the mounts: the "
"rootfs read-only, /data writable, the account files and the banner rendered "
"into tmpfs, the sshd host keys on /data, the factory slots on the dev image only.")
def image_health(ctx):
ev = ctx.evidence
manifest = ctx.runner.manifest
@@ -247,7 +249,55 @@ def image_health(ctx):
ctx.log("/data free: %d MiB", free_mb)
ctx.check(free_mb >= 20, "/data has only %d MiB free", free_mb)
# 8. the manifest itself is coherent
# 8. the mounts: the rootfs read-only, /data the writable partition,
# the state a read-only rootfs hands off (the read-only-rootfs image
# feature, forgefirm-users, forgefirm-banner, the sshd host keys). The
# dev image alone mounts the factory slots under /factory.
mounts = {}
for line in (_read("/proc/mounts", "") or "").splitlines():
parts = line.split()
if len(parts) >= 4:
mounts[parts[1]] = {"source": parts[0], "type": parts[2], "opts": parts[3].split(",")}
def mount_opts(path):
return (mounts.get(path) or {}).get("opts") or []
ev["mounts"] = {p: mounts[p] for p in ("/", "/data", "/var/lib", "/etc/passwd", "/etc/issue") if p in mounts}
ctx.log("/ mounted %s; /data %s; /var/lib %s", ",".join(mount_opts("/")) or "(absent)",
",".join(mount_opts("/data")) or "(absent)", ",".join(mount_opts("/var/lib")) or "(absent)")
ctx.check("ro" in mount_opts("/"), "the rootfs is not mounted read-only: %s", mounts.get("/"))
ctx.check("rw" in mount_opts("/data"), "/data is not mounted read-write: %s", mounts.get("/data"))
ctx.check("rw" in mount_opts("/var/lib"),
"/var/lib is not a writable copy (read-only-rootfs-hook.sh): %s", mounts.get("/var/lib"))
rcs = _read("/etc/default/rcS", "") or ""
ctx.check("ROOTFS_READ_ONLY=yes" in rcs.splitlines(), "/etc/default/rcS lacks ROOTFS_READ_ONLY=yes")
factory = sorted(p for p in mounts if p.startswith("/factory/"))
ev["factory_mounts"] = factory
dev_image = os.path.exists("/etc/forgefirm-dev")
ev["dev_image"] = dev_image
ctx.log("dev image %s; /factory mounts: %s", dev_image, factory or "none")
if dev_image:
for n in (1, 2):
if os.path.exists("/dev/mmcblk2p%d" % n):
p = "/factory/img%d" % n
ctx.check("ro" in mount_opts(p), "%s is not mounted read-only on the dev image: %s", p, mounts.get(p))
else:
ctx.check(not factory, "a release image mounts the factory slots: %s", factory)
if os.path.isfile("/data/forgefirm/users"):
names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines()
if l.strip() and not l.startswith("#")]
passwd_names = {l.split(":")[0] for l in (_read("/etc/passwd", "") or "").splitlines()}
ev["record_accounts"] = names
for f in ("/etc/passwd", "/etc/shadow", "/etc/group"):
ctx.check(f in mounts, "%s is not the tmpfs render of the account record", f)
for n in names:
ctx.check(n in passwd_names, "record account %r is missing from /etc/passwd", n)
ctx.check("/etc/issue" in mounts, "/etc/issue is not the bind-mounted banner copy")
if hw.pidof("sshd"):
key = "/data/forgefirm/ssh/ssh_host_ed25519_key"
ctx.check(os.path.isfile(key), "sshd runs but %s is missing", key)
# 9. the manifest itself is coherent
ctx.check(manifest.content_sha and len(manifest.content_sha) == 64, "manifest content_sha256 missing")
ctx.check("kernel-module-glowforge" in manifest.components, "manifest lacks kernel-module-glowforge")
ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry")