diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 4b875a6..fdee7a5 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -723,6 +723,37 @@ func ParseManifest(raw []byte) (Manifest, error) { "program that reads what the mesh delivered and reconciles", m.Module, r["id"])) } + // **A run-once container is a step the host runs to completion** (novox/hq ADR 0052). It is a + // boolean modifier on the container shape — the host runs the container, requires it to exit 0, + // and starts whatever the declaration places after it only once it has. Two things are refused + // here rather than only on the machine, for the same near-versus-far reason the action ban + // above records: a value that is not a boolean, and the pair run-once + restart-on, which asks + // for two contradictory lifecycles — restart-on brings a *running* container back, and a + // run-once step does not stay running. + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "container" { + continue + } + raw, present := r["run-once"] + if !present { + continue + } + once, ok := raw.(bool) + if !ok { + problems = append(problems, fmt.Sprintf( + "%s declares run-once on %v as a %T; run-once is true or false", + m.Module, r["id"], raw)) + continue + } + if once { + if _, hasRestart := r["restart-on"]; hasRestart { + problems = append(problems, fmt.Sprintf( + "%s declares %v as run-once and with restart-on; a run-once step runs to "+ + "completion rather than staying running to be restarted (novox/hq ADR 0052)", + m.Module, r["id"])) + } + } + } for name, where := range m.OwnSecrets { if !strings.HasPrefix(where, "/") { problems = append(problems, fmt.Sprintf( diff --git a/internal/catalogue/runonce_test.go b/internal/catalogue/runonce_test.go new file mode 100644 index 0000000..e7c9fb4 --- /dev/null +++ b/internal/catalogue/runonce_test.go @@ -0,0 +1,93 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A run-once container is a step the host runs to completion (novox/hq ADR 0052). The control +// plane's part is small and exact: carry the field to the host unchanged, keep the step ahead of +// the container it gates in author order, and refuse a malformed run-once near its cause rather +// than on the machine. These tests defend that. + +// indexOfID returns the position of the resource with the given (module-prefixed) id, or -1. +func indexOfID(out []map[string]any, id string) int { + for i, r := range out { + if r["id"] == id { + return i + } + } + return -1 +} + +func TestARunOnceContainerRendersBeforeTheContainerItGates(t *testing.T) { + // The gate is declaration order, not a resolved dependency: the step is written before the + // container that needs it, and the host applies in order and stops at a step that did not + // complete. So the control plane must carry run-once through untouched and must not reorder the + // two containers. + digest := "@sha256:" + strings.Repeat("a", 64) + r := Resolution{Node: "laptop", Modules: []Manifest{{ + Module: "mosquitto", + Resources: []map[string]any{ + {"id": "seed", "type": "container", "name": "seed", + "image": "registry.example/runtime" + digest, "run-once": true}, + {"id": "server", "type": "container", "name": "broker", + "image": "registry.example/broker" + digest}, + }, + }}} + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + + seed := indexOfID(out, "mosquitto.seed") + server := indexOfID(out, "mosquitto.server") + if seed == -1 || server == -1 { + t.Fatalf("a container was lost in rendering: seed=%d server=%d", seed, server) + } + if seed >= server { + t.Errorf("the run-once step rendered after the container it gates (seed=%d server=%d)", seed, server) + } + if once, _ := out[seed]["run-once"].(bool); !once { + t.Errorf("run-once did not reach the host declaration: %+v", out[seed]) + } + if _, present := out[server]["run-once"]; present { + t.Errorf("run-once leaked onto the container that is not a step: %+v", out[server]) + } +} + +func TestARunOnceMustBeABoolean(t *testing.T) { + // A value that is not true or false is refused here, not sent to a machine that would then have + // to guess what a string means. + digest := "@sha256:" + strings.Repeat("a", 64) + bad := []byte(`{"module":"m","resources":[ + {"id":"seed","type":"container","name":"seed","image":"registry.example/x` + digest + `","run-once":"yes"} + ]}`) + if _, err := ParseManifest(bad); err == nil { + t.Error("a run-once that is not a boolean was accepted") + } else if !strings.Contains(err.Error(), "run-once") { + t.Errorf("refused for the wrong reason: %v", err) + } + + good := []byte(`{"module":"m","resources":[ + {"id":"seed","type":"container","name":"seed","image":"registry.example/x` + digest + `","run-once":true} + ]}`) + if _, err := ParseManifest(good); err != nil { + t.Errorf("a valid run-once container was refused: %v", err) + } +} + +func TestARunOnceContainerCannotAlsoDeclareRestartOn(t *testing.T) { + // restart-on brings a running container back; a run-once step does not stay running. The pair + // is a contradiction, refused at the manifest rather than surfacing far away on the host. + digest := "@sha256:" + strings.Repeat("a", 64) + bad := []byte(`{"module":"m","resources":[ + {"id":"conf","type":"file","path":"/x","content":"y"}, + {"id":"seed","type":"container","name":"seed","image":"registry.example/x` + digest + `","run-once":true,"restart-on":["conf"]} + ]}`) + if _, err := ParseManifest(bad); err == nil { + t.Error("a run-once container that also declared restart-on was accepted") + } else if !strings.Contains(err.Error(), "restart-on") { + t.Errorf("refused for the wrong reason: %v", err) + } +}