From 2b916ac04366631b4a80f904f2832a42dad35644 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:51:09 +0100 Subject: [PATCH] G A-98: the node agent gains Env.Stop, which stops a module's units and containers and removes nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §297, A-98 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- cmd/mesh-host/nodeagent.go | 28 +++++++++++++++ cmd/mesh-host/stop_test.go | 40 +++++++++++++++++++++ internal/agent/agent.go | 4 +++ internal/apply/stop.go | 69 +++++++++++++++++++++++++++++++++++++ internal/apply/stop_test.go | 45 ++++++++++++++++++++++++ 5 files changed, 186 insertions(+) create mode 100644 cmd/mesh-host/stop_test.go create mode 100644 internal/apply/stop.go create mode 100644 internal/apply/stop_test.go diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 45d0ba7d..098c8753 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -28,6 +28,7 @@ import ( "github.com/novox/mesh-host/internal/seatwait" "github.com/novox/mesh-host/internal/selfcompose" "github.com/novox/mesh-host/internal/store" + "github.com/novox/mesh-host/internal/system" ) // The node agent's wiring (novox/hq ADR 0429 §4, ADR 0539 §9): the buckets it watches, the haves it writes, the backup @@ -578,6 +579,9 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus sayFacts(ctx, ag) return res }, + Stop: func(ctx context.Context, module string) agent.Result { + return stopComposition(ctx, opts, module) + }, Remove: func(ctx context.Context, module string, others []compositions.Composed) agent.Result { actx, done := applyContext(session.WithLink(ctx)) res := removeComposition(actx, opts, module, others, ag.Placed(module), sched, say) @@ -857,6 +861,30 @@ func removeComposition(ctx context.Context, opts options, module string, others return agent.Result{} } +// stopComposition stops a module's units and containers from the node's record and removes nothing (design 47 G A-98, +// §297): what the module kept stays, so a revert restores it before the reverted build starts. +func stopComposition(ctx context.Context, opts options, module string) agent.Result { + applying.Lock() + defer applying.Unlock() + unlock, err := store.Lock(opts.state, nil) + if err != nil { + return agent.Result{Failed: true, Detail: shorten(err.Error())} + } + defer unlock() + known, err := store.Load(opts.state) + if err != nil { + return agent.Result{Failed: true, Detail: shorten(err.Error())} + } + built, err := system.For(builtFor) + if err != nil { + return agent.Result{Failed: true, Detail: shorten(err.Error())} + } + if _, err := apply.StopModule(ctx, built, known, module, opts.run()); err != nil { + return agent.Result{Failed: true, Detail: shorten(err.Error())} + } + return agent.Result{} +} + // busyModules are the modules whose last apply left a container running because it said busy. func busyModules() []string { busyMu.Lock() diff --git a/cmd/mesh-host/stop_test.go b/cmd/mesh-host/stop_test.go new file mode 100644 index 00000000..4ca264a6 --- /dev/null +++ b/cmd/mesh-host/stop_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/store" +) + +// The node agent's Stop stops a module's container from the node's record, asks the machine nothing else and leaves the +// record, so the module's kept data and its record survive (design 47 G A-98). +func TestTheNodeAgentsStopStopsAModuleAndRemovesNothing(t *testing.T) { + was := builtFor + builtFor = "arch" + t.Cleanup(func() { builtFor = was }) + path := filepath.Join(t.TempDir(), "state.json") + var s store.State + s.Record(store.Applied{ID: "app.box", Type: "container", Target: "app-box"}) + s.Record(store.Applied{ID: "other.box", Type: "container", Target: "other-box"}) + if err := store.Save(path, s); err != nil { + t.Fatal(err) + } + var asked []string + opts := options{state: path, runner: func(_ context.Context, name string, args ...string) (string, error) { + asked = append(asked, name+" "+strings.Join(args, " ")) + return "", nil + }} + if res := stopComposition(context.Background(), opts, "app"); res.Failed { + t.Fatalf("stop failed: %s", res.Detail) + } + if !strings.Contains(strings.Join(asked, "\n"), "stop app-box") || strings.Contains(strings.Join(asked, "\n"), "other-box") || + strings.Contains(strings.Join(asked, "\n"), " rm ") { + t.Fatalf("the machine was asked %v", asked) + } + if after, err := store.Load(path); err != nil || len(after.Resources) != 2 { + t.Fatalf("the record changed: %v, %v", after.Resources, err) + } +} diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 9aa00adc..2b40d698 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -136,6 +136,10 @@ type Env struct { Apply func(ctx context.Context, c compositions.Composed, others []compositions.Composed) Result // Remove removes the module's own resources and nothing of another's. Remove func(ctx context.Context, module string, others []compositions.Composed) Result + // Stop stops the module's units and containers and removes nothing, so what it kept survives: a revert stops the newer + // build with it, restores the module's kept data, and then applies the reverted build (design 47 G A-98, §297). Nil + // stops nothing. + Stop func(ctx context.Context, module string) Result // Backup backs a module up before a new composition of it is applied; nil backs up nothing. Backup func(ctx context.Context, module string) Backup // Kept is the resources the node last applied, by module, from the declaration it kept before the switch (ADR 0539 diff --git a/internal/apply/stop.go b/internal/apply/stop.go new file mode 100644 index 00000000..8a8b553e --- /dev/null +++ b/internal/apply/stop.go @@ -0,0 +1,69 @@ +package apply + +import ( + "context" + "fmt" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/declaration" + "github.com/novox/mesh-host/internal/store" + "github.com/novox/mesh-host/internal/system" +) + +// StopModule stops the units and containers the node's record holds for a module and removes nothing: no unit, file, +// container, data or record, so what the module kept survives and a later Apply starts the build it is given (design 47 +// G A-98, §297). Units come first, a timer before the service it starts, then containers; a unit the mesh never held +// (Stateless) is left as it is. It answers what it stopped, and the first failure. +func StopModule(ctx context.Context, sys system.System, known store.State, module string, run Runner) ([]string, error) { + var timers, services, containers []store.Applied + for _, r := range known.Resources { + if store.OwnerOf(r, declaration.ModuleGuess) != module { + continue + } + switch r.Type { + case string(declaration.TypeService): + if r.Stateless { + continue + } + if strings.HasSuffix(r.Target, ".timer") { + timers = append(timers, r) + } else { + services = append(services, r) + } + case string(declaration.TypeContainer): + containers = append(containers, r) + } + } + var stopped []string + for _, r := range append(timers, services...) { + manager := managerFor(r.Scope, r.User, run) + if state, err := sys.ServiceState(ctx, manager, r.Target); err != nil { + if strings.Contains(err.Error(), "does not exist on this machine") { + continue + } + return stopped, fmt.Errorf("stopping %s of %s: %w", r.Target, module, err) + } else if state == "stopped" { + continue + } + if err := sys.SetServiceState(ctx, manager, r.Target, "stopped"); err != nil { + return stopped, fmt.Errorf("stopping %s of %s: %w", r.Target, module, err) + } + stopped = append(stopped, r.Target) + } + if len(containers) == 0 { + return stopped, nil + } + cri, err := containerRuntime(ctx, run) + if err != nil { + return stopped, err + } + sort.Slice(containers, func(i, j int) bool { return containers[i].Target < containers[j].Target }) + for _, r := range containers { + if _, err := run(ctx, cri, "stop", r.Target); err != nil { + return stopped, fmt.Errorf("stopping the container %s of %s: %w", r.Target, module, err) + } + stopped = append(stopped, r.Target) + } + return stopped, nil +} diff --git a/internal/apply/stop_test.go b/internal/apply/stop_test.go new file mode 100644 index 00000000..2febfb5d --- /dev/null +++ b/internal/apply/stop_test.go @@ -0,0 +1,45 @@ +package apply + +import ( + "context" + "reflect" + "testing" + + "github.com/novox/mesh-host/internal/store" +) + +// Stop stops a module's timer, then its service, then its container, and leaves every record, and another module's +// units, as they are (design 47 G A-98). +func TestStopModuleStopsAModulesUnitsAndContainersAndRemovesNothing(t *testing.T) { + m := unitsMachine(map[string]*fakeUnit{ + "app.service": {active: "active", enabled: "enabled"}, + "app.timer": {active: "active", enabled: "enabled"}, + "other.service": {active: "active", enabled: "enabled"}, + }) + state := store.State{} + for _, r := range []store.Applied{ + {ID: "app.run", Type: "service", Target: "app.service"}, + {ID: "app.tick", Type: "service", Target: "app.timer"}, + {ID: "app.c", Type: "container", Target: "app-box"}, + {ID: "other.run", Type: "service", Target: "other.service"}, + {ID: "other.c", Type: "container", Target: "other-box"}, + } { + state.Record(r) + } + got, err := StopModule(context.Background(), archHost(t), state, "app", m.run) + if err != nil { + t.Fatal(err) + } + if want := []string{"app.timer", "app.service", "app-box"}; !reflect.DeepEqual(got, want) { + t.Fatalf("stopped %v, want %v, the timer first", got, want) + } + if m.units["app.service"].active != "inactive" || m.units["app.timer"].active != "inactive" || m.units["other.service"].active != "active" { + t.Fatalf("units are %+v", m.units) + } + if !m.did("docker stop app-box") || m.did("docker stop other-box") || m.did("docker rm") { + t.Fatalf("the machine was asked %v", m.asked) + } + if len(state.Resources) != 5 { + t.Fatalf("a record was removed: %d left", len(state.Resources)) + } +}