The node agent runs in the node-engine: watches the wants and compositions on the link, applies one module's composition, asks the backup, writes the haves (R6-A A1, A5, A6)

This commit is contained in:
2026-10-24 16:39:51 +02:00
parent d5dd7761c6
commit 3303797bfb
8 changed files with 776 additions and 103 deletions
+41
View File
@@ -0,0 +1,41 @@
package main
import (
"context"
"git.novox.be/novox/mesh-sdk/go/declaration"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// applyAndKeep applies a whole declaration the way the carried bundle is applied, and says what came of it as the facts
// the node says: what the tests of the apply's steps read. The node-engine applies no whole declaration on its own now
// (novox/hq ADR 0539 §9); this is the test's way to put a machine in a state.
func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.Declared, sched *apply.Scheduler,
say link.Announce) link.Report {
declared, err := declaration.Parse(raw)
if err != nil {
return link.Report{Refused: err.Error()}
}
got := applyModuleHeld(ctx, opts, declared, nil, sched, say)
if got.Refused != "" {
return link.Report{Refused: got.Refused}
}
held := map[string]bool{}
for _, h := range got.Updated.Held {
held[h.ID] = true
}
report := factsOf(ctx, opts, declared, got.Updated, got.Outcome, held)
if signed != nil {
if err := store.SaveDeclared(store.DeclaredPath(opts.state), *signed); err != nil {
report.Failed = map[string]string{"keeping the declaration": err.Error()}
}
}
if got.Err != nil {
report.Failed = map[string]string{"apply": got.Err.Error()}
}
report.Failed = withUnanswered(report.Failed, got.Probe)
report.Unapplied = unapplied(declared, got.Outcome, got.Err)
return report
}
+110 -101
View File
@@ -30,7 +30,9 @@ import (
"time"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/accounts"
"github.com/novox/mesh-host/internal/agent"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/bundle"
"github.com/novox/mesh-host/internal/firewall"
@@ -1158,8 +1160,12 @@ func runLink(ctx context.Context, opts options) error {
Say: say,
Timeout: opts.timeout,
}
current := &agentNow{}
factsOfNow := func(ctx context.Context, ag *agent.Agent) {
sayFacts(ctx, opts, mine.Membership.Signer, queue, say, ag)
}
queue.OnLink = func(ctx context.Context) {
sayFacts(ctx, opts, mine.Membership.Signer, queue, say)
factsOfNow(ctx, current.get())
afterApply()
}
// And the move of a value sealed to this machine into the vault, asked by the controller (design 57's V3,
@@ -1186,6 +1192,14 @@ func runLink(ctx context.Context, opts options) error {
go judging.Probe(aside)
go judgeWhatRuns(aside, judging, queue, say)
}
// The node follows its wants: one module at a time from the composition its want names (novox/hq ADR 0539), and the
// busy containers are asked for again soon (ADR 0360 §11).
go nodeAgent(aside, opts, mine.Membership.Signer, queue, sched, say, afterApply, current, factsOfNow)
go busyPasses(aside, func() {
if ag := current.get(); ag != nil {
ag.Retry(busyModules()...)
}
}, BusyAskEvery)
// And the core builds this host placed are judged, whichever host placed them (to-be 45 §8).
go watchWhatThisHostPlaced(aside, mine.Node, queue, say)
// And mesh-cli is answered on this machine: the account from the kernel, the line asked of the controller
@@ -1563,6 +1577,23 @@ func orNothing(s, unless string) string {
// busyWaiting is whether the last apply left a container running because it said busy (novox/hq ADR 0335).
var busyWaiting atomic.Bool
// busyBy is, per module, whether its last apply left a container running because it said busy; busyWaiting is any of them.
var (
busyMu sync.Mutex
busyBy = map[string]bool{}
)
func noteBusy(module string, waiting bool) {
busyMu.Lock()
defer busyMu.Unlock()
busyBy[module] = waiting
any := false
for _, w := range busyBy {
any = any || w
}
busyWaiting.Store(any)
}
// BusyAskEvery is how often a container waiting because it said busy is asked again (novox/hq ADR 0335 §2):
// each ask is a pass of its own, so a build that ends is followed by its replacement within this, not
// within the reconcile's five minutes — and the bound of 15 minutes is met within this of its end.
@@ -1611,100 +1642,86 @@ func announceOr(say link.Announce) func(string) {
// run by hand beside this service — the lock beside the state does the same (store.Lock).
var applying sync.Mutex
// applyAndKeep applies a declaration and, when it came from the mesh, keeps it so this node can
// go on obeying it while disconnected. One at a time, whoever asks. Given unsigned, the bytes are
// what this node kept, already verified against the mesh's key on load.
func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.Declared,
sched *apply.Scheduler, say link.Announce) link.Report {
applying.Lock()
defer applying.Unlock()
return applyAndKeepHeld(ctx, opts, raw, signed, sched, say)
// appliedModule is how the apply of one module's declaration came out.
type appliedModule struct {
// Refused is why nothing was applied, when nothing was.
Refused string
Outcome apply.Report
Updated store.State
// Err is the apply's own error, when it failed part way.
Err error
Probe *unanswered
}
// applyAndKeepHeld is applyAndKeep for a caller already holding applying.
func applyAndKeepHeld(ctx context.Context, opts options, raw []byte, signed *store.Declared,
sched *apply.Scheduler, say link.Announce) link.Report {
// applyModuleHeld applies one module's declaration (novox/hq ADR 0539 §9): the resources of its composition, with the
// compositions the node's other modules run held out of any removal. A nil scope is a whole declaration, as the carried
// bundle is. One at a time, and the state is read and saved under the lock of the node's state.
func applyModuleHeld(ctx context.Context, opts options, declared *declaration.Declaration, scope *apply.Scope,
sched *apply.Scheduler, say link.Announce) appliedModule {
applying.Lock()
defer applying.Unlock()
unlock, err := store.Lock(opts.state, nil)
if err != nil {
return link.Report{Refused: err.Error()}
return appliedModule{Refused: err.Error()}
}
defer unlock()
declared, err := declaration.Parse(raw)
if err != nil {
return link.Report{Refused: err.Error()}
}
known, err := store.Load(opts.state)
if err != nil {
return link.Report{Refused: err.Error()}
return appliedModule{Refused: err.Error()}
}
// A declaration from the mesh is the controller's word on the node's mode — the flip arrives
// as exactly that, the first converged declaration after adopted ones — and becomes the
// record. So does what this node kept: it is signed by the mesh and verified on load, where
// the state's note of the mode is this host's own. Where they disagree the note is wrong — a
// genesis re-run over a node the mesh converged since, a state saved when the kept declaration
// could not be — and it is repaired and said, not obeyed: refusing would hold this node off what
// the mesh said until a delivery that comes only when something changes (novox/hq issue 104).
if signed == nil && known.Mode != "" && known.Mode != apply.ModeOf(declared) {
if say != nil {
say(fmt.Sprintf("this node's record said %s; what the mesh last said, signed, says %s — the record is repaired",
known.Mode, apply.ModeOf(declared)))
}
node := scope == nil || scope.Module == store.NodeModule
// The node's own composition is the controller's word on the node's mode — the flip arrives as exactly that, the
// first converged composition after adopted ones — and becomes the record (novox/hq issue 104).
if node && known.Mode != "" && known.Mode != apply.ModeOf(declared) {
announceOr(say)(fmt.Sprintf("this node's record said %s; what the mesh last said, signed, says %s — the record is repaired",
known.Mode, apply.ModeOf(declared)))
known.Mode = apply.ModeOf(declared)
}
// **A file given by digest is fetched and verified before anything of the declaration is applied** (novox/hq ADR
// 0486 §1, issue 1695): one object missing or differing refuses the declaration whole. The signed bytes are what is
// kept; only what is applied has the content in place of the reference.
// **A file given by digest is fetched and verified before anything of the composition is applied** (novox/hq ADR
// 0486 §1, issue 1695): one object missing or differing refuses the module whole.
if err := apply.ResolveFileReferences(ctx, declared, link.FilesFrom(ctx)); err != nil {
return link.Report{Refused: "a file of this declaration could not be had by its digest: " + err.Error()}
return appliedModule{Refused: "a file of this composition could not be had by its digest: " + err.Error()}
}
built, err := system.For(builtFor)
if err != nil {
return link.Report{Refused: err.Error()}
return appliedModule{Refused: err.Error()}
}
if err := system.Check(built, declared); err != nil {
return link.Report{Refused: err.Error()}
return appliedModule{Refused: err.Error()}
}
// A package manager that does not answer fails only what needs it, and the report says so (novox/hq
// issue 233, probe.go).
// A package manager that does not answer fails only what needs it, and the report says so (novox/hq issue 233).
probe, err := probePackageManager(ctx, built)
if err != nil {
return link.Report{Refused: err.Error()}
return appliedModule{Refused: err.Error()}
}
if probe != nil {
announceOr(say)(probe.said())
}
// Declared, not carried. A declaration from the mesh removes only what the mesh previously
// declared — never what this machine raised for itself from its bundle (04-ISSUES/010).
// **What the apply says goes to the console, which is the journal when this runs as a service.**
//
// It was nil, and nil is silence. The one-shot path has always passed a real one, so every detail
// the apply produces — a file held, a container replaced, the found firewall retired — was visible
// when a person ran it by hand and discarded in the way the host actually runs. Measured: after a
// machine was converged and its found firewall was not retired, what the host decided was
// unrecoverable, because it had said it to nobody (novox/hq 04-ISSUES/143).
//
// `say` already reaches stdout, and the launcher's unit sends that to the journal, so this needs
// no new mechanism — only for the argument to be passed.
// Once more when the package manager did not answer and this apply wrote something (issue 233, probe.go).
// **What the apply says goes to the console, which is the journal when this runs as a service** (novox/hq
// 04-ISSUES/143). Once more when the package manager did not answer and this apply wrote something (issue 233).
outcome, updated, applyErr, probe, firstPass := applyMindingProbe(ctx, built, probe, known,
func(known store.State) (apply.Report, store.State, error) {
return apply.ApplyMindingWindows(ctx, built, declared, known, store.OriginDeclared,
if scope == nil {
return apply.ApplyMindingWindows(ctx, built, declared, known, store.OriginDeclared,
apply.ExecRunner, announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)),
apply.WindowsIn(filepath.Dir(opts.state)))
}
return apply.ApplyModule(ctx, built, declared, *scope, known, store.OriginDeclared,
apply.ExecRunner, announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)),
apply.WindowsIn(filepath.Dir(opts.state)))
}, announceOr(say))
// The mode the mesh said, recorded whichever way the apply went: the declaration is kept
// either way, and the node is held to it from the next reconcile (novox/hq ADR 0100).
updated.Mode = apply.ModeOf(declared)
if node {
updated.Mode = apply.ModeOf(declared)
}
// Root acted on what an agent account's judgement reads — an account, a package, an action, sudo's
// rules: the search for setuid programs runs again (novox/hq issue 361). A failed apply counts what it
// did before it stopped.
// Root acted on what an agent account's judgement reads — an account, a package, an action, sudo's rules: the search
// for setuid programs runs again (novox/hq issue 361). A failed apply counts what it did before it stopped.
moved := append(append([]apply.Outcome(nil), firstPass...), outcome.Outcomes...)
var partial *apply.Error
if errors.As(applyErr, &partial) {
@@ -1714,55 +1731,34 @@ func applyAndKeepHeld(ctx context.Context, opts options, raw []byte, signed *sto
setuidSearch.SearchAgain()
}
// Saved whichever way it went. Recording only on success would lose the footprint of a
// failed apply, and that footprint is on the machine either way.
// Saved whichever way it went: the footprint of a failed apply is on the machine either way.
if saveErr := store.Save(opts.state, updated); saveErr != nil {
return link.Report{Refused: "applied, and the node's state could not be saved: " +
saveErr.Error()}
return appliedModule{Refused: "applied, and the node's state could not be saved: " + saveErr.Error()}
}
// Re-establish the scheduled steps from the declaration just applied (novox/hq ADR 0053). Done
// from the parsed declaration, which is the source of truth (ADR 0018): a schedule newly declared
// is armed, one whose image, environment or cadence changed is re-armed, and one no longer
// declared is forgotten — and after a host restart the first apply rebuilds them all. A nil
// scheduler is the one-shot CLI path, which exits rather than staying up to fire anything.
// Re-establish the scheduled steps of this declaration (novox/hq ADR 0053): a schedule newly declared is armed, one
// whose image, environment or cadence changed is re-armed, one no longer declared is forgotten, and the other
// modules' are left as they are. A nil scheduler is a test.
held := map[string]bool{}
for _, h := range updated.Held {
held[h.ID] = true
}
if sched != nil {
sched.Sync(declared, held)
}
report := factsOf(ctx, opts, declared, updated, outcome, held)
// Kept whichever way it went, so a node that is disconnected next minute still knows what it
// was told. Saved after applying rather than before: what is kept is what this node acted on.
var before []string
if signed != nil {
// The modules the declaration kept until now held, so a have key written for one the new declaration no longer
// holds is written removed even by a node-engine that started since (issue 1369).
if kept, keptErr := store.ReadDeclared(store.DeclaredPath(opts.state)); keptErr == nil {
if old, parseErr := declaration.ParseTrusted(kept.Declaration); parseErr == nil {
for _, f := range moduleFacts(old, apply.Report{}, nil) {
before = append(before, f.Module)
}
}
}
if err := store.SaveDeclared(store.DeclaredPath(opts.state), *signed); err != nil {
report.Failed = map[string]string{"keeping the declaration": err.Error()}
if scope == nil {
sched.Sync(declared, held)
} else {
sched.SyncModule(declared, held, func(id string) bool {
m, ok := declaration.ModuleGuess(id)
return ok && m == scope.Module || (!ok && scope.Module == store.NodeModule)
})
}
}
if applyErr != nil {
report.Failed = map[string]string{"apply": applyErr.Error()}
name := ""
if scope != nil {
name = scope.Module
}
report.Failed = withUnanswered(report.Failed, probe)
// And how each module of the declaration came out, for the have keys written beside this report (novox/hq ADR 0429
// §4, issue 1132): not on the wire.
report.Modules = moduleFacts(declared, outcome, applyErr)
report.Before = before
// Which resources did not apply, for whether a reconcile's account is news (issue 233): not said on the wire.
report.Unapplied = unapplied(declared, outcome, applyErr)
return report
noteBusy(name, outcome.Waiting())
return appliedModule{Outcome: outcome, Updated: updated, Err: applyErr, Probe: probe}
}
// factsOf is what this node says of itself: its ports, its profile, how what runs here is, which links face outside,
@@ -1896,16 +1892,22 @@ func factsOf(ctx context.Context, opts options, declared *declaration.Declaratio
return report
}
// sayFacts says this node's facts on the link open now (novox/hq ADR 0539 §9): what the node is made of, how what runs here
// is, and the haves of the modules the declaration it last applied holds. It applies nothing.
func sayFacts(ctx context.Context, opts options, signer ed25519.PublicKey, session *link.Session, say link.Announce) {
// sayFacts says this node's facts on the link open now (novox/hq ADR 0539 §9): what the node is made of, how what runs
// here is, and its filters and ports. What runs is the compositions the node agent holds running (ag), or, before it
// has run, the declaration the node kept before the switch. It applies nothing; the haves are the agent's.
func sayFacts(ctx context.Context, opts options, signer ed25519.PublicKey, session *link.Session, say link.Announce,
ag *agent.Agent) {
known, err := store.Load(opts.state)
if err != nil {
say("cannot say this node's facts: " + err.Error())
return
}
declared := &declaration.Declaration{}
if raw, err := store.LoadDeclared(store.DeclaredPath(opts.state), signer); err == nil {
if running := runningOf(ag); len(running) > 0 {
if d, err := declarationOf(running...); err == nil {
declared = d
}
} else if raw, err := store.LoadDeclared(store.DeclaredPath(opts.state), signer); err == nil {
if d, err := declaration.Parse(raw); err == nil {
declared = d
}
@@ -1918,6 +1920,13 @@ func sayFacts(ctx context.Context, opts options, signer ed25519.PublicKey, sessi
session.SayFacts(ctx, report)
}
func runningOf(ag *agent.Agent) []delivery.Composed {
if ag == nil {
return nil
}
return ag.Running()
}
// waitForEnrolment returns this node's identity, waiting for one if it has none.
//
// It applies the carried bundle first, if there is one, because that is what a first node does
+353
View File
@@ -0,0 +1,353 @@
package main
import (
"context"
"crypto/ed25519"
"encoding/json"
"fmt"
"sort"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/agent"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// The node agent's wiring (novox/hq ADR 0429 §4, ADR 0539 §9): the buckets it watches, the haves it writes, the backup
// it asks and the apply of one module's composition.
// bucketWatch is a bucket of the bus as the agent watches it, through the session's link.
type bucketWatch struct {
session *link.Session
bucket string
}
func (w bucketWatch) Watch(ctx context.Context, pattern string) (<-chan agent.Update, error) {
in, err := w.session.KVWatch(ctx, w.bucket, pattern)
if err != nil {
return nil, err
}
out := make(chan agent.Update, 64)
go func() {
defer close(out)
for u := range in {
select {
case out <- agent.Update{Key: u.Key, Value: u.Value, Revision: u.Revision, Deleted: u.Deleted, Caught: u.Caught}:
case <-ctx.Done():
return
}
}
}()
return out, nil
}
// sessionHaves writes the haves the agent says, through the session.
type sessionHaves struct{ session *link.Session }
func (h sessionHaves) Read(ctx context.Context, module string) (agent.Have, bool) {
got, ok := h.session.ReadHave(ctx, module)
if !ok {
return agent.Have{}, false
}
return agent.Have{Module: module, FP: got.FP, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail}, true
}
func (h sessionHaves) Write(ctx context.Context, x agent.Have) {
if x.Module == agent.NodeModule {
// The node's own key holds its facts and the digest of the composition it runs.
if x.Phase == "" || x.Phase == string(delivery.PhaseRunning) {
h.session.NoteNode(ctx, x.Composed)
}
return
}
phase := x.Phase
if phase == string(delivery.PhaseRunning) {
phase = ""
}
h.session.NoteModule(ctx, link.ModuleFact{Module: x.Module, FP: x.FP, Composed: x.Composed, Phase: phase,
Failed: phase == string(delivery.PhaseFailed), Detail: shorten(x.Detail)})
}
func (h sessionHaves) Removed(ctx context.Context, module string) {
h.session.NoteRemoved(ctx, module, "")
}
// shorten keeps a have's detail within what a have value holds.
func shorten(s string) string {
const most = 300
if len(s) <= most {
return s
}
return s[:most-1] + "…"
}
// backupWithin is how long a backup is given from its ask to say it is done (ADR 0429 §14), and backupPollEvery how
// soon the holder is asked again how it went while it works.
var (
backupWithin = 10 * time.Minute
backupPollEvery = 5 * time.Second
)
// backupNode asks the holder of node-backup on this node to back a module up now, and waits for it to say how it went:
// the holder answers when it has started, and its `backed-up` says the module's last night.
func backupNode(session *link.Session) func(ctx context.Context, module string) agent.Backup {
return func(ctx context.Context, module string) agent.Backup {
asked := time.Now()
if _, err := session.AskSeat(ctx, "node-backup", "now", map[string]any{"module": module}, 30*time.Second); err != nil {
return agent.Backup{Words: err.Error()}
}
deadline := asked.Add(backupWithin)
for {
select {
case <-ctx.Done():
return agent.Backup{Words: "the node agent was stopped while the backup ran"}
case <-time.After(backupPollEvery):
}
raw, err := session.AskSeat(ctx, "node-backup", "backed-up", map[string]any{"module": module}, 10*time.Second)
if err == nil {
var read []struct {
Module string `json:"module"`
LastNight *struct {
OK bool `json:"ok"`
At time.Time `json:"at"`
Error string `json:"error"`
} `json:"lastNight"`
}
if json.Unmarshal(raw, &read) == nil {
for _, m := range read {
if m.Module == module && m.LastNight != nil && m.LastNight.At.After(asked) {
if m.LastNight.OK {
return agent.Backup{Done: true}
}
return agent.Backup{Words: "the backup failed: " + m.LastNight.Error}
}
}
}
}
if time.Now().After(deadline) {
return agent.Backup{Words: fmt.Sprintf("the backup did not say it was done within %s", backupWithin)}
}
}
}
}
// declarationOf is the declaration the compositions make together: their resources, and the node's own parts when the
// node's composition is among them. It is what the node judges and says its health by, and what one module's apply is
// made of (with a single composition).
func declarationOf(cs ...delivery.Composed) (*declaration.Declaration, error) {
d := &declaration.Declaration{Version: declaration.Version}
for _, c := range cs {
for _, raw := range c.Resources {
r, err := declaration.ParseResource(raw)
if err != nil {
return nil, fmt.Errorf("a resource of %q: %w", c.Module, err)
}
d.Resources = append(d.Resources, r)
}
d.Filled = append(d.Filled, c.Filled...)
if c.Module == "" {
d.Adoption = c.Own.Adoption
d.Identities = append(d.Identities, c.Own.Identities...)
d.Groups = append(d.Groups, c.Own.Groups...)
d.Libraries = append(d.Libraries, c.Own.Libraries...)
d.Provisioner = c.Own.Provisioner
}
}
return d, nil
}
func scopeName(c delivery.Composed) string {
if c.Module == "" {
return agent.NodeModule
}
return c.Module
}
func resourcesOf(cs []delivery.Composed) []declaration.Resource {
var out []declaration.Resource
for _, c := range cs {
for _, raw := range c.Resources {
if r, err := declaration.ParseResource(raw); err == nil {
out = append(out, r)
}
}
}
return out
}
// keptByModule is the resources of the declaration the node kept before the switch, by the module they say; the node's
// own are those that say none (ADR 0539 §9).
func keptByModule(opts options, signer ed25519.PublicKey) func(module string) ([]json.RawMessage, bool) {
var once sync.Once
var by map[string][]json.RawMessage
return func(module string) ([]json.RawMessage, bool) {
once.Do(func() {
raw, err := store.LoadDeclared(store.DeclaredPath(opts.state), signer)
if err != nil {
return
}
var kept struct {
Resources []json.RawMessage `json:"resources"`
}
if json.Unmarshal(raw, &kept) != nil {
return
}
by = map[string][]json.RawMessage{}
for _, r := range kept.Resources {
var head struct {
Module string `json:"module"`
}
_ = json.Unmarshal(r, &head)
name := head.Module
if name == "" {
name = agent.NodeModule
}
by[name] = append(by[name], r)
}
})
r, ok := by[module]
return r, ok
}
}
// nodeAgent runs the node agent under the session's links for as long as ctx lasts: it waits for a link, follows the
// node's wants and compositions on it, and begins again with the next link. A watch it cannot make (a bucket this node's
// account may not read) is said, and tried again with a growing wait, as the link is.
func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, session *link.Session, sched *apply.Scheduler,
say link.Announce, afterApply func(), current *agentNow, sayFacts func(ctx context.Context, ag *agent.Agent)) {
wait := 2 * time.Second
for ctx.Err() == nil {
linked, cancel, err := session.WhenLinked(ctx)
if err != nil {
return
}
began := time.Now()
node := session.Membership.Node
composedBucket, err := delivery.ComposedBucketFor(node)
if err != nil {
cancel()
say("the node agent cannot name this node's composition bucket: " + err.Error())
return
}
var ag *agent.Agent
env := agent.Env{
Node: node,
Wants: bucketWatch{session, delivery.AssignerWantsBucket},
Composed: bucketWatch{session, composedBucket},
Haves: sessionHaves{session},
Kept: keptByModule(opts, signer),
Backup: backupNode(session),
Say: say,
Apply: func(ctx context.Context, c delivery.Composed, others []delivery.Composed) agent.Result {
res := applyComposition(session.WithLink(ctx), opts, c, others, sched, say)
afterApply()
sayFacts(ctx, ag)
return res
},
Remove: func(ctx context.Context, module string, others []delivery.Composed) agent.Result {
res := removeComposition(session.WithLink(ctx), opts, module, others, sched, say)
afterApply()
sayFacts(ctx, ag)
return res
},
}
ag = agent.New(env)
current.set(ag)
err = ag.Run(linked)
cancel()
current.set(nil)
if ctx.Err() != nil {
return
}
if time.Since(began) > 30*time.Second {
wait = 2 * time.Second
}
if err != nil {
say(fmt.Sprintf("the node agent stopped: %v — beginning again in %s", err, wait))
}
select {
case <-ctx.Done():
return
case <-time.After(wait):
}
if wait *= 2; wait > 2*time.Minute {
wait = 2 * time.Minute
}
}
}
// agentNow is the agent running now, which the node's facts are built from.
type agentNow struct {
mu sync.Mutex
ag *agent.Agent
}
func (c *agentNow) set(a *agent.Agent) {
c.mu.Lock()
c.ag = a
c.mu.Unlock()
}
func (c *agentNow) get() *agent.Agent {
c.mu.Lock()
defer c.mu.Unlock()
return c.ag
}
// applyComposition applies one composition: a module's, or the node's own.
func applyComposition(ctx context.Context, opts options, c delivery.Composed, others []delivery.Composed,
sched *apply.Scheduler, say link.Announce) agent.Result {
name := scopeName(c)
d, err := declarationOf(c)
if err != nil {
return agent.Result{Failed: true, Detail: shorten(err.Error())}
}
scope := apply.Scope{Module: name, Others: resourcesOf(others)}
got := applyModuleHeld(ctx, opts, d, &scope, sched, say)
if got.Refused != "" {
return agent.Result{Failed: true, Detail: shorten(got.Refused)}
}
results := apply.ModuleResults(d, got.Outcome, got.Err)
if r, ok := results[name]; ok && r.Failed {
return agent.Result{Failed: true, Detail: shorten(r.Detail)}
}
if got.Err != nil {
return agent.Result{Failed: true, Detail: shorten(got.Err.Error())}
}
if got.Probe != nil {
return agent.Result{Failed: true, Detail: shorten(got.Probe.partial().Error())}
}
return agent.Result{}
}
// removeComposition removes one module's own resources and nothing of another's.
func removeComposition(ctx context.Context, opts options, module string, others []delivery.Composed,
sched *apply.Scheduler, say link.Announce) agent.Result {
scope := apply.Scope{Module: module, Others: resourcesOf(others)}
got := applyModuleHeld(ctx, opts, &declaration.Declaration{Version: declaration.Version}, &scope, sched, say)
switch {
case got.Refused != "":
return agent.Result{Failed: true, Detail: shorten(got.Refused)}
case got.Err != nil:
return agent.Result{Failed: true, Detail: shorten(got.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()
defer busyMu.Unlock()
var out []string
for m, w := range busyBy {
if w && m != "" {
out = append(out, m)
}
}
sort.Strings(out)
return out
}
+46 -1
View File
@@ -107,6 +107,7 @@ type Agent struct {
// prior is a module the node ran before this agent started, so it has something to back up.
prior map[string]bool
said map[string]string
retry chan []string
}
type wantNow struct {
@@ -137,7 +138,8 @@ func New(env Env) *Agent {
env.Say = func(string) {}
}
return &Agent{env: env, wants: map[string]wantNow{}, comps: map[string]compNow{}, running: map[string]delivery.Have{},
attempt: map[string]attempt{}, checked: map[string]bool{}, prior: map[string]bool{}, said: map[string]string{}}
attempt: map[string]attempt{}, checked: map[string]bool{}, prior: map[string]bool{}, said: map[string]string{},
retry: make(chan []string, 8)}
}
// Run follows the wants until ctx ends. The keys there when it starts are taken first, the node's own composition
@@ -157,6 +159,16 @@ func (a *Agent) Run(ctx context.Context) error {
select {
case <-ctx.Done():
return nil
case modules := <-a.retry:
// Applied again though the keys did not change: a container that waited because it said busy (ADR 0360
// §11) is replaced once it is idle.
a.mu.Lock()
for _, m := range modules {
delete(a.running, m)
delete(a.attempt, m)
dirty[m] = true
}
a.mu.Unlock()
case u, ok := <-wants:
if !ok {
return fmt.Errorf("the watch of the wants ended")
@@ -186,6 +198,39 @@ func (a *Agent) Run(ctx context.Context) error {
}
}
// Retry asks for the modules to be applied again from the keys they have, though nothing changed: what ADR 0360 §11
// needs for a container that waits because it said busy. It never blocks; a retry asked while eight wait is dropped, and
// asked again by its caller's next look.
func (a *Agent) Retry(modules ...string) {
select {
case a.retry <- modules:
default:
}
}
// Running is the compositions the node runs now: each module's that the agent applied or found already running, and
// the node's own. What the node judges its health by and says of itself is built from them.
func (a *Agent) Running() []delivery.Composed {
a.mu.Lock()
defer a.mu.Unlock()
mods := make([]string, 0, len(a.running))
for m := range a.running {
mods = append(mods, m)
}
sort.Strings(mods)
var out []delivery.Composed
for _, m := range mods {
h := a.running[m]
if h.Phase == delivery.PhaseRemoved {
continue
}
if c, ok := a.compositionFor(m, h.FP); ok && c.Err == nil && c.Dig == h.Composed {
out = append(out, c.C)
}
}
return out
}
// takeWant takes a change of a want key and returns the module it concerns, or "" for a key that is not a want.
func (a *Agent) takeWant(u Update) string {
a.mu.Lock()
+12 -1
View File
@@ -127,6 +127,17 @@ func (s *Scheduler) RecordWindowsIn(w *Windows) {
// work on the predecessor's data, under the predecessor's service, which is the one thing an
// adopted node must not do. Nil on a converged node, where nothing is held.
func (s *Scheduler) Sync(d *declaration.Declaration, held map[string]bool) {
s.sync(d, held, nil)
}
// SyncModule is Sync for one module's declaration (novox/hq ADR 0539 §9): the schedules of that module are armed,
// re-armed or forgotten, and the schedules of every other module are left as they are. owns says whether a scheduled
// step's id is the module's.
func (s *Scheduler) SyncModule(d *declaration.Declaration, held map[string]bool, owns func(id string) bool) {
s.sync(d, held, owns)
}
func (s *Scheduler) sync(d *declaration.Declaration, held map[string]bool, owns func(id string) bool) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -177,7 +188,7 @@ func (s *Scheduler) Sync(d *declaration.Declaration, held map[string]bool) {
}
for id := range s.jobs {
if !seen[id] {
if !seen[id] && (owns == nil || owns(id)) {
delete(s.jobs, id)
}
}
+32
View File
@@ -420,3 +420,35 @@ func (q *Session) NoteNode(ctx context.Context, composed string) {
q.mu.Unlock()
q.writeHaves(ctx, bus)
}
// ReadHave is the have this node-engine last wrote for a module, and whether the bucket holds one. A bucket that cannot
// be read answers as none: the agent then compares with the declaration it kept, and applies only what differs.
func (q *Session) ReadHave(ctx context.Context, module string) (delivery.Have, bool) {
q.init()
q.mu.Lock()
bus := q.bus
q.mu.Unlock()
hb, ok := bus.(HaveBus)
if !ok || bus == nil {
return delivery.Have{}, false
}
key, err := delivery.HaveKey(q.Membership.Node, module)
if err != nil {
return delivery.Have{}, false
}
read, cancel := context.WithTimeout(ctx, q.timeout())
defer cancel()
bucket, err := hb.HaveBind(read)
if err != nil {
return delivery.Have{}, false
}
value, _, err := bucket.Get(read, key)
if err != nil {
return delivery.Have{}, false
}
h, err := delivery.DecodeHave(value)
if err != nil {
return delivery.Have{}, false
}
return h, true
}
+53
View File
@@ -54,6 +54,9 @@ type Session struct {
mu sync.Mutex
bus Bus // the link open now; nil while there is none
// gone is closed when the link open now closes, and changed whenever a link opens or closes (WhenLinked).
gone chan struct{}
changed chan struct{}
// saying is held while a facts event is made and said, so they leave in the order they are made.
saying sync.Mutex
@@ -72,6 +75,7 @@ func (q *Session) init() {
q.Say = func(string) {}
}
q.epoch = time.Now().UnixMilli()
q.changed = make(chan struct{})
})
}
@@ -117,6 +121,9 @@ func (q *Session) attach(ctx context.Context, bus Bus) {
q.init()
q.mu.Lock()
q.bus = bus
q.gone = make(chan struct{})
close(q.changed)
q.changed = make(chan struct{})
q.mu.Unlock()
q.FactsAsked(ctx)
}
@@ -141,6 +148,9 @@ func (q *Session) detach(bus Bus) {
q.mu.Lock()
if q.bus == bus {
q.bus = nil
close(q.gone)
close(q.changed)
q.changed = make(chan struct{})
}
q.mu.Unlock()
}
@@ -255,3 +265,46 @@ func ReadToolAnswer(reply []byte) (health.Answer, error) {
}
return a, nil
}
// SeatToolSubject is where a node seat's verb is asked of one machine's holder (design 33 §4): the seat's verb subject
// with the machine as its last token.
func SeatToolSubject(seat, verb, node string) string {
return "mesh.seat." + seat + ".tool." + verb + "." + node
}
// AskSeat asks the holder of a seat on this node a verb, over the link open now, and answers its result, or its refusal
// as an error. The one call the node agent makes of another part: the backup before a data module moves (ADR 0429 §14).
func (q *Session) AskSeat(ctx context.Context, seat, verb string, args any, within time.Duration) (json.RawMessage, error) {
q.init()
q.mu.Lock()
bus := q.bus
q.mu.Unlock()
if bus == nil {
return nil, fmt.Errorf("%w: this machine has no link to the bus open now", ErrNotAsked)
}
asker, ok := bus.(ToolBus)
if !ok {
return nil, fmt.Errorf("%w: the link to the bus open now cannot ask a seat", ErrNotAsked)
}
body, err := json.Marshal(args)
if err != nil {
return nil, err
}
asking, cancel := context.WithTimeout(ctx, within)
defer cancel()
reply, err := asker.Ask(asking, SeatToolSubject(seat, verb, q.Membership.Node), body)
if err != nil {
return nil, fmt.Errorf("%s.%s could not be asked: %w", seat, verb, err)
}
var env struct {
Result json.RawMessage `json:"result"`
Error string `json:"error"`
}
if err := json.Unmarshal(reply, &env); err != nil {
return nil, fmt.Errorf("%s.%s answered with something that is not a tool's answer", seat, verb)
}
if env.Error != "" {
return nil, errors.New(env.Error)
}
return env.Result, nil
}
+129
View File
@@ -0,0 +1,129 @@
package link
import (
"context"
"errors"
"fmt"
"github.com/nats-io/nats.go"
)
// KVUpdate is one change of a key in a bucket the node-engine watches (novox/hq ADR 0539 §9). Caught marks the end of
// the keys that were there when the watch began.
type KVUpdate struct {
Key string
Value []byte
Revision uint64
Deleted bool
Caught bool
}
// KVBus is a link that can watch a bucket's keys: the node agent follows its wants and its compositions with it, and
// never polls.
type KVBus interface {
// KVWatch yields the keys of the bucket matching the pattern now, a Caught update, then every change, until ctx ends
// or the link does (the channel is then closed). A bucket the node's account may not read is an error.
KVWatch(ctx context.Context, bucket, pattern string) (<-chan KVUpdate, error)
}
// KVWatch is the watch on the link open now.
func (b OverNATS) KVWatch(ctx context.Context, bucket, pattern string) (<-chan KVUpdate, error) {
js := b.JS
if b.Conn != nil {
bound, err := b.Conn.JetStream()
if err != nil {
return nil, err
}
js = bound
}
kv, err := js.KeyValue(bucket)
if err != nil {
return nil, fmt.Errorf("the bucket %s: %w", bucket, err)
}
w, err := kv.Watch(pattern, nats.Context(ctx))
if err != nil {
return nil, fmt.Errorf("watching %s in %s: %w", pattern, bucket, err)
}
out := make(chan KVUpdate, 64)
go func() {
defer close(out)
defer func() { _ = w.Stop() }()
for {
select {
case <-ctx.Done():
return
case e, ok := <-w.Updates():
if !ok {
return
}
var u KVUpdate
if e == nil {
u.Caught = true
} else {
u = KVUpdate{Key: e.Key(), Value: e.Value(), Revision: e.Revision(),
Deleted: e.Operation() == nats.KeyValueDelete || e.Operation() == nats.KeyValuePurge}
}
select {
case out <- u:
case <-ctx.Done():
return
}
}
}
}()
return out, nil
}
func (l *natsLink) KVWatch(ctx context.Context, bucket, pattern string) (<-chan KVUpdate, error) {
return OverNATS{Conn: l.conn, JS: l.js}.KVWatch(ctx, bucket, pattern)
}
var (
_ KVBus = OverNATS{}
_ KVBus = (*natsLink)(nil)
)
// ErrNoLink is what a watch asks for with no link open.
var ErrNoLink = errors.New("no link to the bus is open")
// KVWatch watches a bucket on the link open now. It ends when ctx does or the link closes.
func (q *Session) KVWatch(ctx context.Context, bucket, pattern string) (<-chan KVUpdate, error) {
q.init()
q.mu.Lock()
bus := q.bus
q.mu.Unlock()
w, ok := bus.(KVBus)
if bus == nil || !ok {
return nil, ErrNoLink
}
return w.KVWatch(ctx, bucket, pattern)
}
// WhenLinked waits for a link to be open and returns a context that ends when that link does (or ctx does): what the
// node agent runs under, so its watches end with the link and begin again with the next.
func (q *Session) WhenLinked(ctx context.Context) (context.Context, context.CancelFunc, error) {
q.init()
for {
q.mu.Lock()
if q.bus != nil {
gone := q.gone
q.mu.Unlock()
linked, cancel := context.WithCancel(ctx)
go func() {
select {
case <-gone:
cancel()
case <-linked.Done():
}
}()
return linked, cancel, nil
}
changed := q.changed
q.mu.Unlock()
select {
case <-ctx.Done():
return nil, nil, ctx.Err()
case <-changed:
}
}
}