mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08b29fee01 | ||
|
|
f306c9983f | ||
|
|
dc7170d0d1 | ||
|
|
0ceb4abc4a | ||
|
|
aa628d684c | ||
|
|
29b00cc056 | ||
|
|
d482e76402 | ||
|
|
8232c8c9fe | ||
|
|
260f466400 | ||
|
|
cf7b51fb32 | ||
|
|
e2be3ca4c5 | ||
|
|
cd4c176a87 | ||
|
|
8850adcc5e | ||
|
|
f9f4de31c1 | ||
|
|
9ddab969e0 | ||
|
|
12e7d50746 | ||
|
|
b3f0cd54d9 | ||
|
|
fbac17a485 | ||
|
|
0eb764bf75 | ||
|
|
07158e5d7f | ||
|
|
091702c26f | ||
|
|
9a288b175b | ||
|
|
147393322f |
@@ -11,6 +11,7 @@ name: fixture-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'fixture/**'
|
||||
- '.github/workflows/fixture-ci.yml'
|
||||
|
||||
@@ -20,6 +20,7 @@ name: forgetest-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'forgetest/**'
|
||||
- 'scripts/acceptance-gate.py'
|
||||
|
||||
@@ -325,7 +325,11 @@ docstrings, log and console text, commit messages, issue text, and replies.
|
||||
comment style of the file: `Copyright <year> 514 LLC d/b/a OpenGlow` and
|
||||
`Written by Scott Wiederhold`. Keep the year, or the year range, that
|
||||
the file already has. The copyright notices of other holders stay
|
||||
unchanged.
|
||||
unchanged. Those two lines are for files this project authors: an
|
||||
outside contributor who writes a new file puts their own name there, or
|
||||
leaves the lines out, and keeps their copyright in what they wrote.
|
||||
Editing an existing file changes no header - a contribution is recorded
|
||||
by its `Signed-off-by` line and by git, not by a copyright line.
|
||||
- **SPDX.** Every new source file carries an `SPDX-License-Identifier` line
|
||||
under the license of its repository.
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Contributing
|
||||
|
||||
Patches are welcome. How the project works, what proof a change needs, and the
|
||||
writing rules are on the documentation site:
|
||||
<https://docs.forgefirm.org/developers/contributing/>.
|
||||
|
||||
## Sign your work
|
||||
|
||||
Every commit carries a `Signed-off-by` line:
|
||||
|
||||
Signed-off-by: Your Name <your.email@example.com>
|
||||
|
||||
`git commit -s` adds it, using your `user.name` and `user.email`. Use your real
|
||||
name; an anonymous or pseudonymous sign-off certifies nothing.
|
||||
|
||||
The line certifies the Developer Certificate of Origin, version 1.1, below. It
|
||||
is the same statement the Linux kernel and grblHAL use. It is not a copyright
|
||||
assignment: you keep the copyright in what you write, and your contribution
|
||||
comes in under the license of the repository you are contributing to.
|
||||
|
||||
You do not add a copyright line for a patch. A change to an existing file
|
||||
leaves its header alone. If you write a whole new file, put your own name in
|
||||
its copyright line, or leave the line out; either way the file carries an
|
||||
`SPDX-License-Identifier` for the repository's license.
|
||||
|
||||
## Developer Certificate of Origin 1.1
|
||||
|
||||
By making a contribution to this project, I certify that:
|
||||
|
||||
(a) The contribution was created in whole or in part by me and I
|
||||
have the right to submit it under the open source license
|
||||
indicated in the file; or
|
||||
|
||||
(b) The contribution is based upon previous work that, to the best
|
||||
of my knowledge, is covered under an appropriate open source
|
||||
license and I have the right under that license to submit that
|
||||
work with modifications, whether created in whole or in part
|
||||
by me, under the same open source license (unless I am
|
||||
permitted to submit under a different license), as indicated
|
||||
in the file; or
|
||||
|
||||
(c) The contribution was provided directly to me by some other
|
||||
person who certified (a), (b) or (c) and I have not modified
|
||||
it.
|
||||
|
||||
(d) I understand and agree that this project and the contribution
|
||||
are public and that a record of the contribution (including all
|
||||
personal information I submit with it, including my sign-off) is
|
||||
maintained indefinitely and may be redistributed consistent with
|
||||
this project or the open source license(s) involved.
|
||||
|
||||
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
|
||||
This DCO text is licensed under the terms of the Creative Commons
|
||||
Attribution-ShareAlike 3.0 Unported license.
|
||||
@@ -5,5 +5,14 @@ the associated recipe (.bb file) unless otherwise stated.
|
||||
See:
|
||||
COPYING.MIT (MIT)
|
||||
|
||||
The code in this repository that is not layer metadata is MIT licensed:
|
||||
|
||||
forgetest/ the release acceptance tool (see forgetest/LICENSE)
|
||||
scripts/ the release, install and bench scripts
|
||||
fixture/ the bench actuator firmware, excluding
|
||||
fixture/managed_components/, which carries its own licenses
|
||||
|
||||
Every file in those directories states MIT in its own header.
|
||||
|
||||
License information for any other files is either explicitly stated
|
||||
or defaults to GPL version 2.
|
||||
|
||||
@@ -83,4 +83,5 @@ before you install.
|
||||
**This is experimental software. Use of it could seriously maim or kill you or
|
||||
others, and it may void your warranty. Use it at your own risk.**
|
||||
|
||||
This project is not affiliated with or endorsed by Glowforge.
|
||||
Glowforge is a trademark of Glowforge, Inc. This project is not affiliated
|
||||
with, authorized by, or endorsed by Glowforge, Inc.
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
# forgefixture: build, flash and watch the bench actuator's firmware.
|
||||
#
|
||||
# ./fixture.sh env fixture.env from the example, with a fresh API key
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
/* forgefixture firmware version, reported by GET /. */
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
# The host test of the fixture's policy: needs only a C compiler.
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
ForgeFIRM release acceptance tool (forgetest/) and the bench scripts it
|
||||
drives (scripts/bench/).
|
||||
|
||||
Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
Written by Scott Wiederhold
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished
|
||||
to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -1,4 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
### BEGIN INIT INFO
|
||||
# Provides: forgetest
|
||||
# Required-Start: $network forgectrl
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""forgetest - the ForgeFIRM release acceptance tool.
|
||||
|
||||
A daemon on the dev image (HTTP :8090) that runs the acceptance catalog
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""forgetest daemon entry point.
|
||||
|
||||
python3 -m forgetest [--port 8090] [--host ::] [--manifest PATH]
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The release artifact (acceptance.json / acceptance.md) and its verification.
|
||||
|
||||
Every test record is redacted before it enters the artifact (redact.py), so
|
||||
the committed file carries no identity of the machine that ran the campaign.
|
||||
It happens inside build(), ahead of the self-hash, because verify() rejects a
|
||||
file edited after the fact. The gate reads none of the redacted fields.
|
||||
|
||||
The exporter serializes the campaign state with, for every catalog test,
|
||||
the winning result record (same-campaign PASS or inherited PASS, with its
|
||||
origin) and the fingerprint it was recorded under. The gate
|
||||
@@ -15,6 +25,7 @@ from . import VERSION
|
||||
from . import campaign as _campaign
|
||||
from . import manifest as _manifest
|
||||
from .log import now_ts
|
||||
from .redact import scrub
|
||||
|
||||
FORMAT = 1
|
||||
_RESULT_KEYS = ("ts", "campaign", "test", "result", "fingerprint", "manifest_sha", "image",
|
||||
@@ -47,7 +58,7 @@ def build(state, tests, manifest, records, catalog_hash):
|
||||
"fingerprint": st["fingerprint"],
|
||||
"satisfied": st["satisfied"],
|
||||
"inherited": st["status"] == "inherited",
|
||||
"record": _record(rec) if rec else None,
|
||||
"record": scrub(_record(rec)) if rec else None,
|
||||
})
|
||||
tests_out.append(entry)
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The fresh-boot idle state: what every test starts from and leaves behind.
|
||||
|
||||
The runner checks the machine against this baseline before a run and
|
||||
@@ -48,7 +53,7 @@ from .log import now_ts
|
||||
# in the pulse path (a job's Z moves the lens; the driver's Z soft limit
|
||||
# guards it), step_freq its machine tick and ramp_rate the kernel stop
|
||||
# ramp, both derived from the XY microstep mode (the values here are the
|
||||
# x8 default's; fixed_sysfs() gives the mode's); streaming is only ever 1
|
||||
# x8 base; fixed_sysfs() gives the mode's); streaming is only ever 1
|
||||
# inside a live job; the head white LED is a camera lamp, off at idle; the
|
||||
# loop heater and TEC are the diagnostics' tools, off at idle.
|
||||
FIXED_SYSFS = [
|
||||
@@ -133,6 +138,7 @@ BOOT_MAX_AGE_S = 600 # a boot reference is taken only this soon a
|
||||
SETTLE_S = 150 # the supervisor's probe + rail-off ladder
|
||||
CAM_IDLE_S = 20 # camera engine idle stop is 10 s
|
||||
COOL_IDLE_S = 120 # cooldown after motion
|
||||
COOL_PUBLISH_S = 2.5 # the engine publishes once a tick (1 Hz): two ticks and a margin
|
||||
IDLE_S = 30 # cnc/state back to idle after a job
|
||||
GRBL_PORT_S = 30 # the Grbl port after the supervisor reports grblHAL running
|
||||
|
||||
@@ -147,11 +153,12 @@ RETURN_MAX_MM = 100.0 # a displaced head is jogged back at most th
|
||||
POSITION_DEADBAND_MM = 0.1
|
||||
|
||||
# The XY microstep mode (the xy_microsteps setting: 8, 16 or 32; unset =
|
||||
# 8). The GRBL controller reads it at its start and derives its scale,
|
||||
# 32). The GRBL controller reads it at its start and derives its scale,
|
||||
# its machine tick and the kernel stop ramp from it (boards/glowforge.h):
|
||||
# the fixed values of x/y_mode, step_freq and ramp_rate are the mode's.
|
||||
XY_MODES = (8, 16, 32)
|
||||
XY_MODE_DEFAULT = 8
|
||||
XY_MODE_BASE = 8 # the factory x8 reference the tick and ramp scale from
|
||||
XY_MODE_DEFAULT = 32 # the mode an unset or invalid setting reads as
|
||||
XY_STEPS_PER_MM_OF = {8: 53.333, 16: 106.667, 32: 213.333} # $100/$101 per mode
|
||||
XY_TICK_X8_HZ = 28160 # the x8 machine tick
|
||||
XY_RAMP_X8_HZ_PER_S = 125000 # the kernel stop ramp at it
|
||||
@@ -175,7 +182,7 @@ def fixed_sysfs(mode=XY_MODE_DEFAULT):
|
||||
"""FIXED_SYSFS with the mode's own x/y_mode, step_freq and ramp_rate:
|
||||
the tick and the ramp scale with the mode (x16 doubles both, x32
|
||||
quadruples them)."""
|
||||
k = mode // XY_MODE_DEFAULT
|
||||
k = mode // XY_MODE_BASE
|
||||
own = {"cnc/x_mode": str(mode), "cnc/y_mode": str(mode),
|
||||
"cnc/step_freq": str(XY_TICK_X8_HZ * k),
|
||||
"cnc/ramp_rate": str(XY_RAMP_X8_HZ_PER_S * k)}
|
||||
@@ -607,36 +614,60 @@ class Baseline:
|
||||
except OSError as e:
|
||||
act = "failed: %s" % e
|
||||
left.append(Leftover("laser_locked", False, True, act))
|
||||
# cooling engine idle, unarmed
|
||||
self._cool_side(left)
|
||||
|
||||
def _cool_side(self, left):
|
||||
"""The cooling engine idle, unarmed, holding nothing."""
|
||||
st, c = self.fc_get("/cool/status")
|
||||
if st == 200 and isinstance(c, dict):
|
||||
if c.get("phase") != "idle" or c.get("armed") or c.get("hold"):
|
||||
if st != 200 or not isinstance(c, dict):
|
||||
return
|
||||
if c.get("phase") == "idle" and not c.get("armed") and not c.get("hold"):
|
||||
return
|
||||
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
|
||||
idle = lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") # noqa: E731
|
||||
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
|
||||
# The engine publishes once a tick (1 Hz). A read inside the tick
|
||||
# after a run ended still shows the run: a diagnostic's hold until
|
||||
# the engine has the hardware back, a fail tier's hold until the
|
||||
# session ends with the controller. What the engine's next tick
|
||||
# clears was never left behind, so an arm or a hold is read again
|
||||
# past that tick before it is called the run's doing. One that a
|
||||
# run did leave stands for a job that is never coming back, and it
|
||||
# is still there.
|
||||
if c.get("armed") or c.get("hold"):
|
||||
free = lambda: (lambda x: bool(x) and not x.get("armed") # noqa: E731
|
||||
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
|
||||
if self._wait("cool publish", free, COOL_PUBLISH_S) is not None:
|
||||
st, c2 = self.fc_get("/cool/status")
|
||||
c = c2 if st == 200 and isinstance(c2, dict) else {}
|
||||
self.log("cool: %s was the engine's last word on the run; its next tick reads "
|
||||
"%s/armed=%s/hold=%s" % (found, c.get("phase"), c.get("armed"), c.get("hold")))
|
||||
if c.get("phase") == "idle":
|
||||
return
|
||||
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
|
||||
idle = lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") # noqa: E731
|
||||
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
|
||||
# Armed, or holding, is the run's doing. A phase alone is
|
||||
# not: the engine clears smoke at run duty after an armed
|
||||
# session and cools down after a hot one, both timed and
|
||||
# both ending on their own. Waiting for that is right;
|
||||
# calling it a leftover is not, because nothing was left -
|
||||
# the machine was still finishing.
|
||||
dirt = bool(c.get("armed") or c.get("hold"))
|
||||
w = self._wait("cool idle", idle, COOL_IDLE_S)
|
||||
if w is not None and not dirt:
|
||||
self.log("cool: %s ended on its own after %.0f s; the engine's own post-job "
|
||||
"work, not a leftover" % (found, w))
|
||||
else:
|
||||
# The engine holds for a job. A run that ended without
|
||||
# ending its job leaves one alive, and no amount of
|
||||
# waiting ends it: stand the machine down and let the
|
||||
# supervisor bring the controller back clean.
|
||||
act = "waited"
|
||||
if w is None:
|
||||
self.stand_down("the cooling engine is still %s" % found)
|
||||
w = self._wait("cool idle", idle, COOL_IDLE_S)
|
||||
act = "restored (stood the machine down)"
|
||||
left.append(Leftover("cool", found, "idle/unarmed/no hold",
|
||||
act if w is not None else "failed: still %s" % found))
|
||||
# Armed, or holding, is the run's doing. A phase alone is
|
||||
# not: the engine clears smoke at run duty after an armed
|
||||
# session and cools down after a hot one, both timed and
|
||||
# both ending on their own. Waiting for that is right;
|
||||
# calling it a leftover is not, because nothing was left -
|
||||
# the machine was still finishing.
|
||||
dirt = bool(c.get("armed") or c.get("hold"))
|
||||
w = self._wait("cool idle", idle, COOL_IDLE_S)
|
||||
if w is not None and not dirt:
|
||||
self.log("cool: %s ended on its own after %.0f s; the engine's own post-job "
|
||||
"work, not a leftover" % (found, w))
|
||||
return
|
||||
# The engine holds for a job. A run that ended without
|
||||
# ending its job leaves one alive, and no amount of
|
||||
# waiting ends it: stand the machine down and let the
|
||||
# supervisor bring the controller back clean.
|
||||
act = "waited"
|
||||
if w is None:
|
||||
self.stand_down("the cooling engine is still %s" % found)
|
||||
w = self._wait("cool idle", idle, COOL_IDLE_S)
|
||||
act = "restored (stood the machine down)"
|
||||
left.append(Leftover("cool", found, "idle/unarmed/no hold",
|
||||
act if w is not None else "failed: still %s" % found))
|
||||
|
||||
def _lamp_side(self, left):
|
||||
"""The lid lamp at forgectrl's idle level (the lid_lamp_idle setting).
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The bench diagnostics page: registry of the bench tools and the
|
||||
subprocess runner behind the #bench tab.
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Campaign rules: which results apply, what is required, and whether a
|
||||
release is authorized.
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The acceptance catalog: test definitions and the registry.
|
||||
|
||||
A test is a function decorated with @test(...). The decorator records
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Coverage lint: every source path in the manifest must be selected by
|
||||
some test's coverage globs, except the allowlisted non-behavioral paths,
|
||||
and every coverage glob must select at least one path of its component.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The bench actuator (forgefixture): the box on the bench network that
|
||||
opens the lid loop, pulls the interlock loop and presses the button on
|
||||
request, so an operator test can run with nobody in the room.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Hardware and service access for the suite: forgectrl's HTTP API, the
|
||||
kernel module's sysfs, the init scripts, and the Grbl TCP port.
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The append-only result log (JSONL) and the export helpers.
|
||||
|
||||
One JSON object per line under the data directory (default
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The image manifest and the domain fingerprint.
|
||||
|
||||
/etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass)
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The single page: acceptance tab + bench tab, assembled from ui/ into
|
||||
one self-contained response (no external assets). Bootstrap and the
|
||||
OpenGlow theme are inlined; theme.css and the vendor/ files are the same
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Pulse files for the offline cloud service: a job the machine will run
|
||||
without the service having cut it.
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Redaction for the release artifact.
|
||||
|
||||
The artifact is committed to a public repository, so it carries no
|
||||
identity of the machine that ran the campaign. This applies the same two
|
||||
layers forgectrl's log export applies (forgectrl/src/sanitize.c), in the
|
||||
same order, to every value the exporter writes:
|
||||
|
||||
1. Known keys: a value held under a key that names identifying data -
|
||||
the serial, the machine id, the hostname, the wireless credentials,
|
||||
a token - is replaced whole. Naming the key is far more reliable
|
||||
than guessing the shape of what is under it.
|
||||
2. Pattern classes over every remaining string: MAC addresses, IPv4 and
|
||||
IPv6 addresses (loopback and unspecified kept), e-mail addresses,
|
||||
bearer and basic credentials, JWTs, and long hex blobs. Each distinct
|
||||
value in a class keeps a stable number for the life of one export, so
|
||||
"<IP-2>" is the same host wherever it appears and the artifact stays
|
||||
readable as evidence.
|
||||
|
||||
Integrity values are never touched: the self-hash, the manifest and
|
||||
catalog hashes, each test's source hash and fingerprint. Those are what
|
||||
the gate recomputes, and a long hex blob is exactly what they look like.
|
||||
|
||||
Over-redaction is accepted where the alternative is a leak.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Layer 1: the value under one of these keys is replaced whole.
|
||||
REDACT_KEYS = frozenset((
|
||||
"gf_serial", "machine_id", "serial", "serial_number",
|
||||
"hostname", "hostname_file", "host",
|
||||
"ssid", "psk", "passphrase", "password", "passwd",
|
||||
"token", "panel_token", "secret", "api_key", "key",
|
||||
"mac", "macaddr", "mac_suffix", "wlan0", "eth0",
|
||||
"gf_username", "gf_password", "username", "user", "email",
|
||||
))
|
||||
|
||||
# Never touched, by key: the gate recomputes these and they look like
|
||||
# exactly what layer 2 hunts for.
|
||||
KEEP_KEYS = frozenset((
|
||||
"sha256", "manifest_sha", "identity_sha", "catalog_hash",
|
||||
"source_sha", "fingerprint", "content_sha", "srcrev",
|
||||
))
|
||||
|
||||
_KEEP_IP = frozenset(("0.0.0.0", "255.255.255.255"))
|
||||
|
||||
_CLASSES = (
|
||||
("SECRET", re.compile(r"(?i)\b(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}")),
|
||||
("JWT", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")),
|
||||
("EMAIL", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
|
||||
("MAC", re.compile(r"\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b")),
|
||||
# Three or more colons, or a "::" run: two colons is a clock time, and
|
||||
# tagging every timestamp would make the evidence unreadable.
|
||||
("IP6", re.compile(
|
||||
r"(?:[0-9A-Fa-f]{1,4}:){3,7}[0-9A-Fa-f]{1,4}"
|
||||
r"|[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{1,4})*::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?")),
|
||||
("IP", re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")),
|
||||
("HEX", re.compile(r"\b[0-9A-Fa-f]{32,}\b")),
|
||||
)
|
||||
|
||||
|
||||
class Redactor:
|
||||
"""Stable per-export numbering, one instance per artifact."""
|
||||
|
||||
# A known value shorter than this is too generic to replace blind.
|
||||
MIN_KNOWN = 4
|
||||
|
||||
def __init__(self):
|
||||
self._n = {}
|
||||
self._known = {}
|
||||
|
||||
def learn(self, obj, key=None):
|
||||
"""Collect the values held under the layer-1 keys, so they are
|
||||
replaced wherever else they appear - a hostname in a log line is
|
||||
the same leak as a hostname in its own field."""
|
||||
if key is not None and key in KEEP_KEYS:
|
||||
return
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
self.learn(v, k)
|
||||
elif isinstance(obj, list):
|
||||
for v in obj:
|
||||
self.learn(v)
|
||||
elif key in REDACT_KEYS and isinstance(obj, str) and len(obj) >= self.MIN_KNOWN:
|
||||
if obj not in self._known:
|
||||
self._known[obj] = self._tag("REDACTED", "%s=%s" % (key, obj))
|
||||
|
||||
def _tag(self, cls, value):
|
||||
seen = self._n.setdefault(cls, {})
|
||||
if value not in seen:
|
||||
seen[value] = len(seen) + 1
|
||||
return "<%s-%d>" % (cls, seen[value])
|
||||
|
||||
def text(self, s):
|
||||
for known in sorted(self._known, key=len, reverse=True):
|
||||
if known in s:
|
||||
s = s.replace(known, self._known[known])
|
||||
for cls, rx in _CLASSES:
|
||||
def sub(m, cls=cls):
|
||||
v = m.group(0)
|
||||
if cls == "IP" and (v.startswith("127.") or v in _KEEP_IP):
|
||||
return v
|
||||
if cls == "IP6" and v in ("::", "::1"):
|
||||
return v
|
||||
return self._tag(cls, v)
|
||||
s = rx.sub(sub, s)
|
||||
return s
|
||||
|
||||
def value(self, v, key=None):
|
||||
if key is not None and key in KEEP_KEYS:
|
||||
return v
|
||||
if key is not None and key in REDACT_KEYS and not isinstance(v, (dict, list)):
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, str) and v in self._known:
|
||||
return self._known[v]
|
||||
return self._tag("REDACTED", "%s=%s" % (key, v))
|
||||
if isinstance(v, dict):
|
||||
return {k: self.value(x, k) for k, x in v.items()}
|
||||
if isinstance(v, list):
|
||||
return [self.value(x) for x in v]
|
||||
if isinstance(v, str):
|
||||
return self.text(v)
|
||||
return v
|
||||
|
||||
|
||||
def scrub(obj):
|
||||
"""Redact a value tree with one export's numbering."""
|
||||
r = Redactor()
|
||||
r.learn(obj)
|
||||
return r.value(obj)
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The runner: executes one acceptance test or one bench tool at a time.
|
||||
|
||||
Acceptance tests run in a worker thread with a Context: log lines, an
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""HTTP: the page, the JSON API, and the access rules.
|
||||
|
||||
Access follows forgectrl's panel (auth.c): the Host header must be an
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The acceptance suite: one module per subsystem, imported in display
|
||||
order. Each module registers its tests with @catalog.test."""
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""camera.* - the lid camera pipeline through forgectrl."""
|
||||
import json
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""cloud.* - the controller mode switch and the optional Glowforge web-service
|
||||
mode (gfcloud daemon, gfhome homing runner). The mode-switch test makes the
|
||||
grbl -> cloud -> grbl round trip with the connect-time hunt run lid-open and
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""cooling.* - the cooling engine: flow verification through forgectrl's
|
||||
diagnostics runner (the same check the fire gate runs), the fan profile
|
||||
returning to idle after motion, the gate settings (a value inside the
|
||||
@@ -5,6 +10,7 @@ legal range trips the gate, the far end of the range turns it off by
|
||||
value, and both are said out loud: the settings reply, /status, the
|
||||
engine's run-start log line), and the airflow gates (a fan under its
|
||||
floor past the spin-up grace is a fault for the rest of the run)."""
|
||||
import os
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
@@ -1241,7 +1247,6 @@ LOAD_LINES = 27 # two 30 x 4 mm fills back to back, about 35
|
||||
LOAD_SHARE_MIN_C = 0.3 # the tube was lit inside the window
|
||||
LOAD_MARGIN_C = 1.0 # the judged rise this far under the limit
|
||||
LOAD_VERDICT_WAIT_S = 120 # the window opens ~15 s into the session and runs 50 s
|
||||
LOAD_LOG_LINES = "300"
|
||||
_LOAD_LINE_RX = None
|
||||
|
||||
|
||||
@@ -1265,9 +1270,19 @@ def _load_fill():
|
||||
return lines
|
||||
|
||||
|
||||
def _forgectrl_tail(fc):
|
||||
st, body = fc.get("/logs/tail", params={"name": "forgectrl", "lines": LOAD_LOG_LINES})
|
||||
return body.get("text", "") if st == 200 and isinstance(body, dict) else ""
|
||||
def _log_since(path, offset):
|
||||
"""The text a log gained since byte `offset`. A count of lines in a
|
||||
tail of fixed length cannot tell a new line from an old one: the new
|
||||
line comes in at the bottom as an old one leaves at the top. A file now
|
||||
shorter than the offset was rotated under the test, and all of it is
|
||||
newer."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
if os.fstat(f.fileno()).st_size >= offset:
|
||||
f.seek(offset)
|
||||
return f.read().decode("utf-8", "replace")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
@test("cooling.flow-under-load", title="The flow check reads true with the tube lit through its window",
|
||||
@@ -1285,6 +1300,7 @@ def _forgectrl_tail(fc):
|
||||
"the tube's share off and read its baseline as a mean.")
|
||||
def flow_under_load(ctx):
|
||||
from .laser import sample, prepare, LiveJob, ARM_CUE, run_and_sample, wait_disarm
|
||||
from .motion import FORGECTRL_LOG, _log_offset
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
state, check_s = _gate_state(fc, "cool_flow_check_s")
|
||||
@@ -1292,7 +1308,7 @@ def flow_under_load(ctx):
|
||||
ctx.check(state != "off" and check_s, "the flow check is off (cool_flow_check_s=%s)", check_s)
|
||||
ctx.check(limit, "cool_flow_rise unreadable")
|
||||
ev.update({"check_s": check_s, "limit_c": limit})
|
||||
before = _forgectrl_tail(fc).count("heater rise")
|
||||
off = _log_offset(FORGECTRL_LOG) # the verdict is a line written after this
|
||||
# The fill ends one line length out and (LOAD_LINES - 1) pitches up;
|
||||
# the job brings the head back so the baseline finds it where it began.
|
||||
back = "G0 X%g Y%g" % (-LOAD_WIDTH if LOAD_LINES % 2 else 0.0, -LOAD_PITCH * (LOAD_LINES - 1))
|
||||
@@ -1327,12 +1343,11 @@ def flow_under_load(ctx):
|
||||
ctx.checkpoint()
|
||||
if hw.sysfs_int("thermal/heater_pwm", 0) > 0:
|
||||
heater_seen = True
|
||||
text = _forgectrl_tail(fc)
|
||||
if text.count("heater rise") > before:
|
||||
m = None
|
||||
for m in _load_verdict_rx().finditer(text):
|
||||
pass
|
||||
line = m.group(0) if m else None
|
||||
m = None
|
||||
for m in _load_verdict_rx().finditer(_log_since(FORGECTRL_LOG, off)):
|
||||
pass
|
||||
if m:
|
||||
line = m.group(0)
|
||||
break
|
||||
ctx.sleep(1)
|
||||
ev["heater_seen"] = heater_seen
|
||||
@@ -1437,7 +1452,9 @@ def fan_duty_readback(ctx):
|
||||
"watch's thresholds at their lowest (1) make the head's own move trip the abort "
|
||||
"generator inside an armed, dark (S0) job. Expected: the engine logs the crash "
|
||||
"signal, the supervisor logs the stop and starts a new controller (new pid), the "
|
||||
"latch is locked, the kernel idle, no emission, and the thresholds are put back.")
|
||||
"latch is locked, the kernel idle, no emission, the crash fault ends with the run "
|
||||
"session (the engine's next tick after the controller is gone), and the "
|
||||
"thresholds are put back.")
|
||||
def fail_tier_stop(ctx):
|
||||
from .laser import prepare, stream, sample
|
||||
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
||||
@@ -1496,6 +1513,16 @@ def fail_tier_stop(ctx):
|
||||
ctx.check(ev["result"]["latch_locked"], "latch not locked after the crash tier")
|
||||
ctx.check(not (smp and smp["emission"]), "emission during a dark job: %s", smp and smp["emission"])
|
||||
ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the restart")
|
||||
# The crash fault is the run session's: the engine ends the session
|
||||
# on its next 1 Hz tick after the controller is gone, and the fault
|
||||
# and its hold go with it. The supervisor has the new controller up
|
||||
# inside that second, so a test that returned here would hand the
|
||||
# machine back with the fault it raised still standing.
|
||||
cleared = ctx.wait_for(lambda: _cool(fc).get("verdict") != "CRASH", 10)
|
||||
ev["result"]["fault_cleared_s"] = cleared
|
||||
ctx.log("the crash fault ended with the run session after %s s: %s", cleared,
|
||||
{k: _cool(fc).get(k) for k in ("phase", "verdict", "hold", "armed")})
|
||||
ctx.check(cleared is not None, "the crash fault did not end with the run session: %s", _cool(fc))
|
||||
finally:
|
||||
fc.wait_idle(30, abort=ctx.aborted)
|
||||
st, body = fc.post("/settings", params=orig)
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""image.* - the post-flash health of the running image (always-required)."""
|
||||
import glob
|
||||
import gzip
|
||||
import mmap
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import re
|
||||
import stat
|
||||
@@ -395,3 +401,140 @@ def license_bundle(ctx):
|
||||
text = bytes(body).decode("utf-8", "replace") if body else ""
|
||||
ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text,
|
||||
"the Licenses page lacks the download link or the manifest")
|
||||
|
||||
|
||||
# ------------------------------------------------------- the network boot
|
||||
|
||||
IFSTATE = "/var/run/ifstate" # busybox ifupdown's state file
|
||||
INTERFACES = "/etc/network/interfaces"
|
||||
DHCP6_CLIENTS = ("udhcpc6", "dhcp6c", "odhcp6c")
|
||||
|
||||
|
||||
def ifstate_names(text):
|
||||
"""The interfaces ifup finished configuring: one name=name line each
|
||||
in busybox's state file, written when ifup returns for the name."""
|
||||
return {line.split("=", 1)[0].strip() for line in (text or "").splitlines() if "=" in line}
|
||||
|
||||
|
||||
def dhcp6_in_interfaces(text):
|
||||
"""The lines of an interfaces file that start a DHCPv6 client: an
|
||||
inet6 stanza with a method that runs one, or a command that names
|
||||
one. A comment starts nothing."""
|
||||
bad = []
|
||||
for raw in (text or "").splitlines():
|
||||
words = raw.split("#", 1)[0].split()
|
||||
if not words:
|
||||
continue
|
||||
if words[0] == "iface":
|
||||
if len(words) >= 4 and words[2] == "inet6" and words[3] in ("dhcp", "auto"):
|
||||
bad.append(" ".join(words))
|
||||
elif any(w.split("/")[-1] in DHCP6_CLIENTS for w in words[1:]):
|
||||
bad.append(" ".join(words))
|
||||
return bad
|
||||
|
||||
|
||||
def parse_stat(text):
|
||||
"""(pid, comm, ppid) from a /proc/<pid>/stat line, or None. The comm
|
||||
sits in parentheses and may itself hold spaces and parentheses."""
|
||||
try:
|
||||
head, tail = text.split("(", 1)
|
||||
comm, rest = tail.rsplit(")", 1)
|
||||
return int(head), comm, int(rest.split()[1])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _procs():
|
||||
"""(pid, ppid, comm, command line) of every process."""
|
||||
out = []
|
||||
for name in os.listdir("/proc"):
|
||||
if not name.isdigit():
|
||||
continue
|
||||
st = parse_stat(_read("/proc/%s/stat" % name, ""))
|
||||
if st is None:
|
||||
continue
|
||||
cmd = (_read("/proc/%s/cmdline" % name, "") or "").replace("\0", " ").strip()
|
||||
out.append((st[0], st[2], st[1], cmd))
|
||||
return out
|
||||
|
||||
|
||||
def link_addrs(text, iface):
|
||||
"""(link-local, global) IPv6 addresses of one interface, as the hex
|
||||
strings of /proc/net/if_inet6 (address, index, prefix length, scope,
|
||||
flags, name; scope 20 is the link, 00 the world)."""
|
||||
local, world = [], []
|
||||
for line in (text or "").splitlines():
|
||||
f = line.split()
|
||||
if len(f) == 6 and f[5] == iface:
|
||||
if f[3] == "20":
|
||||
local.append(f[0])
|
||||
elif f[3] == "00":
|
||||
world.append(f[0])
|
||||
return local, world
|
||||
|
||||
|
||||
@test("image.network-boot", title="The boot does not wait on the network",
|
||||
subsystem="image", kind="auto", est_min=1,
|
||||
description="The network comes up without the boot waiting on a server. init starts the "
|
||||
"rest of the boot, the console login last, only after the networking script "
|
||||
"returns, so a client that waits in ifup's foreground on a server's answer "
|
||||
"holds the whole machine: no login, no SSH, no control panel. ifup has "
|
||||
"returned for wlan0 (it is in the state file and no ifup is running) and the "
|
||||
"console login is up; the DHCP client is the bounded one (-b, which leaves "
|
||||
"ifup after three unanswered discovers) and has left ifup; no DHCPv6 client "
|
||||
"is named in the interfaces file, running, or on the image; and IPv6 is the "
|
||||
"kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
|
||||
"address up. A global address is evidence only, because a network whose "
|
||||
"router advertisement offers no SLAAC prefix gives none. What a hostile "
|
||||
"server does to a client is a bench drill, not a test. No component covers "
|
||||
"this: the interfaces file is layer content, in the platform identity of "
|
||||
"every fingerprint.")
|
||||
def network_boot(ctx):
|
||||
ev = ctx.evidence
|
||||
procs = _procs()
|
||||
|
||||
# 1. ifup came back, and the boot went on to the console login
|
||||
state = ifstate_names(_read(IFSTATE, ""))
|
||||
ev["ifstate"] = sorted(state)
|
||||
ctx.check("wlan0" in state, "ifup has not finished wlan0: %s holds %s", IFSTATE, sorted(state))
|
||||
stuck = [p for p in procs if p[2] in ("ifup", "ifdown")]
|
||||
ev["ifup_running"] = [p[3] for p in stuck]
|
||||
ctx.check(not stuck, "ifup is still running: %s", [p[3] for p in stuck])
|
||||
logins = [p[3] for p in procs if p[2] == "getty"]
|
||||
ev["getty"] = logins
|
||||
ctx.check(any("ttymxc0" in c for c in logins), "no console login is running: %s", logins)
|
||||
|
||||
# 2. the DHCP client is the bounded one, and it has left ifup
|
||||
v4 = [p for p in procs if p[2] == "udhcpc"]
|
||||
ev["udhcpc"] = [p[3] for p in v4]
|
||||
ctx.check(v4, "no DHCP client is running")
|
||||
for pid, ppid, _comm, cmd in v4:
|
||||
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd)
|
||||
ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid)
|
||||
|
||||
# 3. no DHCPv6 client: not started, not running, not on the image
|
||||
text = _read(INTERFACES)
|
||||
ctx.check(text is not None, "%s is missing", INTERFACES)
|
||||
named = dhcp6_in_interfaces(text)
|
||||
ev["interfaces_dhcp6"] = named
|
||||
ctx.check(not named, "%s starts a DHCPv6 client: %s", INTERFACES, named)
|
||||
alive = [p[3] for p in procs if p[2] in DHCP6_CLIENTS]
|
||||
ev["dhcp6_running"] = alive
|
||||
ctx.check(not alive, "a DHCPv6 client is running: %s", alive)
|
||||
for name in DHCP6_CLIENTS:
|
||||
ctx.check(shutil.which(name) is None, "%s is on the image", name)
|
||||
ctx.check(not os.path.exists("/usr/share/udhcpc/default6.script"),
|
||||
"the DHCPv6 hook script is on the image")
|
||||
|
||||
# 4. IPv6 is the kernel's own
|
||||
conf = "/proc/sys/net/ipv6/conf/wlan0/"
|
||||
ev["disable_ipv6"] = (_read(conf + "disable_ipv6", "") or "").strip()
|
||||
ev["accept_ra"] = (_read(conf + "accept_ra", "") or "").strip()
|
||||
ctx.check(ev["disable_ipv6"] == "0", "IPv6 is off on wlan0 (disable_ipv6=%r)", ev["disable_ipv6"])
|
||||
ctx.check(ev["accept_ra"] not in ("", "0"),
|
||||
"wlan0 does not accept router advertisements (accept_ra=%r)", ev["accept_ra"])
|
||||
local, world = link_addrs(_read("/proc/net/if_inet6", ""), "wlan0")
|
||||
ev["link_local"] = local
|
||||
ev["global"] = world
|
||||
ctx.check(local, "wlan0 has no link-local IPv6 address")
|
||||
ctx.log("wlan0: %d link-local, %d global IPv6 address(es)", len(local), len(world))
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""kernel.* - glowforge.ko safety readbacks and the pulse-engine drills.
|
||||
|
||||
The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py`
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`.
|
||||
|
||||
Every test here can emit, except `laser.power-floor`, which only reads
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""logs.* - unified logging: the log tree, tail, and the sanitized export;
|
||||
the routing path (emitters, relays, rendered rules); the level settings."""
|
||||
import gzip
|
||||
@@ -22,14 +27,46 @@ def _read(path, default=None):
|
||||
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
|
||||
("forgectrl", "src/main.c")]
|
||||
|
||||
LOGS_ROOT = "/data/log/forgefirm"
|
||||
# What the export test plants in the installer's directory: a line to find
|
||||
# again, and two addresses (documentation ranges) the sanitizer must take.
|
||||
_PROBE_MARK = "forgetest export probe"
|
||||
_PROBE_MAC = "02:00:5e:10:20:30"
|
||||
_PROBE_IP = "192.0.2.77"
|
||||
|
||||
|
||||
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
|
||||
kind="auto", est_min=1,
|
||||
covers=_LOG_COVERS, requires=["forgectrl.auth"],
|
||||
description="/logs lists the loggers with their levels and files, /logs/tail returns the "
|
||||
"forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz "
|
||||
"bundle that contains neither the panel token nor the camera key.")
|
||||
"bundle that contains neither the panel token nor the camera key. The bundle "
|
||||
"also carries the installer's directory of the tree (logs/install/), which no "
|
||||
"logger feeds: a probe file planted there for the export comes back in the "
|
||||
"bundle with its addresses redacted, and an install log that the installer "
|
||||
"left is in the bundle too.")
|
||||
def tree_tail_export(ctx):
|
||||
install_dir = os.path.join(LOGS_ROOT, "install")
|
||||
probe = os.path.join(install_dir, "forgetest-probe.txt")
|
||||
made_dir = not os.path.isdir(install_dir)
|
||||
try:
|
||||
os.makedirs(install_dir, exist_ok=True)
|
||||
with open(probe, "w") as f:
|
||||
f.write("%s from %s at %s\n" % (_PROBE_MARK, _PROBE_MAC, _PROBE_IP))
|
||||
_tree_tail_export(ctx, probe)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(probe)
|
||||
except OSError:
|
||||
pass
|
||||
if made_dir:
|
||||
try:
|
||||
os.rmdir(install_dir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _tree_tail_export(ctx, probe):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
st, body = fc.get("/logs")
|
||||
@@ -82,6 +119,21 @@ def tree_tail_export(ctx):
|
||||
ev[what.replace(" ", "_") + "_leaks"] = leaked
|
||||
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
|
||||
|
||||
# the installer's directory: no logger feeds it, the export carries it
|
||||
by_name = dict(contents)
|
||||
want = "logs/install/" + os.path.basename(probe)
|
||||
got = [name for name in by_name if name.endswith(want)]
|
||||
ev["install_members"] = sorted(name for name in by_name if "/logs/install/" in name)
|
||||
ctx.check(got, "the bundle lacks %s: %s", want, ev["install_members"])
|
||||
if got:
|
||||
text = by_name[got[0]].decode("utf-8", "replace")
|
||||
ctx.check(_PROBE_MARK in text, "the probe file came back without its line: %r", text)
|
||||
for what, value in (("MAC address", _PROBE_MAC), ("IPv4 address", _PROBE_IP)):
|
||||
ctx.check(value not in text, "the install directory left the sanitizer with its %s", what)
|
||||
if os.path.isfile(os.path.join(os.path.dirname(probe), "install.log")):
|
||||
ctx.check(any(name.endswith("logs/install/install.log") for name in by_name),
|
||||
"the machine has an install log and the bundle does not")
|
||||
|
||||
|
||||
# The routing test proves the whole path every logger takes: emitter (or
|
||||
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
|
||||
@@ -95,8 +147,7 @@ _ROUTING_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("
|
||||
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
|
||||
("forgefirm-app", "forgefirm-app/gfhome.py")]
|
||||
|
||||
LOGS_ROOT = "/data/log/forgefirm"
|
||||
LOGGERS = ("forgectrl", "grblhal", "gfcloud", "gfhome", "kernel", "system")
|
||||
LOGGERS =("forgectrl", "grblhal", "gfcloud", "gfhome", "kernel", "system")
|
||||
_LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] "
|
||||
r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$")
|
||||
_SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""motion.* - the motion controller under grblHAL: dry motion, no emission.
|
||||
|
||||
Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""setup.* - the first-run setup: the record and the
|
||||
controller gate, the advisories, the account and its login, the HTTPS
|
||||
boundary, SSH, the cloud switch, the machine's name, and the factory
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""setup.check-* - the setup's checks (the dark wizards), driven the
|
||||
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
|
||||
the prompts answered from here (the bench fixture works the lid and the
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""setup.sheet - the setup's sheet (the live wizards) in one run,
|
||||
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
|
||||
polled, the prompts answered from here, each burn armed by a press, the
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""update.* - the A/B slot inventory and the firmware verification path."""
|
||||
import os
|
||||
import re
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Shared fixtures for the forgetest unit tests: synthetic manifests and
|
||||
catalog entries that never touch hardware."""
|
||||
import hashlib
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import copy
|
||||
import json
|
||||
import unittest
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The baseline: fixed resting values are restored, preserved values are
|
||||
handed back, every deviation is a recorded leftover. Runs against a fake
|
||||
sysfs tree; forgectrl is unreachable (service-side checks skip)."""
|
||||
@@ -10,6 +15,11 @@ import unittest
|
||||
|
||||
from forgetest import baseline
|
||||
|
||||
# The fixed machine tick at the mode an unset xy_microsteps reads as.
|
||||
# Taken from baseline rather than typed, so a change to the default mode
|
||||
# moves the expectations with it.
|
||||
DEFAULT_TICK = dict(baseline.fixed_sysfs())["cnc/step_freq"]
|
||||
|
||||
|
||||
class BaselineTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -21,8 +31,11 @@ class BaselineTests(unittest.TestCase):
|
||||
for name in baseline.BUTTON_LEDS + ("lid_led",):
|
||||
os.makedirs(self.leds + name)
|
||||
self._led(name, "0")
|
||||
# a clean machine
|
||||
for attr, val in baseline.FIXED_SYSFS + baseline.IDLE_READBACKS:
|
||||
# A clean machine at the mode an unset xy_microsteps reads as:
|
||||
# x/y_mode, step_freq and ramp_rate are the mode's, not the x8
|
||||
# literals in FIXED_SYSFS, and that is what enforce() compares
|
||||
# against.
|
||||
for attr, val in baseline.fixed_sysfs() + baseline.IDLE_READBACKS:
|
||||
self._attr(attr, val)
|
||||
self._attr("cnc/interlock_circuit", "45")
|
||||
self._attr("pic/lid_led", "0")
|
||||
@@ -90,7 +103,7 @@ class BaselineTests(unittest.TestCase):
|
||||
for x in left:
|
||||
self.assertEqual(x.action, "restored", str(x))
|
||||
self.assertEqual(self._read("cnc/motor_lock"), "0")
|
||||
self.assertEqual(self._read("cnc/step_freq"), "28160")
|
||||
self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
|
||||
self.assertEqual(self._read("cnc/streaming"), "0")
|
||||
self.assertEqual(items["cnc/motor_lock"].found, "15")
|
||||
self.assertEqual(items["cnc/motor_lock"].expected, "0")
|
||||
@@ -153,14 +166,16 @@ class BaselineTests(unittest.TestCase):
|
||||
dev.on_command = lambda line: self._pos(0, 0, 0) if line.startswith("$J=") else None
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
self._pos(221, 0, 0) # 4.144 mm into the held move
|
||||
# 4.144 mm into the held move, in the steps of the mode in force
|
||||
held_mm = 4.144
|
||||
self._pos(round(held_mm * baseline.xy_steps_per_mm(baseline.XY_MODE_DEFAULT)), 0, 0)
|
||||
left = b.enforce("post", captured=cap)
|
||||
items = {x.item: x for x in left}
|
||||
self.assertTrue(items["position"].action.startswith("restored"), items["position"].action)
|
||||
self.assertEqual(dev.sent[0], "^X")
|
||||
jogs = [l for l in dev.sent if l.startswith("$J=")]
|
||||
self.assertEqual(len(jogs), 1)
|
||||
self.assertIn("X-4.144", jogs[0])
|
||||
self.assertIn("X-%.3f" % held_mm, jogs[0])
|
||||
self.assertTrue(any("reset out of Hold:0" in l for l in self.lines), self.lines)
|
||||
finally:
|
||||
dev.stop()
|
||||
@@ -245,7 +260,7 @@ class BaselineTests(unittest.TestCase):
|
||||
def test_fixed_constants_checked_against_a_dump(self):
|
||||
ref = {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000"}}
|
||||
diffs = baseline.check_fixed_against(ref, self.lines.append)
|
||||
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=28160"])
|
||||
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=%s" % DEFAULT_TICK])
|
||||
|
||||
|
||||
# -- the reference is taken after the controller applied its config -----
|
||||
@@ -264,7 +279,8 @@ class BaselineTests(unittest.TestCase):
|
||||
def sleep(_s):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 3: # the controller's init writes land
|
||||
for attr, val in baseline.CONFIGURED_MARKERS:
|
||||
# at the mode wait_controller_configured watches by default
|
||||
for attr, val in baseline.configured_markers():
|
||||
self._attr(attr, val)
|
||||
ok = baseline.wait_controller_configured(
|
||||
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
|
||||
@@ -302,11 +318,15 @@ class BaselineTests(unittest.TestCase):
|
||||
def test_preconfig_reference_is_recognized(self):
|
||||
self.assertTrue(baseline.reference_preconfig(
|
||||
{"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000", "cnc/y_mode": "1"}}))
|
||||
# The markers are read at the reference's own mode, so a dump taken
|
||||
# on an x8 machine carries the setting that says so.
|
||||
self.assertFalse(baseline.reference_preconfig(
|
||||
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"}}))
|
||||
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"},
|
||||
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
|
||||
# a genuinely different single constant is a machine fact, not pre-config
|
||||
self.assertFalse(baseline.reference_preconfig(
|
||||
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"}}))
|
||||
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"},
|
||||
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
|
||||
self.assertFalse(baseline.reference_preconfig({"sysfs": {}}))
|
||||
|
||||
# -- the XY microstep mode drives the fixed values ------------------------
|
||||
@@ -325,16 +345,18 @@ class BaselineTests(unittest.TestCase):
|
||||
self.assertEqual(x32["cnc/x_decay"], x8["cnc/x_decay"])
|
||||
|
||||
def test_the_mode_is_read_from_the_settings_with_a_default(self):
|
||||
# An unset or unusable key reads as the driver's default, x32.
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "16"}), 16)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "32"}), 32)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 8)
|
||||
self.assertEqual(baseline.xy_mode_of({}), 8)
|
||||
self.assertEqual(baseline.xy_mode_of(None), 8)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 8)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 8)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "8"}), 8)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 32)
|
||||
self.assertEqual(baseline.xy_mode_of({}), 32)
|
||||
self.assertEqual(baseline.xy_mode_of(None), 32)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 32)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 32)
|
||||
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": {"xy_microsteps": "16"}}}), 16)
|
||||
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 8)
|
||||
self.assertEqual(baseline.ref_xy_mode({}), 8)
|
||||
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 32)
|
||||
self.assertEqual(baseline.ref_xy_mode({}), 32)
|
||||
|
||||
def test_fixed_constants_are_checked_at_the_reference_mode(self):
|
||||
ref = {"sysfs": {"cnc/x_mode": "16", "cnc/step_freq": "56320", "cnc/ramp_rate": "250000"},
|
||||
@@ -415,6 +437,81 @@ class TransientNotLeftoverTests(BaselineTests):
|
||||
self.assertEqual(items[0].found, "1014")
|
||||
|
||||
|
||||
class CoolPublishTests(unittest.TestCase):
|
||||
"""The cooling engine publishes once a tick. A status read inside the
|
||||
tick after a run ended is the engine's last word on the run, not the
|
||||
machine as the run left it. Found on the bench reference: a diagnostic
|
||||
that finished clean (cooling.aa-offset-calibrate) and a fail tier that
|
||||
did its work (cooling.fail-tier-stop) both failed their hand-back on a
|
||||
hold the engine's next tick cleared."""
|
||||
|
||||
IDLE = {"phase": "idle", "armed": False, "hold": False}
|
||||
|
||||
def setUp(self):
|
||||
self.lines = []
|
||||
|
||||
def bl(self, statuses):
|
||||
"""A baseline whose /cool/status answers play in order (the last
|
||||
one repeats) and whose waits count reads, not seconds: three for
|
||||
the engine's next tick, as the real wait makes, and enough for the
|
||||
cooldown to let a slow script run out."""
|
||||
b = baseline.Baseline(self.lines.append)
|
||||
seq = list(statuses)
|
||||
b.fc_get = lambda path: (200, dict(seq.pop(0) if len(seq) > 1 else seq[0]))
|
||||
|
||||
def wait(what, pred, timeout):
|
||||
for i in range(3 if what == "cool publish" else 50):
|
||||
if pred():
|
||||
return float(i)
|
||||
return None
|
||||
b._wait = wait
|
||||
b.stood_down = []
|
||||
b.stand_down = b.stood_down.append
|
||||
return b
|
||||
|
||||
def test_a_diagnostic_hold_the_next_tick_clears_is_not_a_leftover(self):
|
||||
left = []
|
||||
b = self.bl([{"phase": "diag", "armed": False, "hold": True}, self.IDLE])
|
||||
b._cool_side(left)
|
||||
self.assertEqual(left, [])
|
||||
self.assertTrue(any("last word on the run" in ln for ln in self.lines), self.lines)
|
||||
|
||||
def test_a_fail_tier_hold_clears_into_the_engines_own_post_job_phase(self):
|
||||
left = []
|
||||
b = self.bl([{"phase": "run", "armed": False, "hold": True},
|
||||
{"phase": "smoke", "armed": False, "hold": False},
|
||||
{"phase": "smoke", "armed": False, "hold": False}, self.IDLE])
|
||||
b._cool_side(left)
|
||||
self.assertEqual(left, [])
|
||||
self.assertTrue(any("not a leftover" in ln for ln in self.lines), self.lines)
|
||||
|
||||
def test_a_hold_that_outlives_the_tick_is_the_runs_doing(self):
|
||||
left = []
|
||||
b = self.bl([{"phase": "run", "armed": True, "hold": True}])
|
||||
b._cool_side(left)
|
||||
self.assertEqual([x.item for x in left], ["cool"])
|
||||
self.assertEqual(left[0].found, "run/armed=True/hold=True")
|
||||
self.assertTrue(left[0].action.startswith("failed: still"), left[0].action)
|
||||
self.assertEqual(len(b.stood_down), 1)
|
||||
|
||||
def test_a_hold_that_clears_only_later_is_still_recorded(self):
|
||||
left = []
|
||||
held = {"phase": "run", "armed": False, "hold": True}
|
||||
b = self.bl([held] * 10 + [self.IDLE])
|
||||
b._cool_side(left)
|
||||
self.assertEqual([x.item for x in left], ["cool"])
|
||||
self.assertEqual(left[0].action, "waited")
|
||||
self.assertEqual(b.stood_down, [])
|
||||
|
||||
def test_an_idle_engine_and_a_silent_daemon_leave_nothing(self):
|
||||
left = []
|
||||
self.bl([self.IDLE])._cool_side(left)
|
||||
b = baseline.Baseline(self.lines.append)
|
||||
b.fc_get = lambda path: (None, None)
|
||||
b._cool_side(left)
|
||||
self.assertEqual(left, [])
|
||||
|
||||
|
||||
class CloudOwnedSysfsTests(unittest.TestCase):
|
||||
"""In cloud mode the cloud client configures the machine from the
|
||||
pulse header it is playing, the lens included: z_mode comes from
|
||||
@@ -571,7 +668,7 @@ class BaselineModeTests(BaselineTests):
|
||||
self._attr("pic/lid_led", "77")
|
||||
left = self.bl().enforce("pre", captured=None)
|
||||
self.assertEqual(sorted(x.item for x in left), ["cnc/step_freq", "pic/lid_led"])
|
||||
self.assertEqual(self._read("cnc/step_freq"), "28160")
|
||||
self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
|
||||
self.assertEqual(self._read("pic/lid_led"), "236")
|
||||
|
||||
def test_cloud_mode_leaves_the_clients_config_lamp_and_counters(self):
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The bench registry against scripts/bench: every python tool is
|
||||
registered (or named as not-a-tool), every registered script exists,
|
||||
every argument spec builds a command line, every script compiles, and
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Every phrase the cloud suite greps for in the gfcloud log must be a
|
||||
phrase the pinned cloud app can actually log.
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The cloud.* job-behavior tests against a fake forgectrl and a replayed
|
||||
gfcloud log: the real test functions run under the real runner Context,
|
||||
the operator prompts answered - and the standing notices of the machine
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""cooling.aa-offset-calibrate is registered before the heater tools, so
|
||||
a queue runs it first: a heater trial leaves a warm slug circulating past
|
||||
the coolant sensors for minutes, and the tool's edges read it as
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side
|
||||
under the real runner Context against a scripted machine: a fake
|
||||
forgectrl (/status fans, /cool/status phase and verdict, /settings with
|
||||
@@ -729,3 +734,51 @@ class FanGateTests(unittest.TestCase):
|
||||
self.run_test()
|
||||
self.assertIn("lacks exhaust", str(cm.exception))
|
||||
self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "")
|
||||
|
||||
|
||||
class FlowVerdictLogTests(unittest.TestCase):
|
||||
"""cooling.flow-under-load finds the engine's verdict in what the log
|
||||
gained since the job began, not in a count over a tail of fixed length:
|
||||
there a new verdict line comes in at the bottom as an old one leaves at
|
||||
the top, the count does not move, and a check that verified reads as
|
||||
one that never judged."""
|
||||
|
||||
VERDICT = ("2026-09-19T21:37:46.869674+00:00 forgectrl[2796] INFO cool: coolant flow verified "
|
||||
"(heater rise 11.5 C, dT 9.5 C; laser 1.6 off 13.1)\n")
|
||||
OLD = ("2026-09-19T21:17:27.200840+00:00 forgectrl[965] INFO cool: coolant flow verified "
|
||||
"(heater rise 9.5 C, dT 8.7 C)\n")
|
||||
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp(prefix="flowlog.")
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
self.path = os.path.join(self.dir, "forgectrl.log")
|
||||
|
||||
def write(self, text, mode="w"):
|
||||
with open(self.path, mode, newline="\n") as f:
|
||||
f.write(text)
|
||||
|
||||
def test_only_the_text_after_the_offset_comes_back(self):
|
||||
self.write(self.OLD + "filler\n" * 400)
|
||||
off = os.path.getsize(self.path)
|
||||
self.assertEqual(cooling._log_since(self.path, off), "")
|
||||
self.write(self.VERDICT, mode="a")
|
||||
text = cooling._log_since(self.path, off)
|
||||
self.assertEqual(text, self.VERDICT)
|
||||
m = cooling._load_verdict_rx().search(text)
|
||||
self.assertEqual(m.group(1), "coolant flow verified")
|
||||
self.assertEqual((m.group(2), m.group(5)), ("11.5", "1.6"))
|
||||
|
||||
def test_an_old_verdict_before_the_offset_is_not_the_new_one(self):
|
||||
self.write(self.OLD)
|
||||
off = os.path.getsize(self.path)
|
||||
self.write("2026-09-19T21:36:40+00:00 forgectrl[2796] INFO cool: crash watch armed\n", mode="a")
|
||||
self.assertIsNone(cooling._load_verdict_rx().search(cooling._log_since(self.path, off)))
|
||||
|
||||
def test_a_log_rotated_under_the_test_is_read_whole(self):
|
||||
self.write(self.OLD + "filler\n" * 400)
|
||||
off = os.path.getsize(self.path)
|
||||
self.write(self.VERDICT) # the rotation: a new, shorter file
|
||||
self.assertEqual(cooling._log_since(self.path, off), self.VERDICT)
|
||||
|
||||
def test_a_missing_log_reads_as_nothing(self):
|
||||
self.assertEqual(cooling._log_since(os.path.join(self.dir, "none.log"), 0), "")
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The bench actuator as the tool sees it.
|
||||
|
||||
The fixture is a box on the network that opens the lid loop, pulls the
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import unittest
|
||||
|
||||
from forgetest import catalog
|
||||
from forgetest.suite import image
|
||||
|
||||
|
||||
@@ -20,5 +26,61 @@ class KernelIdentTests(unittest.TestCase):
|
||||
self.assertFalse(image.kernel_matches(rel, ["6.12.19-fslc-fslc"]))
|
||||
|
||||
|
||||
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_the_test_is_registered_and_covers_nothing_by_design(self):
|
||||
t = catalog.load_suite()["image.network-boot"]
|
||||
self.assertEqual(t.kind, "auto")
|
||||
self.assertEqual(t.covers, ()) # the interfaces file is layer content
|
||||
|
||||
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_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"), ([], []))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""scripts/install-forgefirm.sh - the download that retries, and the log.
|
||||
|
||||
The installer runs once per machine, on factory firmware, over whatever
|
||||
network the owner has. These tests run its own functions under sh with a
|
||||
scripted curl in front of them: a transfer that fails resumes and is
|
||||
tried again, the failures that waiting cannot fix end the tries at once,
|
||||
and every step leaves a line in the install log in the log tree's own
|
||||
format."""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import helpers # noqa: F401 (sys.path)
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
SCRIPT = os.path.join(REPO, "scripts", "install-forgefirm.sh")
|
||||
MARK = "# The install log, before anything can fail."
|
||||
|
||||
# The log tree's line format (forgetest/suite/logs.py keeps the same one).
|
||||
LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d install\[\d+\] "
|
||||
r"(EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) .+$")
|
||||
|
||||
# A curl that plays a script: each call takes the next "rc http bytes"
|
||||
# line of $CURL_PLAN, appends that many bytes to the --output file the way
|
||||
# a resumed transfer does, prints the HTTP code as -w '%{http_code}'
|
||||
# would, records its arguments, and exits rc.
|
||||
FAKE_CURL = r"""#!/bin/sh
|
||||
echo "$*" >> "$CURL_CALLS"
|
||||
OUT=""
|
||||
while [ $# -gt 0 ]; do
|
||||
[ "$1" = "--output" ] && OUT="$2"
|
||||
shift
|
||||
done
|
||||
LINE=$(sed -n 1p "$CURL_PLAN")
|
||||
sed -i 1d "$CURL_PLAN"
|
||||
set -- $LINE
|
||||
[ "${3:-0}" -gt 0 ] && dd if=/dev/zero bs=1 count="$3" 2>/dev/null | tr '\0' 'x' >> "$OUT"
|
||||
printf '%s' "$2"
|
||||
exit "$1"
|
||||
"""
|
||||
|
||||
|
||||
def functions():
|
||||
"""The installer up to its first action: constants and functions."""
|
||||
with open(SCRIPT, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
head, mark, _ = text.partition(MARK)
|
||||
assert mark, "the installer lost the line the tests cut it at"
|
||||
return head
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("sh") and os.name == "posix", "needs a POSIX sh")
|
||||
class InstallerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="ffinstall-test.")
|
||||
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
|
||||
self.bin = os.path.join(self.tmp, "bin")
|
||||
os.mkdir(self.bin)
|
||||
for name, body in (("curl", FAKE_CURL), ("nslookup", "#!/bin/sh\nexit 1\n"),
|
||||
("ip", "#!/bin/sh\nexit 0\n"), ("sleep", "#!/bin/sh\nexit 0\n")):
|
||||
path = os.path.join(self.bin, name)
|
||||
with open(path, "w", newline="\n") as f:
|
||||
f.write(body)
|
||||
os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR)
|
||||
self.fw = os.path.join(self.tmp, "forgefirm.fw")
|
||||
self.log = os.path.join(self.tmp, "log", "install.log")
|
||||
self.calls = os.path.join(self.tmp, "curl.calls")
|
||||
self.plan = os.path.join(self.tmp, "curl.plan")
|
||||
|
||||
def run_sh(self, body, plan=()):
|
||||
with open(self.plan, "w", newline="\n") as f:
|
||||
f.write("".join("%s\n" % line for line in plan))
|
||||
open(self.calls, "w").close()
|
||||
script = "%s\nFW_FILE='%s'\nLOG_DIR='%s'\nLOG_FILE='%s'\nmkdir -p \"$LOG_DIR\"\nLOG_OK=yes\n%s\n" % (
|
||||
functions(), self.fw, os.path.dirname(self.log), self.log, body)
|
||||
env = dict(os.environ, PATH=self.bin + os.pathsep + os.environ["PATH"],
|
||||
CURL_PLAN=self.plan, CURL_CALLS=self.calls)
|
||||
return subprocess.run(["sh", "-c", script], env=env, capture_output=True, text=True, timeout=60)
|
||||
|
||||
def download(self, plan):
|
||||
r = self.run_sh('download_fw; echo "rc=$? why=$DL_WHY"', plan)
|
||||
with open(self.calls) as f:
|
||||
calls = f.read().splitlines()
|
||||
return r.stdout, calls, self.log_lines()
|
||||
|
||||
def log_lines(self):
|
||||
try:
|
||||
with open(self.log) as f:
|
||||
return f.read().splitlines()
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
def test_a_clean_download_takes_one_try_and_its_name(self):
|
||||
out, calls, log = self.download(["0 200 5000"])
|
||||
self.assertIn("rc=0", out)
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(os.path.getsize(self.fw), 5000)
|
||||
self.assertFalse(os.path.exists(self.fw + ".part"))
|
||||
self.assertTrue(any("complete on try 1, 5000 bytes" in line for line in log), log)
|
||||
|
||||
def test_a_failed_transfer_resumes_and_is_tried_again(self):
|
||||
out, calls, log = self.download(["6 000 0", "56 200 3000", "0 206 2000"])
|
||||
self.assertIn("rc=0", out)
|
||||
self.assertEqual(len(calls), 3)
|
||||
for c in calls: # every try resumes, bounded in time
|
||||
self.assertIn("-C -", c)
|
||||
self.assertIn("--connect-timeout", c)
|
||||
self.assertIn("--speed-time", c)
|
||||
self.assertEqual(os.path.getsize(self.fw), 5000) # 3000 kept, 2000 resumed
|
||||
warns = [line for line in log if " WARNING download: try " in line]
|
||||
self.assertEqual(len(warns), 2, log)
|
||||
self.assertIn("did not resolve", warns[0])
|
||||
self.assertIn("dropped mid-transfer (3000 bytes so far)", warns[1])
|
||||
self.assertIn("Trying again in 5s (try 2 of 5)", out)
|
||||
self.assertIn("Trying again in 15s (try 3 of 5)", out)
|
||||
|
||||
def test_the_tries_run_out(self):
|
||||
out, calls, log = self.download(["28 000 0"] * 5)
|
||||
self.assertIn("rc=1", out)
|
||||
self.assertIn("timed out or stalled", out)
|
||||
self.assertEqual(len(calls), 5)
|
||||
self.assertFalse(os.path.exists(self.fw))
|
||||
self.assertFalse(os.path.exists(self.fw + ".part"))
|
||||
# each failed try leaves what the network looked like
|
||||
self.assertEqual(sum("github.com does not resolve" in line for line in log), 5, log)
|
||||
|
||||
def test_a_missing_release_and_a_full_disk_end_the_tries_at_once(self):
|
||||
out, calls, _ = self.download(["22 404 0"])
|
||||
self.assertIn("rc=1", out)
|
||||
self.assertIn("HTTP 404", out)
|
||||
self.assertEqual(len(calls), 1)
|
||||
out, calls, _ = self.download(["23 200 100"])
|
||||
self.assertIn("rc=1", out)
|
||||
self.assertIn("could not be written", out)
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_a_partial_file_the_server_will_not_resume_starts_over(self):
|
||||
out, calls, _ = self.download(["56 200 4000", "22 416 0", "0 200 5000"])
|
||||
self.assertIn("rc=0", out)
|
||||
self.assertEqual(len(calls), 3)
|
||||
self.assertEqual(os.path.getsize(self.fw), 5000) # not 9000: the stale part went
|
||||
|
||||
def test_a_tls_failure_names_the_clock(self):
|
||||
out, _, _ = self.download(["60 000 0"] * 5)
|
||||
self.assertRegex(out, r"TLS handshake failed \(a wrong clock does this: \d{4}-\d\d-\d\d")
|
||||
|
||||
def test_every_log_line_is_in_the_tree_format(self):
|
||||
self.download(["7 000 0", "0 200 10"])
|
||||
self.run_sh('log NOTICE "operator declined to continue; no changes made"')
|
||||
lines = self.log_lines()
|
||||
self.assertGreater(len(lines), 5)
|
||||
for line in lines:
|
||||
self.assertRegex(line, LINE_RE)
|
||||
|
||||
def test_die_leaves_the_reason_in_the_log(self):
|
||||
r = self.run_sh('die "archiving slot 1 failed"; echo not-reached')
|
||||
self.assertEqual(r.returncode, 1)
|
||||
self.assertNotIn("not-reached", r.stdout)
|
||||
self.assertTrue(any(" ERR install failed: archiving slot 1 failed" in line
|
||||
for line in self.log_lines()))
|
||||
|
||||
def test_no_writable_log_never_fails_the_install(self):
|
||||
r = self.run_sh('LOG_OK=""; log INFO "x"; LOG_OK=yes; LOG_FILE=/nonexistent/dir/x.log; '
|
||||
'log INFO "y"; echo "still-running rc=$?"')
|
||||
self.assertIn("still-running rc=0", r.stdout)
|
||||
|
||||
|
||||
class InstallerShapeTests(unittest.TestCase):
|
||||
"""What holds on any host, with no shell to run."""
|
||||
|
||||
def test_the_log_lives_in_the_tree_the_export_carries(self):
|
||||
head = functions()
|
||||
self.assertIn('LOG_DIR="/data/log/forgefirm/install"', head)
|
||||
self.assertIn('LOG_FILE="$LOG_DIR/install.log"', head)
|
||||
|
||||
def test_the_download_is_the_retrying_one(self):
|
||||
with open(SCRIPT, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
body = text.partition(MARK)[2]
|
||||
self.assertIn("download_fw", body)
|
||||
self.assertNotRegex(body, r"(?m)^\s*curl ") # no bare curl past the functions
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""wait_hv_off against a scripted charge-pump readback: a chain that
|
||||
releases inside the window passes, a chain held past it is the refusal,
|
||||
and a chain already off costs no wait."""
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The dwell-gap verdict of laser.emission-witness over synthetic trails:
|
||||
the button latch judged from the first emission through every sample
|
||||
whose readback word shows the laser latch unlocked, both bits from that
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The controller mode a test declares: the runner puts the machine there
|
||||
before the test and the baseline keeps it there afterward.
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The operator channel: notices, `ready`, `act`, and the precheck.
|
||||
|
||||
A test asks for the operator's part in a way the page can show before
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The page assembly: the ui/ files inlined in order, the gzipped install
|
||||
(what the dev image carries) assembling to the same bytes as a checkout,
|
||||
the token placeholder carried exactly once, and a missing marker refused
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The two queues: what they select, the order they run in, and where
|
||||
they stop.
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The release artifact carries no machine identity (redact.py).
|
||||
|
||||
The artifact is committed to a public repository. These hold the exporter
|
||||
to that, and to not redacting what the gate recomputes.
|
||||
"""
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from forgetest.redact import scrub
|
||||
|
||||
|
||||
class RedactTests(unittest.TestCase):
|
||||
|
||||
def test_keyed_identity_is_replaced(self):
|
||||
out = scrub({"gf_serial": "15232020", "machine_id": "JTY-876",
|
||||
"hostname": "forgefirm-b00a"})
|
||||
self.assertNotIn("15232020", json.dumps(out))
|
||||
self.assertNotIn("JTY-876", json.dumps(out))
|
||||
self.assertNotIn("forgefirm-b00a", json.dumps(out))
|
||||
|
||||
def test_a_known_value_is_replaced_in_free_text_too(self):
|
||||
# A hostname in a log line is the same leak as one in its own field.
|
||||
out = scrub({"evidence": {"hostname": "forgefirm-b00a"},
|
||||
"log": ["set hostname 'forgefirm-b00a' ok"]})
|
||||
self.assertNotIn("forgefirm-b00a", json.dumps(out))
|
||||
|
||||
def test_addresses_and_macs_go(self):
|
||||
out = scrub(["ping 172.16.1.97", "wlan0 2C:6B:7D:0D:B0:0A up"])
|
||||
t = json.dumps(out)
|
||||
self.assertNotIn("172.16.1.97", t)
|
||||
self.assertNotIn("2C:6B:7D:0D:B0:0A", t)
|
||||
|
||||
def test_loopback_is_kept(self):
|
||||
self.assertIn("127.0.0.1", json.dumps(scrub(["curl http://127.0.0.1:8090/"])))
|
||||
|
||||
def test_numbering_is_stable_within_an_export(self):
|
||||
out = scrub(["a 10.0.0.5", "b 10.0.0.6", "c 10.0.0.5"])
|
||||
self.assertEqual(out[0].split()[1], out[2].split()[1])
|
||||
self.assertNotEqual(out[0].split()[1], out[1].split()[1])
|
||||
|
||||
def test_integrity_values_survive(self):
|
||||
# The gate recomputes these; a long hex blob is what they look like.
|
||||
rec = {"fingerprint": "a" * 64, "manifest_sha": "b" * 64,
|
||||
"source_sha": "c" * 64, "sha256": "d" * 64,
|
||||
"catalog_hash": "e" * 64, "identity_sha": "f" * 64}
|
||||
self.assertEqual(scrub(rec), rec)
|
||||
|
||||
def test_a_clock_time_is_not_an_address(self):
|
||||
out = scrub(["2026-09-18T18:50:11Z baseline: pre: clean"])
|
||||
self.assertIn("18:50:11", out[0])
|
||||
|
||||
def test_result_fields_the_gate_reads_are_untouched(self):
|
||||
rec = {"result": "PASS", "ts": "2026-09-18T18:50:11Z",
|
||||
"campaign": "c-1", "fingerprint": "a" * 64}
|
||||
self.assertEqual(scrub(rec), rec)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""What keeps the page answering the operator instead of the timer.
|
||||
|
||||
Two things went wrong on the bench and are pinned here:
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Runner + HTTP API end to end on localhost with a fake catalog and a
|
||||
fake bench tool. No hardware, no forgectrl."""
|
||||
import json
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The setup's checks in the catalog: registered as automatic tests with
|
||||
no precheck, the takeover ones marked so, every covers map naming the
|
||||
check runner; and the check driver itself against the fake daemon: a
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The setup's sheet in the catalog: one live test on the button with a
|
||||
piece of wood at hand, its covers map naming the live runner, the
|
||||
renderer, the sender, the page, and the driver's laser module; and the
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The setup.* suite on the host: the registration (ids, kinds, the
|
||||
takeover tests, the operator tests' hands), the record builders, the
|
||||
machine's name, the cookie parsing, the LED cue, the settle rule for a
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""scripts/source-bundle.py - the source that a release publishes.
|
||||
|
||||
The bundle must hold the source of every recipe of the image whose license
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""scripts/manifest-from-tree.py - the workstation/CI mirror of the image
|
||||
manifest: recipe pins are read through their pin files, and a layer's
|
||||
content hash leaves the pin files out (a component bump is not a platform
|
||||
|
||||
@@ -11,4 +11,4 @@ overrides:
|
||||
meta-freescale-distro:
|
||||
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
|
||||
meta-openglow:
|
||||
commit: dd8f74c2d58e998913e77e28450e8dca5e164a48
|
||||
commit: 71fad52e8ff5695bce3b9c6bc8530a1468426819
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# forgefirm-image-manifest.bbclass - assemble /etc/forgefirm-manifest.json
|
||||
#
|
||||
# Inherited by the ForgeFIRM images. At rootfs postprocess it collects the
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# forgefirm-manifest.bbclass - source fingerprint of one ForgeFIRM component
|
||||
#
|
||||
# A component recipe that inherits this class records what source it was
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
require conf/distro/include/fslc-base.inc
|
||||
|
||||
DISTRO = "forgefirm"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# We have a conf and classes directory, add to BBPATH
|
||||
BBPATH .= ":${LAYERDIR}"
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# NXP distributes the i.MX VPU/EPDC firmware blobs under its firmware EULA
|
||||
# (accepted with ACCEPT_FSL_EULA), so an image carrying those blobs has to
|
||||
# carry the license text with them.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# ForgeFIRM SSH policy, set in the installed files so the release image
|
||||
# carries it as built:
|
||||
# PermitRootLogin no root logs in at the serial console only
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# ForgeFIRM image manifest: source fingerprint of the hardware library
|
||||
# (forgefirm-manifest.bbclass in this layer).
|
||||
inherit forgefirm-manifest
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# ForgeFIRM image manifest: source fingerprint of the cloud-mode service
|
||||
# layer (forgefirm-manifest.bbclass in this layer).
|
||||
inherit forgefirm-manifest
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# urllib3's optional pyOpenSSL/cryptography backend is not used here: the cloud
|
||||
# client and the machine glue talk TLS through the standard library's ssl
|
||||
# module (nothing imports OpenSSL or cryptography). Dropping the two runtime
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
SUMMARY = "Configurable embedded Linux firmware update creator and runner"
|
||||
DESCRIPTION = "Applies and creates signed .fw firmware archives. ForgeFIRM \
|
||||
uses the factory's own update format: fwup applies ForgeFIRM upgrades and \
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
DESCRIPTION = "Potluck with different functions for C programs (babelouest base library)"
|
||||
HOMEPAGE = "https://github.com/babelouest/orcania"
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# /etc/rsyslog.conf - ForgeFIRM
|
||||
#
|
||||
# rsyslog is the only log writer on the machine. Every ForgeFIRM process
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# rsyslog is the ForgeFIRM system logger: the only log writer, one
|
||||
# directory per logger under /data/log/forgefirm. This appends the
|
||||
# ForgeFIRM /etc/rsyslog.conf (inputs, line format, and the include of
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
DESCRIPTION = "Web Framework to build REST APIs, Webservices or any HTTP endpoint in C"
|
||||
HOMEPAGE = "https://github.com/babelouest/ulfius"
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
DESCRIPTION = "Logging library for C applications (babelouest)"
|
||||
HOMEPAGE = "https://github.com/babelouest/yder"
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
SUMMARY = "Boot-slot selection and inventory for Glowforge factory hardware"
|
||||
DESCRIPTION = "Inventories the bootable partitions (ffboot -l) and switches \
|
||||
the boot target by rewriting the saved U-Boot environment with read-back \
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# forgectrl pin. Bump deliberately after pushing forgectrl changes; keep
|
||||
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||
# layer content hash because the component entry already identifies the
|
||||
# pinned source (forgefirm-image-manifest.bbclass).
|
||||
SRCREV = "da3eddcc0f4324dc6ec8a0042ab7db0f1b16fd4f"
|
||||
PV = "0.1.25"
|
||||
SRCREV = "0dae758cb93e1461034aaa58f4b9fc22edc68367"
|
||||
PV = "0.1.27"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
DESCRIPTION = "System Control Daemon for ForgeFIRM powered Glowforge"
|
||||
HOMEPAGE = "https://github.com/openglow-org/forgectrl"
|
||||
|
||||
LICENSE = "MIT"
|
||||
LIC_FILES_CHKSUM = "file://LICENSE;md5=fe9dfc6caad62544b4b1d0fa55c4b92f"
|
||||
LIC_FILES_CHKSUM = "file://LICENSE;md5=785c97b59b518e7ad943cd9e8b15fc97"
|
||||
|
||||
PE = "1"
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# forgefirm-app pin (python3-gfhardware repository, forgefirm-app/ tree).
|
||||
# Bump deliberately (AUTOREV is not reproducible); keep only SRCREV and PV
|
||||
# here - the image manifest leaves *-pin.inc out of the layer content hash
|
||||
# because the component entry already identifies the pinned source
|
||||
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
|
||||
# meta-glowforge-bsp pins the same repository; move both together.
|
||||
SRCREV = "5ca279a1f6008ddf6e19a17c6d6375972d27ed7c"
|
||||
SRCREV = "6cc4f45d311ab451c7d72dee009103e55a749675"
|
||||
|
||||
# Bump PV with every SRCREV move: the hash-derived package version is not
|
||||
# monotonic on its own and buildhistory QA fails the build when it sorts
|
||||
# backwards.
|
||||
PV = "0.1.29+git"
|
||||
PV = "0.1.30+git"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# Shared fetch for the ForgeFIRM web-service apps (gfhome, python3-ffmachine,
|
||||
# gfcloud). Their sources live in the forgefirm-app/ directory of the
|
||||
# python3-gfhardware repository.
|
||||
@@ -7,7 +9,7 @@ HOMEPAGE = "https://github.com/openglow-org/python3-gfhardware"
|
||||
# repository's LGPL component (the Bayer decoder compiled into
|
||||
# gfhardware._cam) is not part of them; python3-gfhardware declares it.
|
||||
LICENSE = "MIT"
|
||||
LIC_FILES_CHKSUM = "file://LICENSE;md5=7029670f29302d9352647c6c89614bc2"
|
||||
LIC_FILES_CHKSUM = "file://LICENSE;md5=314d21c23b626bdadbb4801c47b16266"
|
||||
|
||||
SRC_URI = "git://github.com/openglow-org/python3-gfhardware.git;protocol=https;branch=master"
|
||||
# SRCREV and PV live in the pin file (forgefirm-image-manifest.bbclass).
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
DESCRIPTION = "Full Glowforge web-service controller daemon for ForgeFIRM (cloud mode)"
|
||||
|
||||
require forgefirm-app.inc
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
DESCRIPTION = "One-shot Glowforge web-service homing for ForgeFIRM"
|
||||
|
||||
require forgefirm-app.inc
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
DESCRIPTION = "Shared ForgeFIRM web-service hardware-machine glue (gfhome + gfcloud)"
|
||||
|
||||
require forgefirm-app.inc
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
### BEGIN INIT INFO
|
||||
# Provides: forgefirm-banner
|
||||
# Required-Start: $network
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
SUMMARY = "ForgeFIRM console banner: the control panel addresses in /etc/issue"
|
||||
DESCRIPTION = "Keeps an address block in the serial-console login banner \
|
||||
(/etc/issue): one control panel URL per global address of wlan0 and eth0. \
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
### BEGIN INIT INFO
|
||||
# Provides: forgefirm-hostname
|
||||
# Required-Start: $local_fs
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
SUMMARY = "ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address"
|
||||
DESCRIPTION = "Names the machine forgefirm-<xxxx>, where xxxx is the last \
|
||||
four hex digits of the wlan0 MAC address (eth0 on a machine with no WiFi). \
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
Glowforge factory keyring
|
||||
=========================
|
||||
|
||||
Eleven Ed25519 *public* keys, 32 raw bytes each, recovered from Glowforge
|
||||
factory firmware. They are the factory trust anchors: ForgeFIRM ships them so
|
||||
the machine can verify the signature on a factory `.fw` archive during a
|
||||
factory restore. They verify archives and nothing else.
|
||||
|
||||
Public key material only. No private or secret key belongs in this directory,
|
||||
and nothing here can be used to sign anything. ForgeFIRM's own release-signing
|
||||
public key is `../forgefirm-release.pub`; its private half is held offline by
|
||||
the operator and is not in this repository, in CI, or in any cloud-synced
|
||||
store.
|
||||
|
||||
Provenance and rights
|
||||
---------------------
|
||||
|
||||
These keys are Glowforge, Inc.'s. They are bare cryptographic public keys --
|
||||
algorithm output, carrying no authorship -- so no copyright subsists in them
|
||||
and none is claimed. 514 LLC d/b/a OpenGlow asserts no rights over them and
|
||||
grants none; the recipe's `LICENSE = "MIT"` covers the recipe, not this key
|
||||
material. Glowforge has not published these keys itself, so there is no
|
||||
upstream license to point to and none is implied.
|
||||
|
||||
For how the trust anchors are used, see "The update manager" and "Decisions"
|
||||
on the documentation site:
|
||||
https://docs.forgefirm.org/technical/forgefirm/install-and-update/
|
||||
@@ -1,8 +1,14 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
SUMMARY = "Firmware verification public keys"
|
||||
DESCRIPTION = "Trust anchors for firmware archive verification: the \
|
||||
ForgeFIRM release-signing public key (verifies release downloads and \
|
||||
uploads) and the Glowforge factory keyring (verifies factory .fw \
|
||||
archives for cloud restore). Public keys only."
|
||||
# LICENSE covers this recipe, not the key material. The Glowforge factory
|
||||
# keyring in files/gf/ is Glowforge, Inc.'s: bare Ed25519 public keys, in which
|
||||
# no copyright subsists and over which OpenGlow claims nothing and grants
|
||||
# nothing. files/gf/README records that and the public-key-only boundary.
|
||||
LICENSE = "MIT"
|
||||
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
### BEGIN INIT INFO
|
||||
# Provides: forgefirm-logging
|
||||
# Required-Start: $local_fs
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user