Files
forgefirm/forgetest/tests/test_image.py
T
ScottW514 0d63f8740e pack-licenses.sh: the image's license step refuses a manifest without the project's recipes; the layer's interfaces file starts no DHCPv6 client
The host proofs of image.license-bundle's pack half and of
image.network-boot's interfaces check. No catalog test is removed here;
suite/image.py is unchanged.

pack-licenses.sh, next to forgefirm-image.bb, is the license step.
pack_licenses calls it with the common-licenses directory and the
output tar path (forgefirm-image-dev.bb requires the release recipe, so
both images run the one call), and do_rootfs's file-checksums name it.
Before it writes the tar or removes the tree, it exits non-zero with a
message on stderr when the directory is missing, when the manifest is
missing, or when the manifest lacks any of the newline-terminated lines
"RECIPE NAME: forgectrl", "RECIPE NAME: grblhal-glowforge" and "RECIPE
NAME: kernel-module-glowforge". On success it packs as before: tar
--sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner, gzip -9 -n,
the member common-licenses/license.manifest, and the loose tree removed
once the bundle holds the manifest.

test_image.py's PackLicensesTests drives that script on a fixture: a
missing directory, a missing manifest, and a manifest missing each one
of the three lines are refused with a message on stderr and no tar, the
tree left; a manifest naming all three packs, the tar holds
common-licenses/license.manifest, and the tree is gone.

NetworkBootTests reads the BSP layer's interfaces file from the
meta-openglow checkout beside forgefirm's (forgetest-ci checks out its
scarthgap branch there) and finds no line that starts a DHCPv6 client.
A missing file fails the case.

meta-forgefirm's content hash moved (forgefirm-image.bb and the new
script are layer content, and the platform identity of
forgefirm-image-manifest.bbclass leaves out only *.md, *-pin.inc and
forgefirm-release.inc): the next image cannot inherit a campaign.

Proof: python3 -B -m unittest discover -s tests -p test_image.py on
Linux (Ubuntu 24.04, GNU tar 1.35, meta-openglow at f012d8e): 14 OK, the
five new cases among them; pyflakes names no undefined name.
2026-10-14 21:42:12 -04:00

238 lines
11 KiB
Python

# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
import os
import shutil
import subprocess
import tarfile
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")
# The BSP layer's interfaces file, from the meta-openglow checkout beside
# forgefirm's (forgetest-ci checks out its scarthgap branch there).
BSP_INTERFACES = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))),
"meta-openglow", "meta-openglow-core", "recipes-core", "init-ifupdown", "init-ifupdown", "interfaces")
# 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"
"iface wlan0 inet dhcp\n"
"\twpa-driver nl80211\n"
"\tudhcpc_opts -x hostname:$(hostname)\n")
def test_ifstate_names_the_interfaces_ifup_finished(self):
self.assertEqual(image.ifstate_names("lo=lo\nwlan0=wlan0\n"), {"lo", "wlan0"})
self.assertEqual(image.ifstate_names("lo=lo\n"), {"lo"})
self.assertEqual(image.ifstate_names(""), set())
self.assertEqual(image.ifstate_names(None), set())
def test_an_interfaces_file_without_a_dhcpv6_client_is_clean(self):
self.assertEqual(image.dhcp6_in_interfaces(self.STANZA), [])
self.assertEqual(image.dhcp6_in_interfaces(None), [])
def test_a_command_that_starts_a_dhcpv6_client_is_named(self):
text = self.STANZA + ("iface wlan0 inet6 manual\n"
"\tup udhcpc6 -i wlan0 -b -S -p /run/udhcpc6.wlan0.pid\n")
self.assertEqual(image.dhcp6_in_interfaces(text),
["up udhcpc6 -i wlan0 -b -S -p /run/udhcpc6.wlan0.pid"])
detached = "\tup start-stop-daemon -S -b -x /usr/bin/udhcpc6 -- -f -i wlan0\n"
self.assertEqual(len(image.dhcp6_in_interfaces(self.STANZA + detached)), 1)
def test_an_inet6_method_that_runs_a_client_is_named(self):
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet6 dhcp\n"),
["iface wlan0 inet6 dhcp"])
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet6 static\n"), [])
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet dhcp\n"), [])
def test_a_comment_starts_nothing(self):
text = self.STANZA + "# No DHCPv6 client runs; udhcpc6 is not on the image.\n"
self.assertEqual(image.dhcp6_in_interfaces(text), [])
def test_the_layer_interfaces_file_starts_no_dhcpv6_client(self):
self.assertTrue(os.path.isfile(BSP_INTERFACES),
"%s is missing: meta-openglow is checked out beside forgefirm" % BSP_INTERFACES)
with open(BSP_INTERFACES, encoding="utf-8") as f:
self.assertEqual(image.dhcp6_in_interfaces(f.read()), [])
def test_parse_stat_reads_the_parent_past_an_awkward_comm(self):
self.assertEqual(image.parse_stat("371 (udhcpc) S 1 371 371 0 -1 4194624"),
(371, "udhcpc", 1))
self.assertEqual(image.parse_stat("9 (a (b) c) S 7 9 9 0"), (9, "a (b) c", 7))
self.assertIsNone(image.parse_stat(""))
self.assertIsNone(image.parse_stat("12 (x) S"))
def test_link_addrs_sorts_one_interface_by_scope(self):
text = ("fe800000000000002e6b7dfffe0db00a 02 40 20 80 wlan0\n"
"26001700000000000000000000001234 02 80 00 00 wlan0\n"
"00000000000000000000000000000001 01 80 10 80 lo\n")
local, world = image.link_addrs(text, "wlan0")
self.assertEqual(local, ["fe800000000000002e6b7dfffe0db00a"])
self.assertEqual(world, ["26001700000000000000000000001234"])
self.assertEqual(image.link_addrs(text, "eth0"), ([], []))
class PackLicensesTests(unittest.TestCase):
"""pack-licenses.sh, the image's license step, on a fixture tree."""
RECIPES = ("forgectrl", "grblhal-glowforge", "kernel-module-glowforge")
def setUp(self):
self.root = tempfile.mkdtemp(prefix="forgetest-lic.")
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
self.tree = os.path.join(self.root, "common-licenses")
self.manifest = os.path.join(self.tree, "license.manifest")
self.out = os.path.join(self.root, "forgefirm", "licenses.tar.gz")
def make_tree(self, recipes=None):
"""The tree, with a manifest naming `recipes` (None: no manifest)."""
os.makedirs(os.path.join(self.tree, "generic_MIT"))
with open(os.path.join(self.tree, "generic_MIT", "generic_MIT"), "w") as f:
f.write("MIT License\n")
if recipes is not None:
with open(self.manifest, "w") as f:
for r in recipes:
f.write("PACKAGE NAME: %s\nPACKAGE VERSION: 1.0\nRECIPE NAME: %s\nLICENSE: MIT\n\n" % (r, r))
def pack(self):
return subprocess.run(["sh", os.path.join(LAYER, "images", "pack-licenses.sh"), self.tree, self.out],
capture_output=True, text=True, timeout=60)
def assertRefused(self, p):
self.assertNotEqual(p.returncode, 0, p.stdout)
self.assertTrue(p.stderr.strip(), "a refusal with no message on stderr")
self.assertFalse(os.path.exists(self.out), "a refusal wrote the tar")
def test_a_missing_tree_is_refused(self):
self.assertRefused(self.pack())
def test_a_tree_with_no_manifest_is_refused_and_left(self):
self.make_tree()
self.assertRefused(self.pack())
self.assertTrue(os.path.isdir(self.tree))
def test_a_manifest_that_leaves_out_a_recipe_is_refused_and_left(self):
for left_out in self.RECIPES:
with self.subTest(left_out=left_out):
shutil.rmtree(self.tree, ignore_errors=True)
self.make_tree([r for r in self.RECIPES if r != left_out])
self.assertRefused(self.pack())
self.assertTrue(os.path.isfile(self.manifest))
def test_a_manifest_that_names_all_three_is_packed_and_the_tree_removed(self):
self.make_tree(self.RECIPES)
p = self.pack()
self.assertEqual(p.returncode, 0, p.stderr)
with tarfile.open(self.out, "r:gz") as t:
self.assertIn("common-licenses/license.manifest", t.getnames())
self.assertFalse(os.path.exists(self.tree))
if __name__ == "__main__":
unittest.main()