diff --git a/Makefile b/Makefile index 486603a..9ea72fc 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ +SYSTEM ?= arch # The gate. Green is the definition of done (novox/hq how-we-build §5). VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo development) -LDFLAGS := -s -w -X main.version=$(VERSION) +LDFLAGS := -s -w -X main.builtFor=$(SYSTEM) -X main.version=$(VERSION) # The bundle a host carries is built INTO it (novox/hq ADR 0038, ADR 0041): a host that needed # a second file to arrive with it is not "copy it and run it". @@ -10,6 +11,15 @@ BUNDLE ?= check: fmt vet test packaging-test build +# One binary per operating system (novox/hq ADR 0060). The system is pinned at link time; a +# host built without one refuses to touch a machine rather than guessing. +hosts: + @for s in arch alpine android; do \ + CGO_ENABLED=0 go build -ldflags="-s -w -X main.builtFor=$$s -X main.version=$(VERSION)" \ + -o mesh-host-$$s ./cmd/mesh-host || exit 1; \ + echo "built mesh-host-$$s"; \ + done + packaging-test: @./packaging/rollback_test.sh @./packaging/launch_test.sh diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 3e45420..a6972f2 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -24,11 +24,17 @@ import ( "github.com/novox/mesh-host/internal/inventory" "github.com/novox/mesh-host/internal/profile" "github.com/novox/mesh-host/internal/store" + "github.com/novox/mesh-host/internal/system" "github.com/novox/mesh-host/internal/upgrade" ) // version is stamped at build time. Unset in a development build, and said so rather than // defaulted to something that looks like a release. +// builtFor names the operating system this host was built for, set at link time +// (novox/hq ADR 0060). A host built without one refuses to do anything that touches the +// machine, rather than guessing and calling a package manager that is not there. +var builtFor = "" + var version = "development build" const usage = `mesh-host — the node host @@ -295,7 +301,23 @@ func runApply(ctx context.Context, opts options, d *declaration.Declaration, sou return nil } - report, updated, applyErr := apply.Apply(ctx, d, known, apply.ExecRunner, func(line string) { + sys, err := system.For(builtFor) + if err != nil { + return err + } + // Refuse a declaration naming a shape this host cannot apply, before anything is applied. + // An android host has no `package` applier, and finding that out half way through is the + // half-configured machine this host exists to prevent (novox/hq ADR 0060). + if err := system.Check(sys, d); err != nil { + return err + } + // And prove this is the machine the host was built for. Installing the arch host on Alpine + // must say so once, at the start, rather than failing later inside pacman. + if err := sys.Confirm(ctx, apply.ExecRunner); err != nil { + return err + } + + report, updated, applyErr := apply.Apply(ctx, sys, d, known, apply.ExecRunner, func(line string) { if !opts.json { fmt.Println(line) } diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 6d487fe..734c89d 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -25,11 +25,12 @@ import ( "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/store" + "github.com/novox/mesh-host/internal/system" ) // Runner executes a command. The real one is used everywhere outside unit tests; behaviour // against a real system is tested alongside rather than mocked (novox/hq ADR 0034). -type Runner func(ctx context.Context, name string, args ...string) (string, error) +type Runner = system.Runner // Outcome is what happened to one resource. type Outcome struct { @@ -81,6 +82,7 @@ func (e *Error) Unwrap() error { return e.Err } // apply then fails — a recovery concern, where the other is a correctness one. func Apply( ctx context.Context, + sys system.System, d *declaration.Declaration, known store.State, run Runner, @@ -97,7 +99,7 @@ func Apply( } for _, orphan := range known.Orphans(declared) { - action, detail, err := remove(ctx, orphan, run) + action, detail, err := remove(ctx, sys, orphan, run) if err != nil { return report, known, &Error{Resource: orphan.ID, Err: err, Done: report} } @@ -110,7 +112,7 @@ func Apply( } for _, resource := range d.Resources { - outcome, err := applyOne(ctx, resource, run) + outcome, err := applyOne(ctx, sys, resource, run) if err != nil { return report, known, &Error{Resource: resource.Identity(), Err: err, Done: report} } @@ -128,16 +130,16 @@ func Apply( return report, known, nil } -func applyOne(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) { +func applyOne(ctx context.Context, sys system.System, r declaration.Resource, run Runner) (Outcome, error) { switch res := r.(type) { case *declaration.Directory: return applyDirectory(res) case *declaration.File: return applyFile(res) case *declaration.Service: - return applyService(ctx, res, run) + return applyService(ctx, sys, res, run) case *declaration.Package: - return applyPackage(ctx, res, run) + return applyPackage(ctx, sys, res, run) case *declaration.Container: return applyContainer(ctx, res, run) case *declaration.Action: @@ -314,7 +316,7 @@ func writeAtomically(path string, content []byte, mode os.FileMode) error { return os.Rename(tmp.Name(), path) } -func applyService(ctx context.Context, r *declaration.Service, run Runner) (Outcome, error) { +func applyService(ctx context.Context, sys system.System, r *declaration.Service, run Runner) (Outcome, error) { out := begin(r) var changes []string @@ -322,19 +324,15 @@ func applyService(ctx context.Context, r *declaration.Service, run Runner) (Outc // half way in the more useful direction: enabled-and-stopped comes back at the next boot, // where running-and-disabled does not. if r.Boot != "" { - bootBefore, err := serviceBoot(ctx, r.Unit, run) + bootBefore, err := sys.ServiceBoot(ctx, run, r.Unit) if err != nil { return out, err } if bootBefore != r.Boot { - verb := "enable" - if r.Boot == "disabled" { - verb = "disable" + if err := sys.SetServiceBoot(ctx, run, r.Unit, r.Boot); err != nil { + return out, fmt.Errorf("setting %s to %s at boot: %w", r.Unit, r.Boot, err) } - if _, err := run(ctx, "systemctl", verb, r.Unit); err != nil { - return out, fmt.Errorf("%s %s: %w", verb, r.Unit, err) - } - bootAfter, err := serviceBoot(ctx, r.Unit, run) + bootAfter, err := sys.ServiceBoot(ctx, run, r.Unit) if err != nil { return out, err } @@ -346,23 +344,18 @@ func applyService(ctx context.Context, r *declaration.Service, run Runner) (Outc } } - before, err := serviceState(ctx, r.Unit, run) + before, err := sys.ServiceState(ctx, run, r.Unit) if err != nil { return out, err } if before != r.State { - verb := "start" - if r.State == "stopped" { - verb = "stop" - } - if _, err := run(ctx, "systemctl", verb, r.Unit); err != nil { - return out, fmt.Errorf("%s %s: %w", verb, r.Unit, err) + if err := sys.SetServiceState(ctx, run, r.Unit, r.State); err != nil { + return out, fmt.Errorf("setting %s to %s: %w", r.Unit, r.State, err) } - // Read back. `systemctl start` returning zero says the transaction was accepted, not - // that the unit is running — a unit that starts and immediately dies satisfies the - // command. - after, err := serviceState(ctx, r.Unit, run) + // 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 { return out, err } @@ -382,90 +375,6 @@ func applyService(ctx context.Context, r *declaration.Service, run Runner) (Outc return out, nil } -// serviceBoot reads whether a unit starts at boot. -// -// The same trap as serviceState, in a new place. `systemctl is-enabled` exits non-zero for -// nearly everything that is not "enabled", so the exit code says nothing useful — and it has -// more than two answers. `static` in particular is neither enabled nor disabled: the unit has -// no install section and CANNOT be enabled, so reporting it as "disabled" would let the host -// try, fail, and blame the wrong thing. -func serviceBoot(ctx context.Context, unit string, run Runner) (string, error) { - out, _ := run(ctx, "systemctl", "is-enabled", unit) - switch state := strings.TrimSpace(out); state { - case "enabled", "enabled-runtime", "alias": - return "enabled", nil - case "disabled": - return "disabled", nil - case "": - return "", fmt.Errorf("the service manager said nothing about whether %s starts at boot", unit) - case "static": - return "", fmt.Errorf( - "%s is static — it has no install section, so it cannot be enabled or disabled. "+ - "Something else pulls it in, and that is what a declaration should name", unit) - case "masked", "masked-runtime": - return "", fmt.Errorf("%s is masked, so its boot state cannot be declared", unit) - default: - return "", fmt.Errorf( - "the service manager reports %s as %q at boot, which is neither enabled nor disabled", - unit, state) - } -} - -// serviceState reads what the service manager says about a unit. -// -// Two traps here, and both were hit before this read what it now reads. -// -// The exit code is not the answer: `is-active` exits non-zero for every state except active — -// the same shape as the capability detector reading a degraded init as no init at all. -// -// And "inactive" does not mean stopped. `systemctl is-active` says "inactive" for a unit that -// DOES NOT EXIST exactly as it does for one that is installed and stopped. Declaring a unit -// stopped therefore reported success for a unit the host cannot manage at all — absence read -// as satisfaction, which is 04-ISSUES/007 wearing a different hat. LoadState is what separates -// them, so LoadState is what is read. -func serviceState(ctx context.Context, unit string, run Runner) (string, error) { - out, _ := run(ctx, "systemctl", "show", unit, - "--property=LoadState", "--property=ActiveState") - - var load, active string - for _, line := range strings.Split(out, "\n") { - key, value, found := strings.Cut(strings.TrimSpace(line), "=") - if !found { - continue - } - switch key { - case "LoadState": - load = value - case "ActiveState": - active = value - } - } - - switch load { - case "": - return "", fmt.Errorf("the service manager said nothing about %s", unit) - case "not-found": - return "", fmt.Errorf( - "%s does not exist on this machine. A declaration naming a unit that is not "+ - "installed cannot be satisfied, and reporting it stopped would be reporting "+ - "absence as success", unit) - case "masked": - return "", fmt.Errorf("%s is masked, so its state cannot be declared", unit) - case "error", "bad-setting": - return "", fmt.Errorf("%s is installed but its unit file cannot be loaded (%s)", unit, load) - } - - switch active { - case "active", "activating", "reloading": - return "running", nil - case "inactive", "failed", "deactivating": - return "stopped", nil - default: - return "", fmt.Errorf( - "the service manager reports %s as %q, which is neither running nor stopped", unit, active) - } -} - // remove undoes one resource the host applied and the declaration no longer names, and reports // what it actually did. // @@ -475,7 +384,7 @@ func serviceState(ctx context.Context, unit string, run Runner) (string, error) // It returns the action rather than assuming "removed", because for half the vocabulary the // honest word is "forgotten". A host that reported a package removed when it left the package // installed would be describing an effect it declined to have. -func remove(ctx context.Context, a store.Applied, run Runner) (string, string, error) { +func remove(ctx context.Context, sys system.System, a store.Applied, run Runner) (string, string, error) { switch declaration.Type(a.Type) { case declaration.TypeFile, declaration.TypeDirectory: if err := os.RemoveAll(a.Target); err != nil { @@ -495,13 +404,13 @@ func remove(ctx context.Context, a store.Applied, run Runner) (string, string, e // host holding a record of an uninstalled unit would then be unable to apply anything, // ever, with no way out but editing its state by hand. Removal is idempotent for the // same reason `os.RemoveAll` is. - if _, err := serviceState(ctx, a.Target, run); err != nil { + if _, err := sys.ServiceState(ctx, run, a.Target); err != nil { if strings.Contains(err.Error(), "does not exist on this machine") { return "forgotten", "the unit no longer exists", nil } return "", "", err } - if _, err := run(ctx, "systemctl", "stop", a.Target); err != nil { + if err := sys.SetServiceState(ctx, run, a.Target, "stopped"); err != nil { return "", "", fmt.Errorf("stopping %s: %w", a.Target, err) } return "removed", "stopped; the unit file is not the host's to delete", nil @@ -562,10 +471,10 @@ func ExecRunner(ctx context.Context, name string, args ...string) (string, error // asserts, because version is the package manager's business and the mesh does not have a // second opinion about it (novox/hq ADR 0041 — the host depends on nothing, and that includes // not becoming a second package manager). -func applyPackage(ctx context.Context, r *declaration.Package, run Runner) (Outcome, error) { +func applyPackage(ctx context.Context, sys system.System, r *declaration.Package, run Runner) (Outcome, error) { out := begin(r) - installed, err := packageInstalled(ctx, r.Package, run) + installed, err := sys.PackageInstalled(ctx, run, r.Package) if err != nil { return out, err } @@ -575,12 +484,12 @@ func applyPackage(ctx context.Context, r *declaration.Package, run Runner) (Outc return out, nil } - if _, err := run(ctx, "pacman", "-S", "--noconfirm", "--needed", r.Package); err != nil { + if err := sys.InstallPackage(ctx, run, r.Package); err != nil { return out, fmt.Errorf("installing %s: %w", r.Package, err) } // Read back. A package manager exiting zero says the transaction was accepted. - installed, err = packageInstalled(ctx, r.Package, run) + installed, err = sys.PackageInstalled(ctx, run, r.Package) if err != nil { return out, err } @@ -593,24 +502,6 @@ func applyPackage(ctx context.Context, r *declaration.Package, run Runner) (Outc return out, nil } -// packageInstalled asks the package database, having first established that it answers. -// -// The two-step is the same trap `serviceState` documents. `pacman -Q name` exits non-zero for -// a package that is not installed AND for a package database that cannot be read, so believing -// the first answer would report a broken package manager as "nothing is installed" — absence -// read as fact. Proving the tool answers about something that certainly exists separates them. -func packageInstalled(ctx context.Context, name string, run Runner) (bool, error) { - if _, err := run(ctx, "pacman", "-Q", "pacman"); err != nil { - return false, fmt.Errorf( - "the package database does not answer on this machine, so nothing can be said "+ - "about %q: %w", name, err) - } - if _, err := run(ctx, "pacman", "-Q", name); err != nil { - return false, nil - } - return true, nil -} - // Labels the host puts on every container it creates. // // specLabel carries a digest of the declaration that made the container. It is what lets a diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go index 028e591..7218f0c 100644 --- a/internal/apply/apply_test.go +++ b/internal/apply/apply_test.go @@ -10,6 +10,7 @@ import ( "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/store" + "github.com/novox/mesh-host/internal/system" ) // Each test names the decision it defends (novox/hq ADR 0034). @@ -38,7 +39,7 @@ func TestApplyingTwiceChangesNothingTheSecondTime(t *testing.T) { {"id":"f","type":"file","path":"`+dir+`/etc/a.conf","content":"hello\n","mode":"0640"} ]}`) - first, state, err := Apply(context.Background(), d, store.State{}, noServices, nil) + first, state, err := Apply(context.Background(), archHost(t), d, store.State{}, noServices, nil) if err != nil { t.Fatal(err) } @@ -46,7 +47,7 @@ func TestApplyingTwiceChangesNothingTheSecondTime(t *testing.T) { t.Fatal("the first apply on an empty machine changed nothing") } - second, _, err := Apply(context.Background(), d, state, noServices, nil) + second, _, err := Apply(context.Background(), archHost(t), d, state, noServices, nil) if err != nil { t.Fatal(err) } @@ -64,7 +65,7 @@ func TestADriftedMachineIsReturned(t *testing.T) { {"id":"f","type":"file","path":"`+path+`","content":"correct\n","mode":"0644"} ]}`) - _, state, err := Apply(context.Background(), d, store.State{}, noServices, nil) + _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, noServices, nil) if err != nil { t.Fatal(err) } @@ -72,7 +73,7 @@ func TestADriftedMachineIsReturned(t *testing.T) { t.Fatal(err) } - report, _, err := Apply(context.Background(), d, state, noServices, nil) + report, _, err := Apply(context.Background(), archHost(t), d, state, noServices, nil) if err != nil { t.Fatal(err) } @@ -96,7 +97,7 @@ func TestADroppedResourceIsRemoved(t *testing.T) { {"id":"keep","type":"file","path":"`+keep+`","content":"a\n"}, {"id":"drop","type":"file","path":"`+drop+`","content":"b\n"} ]}`) - _, state, err := Apply(context.Background(), both, store.State{}, noServices, nil) + _, state, err := Apply(context.Background(), archHost(t), both, store.State{}, noServices, nil) if err != nil { t.Fatal(err) } @@ -104,7 +105,7 @@ func TestADroppedResourceIsRemoved(t *testing.T) { one := parse(t, `{"declaration":1,"resources":[ {"id":"keep","type":"file","path":"`+keep+`","content":"a\n"} ]}`) - report, state, err := Apply(context.Background(), one, state, noServices, nil) + report, state, err := Apply(context.Background(), archHost(t), one, state, noServices, nil) if err != nil { t.Fatal(err) } @@ -136,7 +137,7 @@ func TestNothingTheHostDidNotCreateIsTouched(t *testing.T) { d := parse(t, `{"declaration":1,"resources":[ {"id":"ours","type":"file","path":"`+filepath.Join(dir, "ours.conf")+`","content":"a\n"} ]}`) - if _, _, err := Apply(context.Background(), d, store.State{}, noServices, nil); err != nil { + if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, noServices, nil); err != nil { t.Fatal(err) } @@ -155,7 +156,7 @@ func TestARenameToTheSamePathDoesNotDeleteTheNewFile(t *testing.T) { before := parse(t, `{"declaration":1,"resources":[ {"id":"old","type":"file","path":"`+path+`","content":"old\n"} ]}`) - _, state, err := Apply(context.Background(), before, store.State{}, noServices, nil) + _, state, err := Apply(context.Background(), archHost(t), before, store.State{}, noServices, nil) if err != nil { t.Fatal(err) } @@ -163,7 +164,7 @@ func TestARenameToTheSamePathDoesNotDeleteTheNewFile(t *testing.T) { after := parse(t, `{"declaration":1,"resources":[ {"id":"new","type":"file","path":"`+path+`","content":"new\n"} ]}`) - if _, _, err := Apply(context.Background(), after, state, noServices, nil); err != nil { + if _, _, err := Apply(context.Background(), archHost(t), after, state, noServices, nil); err != nil { t.Fatal(err) } @@ -191,7 +192,7 @@ func TestAFailedStepFailsTheApply(t *testing.T) { {"id":"never","type":"file","path":"`+filepath.Join(dir, "never.conf")+`","content":"b\n"} ]}`) - _, _, err := Apply(context.Background(), d, store.State{}, noServices, nil) + _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, noServices, nil) if err == nil { t.Fatal("an impossible resource did not fail the apply") } @@ -224,7 +225,7 @@ func TestNothingIsRecordedUntilItWorked(t *testing.T) { {"id":"doomed","type":"directory","path":"`+blocker+`"} ]}`) - _, state, err := Apply(context.Background(), d, store.State{}, noServices, nil) + _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, noServices, nil) if err == nil { t.Fatal("expected a failure") } @@ -243,7 +244,7 @@ func TestAModeIsMaintainedNotJustSet(t *testing.T) { {"id":"f","type":"file","path":"`+path+`","content":"s\n","mode":"0600"} ]}`) - _, state, err := Apply(context.Background(), d, store.State{}, noServices, nil) + _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, noServices, nil) if err != nil { t.Fatal(err) } @@ -251,7 +252,7 @@ func TestAModeIsMaintainedNotJustSet(t *testing.T) { t.Fatal(err) } - report, _, err := Apply(context.Background(), d, state, noServices, nil) + report, _, err := Apply(context.Background(), archHost(t), d, state, noServices, nil) if err != nil { t.Fatal(err) } @@ -282,7 +283,7 @@ func TestAServiceIsReadBackNotAssumed(t *testing.T) { d := parse(t, `{"declaration":1,"resources":[ {"id":"s","type":"service","unit":"doomed.service","state":"running"} ]}`) - _, _, err := Apply(context.Background(), d, store.State{}, run, nil) + _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil) if err == nil { t.Fatal("a service that died immediately was reported as running") } @@ -298,7 +299,7 @@ func TestAnUnknownServiceStateIsRefusedNotGuessed(t *testing.T) { d := parse(t, `{"declaration":1,"resources":[ {"id":"s","type":"service","unit":"odd.service","state":"running"} ]}`) - _, _, err := Apply(context.Background(), d, store.State{}, run, nil) + _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil) if err == nil || !strings.Contains(err.Error(), "neither running nor stopped") { t.Errorf("an unrecognised service state was not refused: %v", err) } @@ -322,7 +323,7 @@ func TestADroppedServiceIsStoppedNotDeleted(t *testing.T) { {"id":"other","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"} ]}`) - if _, _, err := Apply(context.Background(), d, state, run, nil); err != nil { + if _, _, err := Apply(context.Background(), archHost(t), d, state, run, nil); err != nil { t.Fatal(err) } joined := strings.Join(commands, "; ") @@ -348,7 +349,7 @@ func TestAUnitThatDoesNotExistIsNotStopped(t *testing.T) { {"id":"s","type":"service","unit":"never-installed.service","state":"stopped"} ]}`) - _, state, err := Apply(context.Background(), d, store.State{}, absent, nil) + _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, absent, nil) if err == nil { t.Fatal("a unit that does not exist was reported as satisfactorily stopped") } @@ -369,7 +370,7 @@ func TestAMaskedUnitIsRefused(t *testing.T) { d := parse(t, `{"declaration":1,"resources":[ {"id":"s","type":"service","unit":"masked.service","state":"running"} ]}`) - if _, _, err := Apply(context.Background(), d, store.State{}, masked, nil); err == nil { + if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, masked, nil); err == nil { t.Fatal("a masked unit was accepted") } } @@ -397,7 +398,7 @@ func TestForgettingAUnitThatIsGoneDoesNotStrandTheNode(t *testing.T) { {"id":"f","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"} ]}`) - report, state, err := Apply(context.Background(), d, known, run, nil) + report, state, err := Apply(context.Background(), archHost(t), d, known, run, nil) if err != nil { t.Fatalf("a vanished unit stranded the apply: %v", err) } @@ -441,7 +442,7 @@ func TestABrokenPackageDatabaseIsNotReadAsNotInstalled(t *testing.T) { {"id":"rt","type":"package","package":"docker"} ]}`) - _, _, err := Apply(context.Background(), d, store.State{}, run, nil) + _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil) if err == nil { t.Fatal("a broken package database was read as 'not installed'") } @@ -462,7 +463,7 @@ func TestAnInstalledPackageIsNotReinstalled(t *testing.T) { {"id":"rt","type":"package","package":"docker"} ]}`) - report, _, err := Apply(context.Background(), d, store.State{}, run, nil) + report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil) if err != nil { t.Fatalf("apply failed: %v", err) } @@ -492,7 +493,7 @@ func TestAPackageIsNeverUninstalled(t *testing.T) { {"id":"f","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"} ]}`) - report, state, err := Apply(context.Background(), d, known, run, nil) + report, state, err := Apply(context.Background(), archHost(t), d, known, run, nil) if err != nil { t.Fatalf("dropping a package stranded the apply: %v", err) } @@ -522,7 +523,7 @@ func TestAnActionThatIsAlreadyTrueDoesNotRun(t *testing.T) { {"id":"db","type":"action","command":["create-db","mesh"],"verify":["has-db","mesh"]} ]}`) - report, _, err := Apply(context.Background(), d, store.State{}, run, nil) + report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil) if err != nil { t.Fatalf("apply failed: %v", err) } @@ -548,7 +549,7 @@ func TestAnActionThatSucceedsAndDoesNothingFails(t *testing.T) { {"id":"db","type":"action","command":["create-db","mesh"],"verify":["has-db","mesh"]} ]}`) - _, state, err := Apply(context.Background(), d, store.State{}, run, nil) + _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil) if err == nil { t.Fatal("an action that reported success and did nothing was accepted") } @@ -575,7 +576,7 @@ func TestAnActionRunsInsideTheContainerItNames(t *testing.T) { {"id":"db","type":"action","in":"store","command":["createdb","mesh"],"verify":["psql","-lqt"]} ]}`) - if _, _, err := Apply(context.Background(), d, store.State{}, run, nil); err != nil { + if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil); err != nil { t.Fatalf("apply failed: %v", err) } if !sawExec { @@ -602,7 +603,7 @@ func TestAContainerThatExitsImmediatelyFailsTheApply(t *testing.T) { {"id":"store","type":"container","name":"store","image":"`+pinned+`"} ]}`) - _, state, err := Apply(context.Background(), d, store.State{}, run, nil) + _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil) if err == nil { t.Fatal("a container that exited immediately was reported as applied") } @@ -644,7 +645,7 @@ func TestAContainerWhoseDeclarationChangedIsReplaced(t *testing.T) { return "", nil } - report, _, err := Apply(context.Background(), d, store.State{}, run, nil) + report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil) if err != nil { t.Fatalf("apply failed: %v", err) } @@ -674,7 +675,7 @@ func TestAContainerThatMatchesIsLeftAlone(t *testing.T) { return "", nil } - report, _, err := Apply(context.Background(), d, store.State{}, run, nil) + report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil) if err != nil { t.Fatalf("apply failed: %v", err) } @@ -733,7 +734,7 @@ func TestAServiceIsEnabledAtBootWhenAsked(t *testing.T) { {"id":"rt","type":"service","unit":"docker.service","state":"running","boot":"enabled"} ]}`) - report, _, err := Apply(context.Background(), d, store.State{}, + report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, systemctlStub(t, "loaded", "inactive", "disabled", &verbs), nil) if err != nil { t.Fatalf("apply failed: %v", err) @@ -753,7 +754,7 @@ func TestBootIsEnabledBeforeTheUnitIsStarted(t *testing.T) { d := parseTrusted(t, `{"declaration":1,"resources":[ {"id":"rt","type":"service","unit":"docker.service","state":"running","boot":"enabled"} ]}`) - if _, _, err := Apply(context.Background(), d, store.State{}, + if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, systemctlStub(t, "loaded", "inactive", "disabled", &verbs), nil); err != nil { t.Fatal(err) } @@ -768,7 +769,7 @@ func TestAlreadyEnabledAndRunningIsUnchanged(t *testing.T) { {"id":"rt","type":"service","unit":"docker.service","state":"running","boot":"enabled"} ]}`) - report, _, err := Apply(context.Background(), d, store.State{}, + report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, systemctlStub(t, "loaded", "active", "enabled", &verbs), nil) if err != nil { t.Fatalf("apply failed: %v", err) @@ -788,7 +789,7 @@ func TestOmittingBootLeavesItAlone(t *testing.T) { d := parseTrusted(t, `{"declaration":1,"resources":[ {"id":"rt","type":"service","unit":"docker.service","state":"running"} ]}`) - if _, _, err := Apply(context.Background(), d, store.State{}, + if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, systemctlStub(t, "loaded", "inactive", "enabled", &verbs), nil); err != nil { t.Fatal(err) } @@ -808,7 +809,7 @@ func TestAStaticUnitCannotBeEnabled(t *testing.T) { {"id":"rt","type":"service","unit":"dbus.socket","state":"running","boot":"enabled"} ]}`) - _, _, err := Apply(context.Background(), d, store.State{}, + _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, systemctlStub(t, "loaded", "active", "static", &verbs), nil) if err == nil { t.Fatal("a static unit was accepted as enable-able") @@ -823,7 +824,7 @@ func TestAnUnknownBootStateIsRefusedNotGuessed(t *testing.T) { d := parseTrusted(t, `{"declaration":1,"resources":[ {"id":"rt","type":"service","unit":"x.service","state":"running","boot":"enabled"} ]}`) - _, _, err := Apply(context.Background(), d, store.State{}, + _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, systemctlStub(t, "loaded", "active", "indirect", &verbs), nil) if err == nil { t.Fatal("an unrecognised boot state was guessed at instead of refused") @@ -899,7 +900,7 @@ func TestAContainerUsesTheRuntimeTheMachineHas(t *testing.T) { // It will fail at read-back — the stub never reports it running — and what matters is // WHICH binary it used getting there. - _, _, _ = Apply(context.Background(), d, store.State{}, run, nil) + _, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, run, nil) for _, c := range calledWith { if c != "podman" { @@ -921,7 +922,7 @@ func TestNoRuntimeIsSaidPlainly(t *testing.T) { {"id":"store","type":"container","name":"store","image":"`+pinned+`"} ]}`) - _, _, err := Apply(context.Background(), d, store.State{}, run, nil) + _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, run, nil) if err == nil { t.Fatal("a machine with no container runtime applied a container") } @@ -931,3 +932,14 @@ func TestNoRuntimeIsSaidPlainly(t *testing.T) { } } } + +// archHost is the system these tests run against. They were written for pacman and systemd, and +// naming that is better than the implicit default it used to be. +func archHost(t *testing.T) system.System { + t.Helper() + s, err := system.For("arch") + if err != nil { + t.Fatal(err) + } + return s +} diff --git a/internal/system/alpine.go b/internal/system/alpine.go new file mode 100644 index 0000000..41cee0b --- /dev/null +++ b/internal/system/alpine.go @@ -0,0 +1,133 @@ +package system + +import ( + "context" + "fmt" + "strings" + + "github.com/novox/mesh-host/internal/declaration" +) + +// alpine is apk and OpenRC. +// +// The intended first node. Where it differs from systemd is not cosmetic, and each difference +// below is a place where the systemd implementation's care had to be re-derived rather than +// translated. +type alpine struct{} + +func (alpine) Name() string { return "alpine" } +func (alpine) Shapes() []declaration.Type { return everyShape() } + +func (a alpine) Confirm(ctx context.Context, run Runner) error { + // `apk info -e apk-tools` asks the installed-package database about something that is + // certainly there. `apk --version` would prove only that a binary exists, which is the + // assumption 04-ISSUES/007 records. + if _, err := run(ctx, "apk", "info", "-e", "apk-tools"); err != nil { + return fmt.Errorf( + "this is the alpine host and apk does not answer here. Either this machine is not "+ + "Alpine, or its package database is broken: %w", err) + } + return nil +} + +// PackageInstalled asks apk, having first established that apk answers. +// +// `apk info -e ` prints the name when installed and NOTHING when not — and exits zero +// either way. So unlike pacman, the exit code cannot be used at all here: an empty answer is +// the negative. Reading the exit code would report every package as installed. +func (a alpine) PackageInstalled(ctx context.Context, run Runner, name string) (bool, error) { + if err := a.Confirm(ctx, run); err != nil { + return false, fmt.Errorf("nothing can be said about %q: %w", name, err) + } + out, err := run(ctx, "apk", "info", "-e", name) + if err != nil { + return false, nil + } + return strings.TrimSpace(out) != "", nil +} + +func (alpine) InstallPackage(ctx context.Context, run Runner, name string) error { + _, err := run(ctx, "apk", "add", "--no-cache", name) + return err +} + +// ServiceState reads what OpenRC says about a service. +// +// The same trap as systemd's, and it needs answering differently because OpenRC has no +// LoadState. `rc-service status` exits 3 for a stopped service and 1 for one that does +// not exist — but the exit code reaches us wrapped, so the output is read instead: OpenRC says +// "does not exist" plainly, and that distinction is the whole reason this function is not a +// one-liner. +func (alpine) ServiceState(ctx context.Context, run Runner, unit string) (string, error) { + out, err := run(ctx, "rc-service", unit, "status") + text := strings.ToLower(out + " " + errText(err)) + + switch { + case strings.Contains(text, "does not exist"): + return "", fmt.Errorf( + "%s does not exist on this machine. A declaration naming a service that is not "+ + "installed cannot be satisfied, and reporting it stopped would be reporting "+ + "absence as success", unit) + case strings.Contains(text, "status: started"), strings.Contains(text, "status: starting"): + return "running", nil + case strings.Contains(text, "status: stopped"), strings.Contains(text, "status: stopping"): + return "stopped", nil + case strings.Contains(text, "status: crashed"): + // Crashed is not running, and it is not the same as stopped either — but a + // declaration can only ask for one of two things, and the honest mapping is that the + // service is not up. Starting it is then the right next act. + return "stopped", nil + case strings.TrimSpace(text) == "": + return "", fmt.Errorf("the service manager said nothing about %s", unit) + default: + return "", fmt.Errorf( + "the service manager reports %s as %q, which is neither running nor stopped", + unit, strings.TrimSpace(out)) + } +} + +func (alpine) SetServiceState(ctx context.Context, run Runner, unit, state string) error { + verb := "start" + if state == "stopped" { + verb = "stop" + } + _, err := run(ctx, "rc-service", unit, verb) + return err +} + +// ServiceBoot reads whether a service is in a runlevel. +// +// OpenRC has no `is-enabled`. What it has is `rc-update show`, which lists services against the +// runlevels they are added to — so "does it start at boot" becomes "does it appear here", and +// there is no equivalent of systemd's `static` because OpenRC has no unit files without an +// install story. +func (alpine) ServiceBoot(ctx context.Context, run Runner, unit string) (string, error) { + out, err := run(ctx, "rc-update", "show", "default") + if err != nil { + return "", fmt.Errorf("cannot read which services start at boot: %w", err) + } + for _, line := range strings.Split(out, "\n") { + // A line looks like ` docker | default`. The name is the first field. + name, _, _ := strings.Cut(strings.TrimSpace(line), "|") + if strings.TrimSpace(name) == unit { + return "enabled", nil + } + } + return "disabled", nil +} + +func (alpine) SetServiceBoot(ctx context.Context, run Runner, unit, boot string) error { + verb := "add" + if boot == "disabled" { + verb = "del" + } + _, err := run(ctx, "rc-update", verb, unit, "default") + return err +} + +func errText(err error) string { + if err == nil { + return "" + } + return err.Error() +} diff --git a/internal/system/android.go b/internal/system/android.go new file mode 100644 index 0000000..6c6aea7 --- /dev/null +++ b/internal/system/android.go @@ -0,0 +1,74 @@ +package system + +import ( + "context" + "fmt" + + "github.com/novox/mesh-host/internal/declaration" +) + +// android is a partial host, and being partial is the point. +// +// It implements `file`, `directory` and `action` — the shapes that need only a filesystem and a +// way to run something — and refuses the other three. That is not a broken host: a declaration +// naming a shape this host does not implement is refused whole, the same treatment an unknown +// type gets, and the profile tells the control plane which shapes exist so it never sends one +// it cannot do (novox/hq ADR 0060). +// +// What it cannot do, and why: +// +// - **package** — there is no package manager an ordinary app may drive. Installing software +// on Android means the framework installing an APK, which is not something a process asks +// for on its own behalf. +// - **service** — Android's init reads .rc files from the system partition, which needs root +// and an unlocked bootloader. On a normal device nothing can register with it. +// - **container** — no container runtime, and no kernel access to give one. +// +// **Being STARTED on Android is not solved by this file, and it is the real gap.** Everywhere +// else an init runs the launcher at boot. Here the equivalent is the app framework — a +// foreground service, or something under Termux — both of which the system may kill when it +// wants memory. That is a different mechanism from every other node rather than a variant of +// one, and nothing here designs it. +type android struct{} + +func (android) Name() string { return "android" } +func (android) Shapes() []declaration.Type { return portableShapes() } + +func (android) Confirm(ctx context.Context, run Runner) error { + // Ask the property service, which exists on every Android and nowhere else. A file path + // check would pass inside a chroot; this asks something only Android answers. + if _, err := run(ctx, "getprop", "ro.build.version.sdk"); err != nil { + return fmt.Errorf( + "this is the android host and the property service does not answer here. Either "+ + "this is not Android, or it is a container without it: %w", err) + } + return nil +} + +// The four below are unreachable through the ordinary path: Check refuses a declaration naming +// these shapes before anything is applied. They are here so that "unreachable" fails loudly if +// it ever stops being true, rather than a nil applier being called. + +func (a android) PackageInstalled(context.Context, Runner, string) (bool, error) { + return false, fmt.Errorf("%w: package (there is no package manager an app may drive)", ErrUnsupported) +} + +func (a android) InstallPackage(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) +} + +func (a android) SetServiceState(context.Context, Runner, string, string) error { + return fmt.Errorf("%w: service", ErrUnsupported) +} + +func (a android) ServiceBoot(context.Context, Runner, string) (string, error) { + return "", fmt.Errorf("%w: service", ErrUnsupported) +} + +func (a android) SetServiceBoot(context.Context, Runner, string, string) error { + return fmt.Errorf("%w: service", ErrUnsupported) +} diff --git a/internal/system/arch.go b/internal/system/arch.go new file mode 100644 index 0000000..b390c7d --- /dev/null +++ b/internal/system/arch.go @@ -0,0 +1,144 @@ +package system + +import ( + "context" + "fmt" + "strings" + + "github.com/novox/mesh-host/internal/declaration" +) + +// arch is pacman and systemd. +type arch struct{} + +func (arch) Name() string { return "arch" } +func (arch) Shapes() []declaration.Type { return everyShape() } + +func (a arch) Confirm(ctx context.Context, run Runner) error { + if _, err := run(ctx, "pacman", "-Q", "pacman"); err != nil { + return fmt.Errorf( + "this is the arch host and pacman does not answer here. Either this machine is not "+ + "Arch, or its package database is broken: %w", err) + } + return nil +} + +// PackageInstalled asks the package database, having first established that it answers. +// +// The two-step is the trap this file exists to remember. `pacman -Q name` exits non-zero for a +// package that is not installed AND for a database that cannot be read, so believing the first +// answer reports a broken package manager as "nothing is installed" — absence read as fact. +// Proving the tool answers about something that certainly exists separates them. +func (a arch) PackageInstalled(ctx context.Context, run Runner, name string) (bool, error) { + if err := a.Confirm(ctx, run); err != nil { + return false, fmt.Errorf("nothing can be said about %q: %w", name, err) + } + if _, err := run(ctx, "pacman", "-Q", name); err != nil { + return false, nil + } + return true, nil +} + +func (arch) InstallPackage(ctx context.Context, run Runner, name string) error { + _, err := run(ctx, "pacman", "-S", "--noconfirm", "--needed", name) + return err +} + +// ServiceState reads what systemd says about a unit. +// +// Two traps, and both were hit before this read what it now reads. +// +// The exit code is not the answer: `is-active` exits non-zero for every state except active. +// +// And "inactive" does not mean stopped. `systemctl is-active` says "inactive" for a unit that +// DOES NOT EXIST exactly as it does for one installed and stopped, so declaring a unit stopped +// reported success for a unit the host cannot manage at all. LoadState is what separates them, +// so LoadState is what is read — and it is the thing an interface spanning systemd and OpenRC +// would have had to drop. +func (arch) ServiceState(ctx context.Context, run Runner, unit string) (string, error) { + out, _ := run(ctx, "systemctl", "show", unit, + "--property=LoadState", "--property=ActiveState") + + var load, active string + for _, line := range strings.Split(out, "\n") { + key, value, found := strings.Cut(strings.TrimSpace(line), "=") + if !found { + continue + } + switch key { + case "LoadState": + load = value + case "ActiveState": + active = value + } + } + + switch load { + case "": + return "", fmt.Errorf("the service manager said nothing about %s", unit) + case "not-found": + return "", fmt.Errorf( + "%s does not exist on this machine. A declaration naming a unit that is not "+ + "installed cannot be satisfied, and reporting it stopped would be reporting "+ + "absence as success", unit) + case "masked": + return "", fmt.Errorf("%s is masked, so its state cannot be declared", unit) + case "error", "bad-setting": + return "", fmt.Errorf("%s is installed but its unit file cannot be loaded (%s)", unit, load) + } + + switch active { + case "active", "activating", "reloading": + return "running", nil + case "inactive", "failed", "deactivating": + return "stopped", nil + default: + return "", fmt.Errorf( + "the service manager reports %s as %q, which is neither running nor stopped", unit, active) + } +} + +func (arch) SetServiceState(ctx context.Context, run Runner, unit, state string) error { + verb := "start" + if state == "stopped" { + verb = "stop" + } + _, err := run(ctx, "systemctl", verb, unit) + return err +} + +// ServiceBoot reads whether a unit starts at boot. +// +// `is-enabled` has more than two answers, and `static` is the one that matters: the unit has no +// install section and CANNOT be enabled. Reading it as "disabled" would have the host try, fail, +// and blame the wrong thing — the same shape as reading a missing unit as "stopped". +func (arch) ServiceBoot(ctx context.Context, run Runner, unit string) (string, error) { + out, _ := run(ctx, "systemctl", "is-enabled", unit) + switch state := strings.TrimSpace(out); state { + case "enabled", "enabled-runtime", "alias": + return "enabled", nil + case "disabled": + return "disabled", nil + case "": + return "", fmt.Errorf("the service manager said nothing about whether %s starts at boot", unit) + case "static": + return "", fmt.Errorf( + "%s is static — it has no install section, so it cannot be enabled or disabled. "+ + "Something else pulls it in, and that is what a declaration should name", unit) + case "masked", "masked-runtime": + return "", fmt.Errorf("%s is masked, so its boot state cannot be declared", unit) + default: + return "", fmt.Errorf( + "the service manager reports %s as %q at boot, which is neither enabled nor disabled", + unit, state) + } +} + +func (arch) SetServiceBoot(ctx context.Context, run Runner, unit, boot string) error { + verb := "enable" + if boot == "disabled" { + verb = "disable" + } + _, err := run(ctx, "systemctl", verb, unit) + return err +} diff --git a/internal/system/system.go b/internal/system/system.go new file mode 100644 index 0000000..4474f1b --- /dev/null +++ b/internal/system/system.go @@ -0,0 +1,145 @@ +// Package system is the part of the host that differs between operating systems. +// +// novox/hq ADR 0060. A machine has apk because it is Alpine; the package manager, the service +// manager and the packaging format arrive together as one decision somebody made at install +// time. So they are not independent knobs — they are one implementation, named after the system +// it belongs to. +// +// Everything else in the host is shared: the declaration vocabulary, the store, the apply loop, +// the read-back discipline, the refusal model, the link. What lives here is two appliers' worth +// of difference and the probes that go with them. +// +// Not abstracted behind a lowest common denominator, deliberately. `systemctl show` reports a +// LoadState that separates *not installed* from *stopped*, and OpenRC has no equivalent — an +// interface spanning both would have to drop it, and dropping it is how absence gets reported +// as success. Each system says what it can say. +package system + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/novox/mesh-host/internal/declaration" +) + +// Runner executes a command. The real one runs a process; tests pass one that records what was +// asked for, because what is being tested is which commands each system issues. +type Runner func(ctx context.Context, name string, args ...string) (string, error) + +// ErrUnsupported is what a system returns for a shape it cannot implement. +// +// Not an error in the ordinary sense — an Android host declining to install packages is +// correct, not broken. It is refused at the declaration rather than attempted and failed, so a +// control plane learns the difference from the profile instead of from a stack trace. +var ErrUnsupported = errors.New("this host does not implement that") + +// System is one operating system's half of the host. +type System interface { + // Name is what this host was built for: "arch", "alpine", "android". + Name() string + + // Shapes are the declaration types this host can apply. Anything else is refused whole. + Shapes() []declaration.Type + + // Confirm proves this is the system the host was built for. + // + // A host installed on the wrong machine must say so, not discover it by calling a package + // manager that is not there. The failure is legible exactly once, at start. + Confirm(ctx context.Context, run Runner) error + + PackageInstalled(ctx context.Context, run Runner, name string) (bool, error) + InstallPackage(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. + ServiceState(ctx context.Context, run Runner, unit string) (string, error) + SetServiceState(ctx context.Context, run Runner, unit, state string) error + + // ServiceBoot is "enabled" or "disabled" — whether the unit starts at boot. + ServiceBoot(ctx context.Context, run Runner, unit string) (string, error) + SetServiceBoot(ctx context.Context, run Runner, unit, boot string) error +} + +// Supports reports whether this host can apply a shape. +func Supports(s System, t declaration.Type) bool { + for _, shape := range s.Shapes() { + if shape == t { + return true + } + } + return false +} + +// Check refuses a declaration naming a shape this host cannot apply. +// +// Refused whole and before anything is applied, which is the same treatment an unknown type +// gets (novox/hq ADR 0043) — a host that applied the parts it understood would leave a machine +// that looks configured and is not. The reason differs and the outcome does not. +func Check(s System, d *declaration.Declaration) error { + var problems []string + seen := map[declaration.Type]bool{} + for _, r := range d.Resources { + t := r.Kind() + if Supports(s, t) || seen[t] { + continue + } + seen[t] = true + problems = append(problems, fmt.Sprintf( + "resource %q is a %s, and the %s host does not implement that shape. This host "+ + "applies %s", + r.Identity(), t, s.Name(), shapeList(s))) + } + if len(problems) > 0 { + return &declaration.RefusalError{Problems: problems} + } + return nil +} + +func shapeList(s System) string { + names := make([]string, 0, len(s.Shapes())) + for _, t := range s.Shapes() { + names = append(names, string(t)) + } + return strings.Join(names, ", ") +} + +// everyShape is what a host on a full operating system can apply. +func everyShape() []declaration.Type { + return []declaration.Type{ + declaration.TypeDirectory, declaration.TypeFile, declaration.TypeService, + declaration.TypePackage, declaration.TypeContainer, declaration.TypeAction, + } +} + +// portableShapes need only a filesystem and a way to run something. +// +// The floor. A host that can do nothing else can still do these, which is what makes a partial +// host a real thing rather than a broken one (novox/hq ADR 0060). +func portableShapes() []declaration.Type { + return []declaration.Type{ + declaration.TypeDirectory, declaration.TypeFile, declaration.TypeAction, + } +} + +// For returns the system with this name, or an error naming the ones that exist. +func For(name string) (System, error) { + for _, s := range All() { + if s.Name() == name { + return s, nil + } + } + var names []string + for _, s := range All() { + names = append(names, s.Name()) + } + return nil, fmt.Errorf( + "this host was built for %q, which is not a system it knows. Built hosts are: %s", + name, strings.Join(names, ", ")) +} + +// All is every system the host can be built for. +func All() []System { + return []System{arch{}, alpine{}, android{}} +} diff --git a/internal/system/system_test.go b/internal/system/system_test.go new file mode 100644 index 0000000..aa6ca5b --- /dev/null +++ b/internal/system/system_test.go @@ -0,0 +1,282 @@ +package system + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/declaration" +) + +// recorder answers a fixed map of commands and remembers what it was asked. +// +// What is being tested is which commands each system issues and how it reads the answers, so +// the commands are real command shapes taken from the tools themselves. +type recorder struct { + answers map[string]string // first two argv words -> stdout + fails map[string]bool + calls []string +} + +func (r *recorder) run(ctx context.Context, name string, args ...string) (string, error) { + r.calls = append(r.calls, strings.TrimSpace(name+" "+strings.Join(args, " "))) + + // Two keys, most specific first. `systemctl show` and `systemctl is-enabled` need telling + // apart, while `rc-service docker status` puts the UNIT where the verb would be — so a + // binary-only key is needed too. + keys := []string{name} + if len(args) > 0 { + keys = []string{name + " " + args[0], name} + } + for _, key := range keys { + if r.fails[key] { + return r.answers[key], errors.New("exit status 1") + } + if out, ok := r.answers[key]; ok { + return out, nil + } + } + return "", errors.New("exit status 127: not found") +} + +func sys(t *testing.T, name string) System { + t.Helper() + s, err := For(name) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestEverySystemIsNamedAndReachable(t *testing.T) { + for _, want := range []string{"arch", "alpine", "android"} { + if _, err := For(want); err != nil { + t.Errorf("the %s host cannot be built: %v", want, err) + } + } + if _, err := For("debian"); err == nil { + t.Error("a system nobody has written was returned instead of refused") + } else if !strings.Contains(err.Error(), "arch") { + t.Errorf("the refusal does not say which hosts exist: %v", err) + } + // A host built without -X main.builtFor must refuse rather than default to something. + if _, err := For(""); err == nil { + t.Error("a host built for nothing was accepted") + } +} + +// --- what each system can do ----------------------------------------------------------------- + +func TestAndroidRefusesTheShapesItCannotDo(t *testing.T) { + // The point of a partial host: refused whole, before anything is applied, naming what this + // host does implement. Not attempted-and-failed half way through. + d, err := declaration.ParseTrusted([]byte(`{"declaration":1,"resources":[ + {"id":"f","type":"file","path":"/data/x","content":"a\n"}, + {"id":"p","type":"package","package":"docker"} + ]}`)) + if err != nil { + t.Fatal(err) + } + + refusal := Check(sys(t, "android"), d) + if refusal == nil { + t.Fatal("the android host accepted a package") + } + for _, want := range []string{"package", "android", "file", "directory", "action"} { + if !strings.Contains(refusal.Error(), want) { + t.Errorf("the refusal does not mention %q: %v", want, refusal) + } + } +} + +func TestAndroidAcceptsThePortableShapes(t *testing.T) { + // file, directory and action need only a filesystem and a way to run something. A host that + // can do nothing else can still do these, which is what makes a partial host a real one. + d, err := declaration.ParseTrusted([]byte(`{"declaration":1,"resources":[ + {"id":"d","type":"directory","path":"/data/mesh"}, + {"id":"f","type":"file","path":"/data/mesh/x","content":"a\n"}, + {"id":"a","type":"action","command":["true"],"verify":["true"]} + ]}`)) + if err != nil { + t.Fatal(err) + } + if err := Check(sys(t, "android"), d); err != nil { + t.Errorf("the android host refused a portable declaration: %v", err) + } +} + +func TestArchAndAlpineDoEveryShape(t *testing.T) { + for _, name := range []string{"arch", "alpine"} { + s := sys(t, name) + for _, shape := range everyShape() { + if !Supports(s, shape) { + t.Errorf("the %s host does not implement %s", name, shape) + } + } + } +} + +// --- confirming the machine is the one the host was built for -------------------------------- + +func TestAHostOnTheWrongMachineSaysSo(t *testing.T) { + // Installing the arch host on Alpine must fail once, at the start, rather than later inside + // a package manager that is not there. + alpineMachine := &recorder{answers: map[string]string{"apk info": "apk-tools-2.14.0\n"}} + if err := sys(t, "arch").Confirm(context.Background(), alpineMachine.run); err == nil { + t.Fatal("the arch host confirmed itself on an Alpine machine") + } else if !strings.Contains(err.Error(), "not Arch") { + t.Errorf("the failure does not say what is wrong: %v", err) + } + + archMachine := &recorder{answers: map[string]string{"pacman -Q": "pacman 7.0.0-1\n"}} + if err := sys(t, "alpine").Confirm(context.Background(), archMachine.run); err == nil { + t.Fatal("the alpine host confirmed itself on an Arch machine") + } +} + +// --- packages --------------------------------------------------------------------------------- + +func TestApkReportsAbsenceByEmptyOutputNotByExitCode(t *testing.T) { + // The difference that matters between apk and pacman, and it is invisible until it bites. + // + // pacman -Q missing -> exits NON-ZERO + // apk info -e missing -> exits ZERO and prints NOTHING + // + // So reading apk's exit code the way pacman's is read reports every package as installed. + r := &recorder{answers: map[string]string{ + "apk info": "", // installed-check for a package that is not there + }} + // apk-tools is what Confirm asks about; both go through the same key, so the empty answer + // stands in for "not installed" while the call still succeeds. + installed, err := sys(t, "alpine").PackageInstalled(context.Background(), r.run, "docker") + if err == nil && installed { + t.Error("apk's empty output was read as 'installed'") + } +} + +func TestApkFindsAnInstalledPackage(t *testing.T) { + r := &recorder{answers: map[string]string{"apk info": "docker-24.0.7-r0\n"}} + installed, err := sys(t, "alpine").PackageInstalled(context.Background(), r.run, "docker") + if err != nil { + t.Fatalf("could not ask: %v", err) + } + if !installed { + t.Error("an installed package was reported missing") + } +} + +func TestABrokenPackageDatabaseIsNotReadAsNotInstalled(t *testing.T) { + // Both systems, same trap: a package manager that cannot answer must not read as "nothing + // is installed", or the host reinstalls on a machine whose database is broken. + for _, name := range []string{"arch", "alpine"} { + r := &recorder{} // answers nothing; every command fails + if _, err := sys(t, name).PackageInstalled(context.Background(), r.run, "docker"); err == nil { + t.Errorf("%s: a broken package database was read as 'not installed'", name) + } + } +} + +// --- services ---------------------------------------------------------------------------------- + +func TestAServiceThatDoesNotExistIsNeverReportedStopped(t *testing.T) { + // The most important thing both service managers must get right, and they say it + // differently: systemd through LoadState=not-found, OpenRC in prose. + for _, tc := range []struct{ name, key, out string }{ + {"arch", "systemctl show", "LoadState=not-found\nActiveState=inactive\n"}, + {"alpine", "rc-service", " * rc-service: service `nope' does not exist\n"}, + } { + r := &recorder{answers: map[string]string{tc.key: tc.out}} + state, err := sys(t, tc.name).ServiceState(context.Background(), r.run, "nope") + if err == nil { + t.Errorf("%s: a service that does not exist was reported as %q", tc.name, state) + continue + } + // Asserted on the DIAGNOSIS, not on "does not exist" — the fall-through error echoes + // the raw output, which contains that phrase, so matching it passed even with the + // distinction removed. Only the correct branch explains why absence is not stopped. + if !strings.Contains(err.Error(), "absence as success") { + t.Errorf("%s: failed for the wrong reason: %v", tc.name, err) + } + } +} + +func TestOpenRCStateIsReadFromItsOwnWords(t *testing.T) { + for _, tc := range []struct{ out, want string }{ + {" * status: started\n", "running"}, + {" * status: stopped\n", "stopped"}, + {" * status: crashed\n", "stopped"}, // not up, so starting it is the right next act + } { + r := &recorder{answers: map[string]string{"rc-service": tc.out}} + got, err := sys(t, "alpine").ServiceState(context.Background(), r.run, "docker") + if err != nil { + t.Errorf("%q: %v", tc.out, err) + continue + } + if got != tc.want { + t.Errorf("%q read as %q, expected %q", tc.out, got, tc.want) + } + } +} + +func TestOpenRCBootStateComesFromTheRunlevel(t *testing.T) { + // OpenRC has no `is-enabled`. What it has is the runlevel listing, so "starts at boot" + // becomes "appears here". + r := &recorder{answers: map[string]string{ + "rc-update show": " docker | default\n sshd | default\n", + }} + s := sys(t, "alpine") + + got, err := s.ServiceBoot(context.Background(), r.run, "docker") + if err != nil || got != "enabled" { + t.Errorf("a service in the default runlevel read as %q (%v)", got, err) + } + got, err = s.ServiceBoot(context.Background(), r.run, "chronyd") + if err != nil || got != "disabled" { + t.Errorf("a service not in any runlevel read as %q (%v)", got, err) + } +} + +func TestEachSystemUsesItsOwnCommands(t *testing.T) { + // The whole point of ADR 0060: the alpine host must never reach for systemctl, and the arch + // host must never reach for rc-service. + for _, tc := range []struct{ name, forbidden string }{ + {"arch", "rc-service"}, + {"arch", "apk"}, + {"alpine", "systemctl"}, + {"alpine", "pacman"}, + } { + r := &recorder{answers: map[string]string{ + "systemctl show": "LoadState=loaded\nActiveState=active\n", + "rc-service": " * status: started\n", + "pacman -Q": "pacman 7.0.0\n", + "apk info": "apk-tools-2.14\n", + "rc-update show": " docker | default\n", + "systemctl is-enabled": "enabled\n", + }} + s := sys(t, tc.name) + _, _ = s.ServiceState(context.Background(), r.run, "docker") + _, _ = s.ServiceBoot(context.Background(), r.run, "docker") + _, _ = s.PackageInstalled(context.Background(), r.run, "docker") + + for _, call := range r.calls { + if strings.HasPrefix(call, tc.forbidden) { + t.Errorf("the %s host called %q", tc.name, call) + } + } + } +} + +func TestAndroidsUnreachableAppliersFailLoudly(t *testing.T) { + // Check refuses these shapes before an applier is reached, so these are unreachable — and + // they say so rather than returning a zero value, in case "unreachable" ever stops being + // true. + s := sys(t, "android") + r := &recorder{} + if _, err := s.PackageInstalled(context.Background(), r.run, "x"); !errors.Is(err, ErrUnsupported) { + t.Errorf("android's package applier did not report it as unsupported: %v", err) + } + if _, err := s.ServiceState(context.Background(), r.run, "x"); !errors.Is(err, ErrUnsupported) { + t.Errorf("android's service applier did not report it as unsupported: %v", err) + } +}