From 0d63f8740e4a1c2183a2aa57686436f602cb9510 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Wed, 14 Oct 2026 21:42:12 -0400 Subject: [PATCH] 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. --- forgetest/tests/test_image.py | 68 +++++++++++++++++++ .../images/forgefirm-image.bb | 18 ++--- .../recipes-forgefirm/images/pack-licenses.sh | 39 +++++++++++ 3 files changed, 116 insertions(+), 9 deletions(-) create mode 100755 meta-forgefirm/recipes-forgefirm/images/pack-licenses.sh diff --git a/forgetest/tests/test_image.py b/forgetest/tests/test_image.py index 5d07a8a..55ee8e1 100644 --- a/forgetest/tests/test_image.py +++ b/forgetest/tests/test_image.py @@ -6,6 +6,7 @@ import os import shutil import subprocess +import tarfile import tempfile import unittest @@ -14,6 +15,11 @@ 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 @@ -148,6 +154,12 @@ class NetworkBootTests(unittest.TestCase): 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)) @@ -165,5 +177,61 @@ class NetworkBootTests(unittest.TestCase): 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() diff --git a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb index 18c76cb..8e5c8ec 100644 --- a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb +++ b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb @@ -216,18 +216,18 @@ ROOTFS_POSTPROCESS_COMMAND += "write_forgefirm_version " # control panel serves the bundle and its manifest (GET /system/licenses, # GET /system/licenses/manifest). The license class runs first # (license_create_manifest is prepended to this list); this step is -# appended, so it runs after. +# appended, so it runs after. pack-licenses.sh, next to this recipe, is +# the step: it refuses a tree with no manifest, and a manifest that does +# not name forgectrl, grblhal-glowforge and kernel-module-glowforge. COPY_LIC_MANIFEST = "1" COPY_LIC_DIRS = "1" +PACK_LICENSES := "${THISDIR}/pack-licenses.sh" +do_rootfs[file-checksums] += "${PACK_LICENSES}:True" + pack_licenses() { - d="${IMAGE_ROOTFS}${datadir}/common-licenses" - [ -d "$d" ] || bbfatal "pack_licenses: $d is missing (COPY_LIC_DIRS off?)" - [ -f "$d/license.manifest" ] || bbfatal "pack_licenses: no license.manifest in $d" - install -d "${IMAGE_ROOTFS}${datadir}/forgefirm" - tar -C "${IMAGE_ROOTFS}${datadir}" --sort=name --mtime=@0 --owner=0 --group=0 \ - --numeric-owner -cf - common-licenses | gzip -9 -n \ - > "${IMAGE_ROOTFS}${datadir}/forgefirm/licenses.tar.gz" - rm -rf "$d" + sh "${PACK_LICENSES}" "${IMAGE_ROOTFS}${datadir}/common-licenses" \ + "${IMAGE_ROOTFS}${datadir}/forgefirm/licenses.tar.gz" \ + || bbfatal "pack_licenses: pack-licenses.sh refused the license tree (its message is above)" } ROOTFS_POSTPROCESS_COMMAND += "pack_licenses " diff --git a/meta-forgefirm/recipes-forgefirm/images/pack-licenses.sh b/meta-forgefirm/recipes-forgefirm/images/pack-licenses.sh new file mode 100755 index 0000000..e9acf4b --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/images/pack-licenses.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# Copyright 2026 514 LLC d/b/a OpenGlow +# Written by Scott Wiederhold +# https://community.openglow.org +# SPDX-License-Identifier: MIT +# +# pack-licenses.sh +# +# Packs the image's license tree into one reproducible tar.gz (sorted +# names, no timestamps, no owners), its members under the tree's own +# name, and removes the tree. It refuses, before it writes the tar or +# removes anything, a tree that is missing, a tree with no +# license.manifest, and a manifest that does not name the project's own +# recipes: forgectrl, grblhal-glowforge and kernel-module-glowforge. +# forgefirm-image.bb's pack_licenses runs it; forgetest's test_image.py +# runs it on a fixture. + +dir=${1:?common-licenses directory} +out=${2:?output tar.gz} +fail() { echo "pack-licenses: $*" >&2; exit 1; } + +[ -d "$dir" ] || fail "$dir is missing (COPY_LIC_DIRS off?)" +manifest=$dir/license.manifest +[ -f "$manifest" ] || fail "no license.manifest in $dir" +# A line counts with its newline: an unterminated last line is not read. +lines=$(wc -l < "$manifest") +for name in forgectrl grblhal-glowforge kernel-module-glowforge; do + head -n "$lines" "$manifest" | grep -Fxq "RECIPE NAME: $name" \ + || fail "$manifest does not name the recipe $name" +done + +base=$(basename "$dir") +mkdir -p "$(dirname "$out")" || fail "cannot make the directory of $out" +tar -C "$(dirname "$dir")" --sort=name --mtime=@0 --owner=0 --group=0 \ + --numeric-owner -cf - "$base" | gzip -9 -n > "$out" || fail "cannot write $out" +# the tree goes only once the bundle holds its manifest +tar -tzf "$out" "$base/license.manifest" > /dev/null 2>&1 \ + || fail "$out holds no $base/license.manifest" +rm -rf "$dir"