package apply import ( "context" "errors" "strings" "testing" "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/store" ) // A run-once container is a step, not a service (novox/hq ADR 0052): the host runs it to // completion, requires exit 0, records that it ran, and — because a failed step gates the apply — // starts whatever the declaration places after it only once the step has finished. These tests // defend that, each named for the claim it holds up. // nameOf returns the value after --name in a docker run argument list. func nameOf(args []string) string { for i, a := range args { if a == "--name" && i+1 < len(args) { return args[i+1] } } return "" } func TestARunOnceStepIsRunToCompletionNotLeftRunning(t *testing.T) { // The difference between a step and a service is that a step is run in the foreground and its // exit code is the answer. So the host must not pass --detach (which returns before the // container exits) nor --restart (a step that is restarted is not a step). var runArgs []string run := func(ctx context.Context, name string, args ...string) (string, error) { switch args[0] { case "info": return "27.0\n", nil case "run": runArgs = args return "", nil // ran and exited 0 case "rm": return "", nil } return "", nil } d := parseTrusted(t, `{"declaration":1,"resources":[ {"id":"seed","type":"container","name":"seed","image":"`+pinned+`","run-once":true} ]}`) report, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil) if err != nil { t.Fatalf("a run-once step that exited 0 failed the apply: %v", err) } if runArgs == nil { t.Fatal("the run-once step was never run") } if got := strings.Join(runArgs, " "); strings.Contains(got, "--detach") { t.Errorf("a run-once step was detached, so its exit could not be observed: %s", got) } if got := strings.Join(runArgs, " "); strings.Contains(got, "--restart") { t.Errorf("a run-once step was given a restart policy, which makes it a service: %s", got) } if report.Outcomes[0].Action != "created" { t.Errorf("a completed run-once step was not reported created: %+v", report.Outcomes[0]) } // It ran, so it was recorded — and the record is the digest of the declaration, which is what // keeps a re-apply from running it again. applied, ok := state.Find("seed") if !ok { t.Fatal("a completed run-once step was not recorded") } if applied.Wrote != containerSpec(d.Resources[0].(*declaration.Container)) { t.Errorf("the run-once record is not the declaration's digest: %q", applied.Wrote) } } func TestARunOnceStepThatExitsNonZeroFailsTheApply(t *testing.T) { // Run in the foreground, a non-zero exit is an error the runtime hands back. That must fail // the apply, not be swallowed — a seed that did not happen leaves the machine unready. run := func(ctx context.Context, name string, args ...string) (string, error) { switch args[0] { case "info": return "27.0\n", nil case "run": return "", errors.New("exit status 1") case "rm": return "", nil } return "", nil } d := parseTrusted(t, `{"declaration":1,"resources":[ {"id":"seed","type":"container","name":"seed","image":"`+pinned+`","run-once":true} ]}`) _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil) if err == nil { t.Fatal("a run-once step that did not complete was accepted") } if !strings.Contains(err.Error(), "did not complete") { t.Errorf("failed for the wrong reason: %v", err) } if _, recorded := state.Find("seed"); recorded { t.Error("a run-once step that did not complete was recorded as done") } } func TestAFailedRunOnceStepGatesWhatFollows(t *testing.T) { // The whole of how "before the broker starts" is enforced: the step is declared first, and a // step that did not complete stops the apply reaching the container that depends on it — the // mirror of a failed action stopping what follows. var startedNames []string run := func(ctx context.Context, name string, args ...string) (string, error) { switch args[0] { case "info": return "27.0\n", nil case "inspect": return "false\t\n", errors.New("no such container") case "run": startedNames = append(startedNames, nameOf(args)) if nameOf(args) == "seed" { return "", errors.New("exit status 1") // the step fails } return "deadbeef\n", nil case "rm": return "", nil } return "", nil } d := parseTrusted(t, `{"declaration":1,"resources":[ {"id":"seed","type":"container","name":"seed","image":"`+pinned+`","run-once":true}, {"id":"broker","type":"container","name":"broker","image":"`+pinned+`"} ]}`) _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil) if err == nil { t.Fatal("a failed run-once step did not fail the apply") } var applyErr *Error if !errors.As(err, &applyErr) { t.Fatalf("got %T", err) } if !applyErr.Gated { t.Error("the failure does not say that nothing after the step was attempted") } for _, n := range startedNames { if n == "broker" { t.Fatal("the broker was started even though its run-once step did not complete") } } } func TestARunOnceStepAlreadyCompletedIsNotReRun(t *testing.T) { // Its record of having happened is the digest of its declaration. A re-apply that finds the // digest already recorded does nothing — a step is not reconciled toward, it is run once. d := parseTrusted(t, `{"declaration":1,"resources":[ {"id":"seed","type":"container","name":"seed","image":"`+pinned+`","run-once":true} ]}`) want := containerSpec(d.Resources[0].(*declaration.Container)) var ran bool run := func(ctx context.Context, name string, args ...string) (string, error) { switch args[0] { case "info": return "27.0\n", nil case "run": ran = true return "", nil case "rm": return "", nil } return "", nil } known := store.State{} known.Record(store.Applied{ID: "seed", Type: "container", Origin: store.OriginCarried, Target: "seed", Wrote: want}) report, _, err := Apply(context.Background(), archHost(t), d, known, store.OriginCarried, run, nil, nil) if err != nil { t.Fatalf("re-applying a completed run-once step failed: %v", err) } if ran { t.Error("a run-once step already completed for this declaration was run again") } if report.Changed() { t.Errorf("a re-applied run-once step reported a change: %+v", report.Outcomes) } } func TestARunOnceStepReRunsWhenItsDeclarationChanged(t *testing.T) { // The marker is the declaration's digest, so a changed image or environment moves it and the // step runs again — a migration or a seed that changed is a different step. d := parseTrusted(t, `{"declaration":1,"resources":[ {"id":"seed","type":"container","name":"seed","image":"`+pinned+`","run-once":true,"env":{"CLIENT":"new-admin"}} ]}`) var ran bool run := func(ctx context.Context, name string, args ...string) (string, error) { switch args[0] { case "info": return "27.0\n", nil case "run": ran = true return "", nil case "rm": return "", nil } return "", nil } // A record from an earlier, different declaration of the same step. known := store.State{} known.Record(store.Applied{ID: "seed", Type: "container", Origin: store.OriginCarried, Target: "seed", Wrote: "an-older-digest"}) if _, _, err := Apply(context.Background(), archHost(t), d, known, store.OriginCarried, run, nil, nil); err != nil { t.Fatalf("apply failed: %v", err) } if !ran { t.Error("a run-once step whose declaration changed was not run again") } } func TestARunOnceContainerCannotAlsoDeclareRestartOn(t *testing.T) { // restart-on brings a running container back when a file it read changed; a run-once step does // not stay running. The two lifecycles contradict, so the parser refuses the pair rather than // silently resolving to one. _, err := declaration.Parse([]byte(`{"declaration":1,"resources":[ {"id":"f","type":"file","path":"/tmp/x","content":"y"}, {"id":"seed","type":"container","name":"seed","image":"`+pinned+`","run-once":true,"restart-on":["f"]} ]}`)) if err == nil { t.Fatal("a run-once container that also declared restart-on was accepted") } if !strings.Contains(err.Error(), "run-once") { t.Errorf("refused for the wrong reason: %v", err) } }