G A-98: the node agent gains Env.Stop, which stops a module's units and containers and removes nothing

Builds: design 47 G §297, A-98

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-11 23:51:09 +01:00
parent 7c7b5e2d68
commit 2b916ac043
5 changed files with 186 additions and 0 deletions
+28
View File
@@ -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()
+40
View File
@@ -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)
}
}
+4
View File
@@ -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
+69
View File
@@ -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
}
+45
View File
@@ -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))
}
}