Test harness isolation: the fleet installs from itself and reaches nothing outside — and four production fixes it turned up - #1781
Conversation
106463d to
5d14b4c
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## fix/restart-pacing-crash-only #1781 +/- ##
================================================================
Coverage ? 65.25%
================================================================
Files ? 176
Lines ? 33061
Branches ? 0
================================================================
Hits ? 21574
Misses ? 11487
Partials ? 0 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Cabecinha84
left a comment
There was a problem hiding this comment.
PR #1781 — review
Verdict: ACK with one small change requested. The production surface is small, well-reasoned, and the three apt-queue
fixes are real bugs with real tests. I found one defect (harness-only impact, but its guard test is green for the
wrong reason) plus a handful of notes. Nothing here justifies blocking.
Note the base is fix/restart-pacing-crash-only (#1780), so this merges after that one.
What I verified myself
- Ran the six touched unit files with NODE_CONFIG_DIR=tests/unit/globalconfig: 189 passing, 0 failing (systemService
69, fifoQueue 18). - npx eslint clean on the four changed production files.
- Config keys stats / pricing / mongodb don't collide with existing top-level keys; all five services that now read
them already require('config'); zero dangling references to the removed geolocation.statsApiBaseUrl. - Traced every caller of the apt path — monitorSystem (serviceManager.js:164, not awaited) is the only entry point;
nothing in routes or other services reaches queueAptGetCommand. - Wrote a throwaway probe test to check the one claim I doubted (below).
Finding 1 — request a change
system:packages-checked is not published when a check throws, and the test that says it is doesn't test that.
systemService.js:735 awaits Promise.all(Object.values(checks)) inside the try. Any rejection skips straight to catch,
so fluxEventBus.publish never runs.
The guard test — "publishes even when a check throws, so a waiter fails rather than hangs" (systemService.test.js) —
does axios.get.rejects(...), but monitorSyncthingPackage's axios call carries its own .catch() that swallows it.
Nothing ever throws, so the test passes without exercising the path it names.
Probe (stubbing serviceHelper.runCommand to reject, so getPackageVersion genuinely throws):
publish called times = 0
AssertionError: expected +0 to equal 1
Impact is confined to the harness — fluxEventBus.publish is a no-op unless testEventStream is set, so production is
unaffected. But waitForPackagesChecked would burn its full 180s (480s in suite 94) instead of failing fast, which is
precisely the failure mode restartFluxosAndAwaitRecovery was written to eliminate. Worth noting fileOperationRecovery
got this right — its early-return path still publishes, and there's a test proving it. The two are inconsistent.
Fix is one line either way: Promise.allSettled, or move the publish into a finally. And make the test throw somewhere
the code doesn't already catch.
Notes — not blockers
--no-install-recommends changes behaviour on every existing legacy node. It lands in aptRunner, so it applies to all
apt invocations, not just the new ones. The justification is solid (21 packages installed for 4 requested; ten nodes
in D-state running update-mime-database), and the four packages involved are all safe without recommends. The 84/84
gate exercised a real unseeded install. Just flag it in release notes — it's the change most likely to surprise
someone operationally.
addWorker now throws instead of no-op'ing. Behaviour change on a public method of a shared util. Only remaining call
site is a test, so it's contained — but it's a hard failure where there used to be a silent one.
systemService now attaches its worker and failed listener at module-load time, unconditionally, including on Arcane
nodes where monitorSystem early-returns. I confirmed nothing reaches the queue there, so it's inert. Still:
require('systemService') now installs an apt worker as a side effect.
The "config is tamper-evident, environment is not" rationale is stated more strongly than the mechanism supports. It
appears in cloudUIUpdateService.js and again in scripts/update-cloudui.sh's header. node-config honours NODE_CONFIG,
NODE_CONFIG_DIR and custom-environment-variables.js — which is exactly how the harness overrides these same keys. So
moving a literal into config makes those eight endpoints redirectable by anyone who controls the process environment,
which a hardcoded string was not. That's a fine trade (env control ≈ game over anyway, and testability is a genuine
win), but I'd soften the comment so nobody later leans on it as a security property.
Nits: scripts/update-cloudui.sh still documents Usage: npm run update:cloudui and bash scripts/update-cloudui.sh —
both now exit 1. And queueAptGetCommand's JSDoc doesn't mention retryDelay/retainErrors now that they're forwarded,
and still says retries defaults to 3 (the queue default is 5).
Possible flake: the sinkhole resolver refuses at 300 ms. Only names Docker's embedded resolver can't answer locally
reach the stub, so the window is small — but a slow relay would be recorded as an escape attempt and returned NXDOMAIN
to the node. Low risk, worth knowing when a suite-95 failure eventually shows up.
What's genuinely good
The fifoQueue work is the strongest part. I re-read runWorker line by line: the cycle counter on props[2] is sound
(push() builds [payload, resolve], the list getter maps x[0], clear() reads task[1] — the third slot is free), the
retain-to-back plus maxRetainCycles ceiling correctly bounds what was an unbounded retain-and-resume loop, and
emitting commandOptions rather than the wrapper is what makes monitorAptCache's opening shortcut fire for the first
time. The retainErrors: false forwarding is the actual fix for the 55k-attempts spin, and it's the kind of bug that
only bites once the network closes.
The two tests that encoded the old behaviour were re-baselined rather than deleted, with the reasoning written down —
that's the right call and it's visible in the diff.
exec → execFile with an argv array is a strict improvement, and the test deliberately stubs config with a
non-production value so it can't pass by coincidence.
5d14b4c to
63bdcd1
Compare
987e59d to
f6c6bc3
Compare
|
Thanks — the requested change and the notes are all addressed, with one I'd push back on. Branch is now Finding 1 —
Module-load side effect — fixed ( Doc nits — fixed ( The tamper-evidence rationale — I don't think this one holds, and I'd rather not soften it. The premise is that node-config honours process.env.NODE_CONFIG_DIR = `${__dirname}/ZelBack/config/`;
// ... NODE_CONFIG is the same door: the config package merges whatever
// JSON it holds over every file, after the directory is settled, so leaving
// it open redirects any endpoint without touching a hashed file - the one
// redirect tamper detection cannot see.
delete process.env.NODE_CONFIG;The directory is pinned, The harness also doesn't work the way the note assumes, and for the same reason: it can't override through the environment, which is why Possible flake on the sinkhole resolver — noted, and worth knowing when a suite-95 failure eventually shows up. Two things worth reporting, because they came out of gating this rather than reviewing it. The gate caught a real regression in this PR. Moving the runner's overrides out of Separately, this PR now carries an image-provenance check ( Verified: 85 of 85 suites green at |
f6c6bc3 to
1db4be7
Compare
Cabecinha84
left a comment
There was a problem hiding this comment.
PR #1781 — review at current head (1db4be7)
Verdict: request one change. One production regression that your earlier review couldn't have seen — it was introduced
by a change that was in the version you reviewed, but it needs a probe to surface. Everything you flagged on
2026-08-10 is genuinely fixed. Nothing else blocks.
What I verified
┌───────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────┐
│ check │ result │
├───────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ Full unit suite, PR head │ 5206 passing / 5 pending / 23 failing │
├───────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ Full unit suite, base │ 5190 passing / 5 pending / 23 failing │
│ (fix/restart-pacing-crash-only) │ │
├───────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ Failure sets │ byte-identical — all 23 are the pre-existing dockerService tests │
│ │ that need the runonflux/website container. Net +16 tests. │
├───────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ Six touched test files in isolation │ 159 passing, 0 failing │
├───────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ npx eslint on the 7 changed production │ clean │
│ files │ │
├───────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ CI │ build (ubuntu-22.04, 20.x, 7.0) passing │
├───────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ Dangling geolocation.statsApiBaseUrl refs │ none │
└───────────────────────────────────────────┴─────────────────────────────────────────────────────────────────────┘
Your Finding 1 is properly closed (101af67): the publish moved into a finally, the checks are Promise.allSettled
with per-check named logging, and the guard test now rejects serviceHelper.runCommand — where nothing catches —
instead of axios, and pins { installed: [] } so it can't pass on a stale payload. I re-read it; it now exercises the
path it names.
The fifoQueue work still holds up on a second read. The props[2] cycle counter is safe (list getter maps x[0], clear()
reads task[1]), abandoned is bounded, and emit('failed', { options: commandOptions }) is what finally lets
monitorAptCache's update shortcut fire. The lazy getQueue() (72511b4) is a better answer to your module-load note
than splitting queue from worker would have been — there's no window where one exists without the other, and import is
now genuinely inert.
Finding — request a change
ZelBack/src/services/systemService.js — moving addSyncthingRepository() after the version check strands a class of
legacy node permanently.
addSyncthingRepository() moved from the top of monitorSyncthingPackage to after the hasNewSources branch. But
updateSyncthingRepository() returns false when /etc/apt/sources.list.d/syncthing.list cannot be read
(systemService.js:452 — "Unable to read syncthing sources"), and that false short-circuits the whole check:
if (!hasNewSources) {
const updated = await updateSyncthingRepository();
if (!updated) return false; // ← returns before addSyncthingRepository()
}
await addSyncthingRepository(); // never reached
So on a node with syncthing installed at < 2.0.0 and no apt source file, the repository is never created and syncthing
is never upgraded. It repeats identically every 24h — the node cannot heal, and the only symptom is one log.warn.
I ran the same probe against both branches (syncthing 1.23.4 installed, fs.stat rejecting, cat returning empty, stats
reporting min 2.0.7):
base: wrote syncthing apt source: true (keyring + sources.list.d/syncthing.list written)
PR: wrote syncthing apt source: false (stops at cat /etc/apt/sources.list.d/syncthing.list)
That state is reachable in production: addSyncthingRepository() returns early without writing whenever addGpgKey fails
(key fetch unreachable, keyring dir not writable) or addAptSource fails. Today those nodes retry daily and recover
once the network comes back; after this PR they never reach the retry. Same for a node whose syncthing came from
Ubuntu universe (1.19.2 on 22.04) rather than the FluxOS installer.
Fix is a one-line move — put await addSyncthingRepository(); immediately after if (upToDate) return false;, before the
hasNewSources branch. That keeps the entire stated benefit (a node already current writes nothing) and restores the
old ordering for every node that actually needs to install. It also keeps the new test green, since that test stubs an
up-to-date node. Worth asking for a unit test on the "1.x installed, no source file" path, since nothing currently
covers it.
Notes — not blockers
-
delete process.env.NODE_CONFIG is an undeclared production change. It landed in 8a0b587 after your review, in
app.js, apiServer.js and homeServer.js. The reasoning is sound and I agree with him over your original note — the door
really was open. But the PR body's production/harness table doesn't list it, and the release note only mentions
--no-install-recommends. Nothing in the repo sets NODE_CONFIG (I grepped), so in-tree it's inert — but any external
tooling or Arcane deployment that used it loses its overrides silently. It belongs next to --no-install-recommends in
the release note. -
The gate evidence doesn't correspond to any commit on the branch. Every SHA cited in the body and in his reply —
5ec88df, 5cb0383, f6c6bc3, 55545fb, 1103448 — is gone. The whole branch was rebased at 17:02 on 2026-08-11,
after the 15:15 "85 of 85 green at f6c6bc3" comment, and #1780 underneath it moved at the same time. The content is
almost certainly the same (author dates all predate the comment), but the 85/85 ran against a different base — and
#1780's replayed commits touch the reconciler burst ceiling and boot-lock admission, which suites 02/04 exercise.
Worth asking for a re-gate at the current head before merge. -
fileOperationRecovery now has the inverse of the bug you found. recoverInterruptedFileOperations publishes
fileops:recovered after sweepEveryMountedVolume() returns — but executor.reapOrphanedContainers() on that function's
first line is unguarded. If it throws, no publish, and waitForFileOpsRecovered burns its full 120s. That's exactly the
failure mode 101af67 fixed in systemService with a finally; the two files are inconsistent again, in the other
direction. Harness-only impact. -
Sinkhole resolver, minor. refuse() calls upstream.close() and is also registered as upstream.on('error'), so an
error on an unbound socket can throw out of the handler and take the stub down. Harness-only, low likelihood. Files
under your existing 300ms-flake note. -
Stack depth. 1775 → 1778 → 1777 → 1779 → 1780 → 1781 → 1782. Six PRs deep before development. Nothing to act on,
just merge-order awareness.
1db4be7 to
31a6918
Compare
31a6918 to
a2edf5a
Compare
588b545 to
4f3649d
Compare
4f3649d to
689398d
Compare
689398d to
cf39b23
Compare
Importing systemService attached a worker and its listeners to the apt queue, unconditionally. On an Arcane node nothing ever reaches that queue - monitorSystem returns immediately - so the worker sat there for the life of the process, and anything reaching into this module for an unrelated function acquired one without asking. The queue is still built COMPLETE, which is what the previous arrangement was protecting: one that arrives without its worker silently accepts work it cannot run, so its behaviour depends on call order and a test that touches it inherits whatever the last one left behind. Building the whole thing on first use keeps that property and drops the side effect - there is no moment when the queue exists without its worker, and no queue at all until something has work for it. Every remaining reference goes through the accessor, so none of them can observe a half-built queue. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
queueAptGetCommand forwards retryDelay and retainErrors as well as retries, and none of them were documented; retries was described as defaulting to 3 where the queue's default is 5, and the timeout as 60 seconds where aptRunner uses 180. An option left unset takes the queue's default, so the numbers are the queue's and are named as such. update-cloudui.sh documented two invocations that both exit 1: the API base URL is a required argument, deliberately not defaulted and not read from the environment, and neither documented form passed one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…late patch A suite asking for discoveryAutostart got a node that never started discovery. Moving the runner's overrides out of NODE_CONFIG and into a written local.js made the entrypoint build that file by REQUIRING the per-node config, which spreads shared.js at that moment and freezes the result. The discovery patch was a sed against shared.js applied afterwards, so it landed on a file nothing read again, and the value stayed false. It now travels in the same JSON as every other override, so there is one path into a node's configuration rather than two with an ordering dependency between them. The env var and the sed are gone. Suites 02 and 04 both assert discovery starts, and both were red on this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…riggers The probe stopped the container, unmounted, and wrote to the bare mountpoint, expecting EPERM from the immutable flag. But the stop emits a die event, that drives a reconcile, and the reconciler mounts the volume before any actuation - so the test asked for a repair and then raced it. Losing that race puts the write on a remounted volume, where it succeeds, which is indistinguishable from the leak the test exists to catch. The gate showed it losing: ensureAppVolumeMounted only sets the flag when the mountpoint is EMPTY and warns otherwise, and the run shows it taking the empty branch and then mounting - so the probe file did not exist yet, and no warning about entries on a bare dir appears anywhere. The backing file is now held aside before anything can trigger a reconcile. getVolumeFilePath matches an exact name, so a renamed volume reads as volume_file_missing and no repair can succeed while the probe runs. Mount state is captured at probe time as well, so a remount that beat us would be reported as that rather than as a leak. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
These assertions were written when a recovery pass could restore displaced data,
so they pinned the published payload at {containers, removed, restored}. The
marker scheme is gone from the branch below this one: a publish is one atomic
exchange, nothing is parked under a second name, and so nothing is restored.
sweepStagingDirectories returns {removed} and the pass returns
{containers, removed}.
The publish itself was already correct - it forwards the pass's own result - so
only the expectations and the stub were describing a shape that no longer
exists. The stub now returns what the real function returns.
11 passing in fileOperationRecovery.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ive up addSyncthingRepository moved behind a branch that returns when the existing sources file cannot be read - and the node that most needs the file created is the one with no file to read: syncthing 1.x installed and no source, from a key fetch that failed in an earlier pass or a package that came from Ubuntu's archive. That node warned once a day forever and never upgraded. The source is now ensured right after the up-to-date return - a current node is still handed nothing - and before the stable-v2 rewrite, which then finds the file it expects on the next pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qWVgYJ4bqaUxdcrp9D1EB
…throwing one included The reap is the sweep's first step and the one whose throw skipped the fileops:recovered publish entirely, leaving a waiter to burn its whole timeout instead of being told - the shape the systemService checks close with a finally, now closed the same way here. The throw still propagates: a startup that throws is retried, and the retry publishes again, which is safe for the same reason the sweep is. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qWVgYJ4bqaUxdcrp9D1EB
…nnot close answer() doubles as the upstream socket's error handler, and close() on a socket that never bound throws - from inside an error handler nothing catches, so one unlucky query took the resolver down for the whole run. An uncloseable socket is left to the garbage collector; the refusal still goes out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qWVgYJ4bqaUxdcrp9D1EB
…l planting one 21ae94388 removed STAGING_UUID with the old boot-recovery signal but the reserved-prefix and own-data tests still plant .flux-op-<uuid> dirs; both threw ReferenceError on their first execution in this stack's first gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qWVgYJ4bqaUxdcrp9D1EB
A failed apt-get update killed the apt queue for the life of the process.
runWorker halted AFTER emitting 'failed'. An emit is a synchronous yield: the
listener runs right there, and monitorAptCache resumes for a failed update with
no await ahead of it, so its resume landed inside the emit and the next line
overwrote it. The loop broke out with work still queued, working went false, and
nothing restarted it - push() only calls work() when the queue is not already
working. Every apt task behind the update was stranded, every one pushed later
too, and monitorSystem's allSettled never settled, so system:packages-checked
was never published and chrony, syncthing and netcat never installed.
Latent until this branch: the emit carried the raw payload, so options.command
was undefined and that shortcut could never fire. Fixing the payload switched the
trapdoor on, which makes this ours.
The fix is not to make listeners behave. It is to stop depending on when they
run: halt first, then notify, and a listener may reply instantly, slowly or not
at all. Two consequences follow. A resumed final failure now reaches the retry
sleep with no retries left, so the ladder-over break stops it waiting out the
full delay - 60s by default - holding everything behind it. And resume() no
longer reassigns `finished` when called re-entrantly: work() returns at once
while the loop is running, and storing that resolved promise told clear() the
queue was idle mid-flight, on the apt-is-broken path systemService reaches from
these very failures.
Four unit regressions, each killed by its own mutation alone: halting after the
emit again fails the draining pair, removing the ladder-over break fails only the
retry-delay test, and removing the re-entrancy guard fails only the clear() test.
The first pass of the retry-delay and clear() tests proved nothing - both passed
under their mutations - and were rewritten until they bit.
Harness: an apt command completing becomes a published fact. A legacy boot runs a
handful of them, so this is an event rather than a cadence, and WHICH completed -
in what order - is the only direct evidence the queue carried on past one that
failed. Suite 94 gains the third regime a real node boots into, packages missing
AND apt-get update failing: createTestEnv({ aptBadSource: true }) adds a source
apt cannot reach alongside the good one, so update fails as it does behind a dead
mirror while the work behind it stays doable - swapping the good source out would
fail that too and prove only that a broken node stays broken. The suite then
asserts two facts off the bus in order, the failure and the command after it,
rather than waiting to conclude that packages never appeared.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SB8Saua6GiMLqEYdvkUccG
…g it testEventStream is false everywhere but the harness, so publish() is inert in a unit run: the ring stays empty and nothing is emitted. Asserting on it there tests a mechanism that does nothing, and stubbing it substitutes for a dependency that already does nothing. Removed: the assertions in systemService, appController, fluxCommunication and syncthingEventsConsumer, and the no-op stubs in appInstaller, appSpawner, fileOperationRecovery and volumeExecutor. Where a test existed only to assert an announcement it goes with the assertion; where the test also proved something independent that part stays, as syncthingEventsConsumer keeps its resync callback and stream-position checks. fileOperationRecovery's reap-throws test goes too, for a different reason: it made reapOrphanedContainers reject, and the real one cannot. It catches the container listing failure and returns 0, and it catches each removal individually, so the only way to reach that assertion is to stub a rejection production cannot produce. A test that green-lights a contract nothing can exercise is worse than no test, because it reads as coverage. fluxEventBus.test.js stays. It tests the utility's own ring and counters rather than using the bus to test something else, and that logic is real either way. The coverage this moves is not lost, it is relocated: an announcement is a fact about a running fleet, and the harness is where a fact about a running fleet can actually be observed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SB8Saua6GiMLqEYdvkUccG
… nothing The stub's dgram socket had no 'error' listener, which in Node makes any socket failure an uncaught exception. The one that happens is the bind: port 53 already held, usually by the previous run's container on its way out. Without a listener the process died on a stack trace naming neither DNS nor the port, while every node in the fleet silently failed to resolve - a fleet-wide fault that is nothing of the kind, and expensive to chase into FluxOS before suspecting the rig. Fatal on purpose. A resolver that never bound is not a resolver, and the run should say so at startup rather than eighty suites later. Errors after the bind are logged rather than fatal - one query is not worth the fleet's DNS - but they are not the motivation. On a bound socket sending a DNS-sized packet to a container on the same bridge, a send does not realistically fail; that path comes free with the handler the bind needs and is not worth a line of its own. No test. The harness cannot make a UDP bind or send fail on demand, and a test that substituted the socket would assert against the substitute rather than the behaviour - which is the fault this branch just deleted a test for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SB8Saua6GiMLqEYdvkUccG
ensurePackageVersion's not-found branch called upgradePackage and threw the
answer away:
await upgradePackage(systemPackage);
return true; // Package was installed/upgraded
upgradePackage returns Boolean(error) - true means it FAILED - so an apt-get that
could not find the package read back exactly like one that installed it. The
upgrade branch ten lines below has always read it (return !upgradeError), and the
chrony entry in monitorSystem sidesteps the question entirely by re-asking the
package database afterwards.
Harmless for as long as it existed, because nothing read it: monitorSystem was
four setImmediate calls with the return values dropped. This branch is what first
collects them, into the installed list published as system:packages-checked - so
the line only became load-bearing here.
The unit tests stub the queue rather than driving it. A real failure walks five
retries a minute apart, which is not the contract under test; what is under test
is that the boolean says what happened. Restoring the old line fails the failure
case and leaves the success case green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docker rm -f leaves a container's ANONYMOUS volumes behind. An image with a VOLUME directive gets one per container - mongo declares /data/db, and the harness starts a fresh mongo per environment - and they carry com.docker.volume.anonymous rather than flux-e2e-run, so the label-scoped volume sweep on the next line cannot see them and nothing ever reclaims them. Measured on chud after a full gate: 34 dangling volumes, 8.9GB in local volumes with 2.2GB reclaimable, on a box whose own sweep had already run. testcontainers' teardown passes removeVolumes itself, so a suite that ends cleanly was never the leak - it is the containers a SWEEP takes: crashed suites, leftovers from a killed run, and the pre-gate clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
727c575 to
dd0e059
Compare
… wiring #1781 creates the harness network Internal, so docker gives node containers no default route. FluxOS decides whether a node holds a fixed public address by looking for one - hasPublicIpOnInterface reads /proc/net/route and returns false when there is no default route at all - so on the stacked tree EVERY node reads DYNAMIC. Verified across both gates: pre-stack every node logged "public IP on interface: true", and on this gate all 65 dumped nodes logged false, none true. That took suite 21's two static_ip deferrals with it. The node can no longer be STATIC, so the deferral the suite waits for cannot fire, and it is skipped for `datacenter` instead - six of ten tests failing on that one fact. Neither side was wrong. The isolation is the point of #1781, and a node with no route out is not a static endpoint. What was wrong is that the property was a side effect of the wiring rather than something a suite declares. So it is declared, the way geolocation and dataCenter already are, and provisioned as the REAL thing rather than a flag the product honours: the entrypoint installs a default route via the network's own gateway before FluxOS starts. It restores the fact without restoring connectivity - an internal network's gateway forwards nothing outward - and its absence is how a suite asks for a NAT'd node, which is what every node is today. Adding it for everyone would have been the same mistake pointing the other way: every node STATIC, no way to be DYNAMIC, and the topology still deciding. seedMongo's staticIp is driven by the same declaration. It is not dead - it is what a node answers during boot, before its first lookup completes and setNodeGeolocation recomputes - but it was seeded true unconditionally while the recompute said DYNAMIC, so it looked like a control and was silently overwritten. db-client's seedGeolocation and dropAndReseed are deleted: dropAndReseed had no callers anywhere in test-infra, and it was seedGeolocation's only one. The route failure is loud rather than `|| true`. A swallowed failure here reads exactly like a node that was never asked for a route, and the suite then fails somewhere else on a classification it cannot explain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… wiring #1781 creates the harness network Internal, so docker gives node containers no default route. FluxOS decides whether a node holds a fixed public address by looking for one - hasPublicIpOnInterface reads /proc/net/route and returns false when there is no default route at all - so on the stacked tree EVERY node reads DYNAMIC. Verified across both gates: pre-stack every node logged "public IP on interface: true", and on this gate all 65 dumped nodes logged false, none true. That took suite 21's two static_ip deferrals with it. The node can no longer be STATIC, so the deferral the suite waits for cannot fire, and it is skipped for `datacenter` instead - six of ten tests failing on that one fact. Neither side was wrong. The isolation is the point of #1781, and a node with no route out is not a static endpoint. What was wrong is that the property was a side effect of the wiring rather than something a suite declares. So it is declared, the way geolocation and dataCenter already are, and provisioned as the REAL thing rather than a flag the product honours: the entrypoint installs a default route via the network's own gateway before FluxOS starts. It restores the fact without restoring connectivity - an internal network's gateway forwards nothing outward - and its absence is how a suite asks for a NAT'd node, which is what every node is today. Adding it for everyone would have been the same mistake pointing the other way: every node STATIC, no way to be DYNAMIC, and the topology still deciding. seedMongo's staticIp is driven by the same declaration. It is not dead - it is what a node answers during boot, before its first lookup completes and setNodeGeolocation recomputes - but it was seeded true unconditionally while the recompute said DYNAMIC, so it looked like a control and was silently overwritten. db-client's seedGeolocation and dropAndReseed are deleted: dropAndReseed had no callers anywhere in test-infra, and it was seedGeolocation's only one. The route failure is loud rather than `|| true`. A swallowed failure here reads exactly like a node that was never asked for a route, and the suite then fails somewhere else on a classification it cannot explain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… wiring #1781 creates the harness network Internal, so docker gives node containers no default route. FluxOS decides whether a node holds a fixed public address by looking for one - hasPublicIpOnInterface reads /proc/net/route and returns false when there is no default route at all - so on the stacked tree EVERY node reads DYNAMIC. Verified across both gates: pre-stack every node logged "public IP on interface: true", and on this gate all 65 dumped nodes logged false, none true. That took suite 21's two static_ip deferrals with it. The node can no longer be STATIC, so the deferral the suite waits for cannot fire, and it is skipped for `datacenter` instead - six of ten tests failing on that one fact. Neither side was wrong. The isolation is the point of #1781, and a node with no route out is not a static endpoint. What was wrong is that the property was a side effect of the wiring rather than something a suite declares. So it is declared, the way geolocation and dataCenter already are, and provisioned as the REAL thing rather than a flag the product honours: the entrypoint installs a default route via the network's own gateway before FluxOS starts. It restores the fact without restoring connectivity - an internal network's gateway forwards nothing outward - and its absence is how a suite asks for a NAT'd node, which is what every node is today. Adding it for everyone would have been the same mistake pointing the other way: every node STATIC, no way to be DYNAMIC, and the topology still deciding. seedMongo's staticIp is driven by the same declaration. It is not dead - it is what a node answers during boot, before its first lookup completes and setNodeGeolocation recomputes - but it was seeded true unconditionally while the recompute said DYNAMIC, so it looked like a control and was silently overwritten. db-client's seedGeolocation and dropAndReseed are deleted: dropAndReseed had no callers anywhere in test-infra, and it was seedGeolocation's only one. The route failure is loud rather than `|| true`. A swallowed failure here reads exactly like a node that was never asked for a route, and the suite then fails somewhere else on a classification it cannot explain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… wiring #1781 creates the harness network Internal, so docker gives node containers no default route. FluxOS decides whether a node holds a fixed public address by looking for one - hasPublicIpOnInterface reads /proc/net/route and returns false when there is no default route at all - so on the stacked tree EVERY node reads DYNAMIC. Verified across both gates: pre-stack every node logged "public IP on interface: true", and on this gate all 65 dumped nodes logged false, none true. That took suite 21's two static_ip deferrals with it. The node can no longer be STATIC, so the deferral the suite waits for cannot fire, and it is skipped for `datacenter` instead - six of ten tests failing on that one fact. Neither side was wrong. The isolation is the point of #1781, and a node with no route out is not a static endpoint. What was wrong is that the property was a side effect of the wiring rather than something a suite declares. So it is declared, the way geolocation and dataCenter already are, and provisioned as the REAL thing rather than a flag the product honours: the entrypoint installs a default route via the network's own gateway before FluxOS starts. It restores the fact without restoring connectivity - an internal network's gateway forwards nothing outward - and its absence is how a suite asks for a NAT'd node, which is what every node is today. Adding it for everyone would have been the same mistake pointing the other way: every node STATIC, no way to be DYNAMIC, and the topology still deciding. seedMongo's staticIp is driven by the same declaration. It is not dead - it is what a node answers during boot, before its first lookup completes and setNodeGeolocation recomputes - but it was seeded true unconditionally while the recompute said DYNAMIC, so it looked like a control and was silently overwritten. db-client's seedGeolocation and dropAndReseed are deleted: dropAndReseed had no callers anywhere in test-infra, and it was seedGeolocation's only one. The route failure is loud rather than `|| true`. A swallowed failure here reads exactly like a node that was never asked for a route, and the suite then fails somewhere else on a classification it cannot explain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… wiring #1781 creates the harness network Internal, so docker gives node containers no default route. FluxOS decides whether a node holds a fixed public address by looking for one - hasPublicIpOnInterface reads /proc/net/route and returns false when there is no default route at all - so on the stacked tree EVERY node reads DYNAMIC. Verified across both gates: pre-stack every node logged "public IP on interface: true", and on this gate all 65 dumped nodes logged false, none true. That took suite 21's two static_ip deferrals with it. The node can no longer be STATIC, so the deferral the suite waits for cannot fire, and it is skipped for `datacenter` instead - six of ten tests failing on that one fact. Neither side was wrong. The isolation is the point of #1781, and a node with no route out is not a static endpoint. What was wrong is that the property was a side effect of the wiring rather than something a suite declares. So it is declared, the way geolocation and dataCenter already are, and provisioned as the REAL thing rather than a flag the product honours: the entrypoint installs a default route via the network's own gateway before FluxOS starts. It restores the fact without restoring connectivity - an internal network's gateway forwards nothing outward - and its absence is how a suite asks for a NAT'd node, which is what every node is today. Adding it for everyone would have been the same mistake pointing the other way: every node STATIC, no way to be DYNAMIC, and the topology still deciding. seedMongo's staticIp is driven by the same declaration. It is not dead - it is what a node answers during boot, before its first lookup completes and setNodeGeolocation recomputes - but it was seeded true unconditionally while the recompute said DYNAMIC, so it looked like a control and was silently overwritten. db-client's seedGeolocation and dropAndReseed are deleted: dropAndReseed had no callers anywhere in test-infra, and it was seedGeolocation's only one. The route failure is loud rather than `|| true`. A swallowed failure here reads exactly like a node that was never asked for a route, and the suite then fails somewhere else on a classification it cannot explain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… wiring #1781 creates the harness network Internal, so docker gives node containers no default route. FluxOS decides whether a node holds a fixed public address by looking for one - hasPublicIpOnInterface reads /proc/net/route and returns false when there is no default route at all - so on the stacked tree EVERY node reads DYNAMIC. Verified across both gates: pre-stack every node logged "public IP on interface: true", and on this gate all 65 dumped nodes logged false, none true. That took suite 21's two static_ip deferrals with it. The node can no longer be STATIC, so the deferral the suite waits for cannot fire, and it is skipped for `datacenter` instead - six of ten tests failing on that one fact. Neither side was wrong. The isolation is the point of #1781, and a node with no route out is not a static endpoint. What was wrong is that the property was a side effect of the wiring rather than something a suite declares. So it is declared, the way geolocation and dataCenter already are, and provisioned as the REAL thing rather than a flag the product honours: the entrypoint installs a default route via the network's own gateway before FluxOS starts. It restores the fact without restoring connectivity - an internal network's gateway forwards nothing outward - and its absence is how a suite asks for a NAT'd node, which is what every node is today. Adding it for everyone would have been the same mistake pointing the other way: every node STATIC, no way to be DYNAMIC, and the topology still deciding. seedMongo's staticIp is driven by the same declaration. It is not dead - it is what a node answers during boot, before its first lookup completes and setNodeGeolocation recomputes - but it was seeded true unconditionally while the recompute said DYNAMIC, so it looked like a control and was silently overwritten. db-client's seedGeolocation and dropAndReseed are deleted: dropAndReseed had no callers anywhere in test-infra, and it was seedGeolocation's only one. The route failure is loud rather than `|| true`. A swallowed failure here reads exactly like a node that was never asked for a route, and the suite then fails somewhere else on a classification it cannot explain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… wiring #1781 creates the harness network Internal, so docker gives node containers no default route. FluxOS decides whether a node holds a fixed public address by looking for one - hasPublicIpOnInterface reads /proc/net/route and returns false when there is no default route at all - so on the stacked tree EVERY node reads DYNAMIC. Verified across both gates: pre-stack every node logged "public IP on interface: true", and on this gate all 65 dumped nodes logged false, none true. That took suite 21's two static_ip deferrals with it. The node can no longer be STATIC, so the deferral the suite waits for cannot fire, and it is skipped for `datacenter` instead - six of ten tests failing on that one fact. Neither side was wrong. The isolation is the point of #1781, and a node with no route out is not a static endpoint. What was wrong is that the property was a side effect of the wiring rather than something a suite declares. So it is declared, the way geolocation and dataCenter already are, and provisioned as the REAL thing rather than a flag the product honours: the entrypoint installs a default route via the network's own gateway before FluxOS starts. It restores the fact without restoring connectivity - an internal network's gateway forwards nothing outward - and its absence is how a suite asks for a NAT'd node, which is what every node is today. Adding it for everyone would have been the same mistake pointing the other way: every node STATIC, no way to be DYNAMIC, and the topology still deciding. seedMongo's staticIp is driven by the same declaration. It is not dead - it is what a node answers during boot, before its first lookup completes and setNodeGeolocation recomputes - but it was seeded true unconditionally while the recompute said DYNAMIC, so it looked like a control and was silently overwritten. db-client's seedGeolocation and dropAndReseed are deleted: dropAndReseed had no callers anywhere in test-infra, and it was seedGeolocation's only one. The route failure is loud rather than `|| true`. A swallowed failure here reads exactly like a node that was never asked for a route, and the suite then fails somewhere else on a classification it cannot explain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… wiring #1781 creates the harness network Internal, so docker gives node containers no default route. FluxOS decides whether a node holds a fixed public address by looking for one - hasPublicIpOnInterface reads /proc/net/route and returns false when there is no default route at all - so on the stacked tree EVERY node reads DYNAMIC. Verified across both gates: pre-stack every node logged "public IP on interface: true", and on this gate all 65 dumped nodes logged false, none true. That took suite 21's two static_ip deferrals with it. The node can no longer be STATIC, so the deferral the suite waits for cannot fire, and it is skipped for `datacenter` instead - six of ten tests failing on that one fact. Neither side was wrong. The isolation is the point of #1781, and a node with no route out is not a static endpoint. What was wrong is that the property was a side effect of the wiring rather than something a suite declares. So it is declared, the way geolocation and dataCenter already are, and provisioned as the REAL thing rather than a flag the product honours: the entrypoint installs a default route via the network's own gateway before FluxOS starts. It restores the fact without restoring connectivity - an internal network's gateway forwards nothing outward - and its absence is how a suite asks for a NAT'd node, which is what every node is today. Adding it for everyone would have been the same mistake pointing the other way: every node STATIC, no way to be DYNAMIC, and the topology still deciding. seedMongo's staticIp is driven by the same declaration. It is not dead - it is what a node answers during boot, before its first lookup completes and setNodeGeolocation recomputes - but it was seeded true unconditionally while the recompute said DYNAMIC, so it looked like a control and was silently overwritten. db-client's seedGeolocation and dropAndReseed are deleted: dropAndReseed had no callers anywhere in test-infra, and it was seedGeolocation's only one. The route failure is loud rather than `|| true`. A swallowed failure here reads exactly like a node that was never asked for a route, and the suite then fails somewhere else on a classification it cannot explain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… wiring #1781 creates the harness network Internal, so docker gives node containers no default route. FluxOS decides whether a node holds a fixed public address by looking for one - hasPublicIpOnInterface reads /proc/net/route and returns false when there is no default route at all - so on the stacked tree EVERY node reads DYNAMIC. Verified across both gates: pre-stack every node logged "public IP on interface: true", and on this gate all 65 dumped nodes logged false, none true. That took suite 21's two static_ip deferrals with it. The node can no longer be STATIC, so the deferral the suite waits for cannot fire, and it is skipped for `datacenter` instead - six of ten tests failing on that one fact. Neither side was wrong. The isolation is the point of #1781, and a node with no route out is not a static endpoint. What was wrong is that the property was a side effect of the wiring rather than something a suite declares. So it is declared, the way geolocation and dataCenter already are, and provisioned as the REAL thing rather than a flag the product honours: the entrypoint installs a default route via the network's own gateway before FluxOS starts. It restores the fact without restoring connectivity - an internal network's gateway forwards nothing outward - and its absence is how a suite asks for a NAT'd node, which is what every node is today. Adding it for everyone would have been the same mistake pointing the other way: every node STATIC, no way to be DYNAMIC, and the topology still deciding. seedMongo's staticIp is driven by the same declaration. It is not dead - it is what a node answers during boot, before its first lookup completes and setNodeGeolocation recomputes - but it was seeded true unconditionally while the recompute said DYNAMIC, so it looked like a control and was silently overwritten. db-client's seedGeolocation and dropAndReseed are deleted: dropAndReseed had no callers anywhere in test-infra, and it was seedGeolocation's only one. The route failure is loud rather than `|| true`. A swallowed failure here reads exactly like a node that was never asked for a route, and the suite then fails somewhere else on a classification it cannot explain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… wiring #1781 creates the harness network Internal, so docker gives node containers no default route. FluxOS decides whether a node holds a fixed public address by looking for one - hasPublicIpOnInterface reads /proc/net/route and returns false when there is no default route at all - so on the stacked tree EVERY node reads DYNAMIC. Verified across both gates: pre-stack every node logged "public IP on interface: true", and on this gate all 65 dumped nodes logged false, none true. That took suite 21's two static_ip deferrals with it. The node can no longer be STATIC, so the deferral the suite waits for cannot fire, and it is skipped for `datacenter` instead - six of ten tests failing on that one fact. Neither side was wrong. The isolation is the point of #1781, and a node with no route out is not a static endpoint. What was wrong is that the property was a side effect of the wiring rather than something a suite declares. So it is declared, the way geolocation and dataCenter already are, and provisioned as the REAL thing rather than a flag the product honours: the entrypoint installs a default route via the network's own gateway before FluxOS starts. It restores the fact without restoring connectivity - an internal network's gateway forwards nothing outward - and its absence is how a suite asks for a NAT'd node, which is what every node is today. Adding it for everyone would have been the same mistake pointing the other way: every node STATIC, no way to be DYNAMIC, and the topology still deciding. seedMongo's staticIp is driven by the same declaration. It is not dead - it is what a node answers during boot, before its first lookup completes and setNodeGeolocation recomputes - but it was seeded true unconditionally while the recompute said DYNAMIC, so it looked like a control and was silently overwritten. db-client's seedGeolocation and dropAndReseed are deleted: dropAndReseed had no callers anywhere in test-infra, and it was seedGeolocation's only one. The route failure is loud rather than `|| true`. A swallowed failure here reads exactly like a node that was never asked for a route, and the suite then fails somewhere else on a classification it cannot explain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… wiring #1781 creates the harness network Internal, so docker gives node containers no default route. FluxOS decides whether a node holds a fixed public address by looking for one - hasPublicIpOnInterface reads /proc/net/route and returns false when there is no default route at all - so on the stacked tree EVERY node reads DYNAMIC. Verified across both gates: pre-stack every node logged "public IP on interface: true", and on this gate all 65 dumped nodes logged false, none true. That took suite 21's two static_ip deferrals with it. The node can no longer be STATIC, so the deferral the suite waits for cannot fire, and it is skipped for `datacenter` instead - six of ten tests failing on that one fact. Neither side was wrong. The isolation is the point of #1781, and a node with no route out is not a static endpoint. What was wrong is that the property was a side effect of the wiring rather than something a suite declares. So it is declared, the way geolocation and dataCenter already are, and provisioned as the REAL thing rather than a flag the product honours: the entrypoint installs a default route via the network's own gateway before FluxOS starts. It restores the fact without restoring connectivity - an internal network's gateway forwards nothing outward - and its absence is how a suite asks for a NAT'd node, which is what every node is today. Adding it for everyone would have been the same mistake pointing the other way: every node STATIC, no way to be DYNAMIC, and the topology still deciding. seedMongo's staticIp is driven by the same declaration. It is not dead - it is what a node answers during boot, before its first lookup completes and setNodeGeolocation recomputes - but it was seeded true unconditionally while the recompute said DYNAMIC, so it looked like a control and was silently overwritten. db-client's seedGeolocation and dropAndReseed are deleted: dropAndReseed had no callers anywhere in test-infra, and it was seedGeolocation's only one. The route failure is loud rather than `|| true`. A swallowed failure here reads exactly like a node that was never asked for a route, and the suite then fails somewhere else on a classification it cannot explain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… wiring #1781 creates the harness network Internal, so docker gives node containers no default route. FluxOS decides whether a node holds a fixed public address by looking for one - hasPublicIpOnInterface reads /proc/net/route and returns false when there is no default route at all - so on the stacked tree EVERY node reads DYNAMIC. Verified across both gates: pre-stack every node logged "public IP on interface: true", and on this gate all 65 dumped nodes logged false, none true. That took suite 21's two static_ip deferrals with it. The node can no longer be STATIC, so the deferral the suite waits for cannot fire, and it is skipped for `datacenter` instead - six of ten tests failing on that one fact. Neither side was wrong. The isolation is the point of #1781, and a node with no route out is not a static endpoint. What was wrong is that the property was a side effect of the wiring rather than something a suite declares. So it is declared, the way geolocation and dataCenter already are, and provisioned as the REAL thing rather than a flag the product honours: the entrypoint installs a default route via the network's own gateway before FluxOS starts. It restores the fact without restoring connectivity - an internal network's gateway forwards nothing outward - and its absence is how a suite asks for a NAT'd node, which is what every node is today. Adding it for everyone would have been the same mistake pointing the other way: every node STATIC, no way to be DYNAMIC, and the topology still deciding. seedMongo's staticIp is driven by the same declaration. It is not dead - it is what a node answers during boot, before its first lookup completes and setNodeGeolocation recomputes - but it was seeded true unconditionally while the recompute said DYNAMIC, so it looked like a control and was silently overwritten. db-client's seedGeolocation and dropAndReseed are deleted: dropAndReseed had no callers anywhere in test-infra, and it was seedGeolocation's only one. The route failure is loud rather than `|| true`. A swallowed failure here reads exactly like a node that was never asked for a route, and the suite then fails somewhere else on a classification it cannot explain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… wiring #1781 creates the harness network Internal, so docker gives node containers no default route. FluxOS decides whether a node holds a fixed public address by looking for one - hasPublicIpOnInterface reads /proc/net/route and returns false when there is no default route at all - so on the stacked tree EVERY node reads DYNAMIC. Verified across both gates: pre-stack every node logged "public IP on interface: true", and on this gate all 65 dumped nodes logged false, none true. That took suite 21's two static_ip deferrals with it. The node can no longer be STATIC, so the deferral the suite waits for cannot fire, and it is skipped for `datacenter` instead - six of ten tests failing on that one fact. Neither side was wrong. The isolation is the point of #1781, and a node with no route out is not a static endpoint. What was wrong is that the property was a side effect of the wiring rather than something a suite declares. So it is declared, the way geolocation and dataCenter already are, and provisioned as the REAL thing rather than a flag the product honours: the entrypoint installs a default route via the network's own gateway before FluxOS starts. It restores the fact without restoring connectivity - an internal network's gateway forwards nothing outward - and its absence is how a suite asks for a NAT'd node, which is what every node is today. Adding it for everyone would have been the same mistake pointing the other way: every node STATIC, no way to be DYNAMIC, and the topology still deciding. seedMongo's staticIp is driven by the same declaration. It is not dead - it is what a node answers during boot, before its first lookup completes and setNodeGeolocation recomputes - but it was seeded true unconditionally while the recompute said DYNAMIC, so it looked like a control and was silently overwritten. db-client's seedGeolocation and dropAndReseed are deleted: dropAndReseed had no callers anywhere in test-infra, and it was seedGeolocation's only one. The route failure is loud rather than `|| true`. A swallowed failure here reads exactly like a node that was never asked for a route, and the suite then fails somewhere else on a classification it cannot explain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Overview
The integration fleet reached the public internet on every run — roughly 1.2 million packets left the box per gate, to Canonical, Docker, NodeSource, Syncthing and GitHub. That made the gate depend on five third parties being up and honest, and it meant a node under test was not quite the node we ship. This branch closes that: the fleet now runs with its network sealed, installs its packages from a signed repository the image carries, and records anything that tries to leave.
Most of this is test harness. But it is not only test harness, and the production changes should not be reviewed as if they were — four of the seven commits are production fixes, three of which were found by building the harness rather than by looking for them, and two of the harness commits carry a production change alongside. The split is spelled out below before anything else, so nothing has to be inferred from a commit title.
Gated twice: 83/83 at
5cb0383b7(60m06s) and 84/84 at5ec88dfbe(60m35s), the second with the fleet network sealed and tcpdump reporting0 packets received by filter.What is production, and what is harness
0ac9563d3endpoints become configurationconfig/default.js,cloudUIUpdateService,geolocationService,fluxService,imageManager,appSpecHelpers,systemService,scripts/update-cloudui.sh85fa7b480a boot sweep is over when it says sofileOperationRecoverygains afileops:recoveredpublication (+14 lines)0c03b6ef9an apt failure resumes into the same failed task, foreverfifoQueue,systemService— no harness files at all5d5425ddca legacy node installs its packages from the fleetmonitorSystem's package checks become awaited rather than fire-and-forgetsetImmediate, publishingsystem:packages-checked5cb0383b7one package apt cannot find stops a node installing the othersfifoQueue,systemService— no harness files at allaaf01e5c8a node installs what a package needs, not what it suggestssystemService— no harness files at all5ec88dfbethe fleet has no way outBy line count:
ZelBack/,scripts/)tests/unit/)test-infra/)The production surface is four files carrying real behaviour change —
fifoQueue.js,systemService.js,cloudUIUpdateService.jsandscripts/update-cloudui.sh— plus five files where an endpoint literal became a config lookup, and one additive event publication.fluxEventBusis test-only, so both publications are observability and neither is on a production path.The apt queue defects
None of these were planned work. All three are invisible while apt succeeds, which is why nothing had found them — and closing the network is precisely what makes apt failure the normal case, so they were fixed one step before they would have started biting.
A failed task resumes into itself, forever
updateAptCachepassesretainErrors: falseso that a failedapt-get updateis dropped rather than left at the head of the queue.queueAptGetCommandbuilt its worker options fromretriesalone, soretainErrorsandretryDelaynever arrived and the queue kept the task.monitorAptCachethen resumes the queue on every failure, and it is async — it awaits severalrunCommands first, so itsresume()lands afterwork()has exited andworkingis back to false. Withretries: 0there is no delay between attempts.Measured against the real modules: 55,812 worker attempts in two seconds, one core flat out, for as long as the mirror stays unreachable.
The fix is to forward every worker option the caller set.
The queue died the moment that shortcut started working (review round three)
Fixing the payload above switched on a trapdoor the payload bug had been holding
shut.
runWorkerhalted the queue after emittingfailed, and an emit is asynchronous yield: the listener runs right there.
monitorAptCacheresumes for afailed update with no
awaitahead of it, so itsresume()landed inside theemit and the next line overwrote it. The loop broke out with work still queued,
workingwent false, and nothing restarted it -push()only callswork()when the queue is not already working. Every apt task behind the update was
stranded, every one pushed later too, and
monitorSystem'sallSettledneversettled:
system:packages-checkedwas never published and chrony, syncthing andnetcat never installed until FluxOS restarted.
The fix is not to make listeners behave, because a queue cannot police them.
It is to stop depending on when they run: settle state first, then notify, and a
listener may reply instantly, slowly or not at all. Two consequences follow. A
resumed final failure reaches the retry sleep with no retries left, so a
ladder-over break stops it waiting out the full delay - 60s by default - holding
everything behind it. And
resume()no longer reassignsfinishedwhen calledre-entrantly:
work()returns at once while the loop is running, and storingthat resolved promise told
clear()the queue was idle mid-flight, on theapt-is-broken path
systemServicereaches from these very failures.An apt command completing is now a published fact, which is what makes this
testable at all. A legacy boot runs a handful of them, so it is an event rather
than a cadence, and which completed - in what order - is the only direct evidence
the queue carried on past one that failed. Suite 94 gains the third regime a real
node boots into, packages missing and
apt-get updatefailing:createTestEnv({ aptBadSource: true })adds a source apt cannot reach alongsidethe good one, so the update fails as it does behind a dead mirror while the work
behind it stays doable - swapping the good source out would fail that too and
prove only that a broken node stays broken. Proven both ways on a real fleet:
green 8/8 with the fix, and with the halt moved back after the emit all three
tests of that regime fail.
The recovery routine could not see what had failed
runWorkeremitted the whole payload asoptions, but for the{commandOptions, workerOptions}shape the command sits one level down — sooptions.commandwas alwaysundefined, and the routine's opening shortcut, which treats anapt-get updatefailure as not worth panicking over, had never once fired. Every apt failure instead took the full recovery path: up to thirty minutes waiting on the dpkg lock, thenfuser -KILLon it, thendpkg --configure -a, thenapt-get install --fix-broken— for a node that simply could not reach a mirror.The fix is to emit
commandOptions.One package apt cannot find stops a node installing any of the others
A retained task went back to the front of the queue, so the next resume handed it straight back to the worker ahead of everything already waiting. Observed on a real node with an empty package index:
netcat-openbsdcould not be located and worked through its retry ladder, and when the ladder ended a resume granted it a fresh one. chrony and syncthing, queued behind it, were never attempted at all — and there was no ceiling, because each resume started the cycle again for the life of the process.Retained tasks now go to the back, and a task is handed back at most
maxRetainCycles(default 3) times before the queue emitsabandonedand gives up.systemServicelogs that, because the caller already took its error when the first ladder ended and nothing else would ever learn the work had stopped.Two existing tests encoded two of these behaviours
One asserted that the failed task sits at the head of the queue, with a comment saying so, and five asserted the exact apt argument list. Both were updated deliberately — the expectations were the defect, not the guard.
Every endpoint on the boot and app-lifecycle path is configuration
Endpoint literals scattered through
cloudUIUpdateService,geolocationService,fluxService,imageManager,appSpecHelpers,systemServiceandscripts/update-cloudui.shmove intoZelBack/config/default.js. Configuration lives in the directory fluxbench hashes, so redirecting a node stays detectable tampering — which is why this is configuration and deliberately not an environment variable.The heading is scoped deliberately, because the sweep is not total. Two endpoints outside that path remain literals and are follow-ups rather than oversights:
watchdogService's clone URL forfluxnode-watchdog, and the Azure provider'slogin.microsoftonline.comauthority. Neither runs during boot or an app operation, which is why suite 95 stays green with them in place — that suite proves what the fleet reaches while a gate runs.Two further literals are deliberately staying, and are not endpoints at all. The Google and Azure registry scopes (
.../auth/cloud-platform,containerregistry.azure.net/.default) are permission identifiers those providers define, carried inside an auth request and never fetched — a node with a "configured" scope is a node that fails to authenticate. AndpaymentService'ssuccess_urlis handed to Stripe so it can redirect the customer's browser after checkout; FluxOS never opens a connection to it.The fleet serves its own packages
The dependency closure is resolved at image build into a signed apt repository the image carries. Nothing names a package or a version: whatever apt puts in the archive cache is the closure it resolved, against this image's own package state.
One production change rides with this and is worth a reviewer's attention.
monitorSystem's four package checks were fired and forgotten throughsetImmediate, so nothing could tell a check that ran and found its work done from one that never started. They are now awaited, and the result is published assystem:packages-checkedwith the list of what was actually installed — published whether or not anything was, on the same principle asfileops:recovered, because presence alone cannot distinguish those two states on a seeded node. The checks still run through the same one-at-a-time apt queue; what changes is thatmonitorSystemnow knows when they are finished.Packages are pre-seeded at image build, so a normal fleet boots into the steady state a production node is already in —
monitorSystem()runs for real, finds its prerequisites satisfied, and installs nothing. A per-suiteaptSeeded: falseflag purges the three packages before FluxOS starts, so the install path is covered too. Purge rather than remove, because a removed package leaves its config behind and reports asdeinstall ok config-files, which is neither installed nor absent and which no real node is ever in.Base packages are served over
file://from the image; only syncthing's source goes over HTTP to the stub, because that is the one source FluxOS actually writes — so the keyring fetch, the source write, apt's HTTP transport and signature verification all run as they do on a node. Signing is load-bearing and proven by mutation: swapping the keyring for an impostor's makes apt refuse both distributions withNO_PUBKEYand the packages become unlocatable.Syncthing now installs from a
.debrather than a release tarball. Its repository carries only the two most recent releases, so a.debcannot pin an old version the way a GitHub release can, and the version therefore moves on every rebuild —build-apt-repo.shrecords it tosyncthing.versionand the stub reads that file, so nothing downstream restates it. One side effect is worth naming: the package is built[noupgrade]and the tarball is not, so theupgrades.syncthing.nettraffic existed because the harness installed the tarball, and switching closed it by construction.The fleet has no way out, and says so when something tries
The fleet network is
Internal: true— no route off it at all. Nodes resolve through the external stub, which relays each query to the embedded Docker resolver at127.0.0.11(the one that knows every container alias on the network) and refuses anything it cannot answer within 300ms, recording the name and the node that asked. Relaying rather than answering from a list means aliases need no second copy in the stub and cannot drift from the ones the runner actually creates.The redirection goes through
/etc/resolv.confdeliberately: FluxOS resolves via c-ares, which does not read/etc/hosts, so a hosts entry would have covered nothing.Suite 95's third test is the reason the other two mean anything. It deliberately reaches for
stats.runonflux.ioand asserts the resolver recorded it. On the first run that test failed while both hermeticity assertions passed — the sinkhole was wired to nothing, so "the fleet reached nothing outside" was being recorded against a resolver that recorded nothing at all. Any assertion that something did not happen needs a sibling proving the instrument can see it happen.systemService hygiene
aptRunnernow passes--no-install-recommends. Without it, a node asking for four packages installed twenty-one, includingshared-mime-info, dbus, glib and python3-gi — which is where ten legacy nodes sitting in uninterruptible sleep runningupdate-mime-databasecame from.Release note. This lands in
aptRunner, so it applies to every apt invocation on every existing legacy node, not just the new ones — the change here most likely to surprise someone operationally. The four packages involved are safe without recommends, and an unseeded install was exercised at gate scale. Also operational: all three entry points nowdelete process.env.NODE_CONFIGat startup, so thenode-configenvironment override no longer reaches FluxOS — nothing in the repo used it, but any external tooling that set it loses its overrides.addSyncthingRepository()moves to where the install is. It previously ran before any version check and short-circuited only if the source file already existed, so a node whose syncthing was perfectly current still fetched a release key and wrote a keyring and an apt source it would never read.Measurement
Egress per full gate, captured on the host with tcpdump filtered to the harness subnet:
archive/security.ubuntu.comdownload.docker.com/deb.nodesource.comapt.syncthing.net/syncthing.netupgrades.syncthing.netgithub.comrelays.syncthing.netThe final capture's own tally is
0 packets captured, 0 packets received by filter. Before anything was claimed from that zero, a container on the harness subnet curledarchive.ubuntu.comthrough the same filter and produced 30 packets with the hostname visible — an empty measurement has two explanations, and the boring one is a broken instrument.Testing
94-legacy-package-provisioningcovers all three regimes a legacy node can boot into — seeded, unseeded, and unseeded withapt-get updatefailing — where none were covered before.95-fleet-reaches-nothing-outsidecovers both node types, plus the instrument check described above.Dockerfile.fluxos, which this branch then deletes by design — syncthing arrives as a package from the image's signed apt repository, whosesigned-byverification supersedes it.Notes for review
fix/restart-pacing-crash-only(Reconciler: the only actuator for every operator command, and a ladder that paces faults not operators #1780). That is sequencing, not a code dependency.git diff 6f7016ad3..HEAD -- ZelBack scriptsis the whole of it — 10 files, +181/−45.