From 754c239e08cd05c07ba8cae6f635e9ac12397c83 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Wed, 14 Oct 2026 21:03:33 -0400 Subject: [PATCH] test_image: the hostname and the banner scripts, run over a scratch tree MachineNameTests runs the layer's own forgefirm-hostname and forgefirm-banner in private mount, UTS and network namespaces: /run a fresh tmpfs, a scratch directory over /sys/class/net, /etc a read-only overlay with the scratch hostname and issue over the host's (so each script makes its copy under /run/forgefirm and writes through its own bind mount, as at boot). unshare -Urmun where user namespaces are open, sudo -n unshare -mun otherwise; skipped only where neither makes a namespace. - the name: wlan0's last four hex digits (forgefirm-12ab with eth0 beside it), eth0's with no wlan0, eth0's with a zero wlan0 MAC; the live name and /etc/hostname agree. - the banner: the image's text, then the heading and one URL per global address of wlan0 (a veth carrying 192.0.2.5, 2001:db8::5 and fe80::5): https://192.0.2.5/ and https://[2001:db8::5]/, the link-local address left out, no marker line, no .local name. Proof: test_image.py under Linux by both namespace routes (9 tests OK), and on Windows with the two skipped. Mutants of scratch copies of the scripts, each failing: eth0 read before wlan0, a zero MAC taken, the file written without the script's bind mount, a marker line in the banner, a .local URL, the link-local address kept. setup.machine-name stays: the DHCP client's -x hostname and no mDNS responder on the image are image content. Nothing under meta-forgefirm changed. --- forgetest/tests/test_image.py | 106 ++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/forgetest/tests/test_image.py b/forgetest/tests/test_image.py index a501a6b..5d07a8a 100644 --- a/forgetest/tests/test_image.py +++ b/forgetest/tests/test_image.py @@ -3,10 +3,116 @@ # https://community.openglow.org # SPDX-License-Identifier: MIT +import os +import shutil +import subprocess +import tempfile import unittest from forgetest.suite import image +# The layer's scripts, when the tests run from a checkout of forgefirm. +LAYER = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "meta-forgefirm", "recipes-forgefirm") + +# A layer script run as the image runs it, in private mount, UTS and +# network namespaces: /run a fresh tmpfs, the scratch tree's net directory +# over /sys/class/net, and /etc read-only with the scratch tree's files +# over the host's (an overlay with no upper layer, as the image's rootfs is +# read-only), so the script makes its own copy under /run/forgefirm and +# writes through its own bind mount. With "net", wlan0 is a veth carrying a +# global IPv4 and IPv6 address and a link-local one. Exit 97: the +# namespace could not be set up. +NAMESPACE = """ +T=$1; SCRIPT=$2 +mount --make-rprivate / && mount -t tmpfs tmpfs /run && + mount --bind "$T/net" /sys/class/net && + mount -t overlay overlay -o "lowerdir=$T/etc:/etc" /etc || exit 97 +if [ "$3" = net ]; then + ip link add wlan0 type veth peer name vpeer0 && + ip addr add 192.0.2.5/24 dev wlan0 && + ip -6 addr add 2001:db8::5/64 dev wlan0 nodad && + ip -6 addr add fe80::5/64 dev wlan0 nodad && + ip link set wlan0 up || exit 97 +fi +sh "$SCRIPT" +echo "rc=$?" +echo "name=$(hostname)" +echo "file=$(cat /etc/hostname)" +echo "--- issue" +cat /etc/issue +""" + + +def run_layer_script(script, macs, etc, net=False): + """Run a layer script over a scratch tree: macs {interface: address or + None for a directory with no address file}, etc {file: text}. Returns + the namespace's output, or None when no namespace can be made here + (unprivileged where user namespaces are open, else root via sudo -n).""" + tree = tempfile.mkdtemp(prefix="forgetest-net.") + try: + for dev, mac in macs.items(): + os.makedirs(os.path.join(tree, "net", dev)) + if mac is not None: + with open(os.path.join(tree, "net", dev, "address"), "w") as f: + f.write(mac + "\n") + os.makedirs(os.path.join(tree, "net"), exist_ok=True) + os.makedirs(os.path.join(tree, "etc")) + for name, text in etc.items(): + with open(os.path.join(tree, "etc", name), "w") as f: + f.write(text) + args = ["sh", "-c", NAMESPACE, "sh", tree, os.path.join(LAYER, script), "net" if net else ""] + setup = [] + for cmd in (["unshare", "-Urmun"], ["sudo", "-n", "unshare", "-mun"]): + try: + p = subprocess.run(cmd + args, capture_output=True, text=True, timeout=60) + except (OSError, subprocess.TimeoutExpired): + continue + if "rc=" in p.stdout: + return p.stdout + if p.returncode == 97: + setup.append(p.stderr.strip()) + if setup: + raise AssertionError("the namespace was made but not set up: %s" % setup) + return None + finally: + shutil.rmtree(tree, ignore_errors=True) + + +def field(out, key): + return [ln[len(key) + 1:] for ln in out.splitlines() if ln.startswith(key + "=")][0] + + +@unittest.skipUnless(os.path.isdir(LAYER) and shutil.which("unshare"), "needs Linux and a forgefirm checkout") +class MachineNameTests(unittest.TestCase): + """forgefirm-hostname and forgefirm-banner, the layer's own files.""" + + def named(self, macs): + out = run_layer_script("forgefirm-hostname/files/forgefirm-hostname", macs, {"hostname": "forgefirm\n"}) + if out is None: + self.skipTest("no namespace can be made here") + self.assertEqual(field(out, "rc"), "0", out) + self.assertEqual(field(out, "file"), field(out, "name"), out) + return field(out, "name") + + def test_the_name_is_built_from_the_first_interface_with_an_address(self): + self.assertEqual(self.named({"wlan0": "02:00:00:00:12:AB", "eth0": "02:00:00:00:34:cd"}), "forgefirm-12ab") + self.assertEqual(self.named({"eth0": "02:00:00:00:34:cd"}), "forgefirm-34cd") + self.assertEqual(self.named({"wlan0": "00:00:00:00:00:00", "eth0": "02:00:00:00:34:cd"}), "forgefirm-34cd") + + def test_the_banner_adds_the_global_addresses_and_nothing_else(self): + base = "ForgeFIRM\n" + out = run_layer_script("forgefirm-banner/files/forgefirm-banner", {"wlan0": "02:00:00:00:12:ab"}, + {"issue": base}, net=True) + if out is None: + self.skipTest("no namespace can be made here") + self.assertEqual(field(out, "rc"), "0", out) + issue = out.split("--- issue\n", 1)[1] + self.assertTrue(issue.startswith(base + "\n"), issue) + heading, *urls = issue[len(base) + 1:].strip("\n").split("\n") + self.assertEqual(urls, [" https://192.0.2.5/", " https://[2001:db8::5]/"], issue) + self.assertFalse(heading.startswith("#") or ".local" in issue, issue) + class NetworkBootTests(unittest.TestCase): STANZA = ("auto wlan0\n"