H-6: the seat verb process-builds reads the build each process was started with; the engine keeps no callers table
Builds: design 47 G §262, §245, §440, §447 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
|
||||
"io"
|
||||
"net"
|
||||
@@ -42,6 +43,7 @@ import (
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
"github.com/novox/mesh-host/internal/network"
|
||||
"github.com/novox/mesh-host/internal/outward"
|
||||
"github.com/novox/mesh-host/internal/processbuilds"
|
||||
"github.com/novox/mesh-host/internal/profile"
|
||||
"github.com/novox/mesh-host/internal/reachable"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
@@ -1018,6 +1020,18 @@ func runLink(ctx context.Context, opts options) error {
|
||||
queue.Acts.Compose = composeFrom(current, &composerNow)
|
||||
queue.Acts.Packages = packagesFrom(mine.Node, queue, &composerNow)
|
||||
queue.Acts.SeatList, queue.Acts.SeatListAgain = seatListRead, seatListAgain
|
||||
queue.Acts.ProcessBuilds = func(module string) (any, error) {
|
||||
d, err := declaredNow(opts.state, nil)
|
||||
if errors.Is(err, store.ErrNothingDeclared) {
|
||||
d, err = nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nodeengine.ProcessBuilds{Node: mine.Node, Error: "what runs on this node does not read: " + err.Error()}, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), lookBound)
|
||||
defer cancel()
|
||||
return processbuilds.Reader{Node: mine.Node, Run: processbuilds.Runner(opts.run())}.Read(ctx, d, module), nil
|
||||
}
|
||||
queue.Acts.ComposeCandidate = composeCandidateFrom(mine.Node, &shadowNow)
|
||||
setRunsHere(runsFromAgent(current))
|
||||
factsOfNow := func(ctx context.Context, ag *agent.Agent) {
|
||||
|
||||
+10
-16
@@ -55,18 +55,11 @@ const (
|
||||
// (novox/hq ADR 0636 §7). The node-engine seat's definition carries both verbs.
|
||||
ActSeatList = nodeengine.VerbSeatList
|
||||
ActSeatListAgain = nodeengine.VerbSeatListAgain
|
||||
// ActProcessBuilds reads the build each process on this node was started with (design 47, *The fingerprint handover*);
|
||||
// it reads only; who may call it is the seat definition's (design 47 G §440, §447).
|
||||
ActProcessBuilds = nodeengine.VerbProcessBuilds
|
||||
)
|
||||
|
||||
// SeatListCallers are who may call the engine's two seat-list verbs (novox/hq ADR 0636 §7; the node-engine seat's
|
||||
// definition: the operator and the healers): every operator's session and the healers, both verbs alike. A call through
|
||||
// the mesh MCP server, and nox, is proved only as its machine's tool runner (<node>.node-tools, issue 627), so node-tools
|
||||
// stands for an operator's session until a session is proved; without it the verbs answered nobody but the healers, and
|
||||
// no healer calls them (issue 1966).
|
||||
var SeatListCallers = stdio.VerbCallers{
|
||||
ActSeatList: {stdio.AnyPerson, "node-tools", "mesh-healers"},
|
||||
ActSeatListAgain: {stdio.AnyPerson, "node-tools", "mesh-healers"},
|
||||
}
|
||||
|
||||
// The states of an act: asked, done and refused are also the seat's events.
|
||||
const (
|
||||
HandAsked = nodeengine.EventAsked
|
||||
@@ -166,6 +159,8 @@ type HandActs struct {
|
||||
// SeatList answers the seat list the engine holds; SeatListAgain makes it read the list now and answers what it holds.
|
||||
SeatList func() (any, error)
|
||||
SeatListAgain func() (any, error)
|
||||
// ProcessBuilds answers the build each process of the node was started with, narrowed to a module when one is given.
|
||||
ProcessBuilds func(module string) (any, error)
|
||||
// ComposeCandidate composes a check's candidate (the indexer's candidate key) on this node in shadow and answers per
|
||||
// module, applying and writing nothing; with input, also the composition input it composed from (the verb
|
||||
// compose-candidate, ADR 0593 §7).
|
||||
@@ -237,17 +232,16 @@ func (h *HandActs) Call(c EngineCall) (any, error) {
|
||||
}
|
||||
return h.Compose(arg("module"))
|
||||
case ActSeatList:
|
||||
if err := SeatListCallers.Allowed(c.Verb, c.Caller); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.SeatList == nil {
|
||||
return nil, errors.New("this engine reads no seat list yet: its node agent has not started")
|
||||
}
|
||||
return h.SeatList()
|
||||
case ActSeatListAgain:
|
||||
if err := SeatListCallers.Allowed(c.Verb, c.Caller); err != nil {
|
||||
return nil, err
|
||||
case ActProcessBuilds:
|
||||
if h.ProcessBuilds == nil {
|
||||
return nil, errors.New("this engine reads no process yet: its node agent has not started")
|
||||
}
|
||||
return h.ProcessBuilds(arg("module"))
|
||||
case ActSeatListAgain:
|
||||
if h.SeatListAgain == nil {
|
||||
return nil, errors.New("this engine reads no seat list yet: its node agent has not started")
|
||||
}
|
||||
|
||||
@@ -271,6 +271,10 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) {
|
||||
ManifestReads: func() ([]string, time.Time) {
|
||||
return []string{"form:a", "seat:b"}, time.Date(2026, 11, 4, 10, 0, 0, 0, time.UTC)
|
||||
},
|
||||
// Design 47: the build each process was started with, narrowed by module.
|
||||
ProcessBuilds: func(module string) (any, error) {
|
||||
return map[string]any{"processes": []any{}, "module": module}, nil
|
||||
},
|
||||
// ADR 0565 §6, §15: the node's key of the engine's state packages.
|
||||
Packages: func(write bool) (any, error) {
|
||||
key := []byte(`{"node":"n1","packages":[{"package":"curl","modules":["backup","web"],"absent":false}]}`)
|
||||
@@ -301,16 +305,18 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) {
|
||||
{"packages", EngineToolSubject("packages", "n1") + " " + `{"write":true}`, `"key":null,"wrote":true`},
|
||||
{"packages", EngineToolSubject("packages", "n1") + " " + `{"write":"true"}`, `"note":"the engine has written no key for this node yet`},
|
||||
{"manifest-reads", EngineToolSubject("manifest-reads", "n1"), `"manifest-reads":["form:a","seat:b"],"node":"n1","stated-at":"2026-11-04T10:00:00Z","v":1`},
|
||||
// ADR 0636 §7: both seat-list verbs answer every operator's session, proved as a person or, through the mesh MCP
|
||||
// server, as its machine's tool runner (issue 627, issue 1966), and the healers, no other module.
|
||||
// Design 47 G §447: the engine keeps no callers table; who may call a verb is the seat definition's, which the
|
||||
// grants part makes the bus enforce, so a call that reaches the engine is answered whoever the caller is.
|
||||
{"seat-list", "mesh.seat.node-engine.call.seat-list.n1.person~jochen", `"held":true`},
|
||||
{"seat-list", "mesh.seat.node-engine.call.seat-list.n1.shanks~node-tools", `"held":true`},
|
||||
{"seat-list", "mesh.seat.node-engine.call.seat-list.n1.ace~messenger", `not on the holder's caller list for seat-list`},
|
||||
{"seat-list", "mesh.seat.node-engine.call.seat-list.n1.ace~messenger", `"held":true`},
|
||||
{"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.shanks~node-tools", `"read":true`},
|
||||
{"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.person~jochen", `"read":true`},
|
||||
{"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.ace~mesh-healers", `"read":true`},
|
||||
{"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.ace~messenger", `not on the holder's caller list for seat-list-again`},
|
||||
{"seat-list-again", EngineToolSubject("seat-list-again", "n1"), `no caller the tool runner proved`},
|
||||
{"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.ace~messenger", `"read":true`},
|
||||
|
||||
{"process-builds", "mesh.seat.node-engine.call.process-builds.n1.person~jochen " + `{"module":"web"}`, `"module":"web","processes":[]`},
|
||||
{"process-builds", "mesh.seat.node-engine.call.process-builds.n1.ace~messenger", `"processes":[]`},
|
||||
{"unserved", "mesh.seat.node-engine.call.unserved.n1.x", `"error":"the node seat node-engine serves no verb \"unserved\""`},
|
||||
} {
|
||||
subject, body, ok := strings.Cut(row.subject, " ")
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Package processbuilds reads the build each process of this node was started with (design 47, *The fingerprint handover*):
|
||||
// the value MESH_FINGERPRINT the node-engine handed a daemon or a container at its start, read from the running process
|
||||
// itself, never from what the declaration would hand it now. A process started before the node-engine handed the value,
|
||||
// or one whose value cannot be read, says why its build is unknown instead of a fingerprint.
|
||||
package processbuilds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
)
|
||||
|
||||
// Var is the environment value the build is handed in.
|
||||
const Var = "MESH_FINGERPRINT"
|
||||
|
||||
// Runner runs a command and answers its output.
|
||||
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
||||
|
||||
// Reader reads the running processes of a node.
|
||||
type Reader struct {
|
||||
Node string
|
||||
Run Runner
|
||||
// Environ reads a process's environment by its pid; nil reads /proc/<pid>/environ.
|
||||
Environ func(pid int) ([]byte, error)
|
||||
}
|
||||
|
||||
func (r Reader) environ(pid int) ([]byte, error) {
|
||||
if r.Environ != nil {
|
||||
return r.Environ(pid)
|
||||
}
|
||||
return os.ReadFile(fmt.Sprintf("/proc/%d/environ", pid))
|
||||
}
|
||||
|
||||
// Read answers the build of every daemon and container the declaration holds, narrowed to module when it is not empty,
|
||||
// ordered by module and process.
|
||||
func (r Reader) Read(ctx context.Context, d *declaration.Declaration, module string) nodeengine.ProcessBuilds {
|
||||
out := nodeengine.ProcessBuilds{Node: r.Node, Processes: []nodeengine.ProcessBuild{}}
|
||||
if d == nil {
|
||||
out.Error = "this node keeps no declaration of what it runs"
|
||||
return out
|
||||
}
|
||||
for _, res := range d.Resources {
|
||||
owner := res.DeclaredModule()
|
||||
if owner == "" {
|
||||
owner, _ = declaration.ModuleGuess(res.Identity())
|
||||
}
|
||||
if module != "" && owner != module {
|
||||
continue
|
||||
}
|
||||
switch p := res.(type) {
|
||||
case *declaration.Process:
|
||||
out.Processes = append(out.Processes, r.daemon(ctx, owner, p.Name))
|
||||
case *declaration.Container:
|
||||
out.Processes = append(out.Processes, r.container(ctx, owner, p.Name))
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out.Processes, func(i, j int) bool {
|
||||
a, b := out.Processes[i], out.Processes[j]
|
||||
if a.Module != b.Module {
|
||||
return a.Module < b.Module
|
||||
}
|
||||
return a.Process < b.Process
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (r Reader) daemon(ctx context.Context, module, name string) nodeengine.ProcessBuild {
|
||||
unit := name + ".service"
|
||||
b := nodeengine.ProcessBuild{Module: module, Process: unit, Kind: nodeengine.ProcessBuildKindService}
|
||||
said, err := r.Run(ctx, "systemctl", "show", "--property=MainPID,ActiveEnterTimestamp", "--", unit)
|
||||
if err != nil {
|
||||
b.Unknown = "the unit cannot be read: " + err.Error()
|
||||
return b
|
||||
}
|
||||
props := map[string]string{}
|
||||
for _, line := range strings.Split(said, "\n") {
|
||||
if k, v, ok := strings.Cut(strings.TrimSpace(line), "="); ok {
|
||||
props[k] = v
|
||||
}
|
||||
}
|
||||
pid, _ := strconv.Atoi(props["MainPID"])
|
||||
if pid <= 0 {
|
||||
b.Unknown = "the unit runs no process now"
|
||||
return b
|
||||
}
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", props["ActiveEnterTimestamp"]); err == nil {
|
||||
b.Started = &t
|
||||
}
|
||||
raw, err := r.environ(pid)
|
||||
if err != nil {
|
||||
b.Unknown = "its environment cannot be read: " + err.Error()
|
||||
return b
|
||||
}
|
||||
b.Fingerprint, b.Unknown = fingerprintIn(strings.Split(string(raw), "\x00"))
|
||||
return b
|
||||
}
|
||||
|
||||
func (r Reader) container(ctx context.Context, module, name string) nodeengine.ProcessBuild {
|
||||
b := nodeengine.ProcessBuild{Module: module, Process: name, Kind: nodeengine.ProcessBuildKindContainer}
|
||||
var said string
|
||||
var err error
|
||||
for _, cri := range []string{"docker", "podman"} {
|
||||
if said, err = r.Run(ctx, cri, "container", "inspect", "--format", "{{json .Config.Env}}\t{{.State.StartedAt}}", name); err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
b.Unknown = "the container cannot be read: " + err.Error()
|
||||
return b
|
||||
}
|
||||
env, started, _ := strings.Cut(strings.TrimSpace(said), "\t")
|
||||
var vars []string
|
||||
if err := json.Unmarshal([]byte(env), &vars); err != nil {
|
||||
b.Unknown = "its environment cannot be read: " + err.Error()
|
||||
return b
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, started); err == nil {
|
||||
b.Started = &t
|
||||
}
|
||||
b.Fingerprint, b.Unknown = fingerprintIn(vars)
|
||||
return b
|
||||
}
|
||||
|
||||
// fingerprintIn is the value of the handed variable in a process's environment, or why there is none.
|
||||
func fingerprintIn(vars []string) (fingerprint, unknown string) {
|
||||
for _, v := range vars {
|
||||
if value, ok := strings.CutPrefix(v, Var+"="); ok && value != "" {
|
||||
return value, ""
|
||||
}
|
||||
}
|
||||
return "", "started before the node-engine handed " + Var + ", or without it"
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package processbuilds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
)
|
||||
|
||||
// Design 47, *The fingerprint handover*: the build of a daemon is read from its running process's environment and of a
|
||||
// container from its configuration, narrowed by module; one started without the value, one not running and one the
|
||||
// runtime cannot read each say why their build is unknown, never a guessed fingerprint.
|
||||
func TestProcessBuildsAreReadFromTheRunningProcesses(t *testing.T) {
|
||||
fp := "src2:" + strings.Repeat("a", 64)
|
||||
d := &declaration.Declaration{Resources: []declaration.Resource{
|
||||
&declaration.Process{ID: "web.d", Type: declaration.TypeProcess, Module: "web", Name: "web-d"},
|
||||
&declaration.Process{ID: "old.d", Type: declaration.TypeProcess, Module: "old", Name: "old-d"},
|
||||
&declaration.Process{ID: "down.d", Type: declaration.TypeProcess, Module: "down", Name: "down-d"},
|
||||
&declaration.Container{ID: "db.c", Type: declaration.TypeContainer, Module: "db", Name: "db-c"},
|
||||
&declaration.Container{ID: "gone.c", Type: declaration.TypeContainer, Module: "gone", Name: "gone-c"},
|
||||
&declaration.Directory{ID: "web.dir", Type: declaration.TypeDirectory, Module: "web"},
|
||||
}}
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
switch {
|
||||
case name == "systemctl" && args[len(args)-1] == "web-d.service":
|
||||
return "MainPID=11\nActiveEnterTimestamp=Mon 2026-11-09 10:00:00 UTC\n", nil
|
||||
case name == "systemctl" && args[len(args)-1] == "old-d.service":
|
||||
return "MainPID=12\nActiveEnterTimestamp=\n", nil
|
||||
case name == "systemctl":
|
||||
return "MainPID=0\nActiveEnterTimestamp=\n", nil
|
||||
case name == "docker" && args[len(args)-1] == "db-c":
|
||||
return `["A=1","MESH_FINGERPRINT=` + fp + `"]` + "\t2026-11-09T10:00:00.5Z\n", nil
|
||||
}
|
||||
return "", errors.New("no such container")
|
||||
}
|
||||
environ := func(pid int) ([]byte, error) {
|
||||
if pid == 11 {
|
||||
return []byte("A=1\x00MESH_FINGERPRINT=" + fp + "\x00"), nil
|
||||
}
|
||||
return []byte("A=1\x00"), nil
|
||||
}
|
||||
r := Reader{Node: "n1", Run: run, Environ: environ}
|
||||
got := r.Read(context.Background(), d, "")
|
||||
by := map[string]string{}
|
||||
for _, p := range got.Processes {
|
||||
by[p.Module] = p.Fingerprint + "|" + p.Unknown
|
||||
}
|
||||
want := map[string]string{"web": fp + "|", "db": fp + "|",
|
||||
"old": "|started before the node-engine handed MESH_FINGERPRINT, or without it",
|
||||
"down": "|the unit runs no process now", "gone": "|the container cannot be read: no such container"}
|
||||
if len(by) != len(want) || got.Node != "n1" {
|
||||
t.Fatalf("read %v, want %v", by, want)
|
||||
}
|
||||
for m, w := range want {
|
||||
if by[m] != w {
|
||||
t.Errorf("%s: %q, want %q", m, by[m], w)
|
||||
}
|
||||
}
|
||||
if got.Processes[0].Module != "db" || got.Processes[len(got.Processes)-1].Module != "web" || got.Processes[len(got.Processes)-1].Started == nil {
|
||||
t.Errorf("order or start time wrong: %+v", got.Processes)
|
||||
}
|
||||
if one := r.Read(context.Background(), d, "web"); len(one.Processes) != 1 || one.Processes[0].Process != "web-d.service" {
|
||||
t.Errorf("narrowed by module: %+v", one.Processes)
|
||||
}
|
||||
if none := r.Read(context.Background(), nil, ""); none.Error == "" || none.Processes == nil {
|
||||
t.Errorf("no declaration: %+v", none)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user