From f57386cdeaf04d88a5608b3ab975db3c4dd863ca Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 16:28:27 +0200 Subject: [PATCH] A package may be declared absent, and an uninstalled front end is retired for good (hq ADR 0175) absent: true on a package has the host remove it through the machine's own package manager when it is installed and leave alone a machine that never had it; read back either way. Undeclaring a package still removes nothing. A found firewall whose command is gone is recorded as removed, said once, and asked nothing of. --- internal/apply/apply.go | 19 ++++++++++++++ internal/apply/left_out_test.go | 39 +++++++++++++++++++++++++++++ internal/apply/opening.go | 10 ++++++++ internal/apply/opening_test.go | 31 +++++++++++++++++++++++ internal/declaration/declaration.go | 6 +++++ internal/firewall/filters.go | 9 +++++++ internal/system/alpine.go | 5 ++++ internal/system/android.go | 4 +++ internal/system/arch.go | 8 ++++++ internal/system/system.go | 3 +++ 10 files changed, 134 insertions(+) diff --git a/internal/apply/apply.go b/internal/apply/apply.go index d5b1e55..2184946 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -1407,6 +1407,25 @@ func applyPackage(ctx context.Context, sys system.System, r *declaration.Package if err != nil { return out, err } + if r.Absent { + // Declared absent (novox/hq ADR 0175): 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 { out.Action = "unchanged" out.Detail = "already installed" diff --git a/internal/apply/left_out_test.go b/internal/apply/left_out_test.go index de19496..4f91d3c 100644 --- a/internal/apply/left_out_test.go +++ b/internal/apply/left_out_test.go @@ -173,3 +173,42 @@ func TestACapabilityReachesTheRuntimeAndTheSpec(t *testing.T) { t.Fatal("a capability is not part of the container's spec") } } + +// A package may be declared absent (novox/hq ADR 0175): 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) + } +} diff --git a/internal/apply/opening.go b/internal/apply/opening.go index 0390c8e..4c89239 100644 --- a/internal/apply/opening.go +++ b/internal/apply/opening.go @@ -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 { return "", nil } + if !firewall.Installed(ctx, run) { + // Uninstalled (novox/hq ADR 0175): 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) if !active && !(rec.Forward != nil && !rec.DisabledByMesh) { // Inactive, and either the mesh's doing already or nobody's recorded here: said as found, diff --git a/internal/apply/opening_test.go b/internal/apply/opening_test.go index 4f6783e..1a57764 100644 --- a/internal/apply/opening_test.go +++ b/internal/apply/opening_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/novox/mesh-host/internal/declaration" "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) } } + +// A front end that is no longer installed is recorded as removed, said once, and asked nothing of +// (novox/hq ADR 0175). +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) + } + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index c7419b4..0d611b0 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -870,6 +870,12 @@ 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 + // 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 } diff --git a/internal/firewall/filters.go b/internal/firewall/filters.go index 8b564fa..45888b1 100644 --- a/internal/firewall/filters.go +++ b/internal/firewall/filters.go @@ -319,8 +319,17 @@ func Active(ctx context.Context, run Runner) bool { 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 0175), 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. const ( RetiredByMesh = "mesh" RetiredFoundSo = "found-inactive" + // RetiredRemoved is a front end uninstalled by the module that replaced it (ADR 0175). + RetiredRemoved = "removed" ) diff --git a/internal/system/alpine.go b/internal/system/alpine.go index 5c66602..3eeb759 100644 --- a/internal/system/alpine.go +++ b/internal/system/alpine.go @@ -46,6 +46,11 @@ func (a alpine) PackageInstalled(ctx context.Context, run Runner, name string) ( 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 { _, err := run(ctx, "apk", "add", "--no-cache", name) return err diff --git a/internal/system/android.go b/internal/system/android.go index f46396b..a26c3fd 100644 --- a/internal/system/android.go +++ b/internal/system/android.go @@ -65,6 +65,10 @@ func (a android) InstallPackage(context.Context, Runner, string) error { 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) { return "", fmt.Errorf("%w: service (init is not reachable without root)", ErrUnsupported) } diff --git a/internal/system/arch.go b/internal/system/arch.go index 9582757..09a4955 100644 --- a/internal/system/arch.go +++ b/internal/system/arch.go @@ -39,6 +39,14 @@ func (a arch) PackageInstalled(ctx context.Context, run Runner, name string) (bo 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 { out, err := run(ctx, "pacman", "-S", "--noconfirm", "--needed", name) if err == nil { diff --git a/internal/system/system.go b/internal/system/system.go index 25ae307..a630344 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -51,6 +51,9 @@ 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). + RemovePackage(ctx context.Context, run Runner, name string) error // 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. -- 2.54.0