Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9b963f8b9 | ||
|
|
5a6e51f975 | ||
|
|
3c96683d98 | ||
|
|
8c4e33c165 | ||
|
|
2a332b0e6e | ||
|
|
b94e0f9a77 | ||
|
|
b840ce0a77 | ||
|
|
286865dfa7 | ||
|
|
ca7c4a5915 | ||
|
|
b30d9c5b5a | ||
|
|
5b73e04192 | ||
|
|
f57386cdea | ||
|
|
f92dd3e286 | ||
|
|
cdbe3ab0a4 | ||
|
|
a8f1cdb445 | ||
|
|
ecb3003ba4 | ||
|
|
d3861f82d4 | ||
|
|
b6dbe0a7b9 | ||
|
|
07bdad9e94 |
@@ -161,7 +161,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/var/lib/mesh-bus-conf/accounts.conf",
|
"path": "/var/lib/mesh-bus-conf/accounts.conf",
|
||||||
"mode": "0600",
|
"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",
|
"id": "broker",
|
||||||
|
|||||||
+73
-2
@@ -1041,6 +1041,38 @@ type unitReloader interface {
|
|||||||
ReloadUnits(ctx context.Context, run system.Runner) error
|
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,
|
func applyService(ctx context.Context, sys system.System, r *declaration.Service, run Runner,
|
||||||
changed map[string]bool, previous store.Applied) (Outcome, error) {
|
changed map[string]bool, previous store.Applied) (Outcome, error) {
|
||||||
if r.Stateless() {
|
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,
|
// 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.
|
// not that the unit is running — one that starts and immediately dies satisfies it.
|
||||||
after, err := sys.ServiceState(ctx, run, r.Unit)
|
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 {
|
if err != nil {
|
||||||
return out, err
|
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
|
// Read back, for the same reason as above: a unit that starts and immediately dies
|
||||||
// satisfies a service manager and nothing else.
|
// 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 {
|
if err != nil {
|
||||||
return out, err
|
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 —
|
// 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.
|
// 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 {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -1407,6 +1442,25 @@ func applyPackage(ctx context.Context, sys system.System, r *declaration.Package
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
if r.Absent {
|
||||||
|
// 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"
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if err := sys.RemovePackage(ctx, run, r.Package); err != nil {
|
||||||
|
return out, fmt.Errorf("removing %s: %w", r.Package, err)
|
||||||
|
}
|
||||||
|
if still, err := sys.PackageInstalled(ctx, run, r.Package); err != nil {
|
||||||
|
return out, err
|
||||||
|
} else if still {
|
||||||
|
return out, fmt.Errorf("%s was removed without error and the package database still has it", r.Package)
|
||||||
|
}
|
||||||
|
out.Action = "removed"
|
||||||
|
out.Detail = "declared absent; its configuration is left where the package manager leaves it"
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
if installed {
|
if installed {
|
||||||
out.Action = "unchanged"
|
out.Action = "unchanged"
|
||||||
out.Detail = "already installed"
|
out.Detail = "already installed"
|
||||||
@@ -1583,6 +1637,15 @@ func containerSpecReading(r *declaration.Container, declares, reads map[string]s
|
|||||||
for _, n := range r.Networks {
|
for _, n := range r.Networks {
|
||||||
b.WriteString("also-on " + n + "\n")
|
b.WriteString("also-on " + n + "\n")
|
||||||
}
|
}
|
||||||
|
// And the capabilities it was granted (ADR 0170): one gained or dropped is a different
|
||||||
|
// container, and the runtime cannot change a running one'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
|
// 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
|
// 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.
|
// ordinary container's or run-once step's digest moves for a field it does not set.
|
||||||
@@ -1777,6 +1840,14 @@ func applyContainer(ctx context.Context, r *declaration.Container, run Runner,
|
|||||||
if r.Network != "" {
|
if r.Network != "" {
|
||||||
args = append(args, "--network", r.Network)
|
args = append(args, "--network", r.Network)
|
||||||
}
|
}
|
||||||
|
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 {
|
for _, d := range r.Dns {
|
||||||
args = append(args, "--dns", d)
|
args = append(args, "--dns", d)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,3 +134,81 @@ func TestALeftOutModuleIsNeitherRemovedNorForgotten(t *testing.T) {
|
|||||||
t.Fatalf("keeping the left-out module's container was not said: %+v", report.Outcomes)
|
t.Fatalf("keeping the left-out module's container was not said: %+v", report.Outcomes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A container's capabilities reach the runtime and are part of its spec (novox/hq ADR 0170).
|
||||||
|
func TestACapabilityReachesTheRuntimeAndTheSpec(t *testing.T) {
|
||||||
|
var ran []string
|
||||||
|
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||||
|
if name != "docker" {
|
||||||
|
return "", errors.New("not installed")
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "info":
|
||||||
|
return "29.0.0\n", nil
|
||||||
|
case "container":
|
||||||
|
return "false\t\n", errors.New("no such container")
|
||||||
|
case "run":
|
||||||
|
ran = args
|
||||||
|
return "deadbeef\n", nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||||
|
{"id":"fw","type":"container","name":"fw","image":"`+pinned+`","network":"host","capabilities":["NET_ADMIN"]}
|
||||||
|
]}`)
|
||||||
|
_, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||||
|
granted := false
|
||||||
|
for i, a := range ran {
|
||||||
|
if a == "--cap-add" && i+1 < len(ran) && ran[i+1] == "NET_ADMIN" {
|
||||||
|
granted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !granted {
|
||||||
|
t.Fatalf("the capability was not granted: %v", ran)
|
||||||
|
}
|
||||||
|
with := d.Resources[0].(*declaration.Container)
|
||||||
|
without := *with
|
||||||
|
without.Capabilities = nil
|
||||||
|
if containerSpec(with, inputs{}) == containerSpec(&without, inputs{}) {
|
||||||
|
t.Fatal("a capability is not part of the container's spec")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
var ran []string
|
||||||
|
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||||
|
ran = append(ran, name+" "+strings.Join(args, " "))
|
||||||
|
if name != "pacman" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "-Q":
|
||||||
|
if args[1] == "pacman" || installed {
|
||||||
|
return args[1] + " 1.0\n", nil
|
||||||
|
}
|
||||||
|
return "", errors.New("package not found")
|
||||||
|
case "-R":
|
||||||
|
installed = false
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
d := parseTrusted(t, `{"declaration":1,"resources":[{"id":"front-end","type":"package","package":"ufw","absent":true}]}`)
|
||||||
|
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if report.Outcomes[0].Action != "removed" || !strings.Contains(strings.Join(ran, "\n"), "pacman -R --noconfirm ufw") {
|
||||||
|
t.Fatalf("an installed package declared absent was not removed: %+v\n%v", report.Outcomes[0], ran)
|
||||||
|
}
|
||||||
|
ran = nil
|
||||||
|
report, _, err = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if report.Outcomes[0].Action != "unchanged" || strings.Contains(strings.Join(ran, "\n"), "-R") {
|
||||||
|
t.Fatalf("a package already absent was touched: %+v\n%v", report.Outcomes[0], ran)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,16 @@ func retireFirewall(ctx context.Context, d *declaration.Declaration, origin stri
|
|||||||
if origin != store.OriginDeclared || d.Adoption != nil || rec == nil || rec.Kind != string(firewall.UFW) || !rec.WasActive {
|
if origin != store.OriginDeclared || d.Adoption != nil || rec == nil || rec.Kind != string(firewall.UFW) || !rec.WasActive {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
if !firewall.Installed(ctx, run) {
|
||||||
|
// 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
|
||||||
|
log(" the found firewall (ufw) is no longer installed; the mesh's filter is what filters this machine")
|
||||||
|
return "removed: ufw is no longer installed; the mesh's filter is what filters this machine", nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
active := firewall.Active(ctx, run)
|
active := firewall.Active(ctx, run)
|
||||||
if !active && !(rec.Forward != nil && !rec.DisabledByMesh) {
|
if !active && !(rec.Forward != nil && !rec.DisabledByMesh) {
|
||||||
// Inactive, and either the mesh's doing already or nobody's recorded here: said as found,
|
// Inactive, and either the mesh's doing already or nobody's recorded here: said as found,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/novox/mesh-host/internal/declaration"
|
"github.com/novox/mesh-host/internal/declaration"
|
||||||
"github.com/novox/mesh-host/internal/store"
|
"github.com/novox/mesh-host/internal/store"
|
||||||
@@ -522,3 +523,33 @@ func TestUfwIsNotRetiredUntilTheMeshsOwnFilterIsLoaded(t *testing.T) {
|
|||||||
t.Errorf("ufw was not retired once the mesh's filter was loaded: active %v, %+v", u.active, state.Firewall)
|
t.Errorf("ufw was not retired once the mesh's filter was loaded: active %v, %+v", u.active, state.Firewall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A front end that is no longer installed is recorded as removed, said once, and asked nothing of
|
||||||
|
// (novox/hq ADR 0180).
|
||||||
|
func TestAnUninstalledFrontEndIsRetiredForGood(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
u := &ufwMachine{installed: false, ruleset: "table inet mesh\n"}
|
||||||
|
known := store.State{Firewall: &store.FoundFirewall{Kind: "ufw", WasActive: true, DisabledByMesh: true,
|
||||||
|
RetiredBy: "mesh", FoundAt: time.Now()}}
|
||||||
|
converged := parse(t, `{"declaration":1,"resources":[`+withConf(dir)+`]}`)
|
||||||
|
report, state, err := applyWith(t, converged, known, u.run)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if state.Firewall.RetiredBy != "removed" || !strings.Contains(report.Firewall, "no longer installed") {
|
||||||
|
t.Fatalf("record %+v, said %q", state.Firewall, report.Firewall)
|
||||||
|
}
|
||||||
|
u.asked = nil
|
||||||
|
report, _, err = applyWith(t, converged, state, u.run)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if report.Firewall != "" {
|
||||||
|
t.Errorf("said again: %q", report.Firewall)
|
||||||
|
}
|
||||||
|
for _, a := range u.asked {
|
||||||
|
if strings.HasPrefix(a, "ufw") && a != "ufw status" {
|
||||||
|
t.Errorf("asked something of a front end that is not there: %v", u.asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -870,6 +870,12 @@ type Package struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type Type `json:"type"`
|
Type Type `json:"type"`
|
||||||
Package string `json:"package"`
|
Package string `json:"package"`
|
||||||
|
// 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
|
||||||
|
// declaration drops it: the host does not install what a declaration stopped saying is absent.
|
||||||
|
Absent bool `json:"absent,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Package) Identity() string { return p.ID }
|
func (p *Package) Identity() string { return p.ID }
|
||||||
@@ -940,6 +946,20 @@ type Container struct {
|
|||||||
// its siblings can name before any of them can resolve anything.
|
// its siblings can name before any of them can resolve anything.
|
||||||
Dns []string `json:"dns,omitempty"`
|
Dns []string `json:"dns,omitempty"`
|
||||||
|
|
||||||
|
// Capabilities are the Linux capabilities this container is granted beyond the runtime's
|
||||||
|
// default set, by name (novox/hq ADR 0170): a holder's runtime that changes the machine's packet
|
||||||
|
// filter asks for NET_ADMIN. Exactly these, named in the spec so a change recreates the
|
||||||
|
// 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
|
// 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
|
// 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.
|
// neighbour that resolves it there keeps resolving it until the neighbour is taken too.
|
||||||
@@ -1039,6 +1059,16 @@ func (c *Container) validate(where string, _ bool) []string {
|
|||||||
"static address anywhere but a user-defined one")
|
"static address anywhere but a user-defined one")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, cap := range c.Capabilities {
|
||||||
|
if !capabilityName.MatchString(cap) {
|
||||||
|
problems = append(problems, where+": capabilities names "+strconv.Quote(cap)+", which is not a "+
|
||||||
|
"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 {
|
for _, n := range c.Networks {
|
||||||
problems = append(problems, (&Network{Name: n}).validate(where+": networks", false)...)
|
problems = append(problems, (&Network{Name: n}).validate(where+": networks", false)...)
|
||||||
if n == c.Network {
|
if n == c.Network {
|
||||||
@@ -1209,6 +1239,10 @@ type Adoption struct {
|
|||||||
Untaken map[string][]string `json:"untaken,omitempty"`
|
Untaken map[string][]string `json:"untaken,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// capabilityName is what a Linux capability is called: upper case, underscores, an optional CAP_
|
||||||
|
// prefix. The runtime accepts either spelling.
|
||||||
|
var capabilityName = regexp.MustCompile(`^(CAP_)?[A-Z][A-Z0-9_]*$`)
|
||||||
|
|
||||||
// AdoptionPrefix is the id prefix of what the mesh itself declares because a node is adopted —
|
// AdoptionPrefix is the id prefix of what the mesh itself declares because a node is adopted —
|
||||||
// its openings and its guard. Nothing under it belongs to a module, so none of it is ever held.
|
// its openings and its guard. Nothing under it belongs to a module, so none of it is ever held.
|
||||||
const AdoptionPrefix = "adoption."
|
const AdoptionPrefix = "adoption."
|
||||||
|
|||||||
@@ -504,3 +504,44 @@ func TestKeptNetworksAndLeftOutModulesAreReadStrictly(t *testing.T) {
|
|||||||
t.Fatalf("a carried bundle leaving modules out was accepted: %v", err)
|
t.Fatalf("a carried bundle leaving modules out was accepted: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A container may ask for a capability by name, and nothing else (novox/hq ADR 0170).
|
||||||
|
func TestACapabilityIsNamedOrRefused(t *testing.T) {
|
||||||
|
image := "postgres@sha256:" + strings.Repeat("a", 64)
|
||||||
|
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||||
|
{"id":"fw","type":"container","name":"fw","image":"` + image + `","network":"host","capabilities":["NET_ADMIN","CAP_NET_RAW"]}
|
||||||
|
]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := d.Resources[0].(*Container).Capabilities; len(got) != 2 || got[0] != "NET_ADMIN" {
|
||||||
|
t.Fatalf("capabilities read as %v", got)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{`"net_admin"`, `"ALL;rm -rf /"`, `"privileged"`} {
|
||||||
|
if _, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||||
|
{"id":"fw","type":"container","name":"fw","image":"` + image + `","capabilities":[` + bad + `]}]}`)); err == nil {
|
||||||
|
t.Errorf("%s was accepted as a capability", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -179,27 +179,18 @@ func legacyFilters(rules, tool string, ufwActive bool) []Filter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var entered func(chain string, seen map[string]bool) bool
|
// A chain of refusals is a ban list when every refusal names the sources it refuses and the
|
||||||
entered = func(chain string, seen map[string]bool) bool {
|
// chain accepts nothing — the same rule the nftables side applies, and no more.
|
||||||
if seen[chain] || accepting[chain] || len(jumpedFrom[chain]) == 0 {
|
//
|
||||||
return false
|
// **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
|
||||||
seen[chain] = true
|
// mesh's own intrusion prevention then read as a foreign rule set on the home server: its ban
|
||||||
for _, from := range jumpedFrom[chain] {
|
// chain hangs off the container runtime's user chain as well as INPUT, and that machine's
|
||||||
if p, builtIn := policy[from]; builtIn {
|
// forward policy is DROP because the runtime set it. The machine reported "NOT the mesh alone"
|
||||||
if p != "ACCEPT" {
|
// about a chain the mesh had just written (novox/hq ADR 0186). The policy is already classified
|
||||||
return false
|
// where it belongs — as the runtime's — so requiring it here counted it twice.
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !entered(from, seen) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
ban := func(chain, line string) bool {
|
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 {
|
type seen struct {
|
||||||
owner string
|
owner string
|
||||||
@@ -319,8 +310,17 @@ func Active(ctx context.Context, run Runner) bool {
|
|||||||
return err == nil && statusActive(out)
|
return err == nil && statusActive(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 0180), not one that is silent.
|
||||||
|
func Installed(ctx context.Context, run Runner) bool {
|
||||||
|
_, err := run(ctx, "ufw", "status")
|
||||||
|
return !missing(err)
|
||||||
|
}
|
||||||
|
|
||||||
// Retirements of a found firewall, as the host records them.
|
// Retirements of a found firewall, as the host records them.
|
||||||
const (
|
const (
|
||||||
RetiredByMesh = "mesh"
|
RetiredByMesh = "mesh"
|
||||||
RetiredFoundSo = "found-inactive"
|
RetiredFoundSo = "found-inactive"
|
||||||
|
// RetiredRemoved is a front end uninstalled by the module that replaced it (ADR 0180).
|
||||||
|
RetiredRemoved = "removed"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -15,6 +15,9 @@ const (
|
|||||||
CapServiceManager = "service-manager"
|
CapServiceManager = "service-manager"
|
||||||
CapFirewall = "firewall"
|
CapFirewall = "firewall"
|
||||||
CapOverlay = "overlay"
|
CapOverlay = "overlay"
|
||||||
|
// CapVirtualisation is a running virtualisation daemon: what the lab raises its machines on
|
||||||
|
// (novox/hq ADR 0172), and what grants a module the daemon's socket.
|
||||||
|
CapVirtualisation = "virtualisation"
|
||||||
CapGraphicalSession = "graphical-session"
|
CapGraphicalSession = "graphical-session"
|
||||||
// CapSeat is hardware: somewhere a display server COULD run. CapGraphicalSession above is
|
// CapSeat is hardware: somewhere a display server COULD run. CapGraphicalSession above is
|
||||||
// state: whether one IS running. Assignment needs the first.
|
// state: whether one IS running. Assignment needs the first.
|
||||||
@@ -205,6 +208,11 @@ func Default(runner Runner) []Detector {
|
|||||||
why: "lists the ruleset — needs the tool AND the privilege to use it",
|
why: "lists the ruleset — needs the tool AND the privilege to use it",
|
||||||
runner: runner,
|
runner: runner,
|
||||||
},
|
},
|
||||||
|
commandCapability{
|
||||||
|
name: CapVirtualisation, command: "incus", args: []string{"info"},
|
||||||
|
why: "asks the virtualisation daemon about itself — a running daemon, not an installed client",
|
||||||
|
runner: runner,
|
||||||
|
},
|
||||||
commandCapability{
|
commandCapability{
|
||||||
name: CapOverlay, command: "wg", args: []string{"show", "interfaces"},
|
name: CapOverlay, command: "wg", args: []string{"show", "interfaces"},
|
||||||
why: "asks the kernel for interfaces — needs the module, not just the tool",
|
why: "asks the kernel for interfaces — needs the module, not just the tool",
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ func (a alpine) PackageInstalled(ctx context.Context, run Runner, name string) (
|
|||||||
return strings.TrimSpace(out) != "", nil
|
return strings.TrimSpace(out) != "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (alpine) RemovePackage(ctx context.Context, run Runner, name string) error {
|
||||||
|
_, err := run(ctx, "apk", "del", name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (alpine) InstallPackage(ctx context.Context, run Runner, name string) error {
|
func (alpine) InstallPackage(ctx context.Context, run Runner, name string) error {
|
||||||
_, err := run(ctx, "apk", "add", "--no-cache", name)
|
_, err := run(ctx, "apk", "add", "--no-cache", name)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ func (a android) InstallPackage(context.Context, Runner, string) error {
|
|||||||
return fmt.Errorf("%w: package", ErrUnsupported)
|
return fmt.Errorf("%w: package", ErrUnsupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a android) RemovePackage(context.Context, Runner, string) error {
|
||||||
|
return fmt.Errorf("%w: package", ErrUnsupported)
|
||||||
|
}
|
||||||
|
|
||||||
func (a android) ServiceState(context.Context, Runner, string) (string, error) {
|
func (a android) ServiceState(context.Context, Runner, string) (string, error) {
|
||||||
return "", fmt.Errorf("%w: service (init is not reachable without root)", ErrUnsupported)
|
return "", fmt.Errorf("%w: service (init is not reachable without root)", ErrUnsupported)
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-25
@@ -3,7 +3,10 @@ package system
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/novox/mesh-host/internal/declaration"
|
"github.com/novox/mesh-host/internal/declaration"
|
||||||
)
|
)
|
||||||
@@ -39,6 +42,14 @@ func (a arch) PackageInstalled(ctx context.Context, run Runner, name string) (bo
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemovePackage removes one package and nothing it depends on: `-R`, not `-Rs`, because what else
|
||||||
|
// relied on a dependency is not this declaration's to know. pacman keeps a configuration file the
|
||||||
|
// operator changed as `.pacsave`, which is what "never flushed" comes to once the front end is gone.
|
||||||
|
func (arch) RemovePackage(ctx context.Context, run Runner, name string) error {
|
||||||
|
_, err := run(ctx, "pacman", "-R", "--noconfirm", name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (arch) InstallPackage(ctx context.Context, run Runner, name string) error {
|
func (arch) InstallPackage(ctx context.Context, run Runner, name string) error {
|
||||||
out, err := run(ctx, "pacman", "-S", "--noconfirm", "--needed", name)
|
out, err := run(ctx, "pacman", "-S", "--noconfirm", "--needed", name)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -48,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
|
// **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
|
// 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
|
// 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
|
// **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
|
// 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
|
// 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
|
// 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.
|
// 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.
|
||||||
// So this says which of the two it is looking at. A declaration that is wrong and a machine
|
said := strings.TrimSpace(out + "\n" + err.Error())
|
||||||
// that is out of date fail identically otherwise, and they are fixed in completely different
|
switch classifyInstallFailure(said) {
|
||||||
// places.
|
case installStale:
|
||||||
if staleIndex(out) {
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%s could not be fetched from any mirror, which is what a stale package index looks "+
|
"%s could not be fetched from any mirror, which is what a stale package index looks like: "+
|
||||||
"like: this machine is asking for a version the mirrors have replaced. The "+
|
"the package database on this machine is %s and the mirrors no longer serve what it "+
|
||||||
"package and the declaration are probably both fine. It is fixed by upgrading "+
|
"names. It is fixed by upgrading the machine — a full upgrade (`pacman -Syu`) by its "+
|
||||||
"the machine, not by this host syncing one package — that would be a partial "+
|
"operator — before the mesh can install %s. The package and the declaration are probably both fine; the "+
|
||||||
"upgrade, which this distribution does not support.\n\n%s",
|
"host does not sync one package by itself, because on this distribution that is a "+
|
||||||
name, strings.TrimSpace(out))
|
"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))
|
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
|
// How a failed install is read, from what the package manager said.
|
||||||
// the package being wrong.
|
type installFailure int
|
||||||
//
|
|
||||||
// By what the package manager said, because there is nothing else to go on: the exit code is the
|
const (
|
||||||
// same for both.
|
installOther installFailure = iota
|
||||||
func staleIndex(out string) bool {
|
// installStale: the machine's package database or keyring is older than what the mirrors
|
||||||
said := strings.ToLower(out)
|
// serve — every mirror 404s the file the database names, or a package that did arrive fails
|
||||||
if !strings.Contains(said, "failed retrieving file") && !strings.Contains(said, "404") {
|
// its signature against a keyring that never saw the key.
|
||||||
return false
|
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
|
return installOther
|
||||||
// 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
|
|
||||||
|
// 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.
|
// ServiceState reads what systemd says about a unit.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,9 @@ type System interface {
|
|||||||
|
|
||||||
PackageInstalled(ctx context.Context, run Runner, name string) (bool, error)
|
PackageInstalled(ctx context.Context, run Runner, name string) (bool, error)
|
||||||
InstallPackage(ctx context.Context, run Runner, name string) 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 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
|
// ServiceState is "running" or "stopped". A unit that does not exist is an error, never
|
||||||
// "stopped" — reporting absence as satisfaction is the fault this host exists to prevent.
|
// "stopped" — reporting absence as satisfaction is the fault this host exists to prevent.
|
||||||
|
|||||||
Reference in New Issue
Block a user