Repository navigation
Releases
v0.2.14
Fixed
- The App Health "Errors and warnings" panel now respects the namespace dropdown (bug-223). Its LogsQL query was
{kubernetes.pod_namespace=~"$namespace"} error OR ERROR OR warn OR WARN OR fatal OR panic. The stream filter bound only toerror; every other term matched all logs, from every namespace and the hosts. With "wanderer" selected it returned 45,161 lines in an hour, when the namespace had 568 in total. The OR group is now parenthesised. Verified against VictoriaLogs: that same selection now returns 0, and the per-namespace counts are what each namespace actually logs.
Install
- From server1:
substrate install-fleet v0.2.14to preview, then--apply. substrate deploy-observability --apply, which re-provisions the dashboard.
No pin changes. 216 tests.
Known
- vmagent logs about 96 "skipping duplicate scrape target" errors a minute. Pods that declare several ports yield one identical target per port, and vmagent scrapes one. No metric is wrong, but the volume dominates
observabilityin this panel. Not changed in this release: the obvious relabel would silently drop a k0s system pod that serves metrics on an undeclared port.
v0.2.13
Fixed
- The roll no longer trusts a Longhorn write it did not check (bug-215). Every controller leave or join makes k0s restart every konnectivity agent, and until they reconnect a call through a Longhorn webhook fails with
No agent available. On the 2026-10-27 roll that left a destroyed node's replica records in place while the roll printed that it had removed them, and a volume then could not attach. Now:- the roll waits for
rollout status ds/konnectivity-agentafter the leave and again after the join, before any Longhorn write; - Longhorn writes (the replica delete and the disk re-admit patches) retry
No agent availablefor up to a minute; - the replica delete's result is checked, and the records are re-read afterwards. Any record on the dead node whose deletion was not accepted halts the roll before the node is re-created, with the record names.
- the roll waits for
- The flux-operator bump bot no longer rewrites cosign's checksum. Its
sedreplaced every two-spacesha256:inversions.yml, and cosign is the second one. PR #5 (v0.62.1) carried that error and was closed unmerged. The edit is now scoped to theflux_operatorblock, and the job fails unless exactly those two lines changed.
Pins
| Component | From | To | Why |
|---|---|---|---|
| Grafana | v13.2.4 | v13.3.1 | security: six CVEs fixed in 13.3.1/13.2.6, plus those in 13.3.0 |
| VictoriaMetrics | v1.154.1 | v1.156.0 | Go 1.27.3 and base-image security updates, bug fixes |
| VictoriaLogs datasource | v0.33.1 | v0.34.0 | alerting fix for empty Instant stats queries; needs Grafana ≥ 10.4 |
| flux-operator | v0.61.0 | v0.62.1 | maintenance; applies at the next node build |
| rustix (build dependency) | 1.1.4 | 1.1.6 | RUSTSEC-2026-0486 (unsound), which fails the release audit |
Each sha256 was checked against upstream's published checksum: sha256 for VictoriaMetrics and Grafana; SHA-1 for the datasource, whose sha256 was then computed from the same artefact. The flux-operator sha256 was computed from the downloaded install.yaml.
Install
- From server1:
substrate install-fleet v0.2.13to preview, then--apply. It installs on every hypervisor and ends by checking each host's version. substrate deploy-observability --apply(Grafana, VictoriaMetrics, the datasource). Then confirm Grafana's VictoriaLogs panels still load.- flux-operator changes nothing until the next node build. The roll changes are proven by the next roll.
216 tests.
Known
- The konnectivity wait narrows the window rather than closing it: k0s may rewrite the DaemonSet a moment after the wait returns. The retry and the re-read are what make the roll safe. The proof is the next roll passing without
roll_runbook.md§2a done by hand.
v0.2.12
v0.2.12 — what it reports is what it checked, and a roll leaves nothing behind
Two kinds of fix, both found the hard way. On 2026-10-26 the site read "10 of 10 invariants hold — no findings" while one host could not read its own configuration. And the 2026-10-23 roll needed a human at every node to do what the roll should have: re-admit disks, clear dead records, keep a second roll from starting.
Reporting what was checked (ADR-206)
- An unreadable config is a failure (bug-208). posture-check reads its configuration once. If it cannot, it records
site config unreadable — every check that needs it was skipped: <error>and exits non-zero. It used to read the file five times and treat each failure as "nothing configured", which is how a host that could not parse its config published a clean, smaller count. - Deploys refuse a mixed fleet (bug-209).
deploy-updates,deploy-posture,deploy-observability,deploy-cplbanddeploy-resolver --applyfirst ask every hypervisor which substrate it runs, and refuse unless all match the deploying release. A host that cannot be asked counts as a mismatch. - A firewall port counts only when the allow-list is proven (bug-211). Held means the denied source was refused AND the allowed source connected. The store's ports are probed only on the host that runs the store. A host that cannot reach its allowed prober now reports the port as information instead of a pass.
A roll that does its own steps (ADR-205, ADR-207)
- One roll at a time (bug-205): a fleet-wide lock in the cluster (
kube-system/substrate-roll-lock). A second roll refuses and names the holder. A killed roll leaves the lock, and the refusal says how to clear it after checking the volumes. - Drain before destroy (bug-203): pods stop cleanly and move while the node still answers. A drain that does not finish in 300 s un-cordons the node and halts the roll with nothing destroyed.
- No dead records left behind (bug-201): the destroyed node's Longhorn replica records are deleted with its etcd member, Node object and volume attachments. Left in place, they fooled the last-replica guard and stopped a detached volume from attaching.
- The guard trusts evidence, not records (bug-201): a replica is a copy only if it never failed, finished building, and sits on a disk that is Ready under the same UUID on a node Longhorn sees Ready. The nightly storage line uses the same rule. The guard fails closed when it cannot read the answer.
- The disk re-admit waits for a verdict (bug-202): it polls until Longhorn calls the new disk stale (then re-admits it) or freshly Ready, ignores a Ready left over from the destroyed disk, and halts after 300 s of neither. The roll will not move on while the rolled node's disk is not admitted.
--nodesays what it did (bug-207): " rebuilt on the pinned image — the other nodes were not touched".
Every node trusts every operator (ADR-207, bug-210)
hypervisors.<name>.ssh_public_keydeclares each hypervisor's public key, and every node authorizes all of them. Before this, a node trusted only the key of the machine that built it, so after a roll from server1, server2 could reach no node.- A site that declares no keys behaves as before. If keys are declared and the roller's own key is not among them, roll, provision and rebuild refuse before destroying anything.
One command installs the fleet (ADR-208)
substrate install-fleet <version>installs a release on every hypervisor from one place.install.shonly ever installed where it ran, which is how server2 was left a release behind twice. The command:- is a dry run by default;
--applyinstalls - runs each host's installs in one interactive session, so sudo asks once per host
- installs every release in between, in order, so a host two behind gets the middle one first
- refuses a downgrade, and refuses any path through a release that has no binary
- installs nowhere if any host is refused
- finishes by asking each host what it runs
- is a dry run by default;
- Each host still verifies the download itself, with its own cosign.
install.shis now carried in the signed binary, so the host without a checkout no longer fetches it from raw GitHub.
Operator notes — ORDER MATTERS
./install.sh v0.2.12on server1, thensubstrate install-fleet v0.2.12 --applyfrom server1, which brings server2. This bootstrap is needed only once, because v0.2.11 lacks the command. It ends by checking every host's version.- Only then add
ssh_public_keyfor both hypervisors to site.yml. v0.2.11 and earlier cannot parse the field. The new version check refuses a deploy to an older host, but it should not be the only thing standing in the way. substrate deploy-posture --apply.- The new keys reach the nodes at their next build, so the next roll is when server2 regains access. Until then server2 reports its 9100 firewall probe as information and publishes 18 held invariants, against server1's 19. That is the honest count.
- The
roll_runbook.mdhand-steps for disk re-admit, dead replica records and "one node at a time" are now done by the roll. Keep the runbook's checks until one full roll has proven it.
214 tests.
v0.2.11
v0.2.11 — a volume survives losing a machine, and the platform checks it
On 2026-10-23 every replica of every Longhorn volume was on one hypervisor. The settings promised replicas would reach the other one; nothing checked, and nothing made it happen. Losing that machine would have meant a restore, not a survival.
Three gaps lined up. The replica anti-affinity was per node, and three nodes on one machine satisfy it. No node carried a zone: the hypervisor was declared under invariant-platform.io/hypervisor, which Longhorn ignores, and the standard key had been left out on the belief that a kubelet may not set *.kubernetes.io/ labels on itself. That is false for this key — topology.kubernetes.io/zone is on the kubelet allow list. And with every node in one zone, Longhorn placed replicas by free disk.
Fixed
- bug-200 — every node now registers with
topology.kubernetes.io/zone=<hypervisor>, rendered in the same--labelsflag asinvariant-platform.io/hypervisorand with the same value, so the two keys cannot describe different domains. Kubelet applies it only when it creates the Node, so nodes built before this release need a one-time label (below).
Added
- A storage line in
posture-check. Every volume must hold a usable replica on every hypervisor, judged on where the replicas are rather than on how the cluster is configured — a correct label with replicas still piled up fails. A stopped replica of a detached volume counts; a failed one, or one on a node that no longer exists, does not. One finding per volume short of a machine, naming the volume and how many machines it reaches. Fewer than two hypervisors, or no volumes, is information and uncounted (ADR-199). 18 → 19. substrate gatechecks the zone label, separately from the hypervisor label, and prints the one-time remedy. It is not one of the roll's between-node checks, so it cannot halt a roll part-way.
Changed — YAML is now parsed in safe Rust
- serde_yaml_ng → serde-saphyr (ADR-204). The first build of this release stopped at the RustSec gate:
unsafe-libyaml, the parser underneath serde_yaml_ng, is unmaintained (RUSTSEC-2024-0449, assigned 2026-10-12) — a machine translation of libyaml's C into unsafe Rust that will not be fixed. Rather than ignore the advisory, the parser is gone: serde-saphyr is written in safe Rust, and every YAML call goes through one module (substrate_core::yaml). - Booleans are strict: an unquoted
yes,no,onoroffstays a string, as it did before. - On the real configuration the old and new parsers produce identical plans for every deploy. The two YAML files substrate writes — Grafana's alert provisioning and the merged kubeconfig — carry the same data with their keys in sorted order, so the next
deploy-observabilityrestarts Grafana once. - The advisory gate now runs on every push and daily, not only after a release exists, so the next new advisory is a red build on main rather than a release with no binary.
Security — RUSTSEC-2026-0387
- p256 0.13 → 0.14 (with x509-cert 0.3, sha2 0.11).
crypto-bigint0.5, underneath p256 0.13, could run secret-dependent branches in operations meant to be constant-time — a timing side channel on the key that signs the just-in-time admin certificate requests. Published 2026-10-23, after this release's first build; fixed in crypto-bigint 0.7.6+. The generated key and certificate request were verified independently with OpenSSL (signature, subject, P-256, key matches request).
This release was first published without assets and has been rebuilt in place under the same number: nothing was ever installed from it.
Operator notes
- The matching cluster-side change is in
substrate_config:replica-zone-soft-anti-affinity: "true"(deliberately soft — with three replicas and two zones a hard rule can never place the third) andreplica-auto-balance: "best-effort", which moves replicas that are already lopsided. - Nodes built before v0.2.11 need the zone label once, through break-glass:
kubectl label node <node> topology.kubernetes.io/zone=<hypervisor>. The next roll makes it permanent. install.sh v0.2.11on each host, thensubstrate deploy-posture --applyfrom the host that reaches the others.
187 tests.
v0.2.10
v0.2.10 — the platform checks that its own backups ran
Fifteen invariants were re-asserted every night and not one of them was "the backups ran". The whole claim is that a node is rebuilt rather than repaired, and a rebuild restores from a backup, so the backup was the one load-bearing thing nothing asserted. The restore drills prove the mechanism worked once; they say nothing about whether last night produced a file.
Added
posture.backups— a declared list of{namespace, cronjob, max_age_hours}. Each one's last SUCCESS must be recent, and each gets its own line. 15 → 18 invariants.- Reads
lastSuccessfulTime, neverlastScheduleTime: a CronJob that fires nightly and fails nightly keeps a fresh schedule time forever, which is exactly the silent failure worth catching. - DECLARED rather than discovered. A check that went looking for CronJobs that look like backups would fall silent the moment one was renamed or deleted — the two failures most worth catching. A declared name with no matching CronJob is a FAILURE, so deleting a backup is not a way to stop being asked about it.
- A site that declares none prints
backups: none declared, not assertedas info, never as a note (ADR-199): a line that cannot fail must not raise the number that holds.
- Reads
Fixed
-
bug-198 —
deploy-observabilityinstalled a new binary and left the old process running. The installer already restarted a unit whose FILE changed and one whose startup-only CONFIG changed; a replaced EXECUTABLE was covered by neither, because the unit file and every config file stayed byte-identical andsystemctl enable --nowdoes nothing to a running unit. So the deploy printed "fetching v1.153.0", "active" and "host matches what was deployed" while two stale processes served — from two SECURITY releases, which left a patched binary on disk and an unpatched process answering requests.The install shapes now record what they replaced, and the restart pass asks what the unit loop already asked one layer up: is this process older than the artefact it is executing? That phrasing handles a fresh install for free, since a unit just started is newer than the binary it started from.
-
The verification line reports the version the PROCESS returns, not
systemctl is-active. The old line was true and said nothing about which binary, which is how this passed its own check.
Operator notes
install.sh v0.2.10on each host, then from the host that reaches the others:substrate deploy-posture --apply(ships thesite.ymlcarrying the newbackups:block) andsubstrate deploy-observability --apply.- That
deploy-observability --applyis the one that finally moves VictoriaMetrics to v1.153.0 and VictoriaLogs to v1.53.0 in the running processes. Confirm with the new line:victoria-metrics: active running v1.153.0. - The invariant count on the site follows the hosts, not this release. It reads 15 until a host on v0.2.10 with the new
site.ymlrecords a run (ADR-173).
175 tests.
v0.2.9
v0.2.9 — a roll stops breaking the peers' probes
A roll replaces nodes, and replaced nodes present new SSH host keys. Only the hypervisor that ran the roll learned them; every other hypervisor kept the old ones, so its peer probe failed on a key mismatch and degraded to [ -- ] without ever saying why. The probe looked like it was running. It was refusing to connect.
Fixed
- bug-177 — the host keys are scanned once, from one vantage, and the same lines are then installed on every hypervisor, so the fleet trusts one set of keys rather than each host accepting whatever it is shown first.
scan_host_keys,install_host_keysandrefresh_client_accessare called from the roll and from both rebuild paths. The addresses are passed as positional parameters, never interpolated into the script — a test passes10.99.0.21; rm -rf /as a node address and asserts no address appears in the script text. - bug-179 —
deploy-updates --repo .no longer needs the fleet to preview. The dry run plans a placeholder where the kubeconfig goes and says so; the fetch happens only under--apply, and an apply whose kubeconfig was never fetched refuses rather than writing the placeholder to a host.
Added
deploy-updatesjoins the other two in the CI preview step, in both workflows. All three deploys now plan against the fixture site after every build, which is what bug-179 was blocking.
Operator notes
install.sh v0.2.9on each host. No--applyis required: every shipped file is byte-identical to v0.2.8, and the two fixes are in code paths the roll and the rebuild take.- The bug-177 fix lands on the next roll. Until then, a host whose peer probe shows
[ -- ]can be cleared by hand:ssh-keygen -R <node-ip> && ssh -o StrictHostKeyChecking=accept-new <admin>@<node-ip> true.
163 tests.
v0.2.8
v0.2.8 — deploy-posture plans again
v0.2.7 could not run deploy-posture: it asked the release payload for versions.yml, which is configuration and lives in --repo, and refused itself with versions.yml is not part of this release's payload. The other two deploys were correct. Nothing reached a host from v0.2.7's posture deploy, so nothing on a host is wrong — but the version could not do its job, and under ADR-200 the only fix is the next release.
Fixed
- bug-178 —
deploy_posture::planreadsversions.ymlfrom--repo, assite.ymlalready was. A test now plans against a repo whoseversions.ymlwas edited and asserts the edited bytes are what would reach the host — the plan takes exactly two files from--repoand everything else from the payload.
Added
- Both workflows run
deploy-postureanddeploy-observabilitypreviews againsttests/fixturesafter the build. The preview that would have caught v0.2.7 was never run by hand; it is now run by the machine, every time. (deploy-updatesis not yet in that step: its preview still fetches a kubeconfig over ssh — bug-179, open.) tests/fixtures/site.ymlcarries the posture status ids the deploy needs.
Operator notes
install.sh v0.2.8on each host, then from the host that reaches the others:substrate deploy-updates --apply(firstPEER_HOSTS=apply) andsubstrate deploy-posture --apply.
162 tests.
v0.2.7
v0.2.7 — the release carries its payload
ADR-200. Until now the signed binary was a release but the files it put on a host — the nightly-maintenance script, the posture and observability units, the Grafana templates and dashboards — were read at run time from whatever --repo pointed at: any commit, any branch, a hand-edited tree. deploy-updates --apply from a checkout could ship a script no release ever contained. It cannot now. Every file a deploy-* command installs is embedded in the binary at build time, so the only way a script reaches a hypervisor is inside a signed release.
Changed
payload.rs— all 29 shipped files (hypervisor-update.sh,hypervisor-uncordon.sh,notify.sh,grafana-health.sh,substrate-reconcile.sh, four update units, four posture units, nine observability units, the Grafana and Traefik templates, provisioning, three dashboards) areinclude_bytes!in an explicit list. No build script walks a directory: a test walkssystemd/andobservability-host/and fails when a file is on disk but not listed, or listed but gone. The three auto-roll units are recorded as deliberately unshipped (ADR-186) with the reason beside them.deploy-updates,deploy-postureanddeploy-observabilityread only the payload.--reposuppliessite.ymlandversions.ymland nothing else —/etc/substrateon a host is a complete repo. Dashboards are enumerated from the payload, not from a directory at deploy time.- Every deploy opens with
payload: substrate 0.2.7 (v0.2.7) — every file below is this release's, not the checkout's. release.yamllints every shipped script with the same shellcheck thetestworkflow runs. v0.2.6's commit was red ontestand green onrelease(bug-175); one commit now gets one verdict.
Fixed
- bug-175 — SC2086 in
hypervisor-update.sh(peer count). Rides in this release, which is now the only way a script fix ships. - bug-176 — a deploy could install a script from the working tree. Closed by construction: the working tree is no longer an input.
Operator notes
install.sh v0.2.7on each host. Then, once, from the host that reaches the others:substrate deploy-updates --apply— the first apply that writesPEER_HOSTS=(v0.2.6 introduced the list; this is the release that installs the script honouring it) — andsubstrate deploy-posture --apply. Both fan out to every hypervisor insite.yml.- A change to any shipped script or unit is a release from here on. There is no other path.
161 tests.
v0.2.6
v0.2.6 — the fleet is not two hosts
Phase A of ADR-199: substrate stops assuming a second hypervisor exists, and stops assuming only one does. Nothing on a two-host estate changes — the posture line, the count and the unit files render as before — but a one-host site and a three-host site now get the same tooling with the truth told about each.
Fixed
- bug-173 — peers are a list.
deploy-updatespaired each hypervisor with the first other host by name, so at three hosts two of them gated their nightly reboot on the same third host and could reboot together. The reboot interlock now checks every other host's lock, VMs and reachability (PEER_HOSTS); posture-check probes every peer's SELinux and units and asks the firewall matrix from every vantage point; a one-host site has an empty list, says so, and still checks the cluster. - bug-174 — nothing unasserted is counted. "unreachable, not checked", "not configured, check skipped" and "site config incomplete" were filed as invariants that held. They are now
[ -- ]information: printed, never counted, never published. The count on the strip is the number of things actually asserted.check_credentialsis not asked at all where no secrets manager is configured. validateno longer demands apeer_targeton a site with one hypervisor.
Added
tests/fixtures/site-one-host.ymlandsite-three-hosts.yml— every fleet-shape assumption is a test against one of them. A node's cloud-config is proven byte-identical across one, two and three hosts.updates::peers_of,peer_hosts_value;Report.info;posture::note_no_peers.
Operator notes
install.sh v0.2.6on each host, thensubstrate deploy-updates --apply(rewrites/etc/homelab/update.envwithPEER_HOSTS=; the script honours an oldPEER_HOST=until then) andsubstrate deploy-posture --apply.- Expect the same fifteen lines tomorrow morning.
157 tests.
v0.2.5
v0.2.5 — the roll owns its steps
The first roll on v0.2.4 (2026-09-16, Kairos 4.2.0 → 4.3.0, k0s 1.36.3 → 1.36.4) replaced all five nodes and needed a human four times. This release makes each of those a step in substrate roll, so the operator's terminal stays empty from --yes to roll complete.
Fixed
- bug-154 — the re-created node's Longhorn disk is re-admitted automatically. Longhorn keeps its node record across a same-name replacement, so every fresh disk came back as
DiskNotReady: record diskUUID doesn't match the one on the diskand the three-patch procedure (evict → remove → re-add under a new key) was run by hand on every node of both rolls.rollnow does it after the join, keeping the disk's ownstorageReserved(bug-159 cannot recur), retrying while Longhorn's webhook is still syncing, and halting with the condition message if the disk is not Ready and Schedulable within two minutes.forget_nodeno longer spends two minutes per node waiting for Longhorn to forget a node it never forgets. - bug-170 — the dead node's volumes are released at once. A destroyed node's VolumeAttachments lingered until the attach/detach controller's six-minute unmount wait expired, so every single-writer tenant (PocketBase, Meilisearch) served 500s for ~3.5 minutes per node.
rolldeletes them right after the Node object; the rescheduled pod mounts in seconds. - bug-171 — the gate's local kubectl always uses the
break-glasscontext. It inherited the operator's current context; withbrad(read-only, ADR-071) selected,/healthz/etcdwas forbidden on every node, the etcd wait reported "not serving" across a healthy cluster, and the roll was three minutes from halting after one node. Regression-tested. - bug-158 — refuse to destroy a volume's last healthy replica. Before destroying a node,
rollreads every Longhorn replica and refuses — naming the volumes — if any volume would be left with no never-failed replica elsewhere. Detached volumes are held to the same standard as attached ones; exempting them is howgarmin-sync-stateeroded to zero replicas across four replacements on v0.2.3.
Operator notes
- Nothing changes on the hypervisors beyond the binary:
install.sh v0.2.5on each, thensubstrate deploy-posture --applyto re-stage/etc/substratefrom the checkout (first live run of the installer's 0600 enforcement on a keptpublish-status.env). - The acceptance test is the next bump's roll: no WARNING, no disk dance, no context switch, no 500 on trails.
141 tests. Verified: cosign identity bradpenney/substrate/.github/workflows/release.yaml@refs/tags/v0.2.5.