An apply leaves the resources its scope keeps as they run, so the bus server is not replaced by an unwarranted want (ADR 0594 §2)

This commit is contained in:
2026-11-03 08:56:36 +01:00
parent 089ac78007
commit ffcd8f6d61
2 changed files with 142 additions and 4 deletions
+64 -4
View File
@@ -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.
+78
View File
@@ -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{} }