diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index cf89b72..9b0c44f 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -153,7 +153,12 @@ func run(ctx context.Context, command string, opts options) error { if err != nil { return fmt.Errorf("reading the declaration: %w", err) } - d, err := declaration.Parse(raw) + // ParseTrusted: a file handed to the host by someone already running it as root is + // not the link. novox/hq ADR 0047 bounds what a REMOTE party may push; someone who + // can write this file and run this binary can do anything the binary can, so refusing + // them an action would buy nothing and would make an action untestable except by + // rebuilding the bundle. + d, err := declaration.ParseTrusted(raw) if err != nil { return err } diff --git a/internal/apply/apply.go b/internal/apply/apply.go index a40ae2b..60649aa 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -12,11 +12,13 @@ package apply import ( "context" + "crypto/sha256" "errors" "fmt" "os" "os/exec" "path/filepath" + "sort" "strconv" "strings" "time" @@ -95,15 +97,16 @@ func Apply( } for _, orphan := range known.Orphans(declared) { - if err := remove(ctx, orphan, run); err != nil { + action, detail, err := remove(ctx, orphan, run) + if err != nil { return report, known, &Error{Resource: orphan.ID, Err: err, Done: report} } known.Forget(orphan.ID) report.Outcomes = append(report.Outcomes, Outcome{ - ID: orphan.ID, Type: orphan.Type, Target: orphan.Target, Action: "removed", - Detail: "no longer declared", + ID: orphan.ID, Type: orphan.Type, Target: orphan.Target, + Action: action, Detail: detail, }) - log(fmt.Sprintf(" removed %s (%s)", orphan.ID, orphan.Target)) + log(fmt.Sprintf(" %s %s (%s)", action, orphan.ID, orphan.Target)) } for _, resource := range d.Resources { @@ -133,6 +136,12 @@ func applyOne(ctx context.Context, r declaration.Resource, run Runner) (Outcome, return applyFile(r) case declaration.TypeService: return applyService(ctx, r, run) + case declaration.TypePackage: + return applyPackage(ctx, r, run) + case declaration.TypeContainer: + return applyContainer(ctx, r, run) + case declaration.TypeAction: + return applyAction(ctx, r, run) default: // Unreachable: the declaration refused this already. Present because "unreachable" // stops being true the moment someone adds a type and forgets this switch. @@ -390,20 +399,25 @@ func serviceState(ctx context.Context, unit string, run Runner) (string, error) } } -// remove undoes one resource the host applied and the declaration no longer names. +// remove undoes one resource the host applied and the declaration no longer names, and reports +// what it actually did. // // Only ever called for something in the store, which is what bounds it: the host is // authoritative over its own footprint and inert everywhere else (novox/hq ADR 0043). -func remove(ctx context.Context, a store.Applied, run Runner) 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) { switch declaration.Type(a.Type) { case declaration.TypeFile, declaration.TypeDirectory: if err := os.RemoveAll(a.Target); err != nil { - return err + return "", "", err } if _, err := os.Stat(a.Target); !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("%s is still there after removing it", a.Target) + return "", "", fmt.Errorf("%s is still there after removing it", a.Target) } - return nil + return "removed", "no longer declared", nil case declaration.TypeService: // A unit that is no longer declared is stopped, not deleted. The host did not install @@ -416,17 +430,46 @@ func remove(ctx context.Context, a store.Applied, run Runner) error { // same reason `os.RemoveAll` is. if _, err := serviceState(ctx, a.Target, run); err != nil { if strings.Contains(err.Error(), "does not exist on this machine") { - return nil + return "forgotten", "the unit no longer exists", nil } - return err + return "", "", err } if _, err := run(ctx, "systemctl", "stop", a.Target); err != nil { - return fmt.Errorf("stopping %s: %w", a.Target, err) + return "", "", fmt.Errorf("stopping %s: %w", a.Target, err) } - return nil + return "removed", "stopped; the unit file is not the host's to delete", nil + + case declaration.TypeContainer: + // The host CREATED this one, so the host removes it. That is the line: it removes what + // it made and leaves what it merely configured. + if _, err := run(ctx, "docker", "rm", "-f", a.Target); err != nil { + // Already gone is the state removal wants. Anything else is a real failure. + if _, alive := containerState(ctx, a.Target, run); alive == nil { + return "", "", fmt.Errorf("removing container %s: %w", a.Target, err) + } + } + if _, err := containerState(ctx, a.Target, run); err == nil { + return "", "", fmt.Errorf("container %s is still there after removing it", a.Target) + } + return "removed", "no longer declared", nil + + case declaration.TypePackage: + // Deliberately not uninstalled, and this is a decision rather than an omission. + // + // The host cannot know what else on this machine needs the package. Uninstalling a + // container runtime because a declaration changed would stop every container on the + // node, and the machine may have had the package before the mesh ever saw it + // (novox/hq research 012: adopted, not installed). Undeclaring says "the mesh no + // longer requires this", which is not the same as "remove it". + return "forgotten", "left installed; the host does not uninstall what it cannot know is unused", nil + + case declaration.TypeAction: + // An action has no footprint the host can undo — it ran, and whatever it did belongs + // to whatever it acted on. + return "forgotten", "an action leaves nothing the host owns", nil default: - return fmt.Errorf("no way to remove a %q", a.Type) + return "", "", fmt.Errorf("no way to remove a %q", a.Type) } } @@ -445,3 +488,238 @@ func ExecRunner(ctx context.Context, name string, args ...string) (string, error } return string(out), nil } + +// applyPackage installs a package the machine does not have. +// +// It never upgrades and never removes. "Present" is the whole of what a package resource +// 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.Resource, run Runner) (Outcome, error) { + out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Package} + + installed, err := packageInstalled(ctx, r.Package, run) + if err != nil { + return out, err + } + if installed { + out.Action = "unchanged" + out.Detail = "already installed" + return out, nil + } + + if _, err := run(ctx, "pacman", "-S", "--noconfirm", "--needed", 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) + if err != nil { + return out, err + } + if !installed { + return out, fmt.Errorf( + "%s was installed without error and the package database does not have it", r.Package) + } + + out.Action = "created" + 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 +// reconcile answer "is this container the one the current declaration describes" without +// comparing every field the runtime reports — which cannot be done reliably, because a runtime +// normalises, defaults and reorders what it is given, and the differences that produces are +// indistinguishable from real drift. +const ( + specLabel = "mesh-host.spec" + idLabel = "mesh-host.id" +) + +// containerSpec is the identity of a declared container: everything that, if changed, means +// the running container is no longer what was asked for. +func containerSpec(r declaration.Resource) string { + keys := make([]string, 0, len(r.Env)) + for k := range r.Env { + keys = append(keys, k) + } + sort.Strings(keys) + + var b strings.Builder + b.WriteString(r.Image + "\n" + r.Name + "\n") + for _, k := range keys { + b.WriteString("env " + k + "=" + r.Env[k] + "\n") + } + for _, p := range r.Ports { + b.WriteString("port " + p + "\n") + } + for _, v := range r.Volumes { + b.WriteString("volume " + v + "\n") + } + for _, a := range r.Args { + b.WriteString("arg " + a + "\n") + } + return fmt.Sprintf("%x", sha256.Sum256([]byte(b.String()))) +} + +// containerState reports whether a container is running and which spec made it. +// The error means the container does not exist. +func containerState(ctx context.Context, name string, run Runner) (state struct { + Running bool + Spec string +}, err error) { + out, err := run(ctx, "docker", "inspect", "--format", + "{{.State.Running}}\t{{index .Config.Labels \""+specLabel+"\"}}", name) + if err != nil { + return state, fmt.Errorf("no container named %s", name) + } + running, spec, _ := strings.Cut(strings.TrimSpace(out), "\t") + state.Running = running == "true" + state.Spec = strings.TrimSpace(spec) + return state, nil +} + +// applyContainer makes the declared container the one that is running. +// +// There is no "update" for a container: a container's configuration is fixed when it is +// created, so any change is a replacement. Saying that plainly is better than a partial +// in-place update that leaves the running thing half-declared. +func applyContainer(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) { + out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Name} + want := containerSpec(r) + + if _, err := run(ctx, "docker", "version", "--format", "{{.Server.Version}}"); err != nil { + return out, fmt.Errorf( + "the container runtime does not answer on this machine, so nothing can be said "+ + "about %q: %w", r.Name, err) + } + + before, err := containerState(ctx, r.Name, run) + existed := err == nil + + switch { + case existed && before.Spec == want && before.Running: + out.Action = "unchanged" + return out, nil + case existed: + if _, err := run(ctx, "docker", "rm", "-f", r.Name); err != nil { + return out, fmt.Errorf("replacing container %s: %w", r.Name, err) + } + } + + args := []string{"run", "--detach", "--name", r.Name, "--restart", "unless-stopped", + "--label", specLabel + "=" + want, "--label", idLabel + "=" + r.ID} + for _, k := range sortedKeys(r.Env) { + args = append(args, "--env", k+"="+r.Env[k]) + } + for _, p := range r.Ports { + args = append(args, "--publish", p) + } + for _, v := range r.Volumes { + args = append(args, "--volume", v) + } + args = append(args, r.Image) + args = append(args, r.Args...) + + if _, err := run(ctx, "docker", args...); err != nil { + return out, fmt.Errorf("starting container %s: %w", r.Name, err) + } + + // Read back. `docker run --detach` returning an id says the container was created, not + // that it is still running — a container whose entrypoint exits immediately satisfies the + // command exactly as one that came up does. + after, err := containerState(ctx, r.Name, run) + if err != nil { + return out, fmt.Errorf("started container %s and it is not there: %w", r.Name, err) + } + if !after.Running { + return out, fmt.Errorf( + "container %s was started and is not running. It exited; ask the runtime for its "+ + "logs", r.Name) + } + if after.Spec != want { + return out, fmt.Errorf("container %s is not the one that was declared after creating it", r.Name) + } + + out.Action = "created" + if existed { + out.Action = "updated" + out.Detail = "replaced; a container's configuration is fixed when it is created" + } + return out, nil +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// applyAction runs something the bundle declared, and never learns what it means. +// +// Verify does double duty, and that is the design rather than a convenience: it is both the +// idempotency check and the read-back. Running it first is how the host knows whether there is +// anything to do — it does not know what a database is, so "is the database there" is a +// question only the declaration can ask. Running it again afterwards is how the host knows the +// command had the effect it claimed (novox/hq ADR 0047). +func applyAction(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) { + out := Outcome{ID: r.ID, Type: string(r.Type), Target: strings.Join(r.Command, " ")} + if r.In != "" { + out.Target = "in " + r.In + ": " + out.Target + } + + if _, err := runAction(ctx, r, r.Verify, run); err == nil { + out.Action = "unchanged" + out.Detail = "already true" + return out, nil + } + + if _, err := runAction(ctx, r, r.Command, run); err != nil { + return out, fmt.Errorf("running the action: %w", err) + } + + if _, err := runAction(ctx, r, r.Verify, run); err != nil { + return out, fmt.Errorf( + "the action ran without error and its own verify still fails: %w\n\n"+ + "The command reported success and the thing it was for did not happen, which "+ + "is exactly what verify exists to catch", err) + } + + out.Action = "created" + out.Detail = "verify was false and is now true" + return out, nil +} + +// runAction runs one of an action's command lines, on the machine or inside a container. +func runAction(ctx context.Context, r declaration.Resource, argv []string, run Runner) (string, error) { + if len(argv) == 0 { + return "", errors.New("no command") + } + if r.In != "" { + return run(ctx, "docker", append([]string{"exec", r.In}, argv...)...) + } + return run(ctx, argv[0], argv[1:]...) +} diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go index e2e1a41..0e5b5fb 100644 --- a/internal/apply/apply_test.go +++ b/internal/apply/apply_test.go @@ -407,7 +407,281 @@ func TestForgettingAUnitThatIsGoneDoesNotStrandTheNode(t *testing.T) { if _, still := state.Find("gone"); still { t.Error("the host still believes it owns a unit that is gone") } - if report.Outcomes[0].Action != "removed" { - t.Errorf("the vanished unit was not reported as removed: %+v", report.Outcomes) + // "forgotten", not "removed": the host stopped believing it owns the unit, and did not + // remove anything, because there was nothing there to remove. Reporting an effect it did + // not have would be the same class of untruth as reporting a package uninstalled. + if report.Outcomes[0].Action != "forgotten" { + t.Errorf("the vanished unit was not reported as forgotten: %+v", report.Outcomes) + } +} + +// --- package, container and action (novox/hq 07-the-substrate.md, ADR 0046, ADR 0047) --- + +func parseTrusted(t *testing.T, raw string) *declaration.Declaration { + t.Helper() + d, err := declaration.ParseTrusted([]byte(raw)) + if err != nil { + t.Fatalf("fixture is not a valid declaration: %v", err) + } + return d +} + +const pinned = "docker.io/library/postgres@sha256:" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +func TestABrokenPackageDatabaseIsNotReadAsNotInstalled(t *testing.T) { + // The same trap serviceState documents. `pacman -Q x` exits non-zero both for a package + // that is not installed and for a database that cannot be read — so believing the first + // answer would silently reinstall on a machine whose package manager is broken, or report + // "installed nothing" as success. The apply must fail instead. + run := func(ctx context.Context, name string, args ...string) (string, error) { + return "", errors.New("pacman: error: could not lock database") + } + d := parseTrusted(t, `{"declaration":1,"resources":[ + {"id":"rt","type":"package","package":"docker"} + ]}`) + + _, _, err := Apply(context.Background(), d, store.State{}, run, nil) + if err == nil { + t.Fatal("a broken package database was read as 'not installed'") + } + if !strings.Contains(err.Error(), "does not answer") { + t.Errorf("failed for the wrong reason: %v", err) + } +} + +func TestAnInstalledPackageIsNotReinstalled(t *testing.T) { + var installed bool + run := func(ctx context.Context, name string, args ...string) (string, error) { + if args[0] == "-S" { + installed = true + } + return "docker 27.0-1\n", nil // -Q succeeds for everything + } + d := parseTrusted(t, `{"declaration":1,"resources":[ + {"id":"rt","type":"package","package":"docker"} + ]}`) + + report, _, err := Apply(context.Background(), d, store.State{}, run, nil) + if err != nil { + t.Fatalf("apply failed: %v", err) + } + if installed { + t.Error("a package that was already present was installed again") + } + if report.Changed() { + t.Errorf("an already-installed package reported a change: %+v", report.Outcomes) + } +} + +func TestAPackageIsNeverUninstalled(t *testing.T) { + // Deliberate: the host cannot know what else needs the package. Uninstalling a container + // runtime because a declaration changed would stop every container on the node, and the + // machine may have had it before the mesh ever saw it. Undeclaring is not "remove it". + var uninstalled bool + run := func(ctx context.Context, name string, args ...string) (string, error) { + if len(args) > 0 && (args[0] == "-R" || args[0] == "-Rs") { + uninstalled = true + } + return "", nil + } + known := store.State{Resources: []store.Applied{ + {ID: "rt", Type: "package", Target: "docker"}, + }} + d := parseTrusted(t, `{"declaration":1,"resources":[ + {"id":"f","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"} + ]}`) + + report, state, err := Apply(context.Background(), d, known, run, nil) + if err != nil { + t.Fatalf("dropping a package stranded the apply: %v", err) + } + if uninstalled { + t.Fatal("the host uninstalled a package") + } + if _, still := state.Find("rt"); still { + t.Error("the host still believes it owns the package") + } + // "forgotten", not "removed" — the host must not claim an effect it declined to have. + if report.Outcomes[0].Action != "forgotten" { + t.Errorf("dropping a package was not reported as forgotten: %+v", report.Outcomes[0]) + } +} + +func TestAnActionThatIsAlreadyTrueDoesNotRun(t *testing.T) { + // Verify is the idempotency check as well as the read-back. The host does not know what a + // database is, so "is it already there" is a question only the declaration can ask. + var ran bool + run := func(ctx context.Context, name string, args ...string) (string, error) { + if name == "create-db" { + ran = true + } + return "", nil // verify passes + } + d := parseTrusted(t, `{"declaration":1,"resources":[ + {"id":"db","type":"action","command":["create-db","mesh"],"verify":["has-db","mesh"]} + ]}`) + + report, _, err := Apply(context.Background(), d, store.State{}, run, nil) + if err != nil { + t.Fatalf("apply failed: %v", err) + } + if ran { + t.Error("an action whose verify already passed was run anyway") + } + if report.Changed() { + t.Errorf("an already-satisfied action reported a change: %+v", report.Outcomes) + } +} + +func TestAnActionThatSucceedsAndDoesNothingFails(t *testing.T) { + // The whole reason verify is mandatory: a command that exits zero and has no effect is + // this repository's most expensive failure shape. Here the command "succeeds" every time + // and verify never passes. + run := func(ctx context.Context, name string, args ...string) (string, error) { + if name == "has-db" { + return "", errors.New("no such database") + } + return "", nil + } + d := parseTrusted(t, `{"declaration":1,"resources":[ + {"id":"db","type":"action","command":["create-db","mesh"],"verify":["has-db","mesh"]} + ]}`) + + _, state, err := Apply(context.Background(), d, store.State{}, run, nil) + if err == nil { + t.Fatal("an action that reported success and did nothing was accepted") + } + if !strings.Contains(err.Error(), "verify still fails") { + t.Errorf("failed for the wrong reason: %v", err) + } + if _, recorded := state.Find("db"); recorded { + t.Error("an action that did not work was recorded as applied") + } +} + +func TestAnActionRunsInsideTheContainerItNames(t *testing.T) { + // Steps 2 and 3 of the bootstrap act on something inside the store's container, before + // there is any mesh to ask. + var sawExec bool + run := func(ctx context.Context, name string, args ...string) (string, error) { + if name == "docker" && args[0] == "exec" && args[1] == "store" { + sawExec = true + return "", nil + } + return "", errors.New("not run in the container") + } + d := parseTrusted(t, `{"declaration":1,"resources":[ + {"id":"db","type":"action","in":"store","command":["createdb","mesh"],"verify":["psql","-lqt"]} + ]}`) + + if _, _, err := Apply(context.Background(), d, store.State{}, run, nil); err != nil { + t.Fatalf("apply failed: %v", err) + } + if !sawExec { + t.Error("an action naming a container did not run inside it") + } +} + +func TestAContainerThatExitsImmediatelyFailsTheApply(t *testing.T) { + // `docker run --detach` returning an id says the container was created, not that it is + // still running. A container whose entrypoint dies satisfies the command exactly as one + // that came up does — which is the read-back rule, in the place it matters most. + run := func(ctx context.Context, name string, args ...string) (string, error) { + switch { + case args[0] == "version": + return "27.0\n", nil + case args[0] == "inspect": + return "false\t" + "", nil // exists, not running + case args[0] == "run": + return "deadbeef\n", nil + } + return "", nil + } + d := parseTrusted(t, `{"declaration":1,"resources":[ + {"id":"store","type":"container","name":"store","image":"`+pinned+`"} + ]}`) + + _, state, err := Apply(context.Background(), d, store.State{}, run, nil) + if err == nil { + t.Fatal("a container that exited immediately was reported as applied") + } + if !strings.Contains(err.Error(), "is not running") { + t.Errorf("failed for the wrong reason: %v", err) + } + if _, recorded := state.Find("store"); recorded { + t.Error("a container that is not running was recorded as applied") + } +} + +func TestAContainerWhoseDeclarationChangedIsReplaced(t *testing.T) { + // A container's configuration is fixed when it is created, so any change is a replacement. + // The spec label is what makes the difference visible without diffing everything the + // runtime reports — which cannot be done reliably, because a runtime normalises what it is + // given and that is indistinguishable from drift. + d := parseTrusted(t, `{"declaration":1,"resources":[ + {"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}} + ]}`) + want := containerSpec(d.Resources[0]) + + var removed, created bool + run := func(ctx context.Context, name string, args ...string) (string, error) { + switch args[0] { + case "version": + return "27.0\n", nil + case "inspect": + if created { + return "true\t" + want, nil + } + return "true\tsome-older-spec", nil + case "rm": + removed = true + return "", nil + case "run": + created = true + return "deadbeef\n", nil + } + return "", nil + } + + report, _, err := Apply(context.Background(), d, store.State{}, run, nil) + if err != nil { + t.Fatalf("apply failed: %v", err) + } + if !removed || !created { + t.Fatalf("a changed container was not replaced (removed=%v created=%v)", removed, created) + } + if report.Outcomes[0].Action != "updated" { + t.Errorf("a replacement was not reported as an update: %+v", report.Outcomes[0]) + } +} + +func TestAContainerThatMatchesIsLeftAlone(t *testing.T) { + d := parseTrusted(t, `{"declaration":1,"resources":[ + {"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}} + ]}`) + spec := containerSpec(d.Resources[0]) + + var touched bool + run := func(ctx context.Context, name string, args ...string) (string, error) { + switch args[0] { + case "version": + return "27.0\n", nil + case "inspect": + return "true\t" + spec, nil + } + touched = true + return "", nil + } + + report, _, err := Apply(context.Background(), d, store.State{}, run, nil) + if err != nil { + t.Fatalf("apply failed: %v", err) + } + if touched { + t.Error("a container that already matched was restarted") + } + if report.Changed() { + t.Errorf("a matching container reported a change: %+v", report.Outcomes) } } diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go index 2ea5cef..bdebaae 100644 --- a/internal/bundle/bundle.go +++ b/internal/bundle/bundle.go @@ -56,7 +56,10 @@ func Load() (*declaration.Declaration, error) { if IsEmpty() { return nil, ErrEmpty } - return declaration.Parse(stripComments(substrate)) + // ParseTrusted: the bundle arrives with the binary, so it may carry actions the link may + // not (novox/hq ADR 0047). The bootstrap needs them — creating the control plane's database + // happens before there is any mesh to ask for one. + return declaration.ParseTrusted(stripComments(substrate)) } // stripComments removes whole-line `//` comments so a bundle can be annotated. diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 44cbb0e..06ddd0d 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -26,13 +26,25 @@ const ( TypeDirectory Type = "directory" TypeFile Type = "file" TypeService Type = "service" + TypePackage Type = "package" + TypeContainer Type = "container" + TypeAction Type = "action" ) -// known is the whole vocabulary. Anything else is refused. -var known = map[Type]bool{ - TypeDirectory: true, - TypeFile: true, - TypeService: true, +// uses names the fields each type consumes. A field set on a type that is not listed here as +// using it is refused. +// +// Stated as what each type USES rather than as what it ignores. The negative form needs every +// type revisited whenever a field is added, and the one nobody revisits is the one that +// silently accepts a field it will never read — which is the whole fault this package exists +// to prevent. +var uses = map[Type]map[string]bool{ + TypeDirectory: {"path": true, "mode": true}, + TypeFile: {"path": true, "content": true, "mode": true}, + TypeService: {"unit": true, "state": true}, + TypePackage: {"package": true}, + TypeContainer: {"image": true, "name": true, "env": true, "ports": true, "volumes": true, "args": true}, + TypeAction: {"command": true, "verify": true, "in": true}, } // Resource is one thing that should be true of the machine. @@ -54,6 +66,30 @@ type Resource struct { // Unit and State, for a service. State is "running" or "stopped". Unit string `json:"unit,omitempty"` State string `json:"state,omitempty"` + + // Package, for a package: the name this machine's own package manager knows it by. + Package string `json:"package,omitempty"` + + // Image and Name, for a container. Image is pinned by digest (novox/hq ADR 0046) — a tag + // moves and a digest does not, and a bundle that pinned a tag would not be pinned. + Image string `json:"image,omitempty"` + Name string `json:"name,omitempty"` + // Env, Ports, Volumes and Args, for a container. Literal; the host renders nothing. + Env map[string]string `json:"env,omitempty"` + Ports []string `json:"ports,omitempty"` + Volumes []string `json:"volumes,omitempty"` + Args []string `json:"args,omitempty"` + + // Command, Verify and In, for an action. + // + // Verify is not optional and is not a courtesy. An action that runs and reports success + // without reading anything back is the fault this repository exists to name, and an action + // is the easiest place in the vocabulary to reintroduce it (novox/hq ADR 0047). + Command []string `json:"command,omitempty"` + Verify []string `json:"verify,omitempty"` + // In names a container to run the action inside, when the thing being acted on lives + // there. Empty means the machine itself. + In string `json:"in,omitempty"` } // Declaration is what a machine should be, in the order it should be made so. @@ -85,8 +121,20 @@ func (e *RefusalError) Error() string { strings.Join(e.Problems, "\n - ")) } -// Parse reads a declaration and refuses anything it does not fully understand. -func Parse(raw []byte) (*Declaration, error) { +// Parse reads a declaration that arrived over the link, and refuses anything it does not fully +// understand — including any action, which the link may not carry (novox/hq ADR 0047). +func Parse(raw []byte) (*Declaration, error) { return parse(raw, false) } + +// ParseTrusted reads a declaration from a source already as privileged as the host itself: the +// bundle it carries, or a file handed to it by someone who is running it as root. +// +// Actions are permitted here and nowhere else. The asymmetry is deliberate and is the entire +// content of ADR 0047: refusing actions from the bundle buys nothing, because whoever built the +// bundle built the binary; refusing them from the link buys the bound on what a compromised +// control plane can express. +func ParseTrusted(raw []byte) (*Declaration, error) { return parse(raw, true) } + +func parse(raw []byte, allowActions bool) (*Declaration, error) { // DisallowUnknownFields is the whole point rather than strictness for its own sake: a // field the host does not know is a thing the control plane believes it asked for. dec := json.NewDecoder(bytes.NewReader(raw)) @@ -97,13 +145,13 @@ func Parse(raw []byte) (*Declaration, error) { return nil, &RefusalError{Problems: []string{"not a declaration: " + err.Error()}} } - if problems := validate(&d); len(problems) > 0 { + if problems := validate(&d, allowActions); len(problems) > 0 { return nil, &RefusalError{Problems: problems} } return &d, nil } -func validate(d *Declaration) []string { +func validate(d *Declaration, allowActions bool) []string { var problems []string if d.Version != Version { @@ -138,32 +186,31 @@ func validate(d *Declaration) []string { seen[r.ID] = i } - if !known[r.Type] { + if _, ok := uses[r.Type]; !ok { problems = append(problems, fmt.Sprintf( "%s: unknown type %q. This host understands %s", where, r.Type, vocabulary())) continue } - problems = append(problems, validateResource(where, r)...) + problems = append(problems, validateResource(where, r, allowActions)...) } return problems } -func validateResource(where string, r Resource) []string { - var problems []string +func validateResource(where string, r Resource, allowActions bool) []string { + problems := unusedBy(where, r) + switch r.Type { case TypeDirectory: if r.Path == "" { problems = append(problems, where+": a directory needs a path") } problems = append(problems, checkMode(where, r.Mode)...) - problems = append(problems, unusedBy(where, r, "unit", r.Unit, "state", r.State, "content", r.Content)...) case TypeFile: if r.Path == "" { problems = append(problems, where+": a file needs a path") } problems = append(problems, checkMode(where, r.Mode)...) - problems = append(problems, unusedBy(where, r, "unit", r.Unit, "state", r.State)...) case TypeService: if r.Unit == "" { @@ -173,22 +220,101 @@ func validateResource(where string, r Resource) []string { problems = append(problems, fmt.Sprintf( "%s: state %q; a service is \"running\" or \"stopped\"", where, r.State)) } - problems = append(problems, unusedBy(where, r, "path", r.Path, "content", r.Content, "mode", r.Mode)...) + + case TypePackage: + if r.Package == "" { + problems = append(problems, where+": a package needs a package name") + } + + case TypeContainer: + if r.Name == "" { + problems = append(problems, where+": a container needs a name") + } + problems = append(problems, checkImage(where, r.Image)...) + + case TypeAction: + // The whole reason an action is bounded rather than forbidden (novox/hq ADR 0047). + if !allowActions { + problems = append(problems, where+ + ": an action arrived over the link, and the link may not carry one. The host "+ + "applies declarations of known shape; a command to run is not one. A bundle "+ + "may carry an action because it arrives with the binary — anyone able to put "+ + "a hostile action there could have put it in the host itself") + break + } + if len(r.Command) == 0 { + problems = append(problems, where+": an action needs a command") + } + if len(r.Verify) == 0 { + problems = append(problems, where+ + ": an action needs a verify. An action that runs and reports success without "+ + "reading anything back is the fault this host exists to prevent, and verify is "+ + "also how the host knows whether the action is already done") + } } return problems } +// checkImage insists on a digest. +// +// A tag moves and a digest does not. The bundle's whole claim is that what it names is exact +// (novox/hq ADR 0046), and a bundle pinning `postgres:17` pins nothing — it names whatever +// that tag points at on the day the host happens to run. +func checkImage(where, image string) []string { + if image == "" { + return []string{where + ": a container needs an image"} + } + name, digest, found := strings.Cut(image, "@") + if !found || name == "" { + return []string{fmt.Sprintf( + "%s: image %q is not pinned. Write it as name@sha256:... — a tag moves, and a "+ + "bundle that pinned a tag would not be pinned", where, image)} + } + if !strings.HasPrefix(digest, "sha256:") || len(digest) != len("sha256:")+64 { + return []string{fmt.Sprintf( + "%s: image digest %q is not a sha256 digest", where, digest)} + } + return nil +} + +// setFields names every field carried on this resource, other than its identity and type. +func setFields(r Resource) []string { + var set []string + add := func(name string, populated bool) { + if populated { + set = append(set, name) + } + } + add("path", r.Path != "") + add("content", r.Content != "") + add("mode", r.Mode != "") + add("unit", r.Unit != "") + add("state", r.State != "") + add("package", r.Package != "") + add("image", r.Image != "") + add("name", r.Name != "") + add("env", len(r.Env) > 0) + add("ports", len(r.Ports) > 0) + add("volumes", len(r.Volumes) > 0) + add("args", len(r.Args) > 0) + add("command", len(r.Command) > 0) + add("verify", len(r.Verify) > 0) + add("in", r.In != "") + sort.Strings(set) + return set +} + // unusedBy refuses a field this type does not use. // // A field set and ignored is the fault this package exists to prevent, in miniature: the // control plane believes it asked for something the host will never do. -func unusedBy(where string, r Resource, pairs ...string) []string { +func unusedBy(where string, r Resource) []string { var problems []string - for i := 0; i+1 < len(pairs); i += 2 { - if pairs[i+1] != "" { + for _, name := range setFields(r) { + if !uses[r.Type][name] { problems = append(problems, fmt.Sprintf( "%s: a %s does not use %q, and it is set. Refused rather than ignored", - where, r.Type, pairs[i])) + where, r.Type, name)) } } return problems @@ -213,7 +339,7 @@ func checkMode(where, mode string) []string { func vocabulary() string { var names []string - for t := range known { + for t := range uses { names = append(names, string(t)) } sort.Strings(names) diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go index fd3058a..0b2391c 100644 --- a/internal/declaration/declaration_test.go +++ b/internal/declaration/declaration_test.go @@ -156,3 +156,103 @@ func TestARefusalSaysNothingWasApplied(t *testing.T) { func TestAnEmptyDeclarationIsAMistake(t *testing.T) { refusalFor(t, `{"declaration":1,"resources":[]}`) } + +// --- the vocabulary the substrate bootstrap needs (novox/hq 07-the-substrate.md) --- + +func TestAnActionOverTheLinkIsRefused(t *testing.T) { + // novox/hq ADR 0047. The link may push declarations of known shape and never a command to + // run. This is the boundary the whole security argument rests on, so it is asserted + // directly rather than inferred from the type list. + raw := []byte(`{"declaration":1,"resources":[ + {"id":"schema","type":"action","command":["psql","-f","x.sql"],"verify":["psql","-c","select 1"]} + ]}`) + + if _, err := Parse(raw); err == nil { + t.Fatal("an action arriving over the link was accepted") + } else if !strings.Contains(err.Error(), "the link may not carry one") { + t.Errorf("refused for the wrong reason: %v", err) + } + + // And the same bytes from the bundle are fine — the asymmetry IS the decision. + if _, err := ParseTrusted(raw); err != nil { + t.Errorf("the bundle may carry an action, and this one was refused: %v", err) + } +} + +func TestAnActionWithoutVerifyIsRefused(t *testing.T) { + // An action that runs and reports success without reading anything back is the fault this + // host exists to prevent. Verify is also the idempotency check, so an action without one + // cannot be applied twice safely either. + _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[ + {"id":"schema","type":"action","command":["psql","-f","x.sql"]} + ]}`)) + if err == nil { + t.Fatal("an action with no verify was accepted") + } + if !strings.Contains(err.Error(), "needs a verify") { + t.Errorf("refused for the wrong reason: %v", err) + } +} + +func TestAnImageMustBePinnedByDigest(t *testing.T) { + // novox/hq ADR 0046: reproducibility comes from pinning the identity of a thing. A bundle + // naming a tag pins nothing — it names whatever that tag points at on the day it runs. + for _, image := range []string{ + "postgres:17", + "postgres", + "postgres@sha256:short", + "@sha256:0000000000000000000000000000000000000000000000000000000000000000", + } { + _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[ + {"id":"store","type":"container","name":"store","image":"` + image + `"} + ]}`)) + if err == nil { + t.Errorf("image %q was accepted and is not pinned", image) + } + } + + good := "postgres@sha256:" + strings.Repeat("a", 64) + if _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[ + {"id":"store","type":"container","name":"store","image":"` + good + `"} + ]}`)); err != nil { + t.Errorf("a properly pinned image was refused: %v", err) + } +} + +func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) { + // The field-set check must cover the types added last, not only the three it was written + // for. A package that carries a `content` is a control plane believing it asked for + // something that will never happen. + for _, body := range []string{ + `{"id":"p","type":"package","package":"docker","content":"x"}`, + `{"id":"p","type":"package","package":"docker","image":"x"}`, + `{"id":"c","type":"container","name":"n","image":"i@sha256:` + strings.Repeat("a", 64) + `","unit":"x.service"}`, + `{"id":"a","type":"action","command":["x"],"verify":["y"],"path":"/tmp/x"}`, + } { + _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[` + body + `]}`)) + if err == nil { + t.Errorf("a resource carrying a field its type does not use was accepted: %s", body) + continue + } + if !strings.Contains(err.Error(), "Refused rather than ignored") { + t.Errorf("refused for the wrong reason: %v", err) + } + } +} + +func TestTheVocabularyIsTheSixShapesTheBootstrapNeeds(t *testing.T) { + // novox/hq 07-the-substrate.md names six shapes and the bootstrap uses all of them. + // Asserted so that removing one is a failing test rather than a discovery during a + // first-node install. + for _, want := range []Type{ + TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction, + } { + if _, ok := uses[want]; !ok { + t.Errorf("the host no longer speaks %q", want) + } + } + if len(uses) != 6 { + t.Errorf("the vocabulary is %d shapes; every addition widens what a compromised "+ + "control plane can express, so a change here is a decision: %s", len(uses), vocabulary()) + } +}