Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9b963f8b9 | ||
|
|
5a6e51f975 | ||
|
|
3c96683d98 | ||
|
|
8c4e33c165 | ||
|
|
2a332b0e6e | ||
|
|
b94e0f9a77 | ||
|
|
b840ce0a77 | ||
|
|
286865dfa7 | ||
|
|
ca7c4a5915 | ||
|
|
b30d9c5b5a |
@@ -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
-32
@@ -58,8 +58,6 @@ type Outcome struct {
|
||||
kept string
|
||||
// stateless is a service whose unit's lifecycle is the machine's (novox/hq ADR 0117).
|
||||
stateless bool
|
||||
// scope and user are, for a service, whose manager it was applied through (novox/hq ADR 0177).
|
||||
scope, user string
|
||||
// found is, for a service, its unit as the host first found it (novox/hq ADR 0118).
|
||||
found *store.FoundUnit
|
||||
// reads is, for a container, the digest of each file it was created reading, by path — so
|
||||
@@ -565,8 +563,6 @@ func ApplyKeeping(
|
||||
Kept: kept,
|
||||
Reads: outcome.reads,
|
||||
Stateless: outcome.stateless,
|
||||
Scope: outcome.scope,
|
||||
User: outcome.user,
|
||||
Found: outcome.found,
|
||||
Holds: holds(resource),
|
||||
})
|
||||
@@ -1045,14 +1041,44 @@ 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) {
|
||||
run = managerFor(r.Scope, r.User, run)
|
||||
if r.Stateless() {
|
||||
return reflectOnly(ctx, sys, r, run, changed)
|
||||
}
|
||||
out := begin(r)
|
||||
out.scope, out.user = r.Scope, r.User
|
||||
var changes []string
|
||||
|
||||
// A file the service reflects changed, and it may be the unit's own file or a drop-in: the
|
||||
@@ -1126,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
|
||||
}
|
||||
@@ -1146,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
|
||||
}
|
||||
@@ -1192,9 +1221,7 @@ func applyService(ctx context.Context, sys system.System, r *declaration.Service
|
||||
// a machine that uses another — and it reads the change when whatever starts it does.
|
||||
func reflectOnly(ctx context.Context, sys system.System, r *declaration.Service, run Runner,
|
||||
changed map[string]bool) (Outcome, error) {
|
||||
run = managerFor(r.Scope, r.User, run)
|
||||
out := begin(r)
|
||||
out.scope, out.user = r.Scope, r.User
|
||||
out.stateless = true
|
||||
restart := reflected(r, changed)
|
||||
reload := restartedBy(r.ReloadOn, changed)
|
||||
@@ -1238,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
|
||||
}
|
||||
@@ -1416,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"
|
||||
@@ -1615,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.
|
||||
@@ -1812,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)
|
||||
}
|
||||
@@ -2243,7 +2279,6 @@ func declaredDigest(r declaration.Resource) string {
|
||||
// what was actually done — a unit already as it was found is forgotten, not "restored".
|
||||
func removeService(ctx context.Context, sys system.System, a store.Applied, run Runner,
|
||||
made bool) (string, string, error) {
|
||||
run = managerFor(a.Scope, a.User, run)
|
||||
if a.Stateless {
|
||||
// Declared with no state (novox/hq ADR 0117): its lifecycle was never the mesh's, and the
|
||||
// declaration that said so is the operator's word to hold to, a file of the mesh's or not.
|
||||
@@ -2418,23 +2453,3 @@ func moduleOf(identity string) (string, bool) {
|
||||
}
|
||||
return identity[:at], true
|
||||
}
|
||||
|
||||
// managerFor routes a unit's commands to the manager it belongs to (novox/hq ADR 0177). A system
|
||||
// unit's go to the machine's manager as they always did. A user-scoped unit's go to the account's
|
||||
// own: `systemctl --user --machine=<account>@`, which reaches that manager from the host's own
|
||||
// process without an environment to forge or a user to switch to — and which only answers while
|
||||
// the account's manager runs (a login, or lingering enabled for the account). Done on the runner
|
||||
// rather than in each system: every system's reading of a unit already goes through `systemctl`,
|
||||
// so this is one place instead of one per system and one per method.
|
||||
func managerFor(scope, user string, run Runner) Runner {
|
||||
if scope != declaration.ScopeUser || user == "" {
|
||||
return run
|
||||
}
|
||||
return func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
if name != "systemctl" {
|
||||
return run(ctx, name, args...)
|
||||
}
|
||||
scoped := append([]string{"--user", "--machine=" + user + "@"}, args...)
|
||||
return run(ctx, name, scoped...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0177: a unit in the operator account's own service manager is applied
|
||||
// through that manager — `systemctl --user --machine=<account>@` — and never as a system unit of
|
||||
// the same name; its record remembers the scope so removal goes the same way.
|
||||
|
||||
// accountManager is a user's service manager whose one unit starts when asked, recording the
|
||||
// commands and refusing any that reach it outside the account's scope.
|
||||
func accountManager(account string, commands *[]string) Runner {
|
||||
active, enabled := false, false
|
||||
return func(_ context.Context, name string, args ...string) (string, error) {
|
||||
line := name + " " + strings.Join(args, " ")
|
||||
*commands = append(*commands, line)
|
||||
if name == "systemctl" && !strings.HasPrefix(line, "systemctl --user --machine="+account+"@ ") {
|
||||
return "", fmt.Errorf("a system-scope command reached the account's manager: %s", line)
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(line, " start "):
|
||||
active = true
|
||||
return "", nil
|
||||
case strings.Contains(line, " stop "):
|
||||
active = false
|
||||
return "", nil
|
||||
case strings.Contains(line, " enable "):
|
||||
enabled = true
|
||||
return "", nil
|
||||
case strings.Contains(line, " disable "):
|
||||
enabled = false
|
||||
return "", nil
|
||||
case strings.Contains(line, "is-enabled"):
|
||||
if enabled {
|
||||
return "enabled", nil
|
||||
}
|
||||
return "disabled", nil
|
||||
case strings.Contains(line, "show") && strings.Contains(line, "ActiveState"):
|
||||
if active {
|
||||
return "LoadState=loaded\nActiveState=active\nSubState=running", nil
|
||||
}
|
||||
return "LoadState=loaded\nActiveState=inactive\nSubState=dead", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestAUserScopedUnitIsAppliedThroughTheAccountsManager(t *testing.T) {
|
||||
var commands []string
|
||||
decl := `{"declaration":1,"resources":[
|
||||
{"id":"i3.watcher","type":"service","unit":"i3-reload-watcher.service","state":"running","boot":"enabled","scope":"user","user":"ops"}
|
||||
]}`
|
||||
report, known, err := Apply(context.Background(), archHost(t), parse(t, decl),
|
||||
store.State{}, store.OriginDeclared, accountManager("ops", &commands), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v\n%s", err, strings.Join(commands, "\n"))
|
||||
}
|
||||
if !report.Changed() {
|
||||
t.Fatal("a unit that was stopped and is now running changed nothing")
|
||||
}
|
||||
var started, enabled bool
|
||||
for _, c := range commands {
|
||||
if c == "systemctl --user --machine=ops@ start i3-reload-watcher.service" {
|
||||
started = true
|
||||
}
|
||||
if c == "systemctl --user --machine=ops@ enable i3-reload-watcher.service" {
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
if !started || !enabled {
|
||||
t.Fatalf("the unit was not started and enabled in the account's manager:\n%s", strings.Join(commands, "\n"))
|
||||
}
|
||||
recorded, ok := known.At("service", "i3-reload-watcher.service")
|
||||
if !ok || recorded.Scope != "user" || recorded.User != "ops" {
|
||||
t.Fatalf("the record does not say whose manager the unit is in: %+v", recorded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASystemUnitIsUntouchedByTheScope(t *testing.T) {
|
||||
var commands []string
|
||||
decl := `{"declaration":1,"resources":[
|
||||
{"id":"x.daemon","type":"service","unit":"sshd.service","state":"running","boot":"enabled"}
|
||||
]}`
|
||||
if _, _, err := Apply(context.Background(), archHost(t), parse(t, decl),
|
||||
store.State{}, store.OriginDeclared, unitIn(true, &commands), nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range commands {
|
||||
if strings.Contains(c, "--user") || strings.Contains(c, "--machine") {
|
||||
t.Fatalf("a system unit was addressed to an account's manager: %s", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -684,16 +684,6 @@ type Service struct {
|
||||
// declaration that reports success and stops being true at the next power cut.
|
||||
Boot string `json:"boot,omitempty"`
|
||||
|
||||
// Scope is whose service manager the unit belongs to: "system" (absent means system), or
|
||||
// "user" — the operator account's own manager (novox/hq ADR 0177). A workstation's per-user
|
||||
// daemons — a window manager's reload watcher, an audio mask, a memory guard — are units in
|
||||
// that manager, and until this they had no form the mesh could send. A user-scoped unit names
|
||||
// its User; the host talks to that account's manager and never starts a system unit by the
|
||||
// same name.
|
||||
Scope string `json:"scope,omitempty"`
|
||||
// User is the account whose manager a user-scoped unit lives in. Required with scope "user",
|
||||
// refused otherwise; a module names it ${machine:account} and never the person.
|
||||
User string `json:"user,omitempty"`
|
||||
// RestartOn names resources whose change means this service must be restarted.
|
||||
//
|
||||
// Because a running service does not re-read its configuration. Replace the file, find the
|
||||
@@ -739,33 +729,11 @@ func (s *Service) Identity() string { return s.ID }
|
||||
func (s *Service) Kind() Type { return TypeService }
|
||||
func (s *Service) Target() string { return s.Unit }
|
||||
|
||||
// ScopeSystem and ScopeUser are the two managers a unit may belong to (novox/hq ADR 0177).
|
||||
const (
|
||||
ScopeSystem = "system"
|
||||
ScopeUser = "user"
|
||||
)
|
||||
|
||||
// UserScoped is whether this unit lives in an account's own service manager.
|
||||
func (s *Service) UserScoped() bool { return s.Scope == ScopeUser }
|
||||
|
||||
func (s *Service) validate(where string, _ bool) []string {
|
||||
var problems []string
|
||||
if s.Unit == "" {
|
||||
problems = append(problems, where+": a service needs a unit")
|
||||
}
|
||||
switch s.Scope {
|
||||
case "", ScopeSystem:
|
||||
if s.User != "" {
|
||||
problems = append(problems, where+": a system unit names no user; only a user-scoped unit does")
|
||||
}
|
||||
case ScopeUser:
|
||||
if s.User == "" {
|
||||
problems = append(problems, where+": a user-scoped unit names the account whose manager it lives in")
|
||||
}
|
||||
default:
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: scope %q; a unit is in the \"system\" manager or the operator account's \"user\" one", where, s.Scope))
|
||||
}
|
||||
switch {
|
||||
case s.State == "running" || s.State == "stopped":
|
||||
case s.State != "":
|
||||
@@ -902,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
|
||||
@@ -984,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.
|
||||
@@ -1089,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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package declaration
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0177: a unit is in the system manager or an account's own; a user-scoped one names
|
||||
// the account, a system one may not, and any other word is refused.
|
||||
func TestAUserScopedUnitNamesItsAccountAndASystemOneMayNot(t *testing.T) {
|
||||
cases := []struct{ scope, user, wants string }{
|
||||
{"user", "ops", ""},
|
||||
{"", "", ""},
|
||||
{"system", "", ""},
|
||||
{"user", "", "names the account"},
|
||||
{"", "ops", "names no user"},
|
||||
{"session", "ops", "scope \"session\""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s := &Service{ID: "m.u", Type: TypeService, Unit: "u.service", State: "running", Scope: c.scope, User: c.user}
|
||||
problems := s.validate("m.u", false)
|
||||
got := strings.Join(problems, "; ")
|
||||
if c.wants == "" && len(problems) != 0 {
|
||||
t.Fatalf("scope %q user %q refused: %s", c.scope, c.user, got)
|
||||
}
|
||||
if c.wants != "" && !strings.Contains(got, c.wants) {
|
||||
t.Fatalf("scope %q user %q: wanted a refusal saying %q, got %q", c.scope, c.user, c.wants, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -82,10 +82,6 @@ type Applied struct {
|
||||
// 0117) — kept here because removal happens once the declaration that said so is gone, and a
|
||||
// service removed as if it had a state is stopped: the machine's network manager, for one.
|
||||
Stateless bool `json:"stateless,omitempty"`
|
||||
// Scope and User are, for a service in an account's own manager (novox/hq ADR 0177), which
|
||||
// manager — so removal gives the unit back through the same one it was applied through.
|
||||
Scope string `json:"scope,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
|
||||
// Found is, for a service, the state its unit was in when this host first applied it — before
|
||||
// the mesh started, stopped, enabled or disabled anything. Removal gives that back and nothing
|
||||
|
||||
+105
-25
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user