Catalog: setup.machine-name leaves for the rootfs lint and test_image.py

Its checks, and what holds each now:
- the name is forgefirm-<the last four hex digits of the MAC>, wlan0
  first and eth0 on a machine with no WiFi, and the console banner
  offers the addresses and nothing else: test_image.py's
  MachineNameTests run forgefirm-hostname and forgefirm-banner as the
  image runs them, on a scratch /sys/class/net;
- /etc/hostname is the bind-mounted copy: image.health;
- the DHCP request sends the name, and no mDNS responder is on the
  image: the rootfs lint's dhcp-name and mdns rules (08c7df9), which
  image 20261015142442 ran in do_rootfs and which passed campaign
  c-20261015143634-68f0, 48 of 48.
It covered no component path, so no HOST_TESTED row. Its helpers
mac_suffix and cmdlines had no other caller and go with it, with
test_setup_suite's cases of them and of its registration; setup.py's re
and lan_ip imports went with them.

Fingerprints: every test in setup.py moves with the module text.

Proof: the coverage lint over the tree manifest from the pins finds 0
uncovered paths and 0 host-tested row problems (47 tests); pyflakes on
the touched files is clean; forgetest's unit suite under Linux passes
(329).
This commit is contained in:
ScottW514
2026-10-15 11:28:25 -04:00
parent 08c7df9cbb
commit c40dc6c24c
2 changed files with 2 additions and 134 deletions
-87
View File
@@ -28,7 +28,6 @@ import http.client
import json import json
import os import os
import random import random
import re
import socket import socket
import ssl import ssl
import time import time
@@ -36,7 +35,6 @@ import urllib.parse
from ..catalog import test from ..catalog import test
from .. import hw from .. import hw
from .forgectrl import lan_ip
RECORD = "setup.json" RECORD = "setup.json"
USERS = "users" USERS = "users"
@@ -535,88 +533,3 @@ def login_checks(ctx, name, pw):
w = decode(body) w = decode(body)
ctx.check(isinstance(w, dict) and w.get("session") is False, "the session survives the logout") ctx.check(isinstance(w, dict) and w.get("session") is False, "the session survives the logout")
ctx.log("session set by the login, seen by GET /wiz, ended by the logout") ctx.log("session set by the login, seen by GET /wiz, ended by the logout")
# --------------------------------------------------- the machine's name
def mac_suffix():
"""The last four hex digits of the MAC address the hostname is built
from: wlan0, or eth0 on a machine with no WiFi."""
for dev in ("wlan0", "eth0"):
raw = read_file("/sys/class/net/%s/address" % dev)
mac = (raw or b"").decode("ascii", "replace").strip().replace(":", "").lower()
if mac and mac != "0" * 12:
return mac[-4:]
return ""
def cmdlines():
"""The command line of every process on the machine, one string
each."""
out = []
for name in os.listdir("/proc"):
if not name.isdigit():
continue
raw = read_file("/proc/%s/cmdline" % name)
if raw:
out.append(raw.decode("utf-8", "replace").replace("\0", " ").strip())
return out
@test("setup.machine-name", title="The machine names itself from its MAC address",
subsystem="setup", kind="auto", est_min=1,
requires=["forgectrl.auth"],
description="The machine calls itself forgefirm-<xxxx>, where xxxx is the last four hex "
"digits of its WiFi MAC address, so two machines on one network answer to "
"different names and no name carries a serial number. The live name, "
"/etc/hostname (a bind-mounted copy, because the rootfs is read-only) and the "
"MAC address agree; the DHCP client sends the name as the hostname option, "
"which is what a network with dynamic DNS publishes; the console banner offers "
"the machine's addresses and nothing else, with no marker line and no .local "
"name; and no mDNS responder is on the image. No component covers this: the "
"hostname and banner scripts are layer content, in the platform identity of "
"every fingerprint.")
def machine_name(ctx):
ev = ctx.evidence
# 1. the name, the file and the MAC address agree
suffix = mac_suffix()
ev["mac_suffix"] = suffix
ctx.check(suffix, "no MAC address to build a name from")
live = socket.gethostname()
on_file = ((read_file("/etc/hostname") or b"").decode("utf-8", "replace")).strip()
ev["hostname"] = live
ev["hostname_file"] = on_file
ctx.log("hostname %r (MAC suffix %s)", live, suffix or "none")
ctx.check(live == "forgefirm-" + suffix,
"the hostname is %r, expected forgefirm-%s", live, suffix)
ctx.check(on_file == live, "/etc/hostname holds %r, the live name is %r", on_file, live)
# 2. the DHCP client sends it (option 12), so dynamic DNS can publish it
dhcp = [c for c in cmdlines() if c.split(" ")[0].split("/")[-1] == "udhcpc"]
ev["udhcpc"] = dhcp
ctx.check(dhcp, "no DHCP client is running")
ctx.check(any("-x hostname:" + live in c for c in dhcp),
"the DHCP client does not send the machine's name: %s", dhcp)
# 3. the console banner: the addresses, and nothing that is not the banner
issue = (read_file("/etc/issue") or b"").decode("utf-8", "replace")
ev["issue"] = issue
ip = lan_ip()
ev["lan_ip"] = ip
ctx.check(ip, "cannot determine the board's LAN address")
ctx.check("Control panel:" in issue, "the console banner names no control panel")
ctx.check("https://%s/" % ip in issue,
"the console banner does not carry the LAN address %s", ip)
for junk in ("# end", "# ForgeFIRM addresses", ".local"):
ctx.check(junk not in issue, "the console banner shows %r", junk)
urls = re.findall(r"https://(\S+)/", issue)
ev["banner_urls"] = urls
for u in urls:
literal = u.startswith("[") or all(c.isdigit() or c == "." for c in u)
ctx.check(literal, "the console banner offers %r, which is not an address", u)
# 4. no mDNS on the image; the name is the only name the machine has
ev["avahi_pids"] = hw.pidof("avahi-daemon")
ctx.check(not ev["avahi_pids"], "an mDNS responder is running: %s", ev["avahi_pids"])
ctx.check(not os.path.exists("/etc/avahi"), "/etc/avahi is on the image")
+2 -47
View File
@@ -5,8 +5,7 @@
"""The setup.* suite on the host: the registration (ids, kinds, the """The setup.* suite on the host: the registration (ids, kinds, the
takeover tests, the operator tests' hands), the record builders, the takeover tests, the operator tests' hands), the record builders, the
machine's name, the cookie parsing, and the settle rule for a gated cookie parsing, and the settle rule for a gated supervisor."""
supervisor."""
import json import json
import os import os
import shutil import shutil
@@ -19,7 +18,7 @@ import helpers # noqa: E402
from forgetest import baseline, catalog # noqa: E402 from forgetest import baseline, catalog # noqa: E402
from forgetest.suite import setup # noqa: E402 from forgetest.suite import setup # noqa: E402
IDS = ("setup.account-login", "setup.machine-name") IDS = ("setup.account-login",)
class RegistrationTests(unittest.TestCase): class RegistrationTests(unittest.TestCase):
@@ -60,9 +59,6 @@ class RegistrationTests(unittest.TestCase):
and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock"))) and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
self.assertEqual(attended, ["setup.cloud-header-capture", "setup.focus"]) self.assertEqual(attended, ["setup.cloud-header-capture", "setup.focus"])
def test_the_machine_name_covers_nothing_by_design(self):
self.assertEqual(self.reg["setup.machine-name"].covers, ())
def test_the_login_test_makes_its_own_account(self): def test_the_login_test_makes_its_own_account(self):
# No bench credentials, no precheck: the test installs a temporary # No bench credentials, no precheck: the test installs a temporary
# account under a takeover and restores the real one. # account under a takeover and restores the real one.
@@ -170,47 +166,6 @@ class RecordTests(unittest.TestCase):
os.environ.pop("GF_RUN_DIR", None) os.environ.pop("GF_RUN_DIR", None)
class MachineNameTests(unittest.TestCase):
def setUp(self):
self.root = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
def _net(self, **devs):
"""A /sys/class/net tree; setup.mac_suffix reads it through
setup.read_file, which takes an absolute path."""
for dev, mac in devs.items():
d = os.path.join(self.root, dev)
os.makedirs(d)
with open(os.path.join(d, "address"), "w") as f:
f.write(mac + "\n")
real = setup.read_file
def read(path):
head = "/sys/class/net/"
if path.startswith(head):
return real(os.path.join(self.root, path[len(head):]))
return real(path)
setup.read_file = read
self.addCleanup(setattr, setup, "read_file", real)
def test_the_wifi_address_names_the_machine(self):
self._net(wlan0="2C:6B:7D:0D:B0:0A", eth0="00:11:22:33:44:55")
self.assertEqual(setup.mac_suffix(), "b00a")
def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self):
self._net(eth0="00:11:22:33:44:55")
self.assertEqual(setup.mac_suffix(), "4455")
def test_an_unread_address_is_no_address(self):
self._net(wlan0="00:00:00:00:00:00")
self.assertEqual(setup.mac_suffix(), "")
def test_no_interface_is_no_address(self):
self._net()
self.assertEqual(setup.mac_suffix(), "")
class SmallHelpersTests(unittest.TestCase): class SmallHelpersTests(unittest.TestCase):
def test_cookie_parsing(self): def test_cookie_parsing(self):
sid = "ab" * 32 sid = "ab" * 32