Compare commits

...
Author SHA1 Message Date
jochen c9b963f8b9 A failed install says whether the package database is stale, how old it is, and what fixes it (hq issue 205)
pacman writes its errors to stderr, which the runner folds into the error rather than the output;
the stale-index classifier read the output alone and never saw a single 'failed retrieving file',
so a ten-week-old database on the control node reported as a wall of 404s from the mirrors. The
classifier now reads everything pacman said, knows a failed signature as the same staleness, tells a
mirror outage with a fresh database apart from it, and names the database's date and age beside the
remedy: a full upgrade by the operator, never a one-package sync, which on this distribution is a
partial upgrade. Whose job keeping the database current is stays issue 205's question.
2026-10-03 03:54:17 +02:00
mesh-admin 5a6e51f975 Merge pull request 'The first user list names the build role as node-build-agent (hq ADR 0190)' (#78) from fix/the-build-role-is-node-build-agent into main 2026-10-03 00:53:44 +00:00
jochen 3c96683d98 The first user list carries both build seats during the handover (hq ADR 0190)
Until the retired mesh-build-machine row is dropped the controller asks and hears both roles, so
the genesis list grants both; the controller's own test of this list says so.
2026-10-03 02:53:12 +02:00
jochen 8c4e33c165 The first user list names the build role as node-build-agent (hq ADR 0190)
The controller composes the build seat's subjects as node-build-agent's now; the installer's
genesis list must say the same, or the controller's own test of that list disagrees with it.
2026-10-02 22:37:08 +02:00
mesh-admin 2a332b0e6e Merge pull request 'The mesh's own ban chain is a ban wherever it hangs (hq ADR 0186)' (#76) from fix/a-ban-list-never-holds-a-neighbour into main 2026-10-02 16:43:09 +00:00
jschoubben b94e0f9a77 The mesh's own ban chain is a ban wherever it hangs; the front end's record is cited as 0180 (hq ADR 0186)
The legacy reader required every path into a chain of refusals to come from a built-in whose policy
accepts. The home server's ban chain hangs off the container runtime's user chain, whose forward
policy the runtime set to DROP, so the machine reported the mesh's own intrusion prevention as a
rule set the mesh did not write. A chain is a ban when every refusal names its sources and the
chain accepts nothing — the rule the nftables side already used. A chain that accepts anything is
still not a ban. Fixture captured from the machine. The citations for the uninstalled front end move
to ADR 0180, which another session's renumber had left pointing at an unrelated record.
2026-10-02 18:42:16 +02:00
mesh-admin b840ce0a77 Merge pull request 'A service the mesh asked to run is still running a moment later (hq ADR 0184)' (#75) from fix/a-service-asked-to-run-is-still-running into main 2026-10-02 16:25:25 +00:00
jschoubben 286865dfa7 A service the mesh asked to run is still running a moment later (hq ADR 0184)
The read-back raced the failure: a service manager returns when it has started the process, and a
daemon that refuses its configuration exits a fraction of a second later, so one look saw it alive.
fail2ban took 221ms on the control node and the apply reported "restarted" onto a dead daemon while
both public machines kept no bans at all. The host looks again, after that moment. A unit still
starting is accepted at both looks; a service asked to stop is not waited on.
2026-10-02 18:16:24 +02:00
mesh-admin ca7c4a5915 Merge pull request 'A container may log to the journal (hq ADR 0179)' (#73) from feat/the-intrusion-seat-serves-its-verbs into main 2026-10-02 15:04:02 +00:00
jschoubben b30d9c5b5a A container may log to the journal (hq ADR 0179)
A jail reads a log; a container's output went to a file of the runtime's own under a path that
changes on recreate, so no jail could read a container's service. logging: journald runs the
container with the journal as its driver, named in the spec so moving it recreates it; any other
place is refused.
2026-10-02 17:02:49 +02:00
15 changed files with 669 additions and 56 deletions
+1 -1
View File
@@ -161,7 +161,7 @@
"type": "file",
"path": "/var/lib/mesh-bus-conf/accounts.conf",
"mode": "0600",
"content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.API.>\", \"_INBOX.enrol.>\", \"mesh.assignment.>\", \"mesh.control.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\"] }\n subscribe: { allow: [\"$JS.API.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.mesh-controller.tool.>\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n"
"content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.API.>\", \"_INBOX.enrol.>\", \"mesh.assignment.>\", \"mesh.control.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.node-build-agent.accept.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\"] }\n subscribe: { allow: [\"$JS.API.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.node-build-agent.event.built\", \"mesh.seat.mesh-controller.tool.>\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n"
},
{
"id": "broker",
+47 -3
View File
@@ -1041,6 +1041,38 @@ type unitReloader interface {
ReloadUnits(ctx context.Context, run system.Runner) error
}
// serviceSettle is how long the host waits before looking at a unit a second time. A test sets it
// to nothing; on a machine it is the window in which a daemon that refuses its configuration dies.
var serviceSettle = 2 * time.Second
// stayedRunning is the state of a unit the host has just asked to run, read twice.
//
// **Because the first read races the failure.** A service manager returns when it has started the
// process, and the unit is "activating" or "active" at that instant whatever the process is about
// to do. A daemon that reads its configuration, refuses it and exits does so a fraction of a second
// later — fail2ban took 221 milliseconds the day this was written — so a single read back says
// running about a machine whose daemon is already gone, and the apply reports "restarted" for a
// service that is dead. Every ban on both public machines was lost that way while every check
// passed (novox/hq ADR 0184), which is the one shape of failure this host exists to refuse.
//
// So it looks again, after the moment in which that happens. It does not wait for a slow unit to
// finish starting: a unit still coming up reads as running both times and is accepted, as before.
// What this catches is a unit that was running and is not any more.
func stayedRunning(ctx context.Context, sys system.System, run Runner, unit string) (string, error) {
state, err := sys.ServiceState(ctx, run, unit)
if err != nil || state != "running" {
return state, err
}
timer := time.NewTimer(serviceSettle)
defer timer.Stop()
select {
case <-ctx.Done():
return state, ctx.Err()
case <-timer.C:
}
return sys.ServiceState(ctx, run, unit)
}
func applyService(ctx context.Context, sys system.System, r *declaration.Service, run Runner,
changed map[string]bool, previous store.Applied) (Outcome, error) {
if r.Stateless() {
@@ -1120,6 +1152,9 @@ func applyService(ctx context.Context, sys system.System, r *declaration.Service
// Read back. A service manager accepting a command says the transaction was accepted,
// not that the unit is running — one that starts and immediately dies satisfies it.
after, err := sys.ServiceState(ctx, run, r.Unit)
if err == nil && r.State == "running" {
after, err = stayedRunning(ctx, sys, run, r.Unit)
}
if err != nil {
return out, err
}
@@ -1140,7 +1175,7 @@ func applyService(ctx context.Context, sys system.System, r *declaration.Service
}
// Read back, for the same reason as above: a unit that starts and immediately dies
// satisfies a service manager and nothing else.
after, err := sys.ServiceState(ctx, run, r.Unit)
after, err := stayedRunning(ctx, sys, run, r.Unit)
if err != nil {
return out, err
}
@@ -1230,7 +1265,7 @@ func reflectOnly(ctx context.Context, sys system.System, r *declaration.Service,
}
// Read back: it was running, and a restart or reload that left it otherwise is a failure —
// the machine's network manager down is not a change to report and move past.
after, err := sys.ServiceState(ctx, run, r.Unit)
after, err := stayedRunning(ctx, sys, run, r.Unit)
if err != nil {
return out, err
}
@@ -1408,7 +1443,7 @@ func applyPackage(ctx context.Context, sys system.System, r *declaration.Package
return out, err
}
if r.Absent {
// Declared absent (novox/hq ADR 0175): removed when it is here, left alone when it is not.
// Declared absent (novox/hq ADR 0180): removed when it is here, left alone when it is not.
if !installed {
out.Action = "unchanged"
out.Detail = "not installed, as declared"
@@ -1607,6 +1642,10 @@ func containerSpecReading(r *declaration.Container, declares, reads map[string]s
for _, c := range r.Capabilities {
b.WriteString("cap " + c + "\n")
}
// And where it logs (ADR 0179): the runtime cannot move a running container's output.
if r.Logging != "" {
b.WriteString("log " + r.Logging + "\n")
}
// The cadence is part of what was declared, so a changed schedule is a changed spec — the marker
// moves and the install is reported "updated" and re-established. Added only when present, so no
// ordinary container's or run-once step's digest moves for a field it does not set.
@@ -1804,6 +1843,11 @@ func applyContainer(ctx context.Context, r *declaration.Container, run Runner,
for _, c := range r.Capabilities {
args = append(args, "--cap-add", c)
}
if r.Logging != "" {
// The journal keeps the container's name on every line (CONTAINER_NAME), which is what a
// jail matches on (novox/hq ADR 0179); `docker logs` keeps working against the journal.
args = append(args, "--log-driver", r.Logging)
}
for _, d := range r.Dns {
args = append(args, "--dns", d)
}
+1 -1
View File
@@ -174,7 +174,7 @@ func TestACapabilityReachesTheRuntimeAndTheSpec(t *testing.T) {
}
}
// A package may be declared absent (novox/hq ADR 0175): removed when it is installed, read back,
// A package may be declared absent (novox/hq ADR 0180): removed when it is installed, read back,
// left alone when it is not.
func TestAPackageDeclaredAbsentIsRemovedWhenPresentAndLeftWhenNot(t *testing.T) {
installed := true
+43
View File
@@ -0,0 +1,43 @@
package apply
import (
"context"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// A container declared to log to the journal is run with the journal as its log driver, and the
// place it logs is part of its spec, so moving it recreates the container (novox/hq ADR 0179).
func TestAContainerLoggingToTheJournalIsRunThatWayAndRecreatedWhenMoved(t *testing.T) {
pinned := "postgres@sha256:" + strings.Repeat("a", 64)
var ran []string
run := func(_ context.Context, cmd string, args ...string) (string, error) {
if cmd == "docker" && len(args) > 0 && args[0] == "run" {
ran = args
return "deadbeef\n", nil
}
return "", nil
}
d := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"front","type":"container","name":"front","image":"`+pinned+`","logging":"journald"}
]}`)
_, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
sent := false
for i, a := range ran {
if a == "--log-driver" && i+1 < len(ran) && ran[i+1] == "journald" {
sent = true
}
}
if !sent {
t.Fatalf("the container's output was not sent to the journal: %v", ran)
}
with := d.Resources[0].(*declaration.Container)
without := *with
without.Logging = ""
if containerSpec(with, inputs{}) == containerSpec(&without, inputs{}) {
t.Fatal("where a container logs is not part of its spec, so moving it would not recreate it")
}
}
+1 -1
View File
@@ -77,7 +77,7 @@ func retireFirewall(ctx context.Context, d *declaration.Declaration, origin stri
return "", nil
}
if !firewall.Installed(ctx, run) {
// Uninstalled (novox/hq ADR 0175): retired for good, by the module that replaced it. Said
// Uninstalled (novox/hq ADR 0180): retired for good, by the module that replaced it. Said
// once, and nothing is asked of a command that is not there.
if rec.RetiredBy != firewall.RetiredRemoved {
rec.RetiredBy = firewall.RetiredRemoved
+1 -1
View File
@@ -525,7 +525,7 @@ func TestUfwIsNotRetiredUntilTheMeshsOwnFilterIsLoaded(t *testing.T) {
}
// A front end that is no longer installed is recorded as removed, said once, and asked nothing of
// (novox/hq ADR 0175).
// (novox/hq ADR 0180).
func TestAnUninstalledFrontEndIsRetiredForGood(t *testing.T) {
dir := t.TempDir()
u := &ufwMachine{installed: false, ruleset: "table inet mesh\n"}
+100
View File
@@ -0,0 +1,100 @@
package apply
import (
"context"
"os"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// A daemon that reads a configuration it refuses dies a fraction of a second after the service
// manager has reported it started — fail2ban took 221 milliseconds on the control node the day this
// was written. One read back catches nothing: the unit is active at that instant. The mesh reported
// the service "restarted" while every ban on two public machines was gone, and every check passed
// (novox/hq ADR 0184). The host looks again, after the moment in which that happens.
func TestAServiceThatDiesJustAfterItsRestartIsNotReportedRestarted(t *testing.T) {
serviceSettle = 0
// Alive at the first look after starting, dead at the second — the shape of a daemon that
// refuses what it was just given.
started, looks := false, 0
run := func(ctx context.Context, name string, args ...string) (string, error) {
if args[0] == "show" {
state := "active"
if started {
if looks++; looks >= 2 {
state = "failed"
}
}
return "LoadState=loaded\nActiveState=" + state + "\n", nil
}
if args[0] == "start" {
started = true
}
return "", nil
}
dir := t.TempDir()
d := parse(t, `{"declaration":1,"resources":[
{"id":"conf","type":"file","path":"`+dir+`/jail.conf","content":"[sshd]\n","mode":"0644"},
{"id":"run","type":"service","unit":"fail2ban.service","state":"running","restart-on":["conf"]}
]}`)
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
if err == nil {
t.Fatal("a service that died just after being restarted was reported as restarted")
}
if !strings.Contains(err.Error(), "fail2ban.service") || !strings.Contains(err.Error(), "is stopped") {
t.Errorf("the failure does not name the unit and what it is now: %v", err)
}
if looks < 2 {
t.Errorf("the host looked at the unit %d time(s) after starting it; it must look again", looks)
}
}
// A unit still coming up reads as running at both looks and is accepted: the second look is for a
// unit that WAS running and is not any more, never a wait for a slow one to finish starting.
func TestAUnitStillStartingIsNotAFailure(t *testing.T) {
serviceSettle = 0
run := func(ctx context.Context, name string, args ...string) (string, error) {
if args[0] == "show" {
return "LoadState=loaded\nActiveState=activating\n", nil
}
return "", nil
}
d := parse(t, `{"declaration":1,"resources":[
{"id":"s","type":"service","unit":"slow.service","state":"running"}
]}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil); err != nil {
t.Fatalf("a unit still starting was reported as a failure: %v", err)
}
}
// And a service the declaration asks to be stopped is not waited on at all.
func TestAServiceAskedToStopIsNotWaitedOn(t *testing.T) {
serviceSettle = 0
shows := 0
run := func(ctx context.Context, name string, args ...string) (string, error) {
if args[0] == "show" {
shows++
if shows == 1 {
return "LoadState=loaded\nActiveState=active\n", nil
}
return "LoadState=loaded\nActiveState=inactive\n", nil
}
return "", nil
}
d := parse(t, `{"declaration":1,"resources":[
{"id":"s","type":"service","unit":"off.service","state":"stopped"}
]}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil); err != nil {
t.Fatalf("stopping a service was reported as a failure: %v", err)
}
}
// The settle between a unit's two read-backs is a real pause on a machine and nothing in a test:
// no test here drives a service manager that takes time, so paying it would only slow the suite
// (novox/hq ADR 0184).
func TestMain(m *testing.M) {
serviceSettle = 0
os.Exit(m.Run())
}
+13 -1
View File
@@ -870,7 +870,7 @@ type Package struct {
ID string `json:"id"`
Type Type `json:"type"`
Package string `json:"package"`
// Absent declares that the package is NOT installed (novox/hq ADR 0175): the host removes it
// Absent declares that the package is NOT installed (novox/hq ADR 0180): the host removes it
// when it is, and leaves a machine that never had it alone. For the one case a module replaces
// software the machine was found with and the operator has decided it does not come back — the
// firewall front end a converged machine's filter module retired. Nothing to undo when the
@@ -952,6 +952,14 @@ type Container struct {
// container; a privileged container stays undeclarable.
Capabilities []string `json:"capabilities,omitempty"`
// Logging names where the runtime sends this container's output: "journald" sends it to the
// machine's journal, under the container's name, where what reads the machine's logs — its
// intrusion prevention first of all (novox/hq ADR 0179) — can read it the way it reads the
// machine's own services. Empty keeps the runtime's default, which is a file of the runtime's
// own that nothing but the runtime reads. Part of the spec: a container that logs elsewhere
// is a different container, and the runtime cannot change a running one's driver.
Logging string `json:"logging,omitempty"`
// Networks are networks this container also joins once created, by name — a found network a
// per-machine setting keeps for a taken container (novox/hq ADR 0163, rule 4), so a
// neighbour that resolves it there keeps resolving it until the neighbour is taken too.
@@ -1057,6 +1065,10 @@ func (c *Container) validate(where string, _ bool) []string {
"capability's name (CAP_NET_ADMIN or NET_ADMIN)")
}
}
if c.Logging != "" && c.Logging != "journald" {
problems = append(problems, where+": logging is "+strconv.Quote(c.Logging)+", and the only place a "+
"container's output can be sent besides the runtime's own file is \"journald\"")
}
for _, n := range c.Networks {
problems = append(problems, (&Network{Name: n}).validate(where+": networks", false)...)
if n == c.Network {
+21
View File
@@ -524,3 +524,24 @@ func TestACapabilityIsNamedOrRefused(t *testing.T) {
}
}
}
// A container may send its output to the machine's journal, and nowhere else but the runtime's own
// file (novox/hq ADR 0179): what reads the machine's logs then reads the container's too.
func TestAContainerMayLogToTheJournalAndNowhereElse(t *testing.T) {
image := "postgres@sha256:" + strings.Repeat("a", 64)
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"front","type":"container","name":"front","image":"` + image + `","logging":"journald"}
]}`))
if err != nil {
t.Fatal(err)
}
if got := d.Resources[0].(*Container).Logging; got != "journald" {
t.Fatalf("logging read as %q", got)
}
for _, bad := range []string{`"syslog"`, `"none"`, `"json-file"`} {
if _, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"front","type":"container","name":"front","image":"` + image + `","logging":` + bad + `}]}`)); err == nil {
t.Errorf("%s was accepted as a place to log", bad)
}
}
}
+13 -22
View File
@@ -179,27 +179,18 @@ func legacyFilters(rules, tool string, ufwActive bool) []Filter {
}
}
}
var entered func(chain string, seen map[string]bool) bool
entered = func(chain string, seen map[string]bool) bool {
if seen[chain] || accepting[chain] || len(jumpedFrom[chain]) == 0 {
return false
}
seen[chain] = true
for _, from := range jumpedFrom[chain] {
if p, builtIn := policy[from]; builtIn {
if p != "ACCEPT" {
return false
}
continue
}
if !entered(from, seen) {
return false
}
}
return true
}
// A chain of refusals is a ban list when every refusal names the sources it refuses and the
// chain accepts nothing — the same rule the nftables side applies, and no more.
//
// **The policy of the chains that jump to it says nothing about what it is.** An earlier cut
// required every path into the chain to come from a built-in whose policy accepts, and the
// mesh's own intrusion prevention then read as a foreign rule set on the home server: its ban
// chain hangs off the container runtime's user chain as well as INPUT, and that machine's
// forward policy is DROP because the runtime set it. The machine reported "NOT the mesh alone"
// about a chain the mesh had just written (novox/hq ADR 0186). The policy is already classified
// where it belongs — as the runtime's — so requiring it here counted it twice.
ban := func(chain, line string) bool {
return bansSources(line) && entered(chain, map[string]bool{})
return bansSources(line) && !accepting[chain] && len(jumpedFrom[chain]) > 0
}
type seen struct {
owner string
@@ -320,7 +311,7 @@ func Active(ctx context.Context, run Runner) bool {
}
// Installed says whether ufw is on this machine at all: a command that is not there is a front end
// that was uninstalled (novox/hq ADR 0175), not one that is silent.
// that was uninstalled (novox/hq ADR 0180), not one that is silent.
func Installed(ctx context.Context, run Runner) bool {
_, err := run(ctx, "ufw", "status")
return !missing(err)
@@ -330,6 +321,6 @@ func Installed(ctx context.Context, run Runner) bool {
const (
RetiredByMesh = "mesh"
RetiredFoundSo = "found-inactive"
// RetiredRemoved is a front end uninstalled by the module that replaced it (ADR 0175).
// RetiredRemoved is a front end uninstalled by the module that replaced it (ADR 0180).
RetiredRemoved = "removed"
)
+60
View File
@@ -0,0 +1,60 @@
package firewall
import (
"os"
"testing"
)
// The mesh's own ban list is a ban wherever it hangs (novox/hq ADR 0186).
//
// Captured from the home server after the intrusion prevention had banned four addresses: its ban
// chain is jumped to from INPUT, whose policy accepts, and from the container runtime's user chain,
// which hangs off a FORWARD the runtime set to DROP. Requiring every path to come from an accepting
// built-in made the machine report "NOT the mesh alone" about a chain the mesh had just written.
func TestTheMeshsOwnBanChainIsABanBehindADroppingForward(t *testing.T) {
legacy, err := os.ReadFile("testdata/home-server-bans-S.txt")
if err != nil {
t.Fatal(err)
}
filters := Filters("", map[string]string{"iptables-legacy": string(legacy)}, false)
var ban, other []string
for _, f := range filters {
switch f.Owner {
case OwnerBan:
ban = append(ban, f.Where)
case OwnerOther:
other = append(other, f.Where)
}
}
if len(other) > 0 {
t.Errorf("the machine reports %v as rule sets the mesh did not write", other)
}
found := false
for _, w := range ban {
if w == "chain f2b-route-proxy (iptables-legacy)" {
found = true
}
}
if !found {
t.Errorf("the intrusion prevention's own chain was not read as a ban; bans were %v", ban)
}
if !Alone(filters) {
t.Error("a machine filtered by the mesh and its own bans does not read as the mesh alone")
}
}
// A chain that accepts anything is doing more than banning, and is still not a ban — which is what
// keeps a predecessor's allow-and-drop chain classified as something the operator must look at.
func TestAChainThatAcceptsIsNotABan(t *testing.T) {
rules := "-P INPUT ACCEPT\n" +
"-A INPUT -j HAL-MESH-ONLY\n" +
"-N HAL-MESH-ONLY\n" +
"-A HAL-MESH-ONLY -s 10.0.0.0/8 -j ACCEPT\n" +
"-A HAL-MESH-ONLY -s 203.0.113.7/32 -j DROP\n"
for _, f := range Filters("", map[string]string{"iptables-legacy": rules}, false) {
if f.Where == "chain HAL-MESH-ONLY (iptables-legacy)" && f.Owner != OwnerOther {
t.Errorf("a chain that accepts was classified as %s", f.Owner)
}
}
}
+147
View File
@@ -0,0 +1,147 @@
-P INPUT ACCEPT
-P FORWARD DROP
-P OUTPUT ACCEPT
-N DOCKER
-N DOCKER-BRIDGE
-N DOCKER-CT
-N DOCKER-FORWARD
-N DOCKER-INTERNAL
-N DOCKER-USER
-N f2b-route-proxy
-N ufw-after-forward
-N ufw-after-input
-N ufw-after-logging-forward
-N ufw-after-logging-input
-N ufw-after-logging-output
-N ufw-after-output
-N ufw-before-forward
-N ufw-before-input
-N ufw-before-logging-forward
-N ufw-before-logging-input
-N ufw-before-logging-output
-N ufw-before-output
-N ufw-reject-forward
-N ufw-reject-input
-N ufw-reject-output
-N ufw-track-forward
-N ufw-track-input
-N ufw-track-output
-A INPUT -p tcp -j f2b-route-proxy
-A INPUT -j ufw-before-logging-input
-A INPUT -j ufw-before-input
-A INPUT -j ufw-after-input
-A INPUT -j ufw-after-logging-input
-A INPUT -j ufw-reject-input
-A INPUT -j ufw-track-input
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-FORWARD
-A FORWARD -j ufw-before-logging-forward
-A FORWARD -j ufw-before-forward
-A FORWARD -j ufw-after-forward
-A FORWARD -j ufw-after-logging-forward
-A FORWARD -j ufw-reject-forward
-A FORWARD -j ufw-track-forward
-A OUTPUT -j ufw-before-logging-output
-A OUTPUT -j ufw-before-output
-A OUTPUT -j ufw-after-output
-A OUTPUT -j ufw-after-logging-output
-A OUTPUT -j ufw-reject-output
-A OUTPUT -j ufw-track-output
-A DOCKER -d 172.17.0.18/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8686 -j ACCEPT
-A DOCKER -d 172.17.0.14/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8989 -j ACCEPT
-A DOCKER -d 172.17.0.15/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 7878 -j ACCEPT
-A DOCKER -d 172.17.0.5/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 9117 -j ACCEPT
-A DOCKER -d 172.17.0.13/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 6789 -j ACCEPT
-A DOCKER -d 172.19.0.2/32 ! -i br-32062158f584 -o br-32062158f584 -p tcp -m tcp --dport 8080 -j ACCEPT
-A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 5432 -j ACCEPT
-A DOCKER -d 172.27.0.2/32 ! -i br-0910a98c6158 -o br-0910a98c6158 -p tcp -m tcp --dport 5678 -j ACCEPT
-A DOCKER -d 172.17.0.21/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3579 -j ACCEPT
-A DOCKER -d 172.17.0.19/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8181 -j ACCEPT
-A DOCKER -d 172.17.0.17/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8787 -j ACCEPT
-A DOCKER -d 172.17.0.16/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 6767 -j ACCEPT
-A DOCKER -d 172.17.0.12/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3000 -j ACCEPT
-A DOCKER -d 172.17.0.11/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 80 -j ACCEPT
-A DOCKER -d 172.17.0.10/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 9443 -j ACCEPT
-A DOCKER -d 172.17.0.10/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 9000 -j ACCEPT
-A DOCKER -d 172.17.0.9/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3000 -j ACCEPT
-A DOCKER -d 172.17.0.7/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 1880 -j ACCEPT
-A DOCKER -d 172.28.0.2/32 ! -i br-b11461b5b028 -o br-b11461b5b028 -p tcp -m tcp --dport 80 -j ACCEPT
-A DOCKER -d 172.23.0.14/32 ! -i br-66ffa5c1cba5 -o br-66ffa5c1cba5 -p tcp -m tcp --dport 6543 -j ACCEPT
-A DOCKER -d 172.23.0.14/32 ! -i br-66ffa5c1cba5 -o br-66ffa5c1cba5 -p tcp -m tcp --dport 5432 -j ACCEPT
-A DOCKER -d 172.23.0.5/32 ! -i br-66ffa5c1cba5 -o br-66ffa5c1cba5 -p tcp -m tcp --dport 8000 -j ACCEPT
-A DOCKER -d 172.26.0.3/32 ! -i br-b0fec361ccaa -o br-b0fec361ccaa -p tcp -m tcp --dport 6167 -j ACCEPT
-A DOCKER -d 172.26.0.2/32 ! -i br-b0fec361ccaa -o br-b0fec361ccaa -p tcp -m tcp --dport 80 -j ACCEPT
-A DOCKER -d 172.17.0.8/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8000 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p udp -m udp --dport 10001 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8880 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8843 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8443 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8080 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 6789 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p udp -m udp --dport 5514 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p udp -m udp --dport 3478 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p udp -m udp --dport 1900 -j ACCEPT
-A DOCKER -d 172.25.0.3/32 ! -i br-b98821f7dc38 -o br-b98821f7dc38 -p tcp -m tcp --dport 8000 -j ACCEPT
-A DOCKER -d 172.18.0.3/32 ! -i br-442a0bfc65f8 -o br-442a0bfc65f8 -p tcp -m tcp --dport 1433 -j ACCEPT
-A DOCKER -d 172.20.0.3/32 ! -i br-afa37ac8b33d -o br-afa37ac8b33d -p tcp -m tcp --dport 8081 -j ACCEPT
-A DOCKER -d 172.20.0.3/32 ! -i br-afa37ac8b33d -o br-afa37ac8b33d -p tcp -m tcp --dport 1883 -j ACCEPT
-A DOCKER -d 172.17.0.4/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8086 -j ACCEPT
-A DOCKER -d 172.21.0.2/32 ! -i br-df15d8e19ec7 -o br-df15d8e19ec7 -p tcp -m tcp --dport 6379 -j ACCEPT
-A DOCKER -d 172.30.0.3/32 ! -i br-521eab9a3a5e -o br-521eab9a3a5e -p tcp -m tcp --dport 8283 -j ACCEPT
-A DOCKER -d 172.17.0.3/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3000 -j ACCEPT
-A DOCKER ! -i br-32062158f584 -o br-32062158f584 -j DROP
-A DOCKER ! -i docker0 -o docker0 -j DROP
-A DOCKER ! -i br-521eab9a3a5e -o br-521eab9a3a5e -j DROP
-A DOCKER ! -i br-df15d8e19ec7 -o br-df15d8e19ec7 -j DROP
-A DOCKER ! -i br-afa37ac8b33d -o br-afa37ac8b33d -j DROP
-A DOCKER ! -i br-442a0bfc65f8 -o br-442a0bfc65f8 -j DROP
-A DOCKER ! -i br-b98821f7dc38 -o br-b98821f7dc38 -j DROP
-A DOCKER ! -i br-b0fec361ccaa -o br-b0fec361ccaa -j DROP
-A DOCKER ! -i br-66ffa5c1cba5 -o br-66ffa5c1cba5 -j DROP
-A DOCKER ! -i br-b11461b5b028 -o br-b11461b5b028 -j DROP
-A DOCKER ! -i br-2df4e541b877 -o br-2df4e541b877 -j DROP
-A DOCKER ! -i br-0910a98c6158 -o br-0910a98c6158 -j DROP
-A DOCKER-BRIDGE -o br-32062158f584 -j DOCKER
-A DOCKER-BRIDGE -o docker0 -j DOCKER
-A DOCKER-BRIDGE -o br-521eab9a3a5e -j DOCKER
-A DOCKER-BRIDGE -o br-df15d8e19ec7 -j DOCKER
-A DOCKER-BRIDGE -o br-afa37ac8b33d -j DOCKER
-A DOCKER-BRIDGE -o br-442a0bfc65f8 -j DOCKER
-A DOCKER-BRIDGE -o br-b98821f7dc38 -j DOCKER
-A DOCKER-BRIDGE -o br-b0fec361ccaa -j DOCKER
-A DOCKER-BRIDGE -o br-66ffa5c1cba5 -j DOCKER
-A DOCKER-BRIDGE -o br-b11461b5b028 -j DOCKER
-A DOCKER-BRIDGE -o br-2df4e541b877 -j DOCKER
-A DOCKER-BRIDGE -o br-0910a98c6158 -j DOCKER
-A DOCKER-CT -o br-32062158f584 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-521eab9a3a5e -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-df15d8e19ec7 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-afa37ac8b33d -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-442a0bfc65f8 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-b98821f7dc38 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-b0fec361ccaa -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-66ffa5c1cba5 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-b11461b5b028 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-2df4e541b877 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-0910a98c6158 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-FORWARD -j DOCKER-CT
-A DOCKER-FORWARD -j DOCKER-INTERNAL
-A DOCKER-FORWARD -j DOCKER-BRIDGE
-A DOCKER-FORWARD -i br-32062158f584 -j ACCEPT
-A DOCKER-FORWARD -i docker0 -j ACCEPT
-A DOCKER-FORWARD -i br-521eab9a3a5e -j ACCEPT
-A DOCKER-FORWARD -i br-df15d8e19ec7 -j ACCEPT
-A DOCKER-FORWARD -i br-afa37ac8b33d -j ACCEPT
-A DOCKER-FORWARD -i br-442a0bfc65f8 -j ACCEPT
-A DOCKER-FORWARD -i br-b98821f7dc38 -j ACCEPT
-A DOCKER-FORWARD -i br-b0fec361ccaa -j ACCEPT
-A DOCKER-FORWARD -i br-66ffa5c1cba5 -j ACCEPT
-A DOCKER-FORWARD -i br-b11461b5b028 -j ACCEPT
-A DOCKER-FORWARD -i br-2df4e541b877 -j ACCEPT
-A DOCKER-FORWARD -i br-0910a98c6158 -j ACCEPT
-A DOCKER-USER -p tcp -j f2b-route-proxy
-A f2b-route-proxy -s 13.70.107.184/32 -j REJECT --reject-with icmp-port-unreachable
-A f2b-route-proxy -s 45.138.12.51/32 -j REJECT --reject-with icmp-port-unreachable
-A f2b-route-proxy -s 20.214.191.94/32 -j REJECT --reject-with icmp-port-unreachable
-A f2b-route-proxy -j RETURN
+105 -25
View File
@@ -3,7 +3,10 @@ package system
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/novox/mesh-host/internal/declaration"
)
@@ -56,42 +59,119 @@ func (arch) InstallPackage(ctx context.Context, run Runner, name string) error {
// **The package manager's own words, and a name for the case that looks like a bug in the
// declaration and is not.** A stale index asks the mirrors for a version they have already
// superseded and gets a 404 from every one of them — so the package exists, the declaration is
// correct, and the machine's idea of what exists is old (novox/hq 04-ISSUES/002).
// correct, and the machine's idea of what exists is old (novox/hq 04-ISSUES/002). A keyring as
// old as the index fails one step later, on the signature of whatever a mirror still had.
//
// **Read from everything pacman said.** Its errors go to stderr, which the runner folds into
// the error rather than the output; this classifier read the output alone and so never saw a
// single "failed retrieving file", and the control node reported a ten-week-old database as
// a mirror outage with a wall of 404s (novox/hq 04-ISSUES/205).
//
// **It is not fixed by syncing here.** `pacman -Sy <pkg>` installs a package built against
// libraries this machine does not have: a partial upgrade, which Arch does not support and
// which breaks the machine in a way that surfaces much later as something unrelated. The
// remedy is a full upgrade, and it is a decision about the whole machine rather than
// something to do silently in the middle of applying one resource.
//
// So this says which of the two it is looking at. A declaration that is wrong and a machine
// that is out of date fail identically otherwise, and they are fixed in completely different
// places.
if staleIndex(out) {
// something to do silently in the middle of applying one resource. Whose decision, and on
// what schedule, is issue 205's question; until it is answered the host says what it sees.
said := strings.TrimSpace(out + "\n" + err.Error())
switch classifyInstallFailure(said) {
case installStale:
return fmt.Errorf(
"%s could not be fetched from any mirror, which is what a stale package index looks "+
"like: this machine is asking for a version the mirrors have replaced. The "+
"package and the declaration are probably both fine. It is fixed by upgrading "+
"the machine, not by this host syncing one package — that would be a partial "+
"upgrade, which this distribution does not support.\n\n%s",
name, strings.TrimSpace(out))
"%s could not be fetched from any mirror, which is what a stale package index looks like: "+
"the package database on this machine is %s and the mirrors no longer serve what it "+
"names. It is fixed by upgrading the machine — a full upgrade (`pacman -Syu`) by its "+
"operator — before the mesh can install %s. The package and the declaration are probably both fine; the "+
"host does not sync one package by itself, because on this distribution that is a "+
"partial upgrade (novox/hq 04-ISSUES/205).\n\n%s",
name, syncDatabaseAge(), name, said)
case installMirrors:
return fmt.Errorf(
"no mirror could be reached to fetch %s, and the package database on this machine is "+
"%s: this reads as the mirrors or the network, not as this machine being out of "+
"date — try again when they answer.\n\n%s",
name, syncDatabaseAge(), said)
}
return fmt.Errorf("%w\n\n%s", err, strings.TrimSpace(out))
}
// staleIndex reports whether a failed install looks like the machine's view being old rather than
// the package being wrong.
//
// By what the package manager said, because there is nothing else to go on: the exit code is the
// same for both.
func staleIndex(out string) bool {
said := strings.ToLower(out)
if !strings.Contains(said, "failed retrieving file") && !strings.Contains(said, "404") {
return false
// How a failed install is read, from what the package manager said.
type installFailure int
const (
installOther installFailure = iota
// installStale: the machine's package database or keyring is older than what the mirrors
// serve — every mirror 404s the file the database names, or a package that did arrive fails
// its signature against a keyring that never saw the key.
installStale
// installMirrors: no mirror could be reached at all, and nothing says the database is old.
installMirrors
)
// classifyInstallFailure reads pacman's words, because there is nothing else to go on: the exit
// code is the same for every one of these.
func classifyInstallFailure(said string) installFailure {
lower := strings.ToLower(said)
gone := strings.Count(lower, "returned error: 404")
fetching := strings.Contains(lower, "failed retrieving file")
badSignature := strings.Contains(lower, "invalid or corrupted package (pgp signature)") ||
strings.Contains(lower, "signature from") && strings.Contains(lower, "is invalid") ||
strings.Contains(lower, "is unknown trust") ||
strings.Contains(lower, "could not be looked up remotely")
switch {
case badSignature:
return installStale
case fetching && gone > 0:
// Every mirror, not one: a single mirror failing is an ordinary transient thing and
// retrying is the answer. pacman walks its whole mirror list before giving up, so more
// than one 404 among the lines is the index being old rather than one host being wrong.
if gone > 1 || !strings.Contains(lower, "could not resolve host") &&
!strings.Contains(lower, "connection timed out") && !strings.Contains(lower, "failed to connect") {
return installStale
}
return installMirrors
case fetching:
return installMirrors
}
// Every mirror, not one. A single mirror failing is an ordinary transient thing and retrying
// is the answer; every one of them saying the file is gone is the index being old.
return strings.Contains(said, "error") || strings.Count(said, "404") > 1
return installOther
}
// staleIndex is the yes-or-no form older callers and tests use.
func staleIndex(out string) bool { return classifyInstallFailure(out) == installStale }
// syncDatabaseAge says how old this machine's package database is, in words a person acts on:
// the newest of pacman's sync databases, dated, and how long ago that was. Said beside a failed
// install so a ten-week-old database is told apart from a mirror outage by reading one line.
//
// A variable so a test can say what the machine's database looks like without having one.
var syncDatabaseAge = func() string {
entries, err := filepath.Glob("/var/lib/pacman/sync/*.db")
if err != nil || len(entries) == 0 {
return "of unknown age (no sync database found under /var/lib/pacman/sync)"
}
var newest time.Time
for _, e := range entries {
info, err := os.Stat(e)
if err == nil && info.ModTime().After(newest) {
newest = info.ModTime()
}
}
if newest.IsZero() {
return "of unknown age"
}
return describeAge(newest, time.Now())
}
// describeAge is "from 2026-07-24, 10 weeks old" — the date for the record, the span for the eye.
func describeAge(when, now time.Time) string {
days := int(now.Sub(when).Hours() / 24)
span := fmt.Sprintf("%d days old", days)
switch {
case days < 1:
span = "less than a day old"
case days >= 14:
span = fmt.Sprintf("%d weeks old", days/7)
}
return fmt.Sprintf("from %s, %s", when.Format("2006-01-02"), span)
}
// ServiceState reads what systemd says about a unit.
+115
View File
@@ -0,0 +1,115 @@
package system
import (
"context"
"errors"
"strings"
"testing"
"time"
)
// pacman's own words from the control node on 2026-10-02 (novox/hq 04-ISSUES/205): every mirror
// 404s the versioned file a ten-week-old database names, and the one copy that arrives fails its
// signature. Errors are pacman's stderr, which the runner folds into the error, not the output.
const staleStderr = `error: failed retrieving file 'nodejs-26.5.0-1-x86_64.pkg.tar.zst' from mirror.hetzner.com : The requested URL returned error: 404
error: failed retrieving file 'nodejs-26.5.0-1-x86_64.pkg.tar.zst' from mirror.rackspace.com : The requested URL returned error: 404
error: failed retrieving file 'nodejs-26.5.0-1-x86_64.pkg.tar.zst' from arch.lucassymons.net : Could not resolve host: arch.lucassymons.net
warning: fatal error from arch.lucassymons.net, skipping for the remainder of this transaction
error: failed retrieving file 'nodejs-26.5.0-1-x86_64.pkg.tar.zst' from mirrors.cqu.edu.cn : Connection timed out after 10002 milliseconds
error: nodejs: signature from "Bert Peters (packager key) <bertptrs@archlinux.org>" is invalid
error: failed to commit transaction (invalid or corrupted package (PGP signature))`
const staleStdout = `resolving dependencies...
looking for conflicting packages...
Packages (4) ada-3.4.4-1 c-ares-1.34.8-1 simdjson-1:4.6.4-1 nodejs-26.5.0-1
:: Retrieving packages...
nodejs-26.5.0-1-x86_64 downloading...
checking keyring...
checking package integrity...
:: File /var/cache/pacman/pkg/nodejs-26.5.0-1-x86_64.pkg.tar.zst is corrupted (invalid or corrupted package (PGP signature)).
Errors occurred, no packages were upgraded.`
// A runner that behaves as ExecRunner does on failure: stdout as the output, stderr in the error.
func pacmanFailing(stdout, stderr string) Runner {
return func(_ context.Context, name string, args ...string) (string, error) {
return stdout, errors.New(name + " exited 1: " + stderr)
}
}
func TestAStaleDatabaseIsSaidAsOneWithItsAgeAndTheRemedy(t *testing.T) {
was := syncDatabaseAge
defer func() { syncDatabaseAge = was }()
syncDatabaseAge = func() string {
return describeAge(time.Date(2026, 7, 24, 16, 56, 0, 0, time.UTC), time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC))
}
err := arch{}.InstallPackage(context.Background(), pacmanFailing(staleStdout, staleStderr), "nodejs")
if err == nil {
t.Fatal("a failed install must fail")
}
for _, want := range []string{
"the package database on this machine is from 2026-07-24, 10 weeks old",
"stale package index",
"upgrading the machine — a full upgrade (`pacman -Syu`) by its operator — before the mesh can install nodejs",
"partial upgrade",
"returned error: 404", // pacman's own words follow
} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the error does not say %q:\n%s", want, err)
}
}
if strings.Contains(err.Error(), "mirrors or the network") {
t.Errorf("a stale database must not be read as a mirror outage:\n%s", err)
}
}
// The same 404s read from stderr alone — the half this classifier used to be blind to.
func TestTheClassifierReadsWhatPacmanWroteToStderr(t *testing.T) {
if classifyInstallFailure(staleStderr) != installStale {
t.Fatal("every mirror 404ing the named file is a stale database")
}
if classifyInstallFailure(staleStdout) != installStale {
t.Fatal("a corrupted-signature line alone is a stale keyring")
}
if classifyInstallFailure("") != installOther {
t.Fatal("nothing said is nothing classified")
}
}
func TestAnUnreachableMirrorWithAFreshDatabaseIsAMirrorProblem(t *testing.T) {
was := syncDatabaseAge
defer func() { syncDatabaseAge = was }()
syncDatabaseAge = func() string { return "from 2026-10-02, less than a day old" }
outage := `error: failed retrieving file 'core.db' from mirror.hetzner.com : Could not resolve host: mirror.hetzner.com
error: failed retrieving file 'core.db' from mirror.rackspace.com : Connection timed out after 10001 milliseconds
error: failed to synchronize all databases (failed to retrieve some files)`
if classifyInstallFailure(outage) != installMirrors {
t.Fatal("no mirror answering, no 404, no signature fault: the mirrors, not the machine")
}
err := arch{}.InstallPackage(context.Background(), pacmanFailing("", outage), "nodejs")
if err == nil || !strings.Contains(err.Error(), "mirrors or the network") || !strings.Contains(err.Error(), "less than a day old") {
t.Errorf("a mirror outage is said as one, with the database's age beside it:\n%v", err)
}
}
func TestAFailureThatIsNeitherKeepsPacmansWords(t *testing.T) {
err := arch{}.InstallPackage(context.Background(), pacmanFailing("", "error: target not found: nodejsx"), "nodejsx")
if err == nil || !strings.Contains(err.Error(), "target not found") || strings.Contains(err.Error(), "package database on this machine") {
t.Errorf("an unknown package is pacman's own error, not a stale database:\n%v", err)
}
}
func TestDescribeAge(t *testing.T) {
now := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
for when, want := range map[time.Time]string{
now.Add(-2 * time.Hour): "less than a day old",
now.Add(-5 * 24 * time.Hour): "5 days old",
now.Add(-71 * 24 * time.Hour): "10 weeks old",
} {
if got := describeAge(when, now); !strings.HasSuffix(got, want) {
t.Errorf("%s: got %q, want suffix %q", when, got, want)
}
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ type System interface {
PackageInstalled(ctx context.Context, run Runner, name string) (bool, error)
InstallPackage(ctx context.Context, run Runner, name string) error
// RemovePackage uninstalls one package, leaving its dependencies and anything the operator
// changed in its configuration where the package manager leaves them (novox/hq ADR 0175).
// changed in its configuration where the package manager leaves them (novox/hq ADR 0180).
RemovePackage(ctx context.Context, run Runner, name string) error
// ServiceState is "running" or "stopped". A unit that does not exist is an error, never