diff --git a/internal/apply/apply.go b/internal/apply/apply.go index fd1432e..ff94eda 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -143,8 +143,9 @@ func (r Report) Changed() bool { // (novox/hq issue 224). // waits busy is a container left running because it said busy (novox/hq ADR 0335), which nothing // moved either. + // Kept as it runs is a bus server a want without the warrant's mark left alone (novox/hq ADR 0594 §2). if o.Action != "unchanged" && o.Action != "held" && o.Action != "waiting" && o.Action != heldStill && - o.Action != waitsBusy { + o.Action != waitsBusy && o.Action != keptAsItRuns { return true } } @@ -321,6 +322,11 @@ type Scope struct { // in its own composition has no want, so Placed does not name it (issue 774, review of the re-land, round 1: nats on // the bus node has none, and its nats.conf was guessed as nats's and removed). Nil guards by Placed alone. RemovedByWant map[string]bool + // Keep are the resources, by id, the apply leaves as they run when they exist (novox/hq ADR 0594 §2): the bus + // server's container and what restarts it, on a want of the bus module without the warrant's mark. A kept resource + // that exists is neither recreated nor rewritten and is recorded as the module's with what it was last recorded + // reading; one that does not exist is made. Nil keeps nothing. + Keep map[string]bool } // placedOwner is the module a want still places on the node that the record belongs to, other than the scope's own: @@ -1065,7 +1071,11 @@ func applyScoped( if from, handed := handedFiles[resource.Identity()]; handed && was.ID == "" { previous.Wrote = from.Wrote } - outcome, err = applyOne(ctx, sys, resource, run, changed, in, previous, unseal, keepFound) + if o, kept := keepAsItRuns(ctx, scope, resource, run, previous); kept { + outcome = o + } else { + outcome, err = applyOne(ctx, sys, resource, run, changed, in, previous, unseal, keepFound) + } } if err != nil { // **What was found is kept whatever the apply then did** (novox/hq ADR 0118). The @@ -1272,7 +1282,7 @@ func applyScoped( if outcome.busy != nil { report.Busy = append(report.Busy, *outcome.busy) } - if outcome.Action == heldStill || outcome.Action == waitsBusy { + if outcome.Action == heldStill || outcome.Action == waitsBusy || outcome.Action == keptAsItRuns { // Not changed: nothing about it moved, so nothing that restarts on it restarts. log(fmt.Sprintf(" %s %s (%s): %s", outcome.Action, outcome.ID, outcome.Target, outcome.Detail)) } else if moved(outcome) { @@ -1429,7 +1439,8 @@ func recordApplied(known *store.State, origin string, resource declaration.Resou // moved reports whether applying a resource moved it, so what restarts or reloads on it does: anything but // unchanged, and not a container held still or waiting for its busy one to say idle. func moved(outcome Outcome) bool { - return outcome.Action != "unchanged" && outcome.Action != heldStill && outcome.Action != waitsBusy + return outcome.Action != "unchanged" && outcome.Action != heldStill && outcome.Action != waitsBusy && + outcome.Action != keptAsItRuns } // afterWhatTheyRead is the resources in declared order, except that **a service comes after every @@ -3019,6 +3030,55 @@ func applyNetwork(ctx context.Context, r *declaration.Network, run Runner) (Outc return out, nil } +// keptAsItRuns is the action of a resource the scope keeps (Scope.Keep, novox/hq ADR 0594 §2), found on the machine +// and left as it runs: the bus server on a want of the bus module without the warrant's mark. Its own word, beside +// heldStill: nothing about it moved, so nothing that restarts on it restarts, and a person reading the apply sees why +// a changed server was not replaced. +const keptAsItRuns = "kept-as-it-runs" + +// keepAsItRuns is the outcome of a resource the scope keeps that exists on the machine, true; false for any other, +// which is applied as always. A container exists when the runtime knows it by its name; an archive when it was +// unpacked at its path before. The outcome carries what the resource was last recorded reading and holding, so its +// record is the module's from here on (taken over from the node's own composition at the bus module's first want) and +// a later warranted apply still sees what changed under it. +func keepAsItRuns(ctx context.Context, scope *Scope, r declaration.Resource, run Runner, previous store.Applied) (Outcome, bool) { + if scope == nil || !scope.Keep[r.Identity()] { + return Outcome{}, false + } + out := begin(r) + out.Action = keptAsItRuns + why := "the bus server is left as it runs: only a want carrying the operator's warrant replaces it (novox/hq ADR 0594 §2)" + switch res := r.(type) { + case *declaration.Container: + if res.Schedule != "" || res.RunOnce { + return Outcome{}, false + } + if _, err := containerState(ctx, res.Name, run); err != nil { + return Outcome{}, false + } + out.reads = previous.Reads + out.busySince = previous.BusySince + out.Detail = why + return out, true + case *declaration.Archive: + if previous.Wrote == "" || previous.Target != res.Path { + return Outcome{}, false + } + if _, err := os.Stat(res.Path); err != nil { + return Outcome{}, false + } + out.wrote = previous.Wrote + out.unpacked = previous.Unpacked + if previous.Wrote != res.Digest { + out.Detail = why + "; the declared " + res.Digest + " waits for the warrant" + } else { + out.Detail = why + } + return out, true + } + return Outcome{}, false +} + // heldStill is the action of a container left alone because a maintenance window holds it stopped // (novox/hq issue 224). Its own word rather than "unchanged": a person reading a machine that looks // half-stopped needs to see why, and "unchanged" beside a stopped server says the opposite. diff --git a/internal/apply/module_scope_test.go b/internal/apply/module_scope_test.go index 1bdb768..4ac1aee 100644 --- a/internal/apply/module_scope_test.go +++ b/internal/apply/module_scope_test.go @@ -2,6 +2,8 @@ package apply import ( "context" + "fmt" + "os" "path/filepath" "strings" "testing" @@ -219,6 +221,82 @@ func TestAResourceThatMovedToItsModulesCompositionIsLeftByTheNodesOwnApply(t *te } }) } + // Issue 774, ADR 0594 §2: the bus module's first own apply takes over the server the node's own composition made + // while the bus was folded. Kept (a want without the warrant's mark), the server's container and its program archive + // are left as they run, whatever the build changed in them, and become nats's; its files and the tools' account file + // are written. Not kept, the same apply replaces the container. + for _, keep := range []bool{true, false} { + t.Run(fmt.Sprintf("the bus server kept %v", keep), func(t *testing.T) { + dir := t.TempDir() + conf, account, program := filepath.Join(dir, "nats.conf"), filepath.Join(dir, "tools", "account"), filepath.Join(dir, "program") + bus := func(env, confBody, programBody, module string) *declaration.Declaration { + archive, _ := archiveDecl(t, "nats.program-snapshot", program, map[string]string{"snapshot": programBody}, "") + if module != "" { + archive = strings.Replace(archive, `"id":"nats.program-snapshot"`, `"id":"nats.program-snapshot","module":"nats"`, 1) + module = `,"module":"nats"` + } + return declare(t, archive+`, + {"id":"nats.server-conf","type":"file","path":"`+conf+`","content":"`+confBody+`"`+module+`}, + {"id":"nats.server","type":"container","name":"mesh-broker-nats","image":"`+pinned+`","env":{"V":"`+env+ + `"},"restart-on":["nats.program-snapshot","nats.server-conf"]`+module+`}`) + } + m := &machine{containers: map[string]*fakeContainer{}} + _, state, err := ApplyModule(context.Background(), archHost(t), bus("1", "a", "one", ""), Scope{Module: store.NodeModule}, + state0(), store.OriginDeclared, m.run, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + made := m.containers["mesh-broker-nats"] + if made == nil { + t.Fatalf("the node's own made no server: %v", m.asked) + } + m.asked = nil + next := bus("2", "b", "two", "nats") + next.Resources = append(next.Resources, declare(t, `{"id":"nats.tools-account","type":"file","path":"`+account+ + `","content":"tools","module":"nats"}`).Resources...) + scope := Scope{Module: "nats", Placed: map[string]bool{"nats": true}} + if keep { + scope.Keep = map[string]bool{"nats.server": true, "nats.program-snapshot": true} + } + report, after, err := ApplyModule(context.Background(), archHost(t), next, scope, state, + store.OriginDeclared, m.run, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + var moved []string + for _, a := range m.asked { + for _, verb := range []string{"docker rm", "docker run", "docker create", "docker stop", "docker restart"} { + if strings.HasPrefix(a, verb+" ") { + moved = append(moved, a) + } + } + } + if keep == (len(moved) > 0) { + t.Fatalf("kept %v, and the server was moved by %v", keep, moved) + } + if got, _ := os.ReadFile(conf); string(got) != "b" { + t.Errorf("the server's file holds %q", got) + } + if got, _ := os.ReadFile(account); string(got) != "tools" { + t.Errorf("the tools' account file holds %q", got) + } + if got, _ := os.ReadFile(filepath.Join(program, "snapshot")); keep != (string(got) == "one") { + t.Errorf("kept %v, and the server's program is %q", keep, got) + } + for _, id := range []string{"nats.server", "nats.program-snapshot"} { + rec, ok := after.Find(id) + if !ok || rec.Module != "nats" { + t.Errorf("%s is recorded %+v, not as nats's", id, rec) + } + if o := outcomeOf(report, id); keep != (o.Action == keptAsItRuns) { + t.Errorf("kept %v, and %s was %s: %s", keep, id, o.Action, o.Detail) + } + } + if keep && report.Changed() == false { + t.Error("an apply that wrote the server's files says nothing changed") + } + }) + } } func state0() store.State { return store.State{} }