Two faults that both reported success while being wrong, found while proving the firewall module actually delivers. A unit whose job is to apply something and exit — load a rule set, set a sysctl — is inactive the instant it succeeds. Reading that as stopped made it permanently unsatisfiable: the host started it, it worked, the host read back stopped and reported the machine as not doing what it was told, on every apply, for ever, with the rules correctly in place the whole time. That is what the firewall has been doing on every machine it was assigned to, and why the four-machine bed was red. And a container took its identity from its own fields, not from the files it reads. A file written in an earlier apply — or before the container declared it as a dependency — left a process holding a credential the mesh had already replaced, with everything reporting success (novox/hq 04-ISSUES/045). What a container reads is now part of what it is, so the comparison is a standing one rather than a tripwire that fires during one apply and never again.
234 lines
8.2 KiB
Go
234 lines
8.2 KiB
Go
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), nil) {
|
|
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), nil)
|
|
|
|
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)
|
|
}
|
|
}
|