ADR 0593 §6, §7, §9: the integration test runs the node agent against the real bus under the grant the grants part composes for a node: it publishes web's composition in composed-<node> with the digest its have names, and answers compose-candidate for the indexer's candidate, changing nothing. A node that cannot compose now answers unready, so the checker counts it as no answer by the verdict and never by words. The golden grant reads the indexer's candidates and verdicts as it reads the builder's builds. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
297 lines
10 KiB
Go
297 lines
10 KiB
Go
package selfcompose
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"sort"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
|
|
"github.com/novox/mesh-host/internal/agent"
|
|
"github.com/novox/mesh-host/internal/compose/frombus"
|
|
)
|
|
|
|
// **A check's candidate, composed in shadow on a call** (novox/hq ADR 0593 §7): the node seat's verb compose-candidate.
|
|
// Given the key of the indexer's candidate (delivery.CandidatesBucket, `<group>.<heads hash>`), the node composes itself
|
|
// twice from what its shadow holds (placement, records, the indexer's modules, its config values), with its own sealed
|
|
// values: once as it is, and once with the candidate's modules laid over the indexer's (each at the candidate's
|
|
// fingerprint, its manifest by digest and the indexer's verdict on it, ADR 0578 §3; a module the candidate removes left
|
|
// out). It answers per module placed here composed (its digest, and whether the candidate changes it), refused by the
|
|
// candidate, or held without it too, and the verdict of its own composition. It applies nothing and writes nothing: it
|
|
// composes on a copy of the shadow's inputs, so neither the shadow's answers nor the node's own composer move.
|
|
|
|
// errNotReady is a shadow that has not read every input yet.
|
|
var errNotReady = errors.New("this node's shadow has not read every input yet")
|
|
|
|
// ComposeCandidate is the node's answer to ask, within delivery.ComposeAnswerBound (or ctx's deadline, if sooner).
|
|
func (s *Shadow) ComposeCandidate(ctx context.Context, ask delivery.ComposeCandidateAsk) delivery.ComposeCandidateAnswer {
|
|
began := time.Now()
|
|
s.mu.Lock()
|
|
c := s.c
|
|
s.mu.Unlock()
|
|
node := c.env.Node
|
|
a := delivery.ComposeCandidateAnswer{Node: node, Candidate: ask.Candidate}
|
|
refuse := func(why string) delivery.ComposeCandidateAnswer {
|
|
a.Verdict, a.Refused, a.Modules, a.Own = delivery.ComposeCandidateRefused, why, nil, delivery.ModuleComposed{}
|
|
a.Took = time.Since(began).Milliseconds()
|
|
return a
|
|
}
|
|
// A node that cannot compose now (its shadow not ready, a read the bus did not answer, the bound passed) answers
|
|
// unready: the checker takes it as no answer (ADR 0593 §9) by the verdict, the words only saying why.
|
|
unready := func(why string) delivery.ComposeCandidateAnswer {
|
|
a = refuse(why)
|
|
a.Verdict = delivery.ComposeCandidateUnready
|
|
return a
|
|
}
|
|
if _, _, err := delivery.ParseCompositionKey(ask.Candidate); err != nil {
|
|
return refuse(err.Error())
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, delivery.ComposeAnswerBound)
|
|
defer cancel()
|
|
if !s.ready.Load() {
|
|
return unready(errNotReady.Error())
|
|
}
|
|
base := c.scratch()
|
|
raw, err := base.get(ctx, delivery.CandidatesBucket, ask.Candidate)
|
|
if err != nil {
|
|
return unready(fmt.Sprintf("the indexer's candidate %s cannot be read: %v", ask.Candidate, err))
|
|
}
|
|
if raw == nil {
|
|
return refuse("the indexer holds no candidate " + ask.Candidate)
|
|
}
|
|
cand, err := delivery.DecodeCandidate(raw)
|
|
if err != nil {
|
|
return refuse(fmt.Sprintf("the indexer's candidate %s cannot be read: %v", ask.Candidate, err))
|
|
}
|
|
laid := c.scratch()
|
|
touched, err := laid.layCandidate(ctx, cand)
|
|
if err != nil {
|
|
return unready(err.Error())
|
|
}
|
|
|
|
var running []delivery.Composed
|
|
if c.env.Running != nil {
|
|
running = c.env.Running()
|
|
}
|
|
baseComps, baseHeld, baseErr := base.composeShadow(ctx, running, nil, nil)
|
|
laidComps, laidHeld, laidErr := laid.composeShadow(ctx, running, touched, func(scope frombus.Scope) {
|
|
if !ask.Input {
|
|
return
|
|
}
|
|
in, err := frombus.Input(ctx, reader{c: laid}, laid.env.Mesh, scope)
|
|
if err != nil {
|
|
return
|
|
}
|
|
// The request's input carries no catalogue (composer.Request.Input): the cases give the manifests.
|
|
in.Catalogue = nil
|
|
for i := range in.Nodes {
|
|
in.Nodes[i].Kept = nil
|
|
}
|
|
if body, err := json.Marshal(in); err == nil {
|
|
a.Input = body
|
|
}
|
|
})
|
|
if ctx.Err() != nil {
|
|
return unready(fmt.Sprintf("the bound of %s passed before the node composed the candidate", delivery.ComposeAnswerBound))
|
|
}
|
|
switch {
|
|
case laidErr != nil && BusFailed(laidErr):
|
|
return unready("a read on the bus did not answer: " + laidErr.Error())
|
|
case laidErr != nil && baseErr != nil:
|
|
// The node composes none of its own without the candidate either: the candidate changes nothing of that.
|
|
a.Verdict, a.Modules = delivery.ComposeCandidateComposed, []delivery.ModuleComposed{}
|
|
a.Own = delivery.ModuleComposed{Module: delivery.HaveNodeWord, State: delivery.ModuleHeldState, Words: laidErr.Error()}
|
|
a.Took = time.Since(began).Milliseconds()
|
|
return a
|
|
case laidErr != nil:
|
|
return refuse(laidErr.Error())
|
|
}
|
|
byModule := func(comps map[string]delivery.Composed) map[string]delivery.Composed {
|
|
out := map[string]delivery.Composed{}
|
|
for _, comp := range comps {
|
|
out[comp.Module] = comp
|
|
}
|
|
return out
|
|
}
|
|
was, now := byModule(baseComps), byModule(laidComps)
|
|
one := func(module string) (delivery.ModuleComposed, bool) {
|
|
m := delivery.ModuleComposed{Module: module}
|
|
if module == "" {
|
|
m.Module = delivery.HaveNodeWord
|
|
}
|
|
if why, held := laidHeld[module]; held {
|
|
m.State, m.Words = delivery.ModuleRefusedState, why
|
|
if _, before := baseHeld[module]; before || baseErr != nil {
|
|
m.State = delivery.ModuleHeldState
|
|
}
|
|
return m, true
|
|
}
|
|
comp, ok := now[module]
|
|
if !ok {
|
|
return m, false
|
|
}
|
|
dig, err := delivery.ComposedDigestOf(comp)
|
|
if err != nil {
|
|
m.State, m.Words = delivery.ModuleRefusedState, err.Error()
|
|
return m, true
|
|
}
|
|
m.State, m.FP, m.Digest = delivery.ModuleComposedState, comp.FP, dig
|
|
if before, ok := was[module]; !ok {
|
|
m.Changed = true
|
|
} else if d, err := delivery.ComposedDigestOf(before); err != nil || d != dig {
|
|
m.Changed = true
|
|
}
|
|
return m, true
|
|
}
|
|
modules := map[string]bool{}
|
|
for m := range now {
|
|
modules[m] = m != ""
|
|
}
|
|
for m := range laidHeld {
|
|
modules[m] = m != "" && m != agent.NodeModule
|
|
}
|
|
names := make([]string, 0, len(modules))
|
|
for m, isModule := range modules {
|
|
if isModule {
|
|
names = append(names, m)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
a.Modules = []delivery.ModuleComposed{}
|
|
refused := false
|
|
for _, m := range names {
|
|
if mc, ok := one(m); ok {
|
|
a.Modules = append(a.Modules, mc)
|
|
refused = refused || mc.State == delivery.ModuleRefusedState
|
|
}
|
|
}
|
|
if own, ok := one(""); ok {
|
|
a.Own = own
|
|
} else {
|
|
a.Own = delivery.ModuleComposed{Module: delivery.HaveNodeWord, State: delivery.ModuleHeldState,
|
|
Words: "the node composes no composition of its own"}
|
|
}
|
|
refused = refused || a.Own.State == delivery.ModuleRefusedState
|
|
a.Verdict = delivery.ComposeCandidateComposed
|
|
if refused {
|
|
a.Verdict = delivery.ComposeCandidateRefused
|
|
}
|
|
a.Took = time.Since(began).Milliseconds()
|
|
return a
|
|
}
|
|
|
|
// scratch is a composer over a copy of what c holds of its inputs, for composing in shadow without moving c.
|
|
func (c *Composer) scratch() *Composer {
|
|
out := New(c.env)
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
out.placements = maps.Clone(c.placements)
|
|
out.records = maps.Clone(c.records)
|
|
out.inOwn = maps.Clone(c.inOwn)
|
|
out.modules = maps.Clone(c.modules)
|
|
out.values = maps.Clone(c.values)
|
|
out.manifests = maps.Clone(c.manifests)
|
|
out.archives = maps.Clone(c.archives)
|
|
out.builds = maps.Clone(c.builds)
|
|
out.sealedOwn = maps.Clone(c.sealedOwn)
|
|
out.ownHanded = maps.Clone(c.ownHanded)
|
|
out.certified = c.certified
|
|
out.placementKeys = c.placementKeys
|
|
return out
|
|
}
|
|
|
|
// layCandidate lays the candidate's modules over the indexer's: each at its fingerprint, with its manifest's digest and
|
|
// the indexer's verdict on it (read from its verdicts, none read taken as accepted); a module the candidate removes goes.
|
|
// It answers the modules laid.
|
|
func (c *Composer) layCandidate(ctx context.Context, cand delivery.Candidate) (map[string]bool, error) {
|
|
touched := map[string]bool{}
|
|
for _, pull := range cand.Pulls {
|
|
for module, cm := range pull.Fps {
|
|
if cm.None2 == delivery.CandidateNoneNoModule {
|
|
c.mu.Lock()
|
|
delete(c.modules, module)
|
|
c.mu.Unlock()
|
|
continue
|
|
}
|
|
if cm.FP2 == "" {
|
|
continue // no fingerprint at the preview: composed as the trunk holds it
|
|
}
|
|
m := delivery.Module{FP2: cm.FP2, Manifest: cm.Manifest, Repository: pull.Repository, Commit: pull.Head.Head}
|
|
if cm.Manifest != "" {
|
|
key, err := delivery.VerdictsKey(cm.Manifest)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw, err := c.get(ctx, delivery.VerdictsBucket, key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the indexer's verdict on %s's manifest cannot be read: %w", module, err)
|
|
}
|
|
if raw != nil {
|
|
v, err := delivery.DecodeManifestVerdict(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the indexer's verdict on %s's manifest cannot be read: %w", module, err)
|
|
}
|
|
m.Verdict, m.Problems, m.Warnings = v.State, v.Problems, v.Warnings
|
|
}
|
|
}
|
|
c.mu.Lock()
|
|
c.modules[module] = m
|
|
c.mu.Unlock()
|
|
touched[module] = true
|
|
}
|
|
}
|
|
return touched, nil
|
|
}
|
|
|
|
// composeShadow composes this node from what c holds, as the shadow composes it (shadowBuilds): each module at the build
|
|
// it runs, one placed and not run at the indexer's, and one the candidate laid (laid) at the candidate's. input, when
|
|
// given, is handed the scope.
|
|
func (c *Composer) composeShadow(ctx context.Context, running []delivery.Composed, laid map[string]bool, input func(frombus.Scope)) (map[string]delivery.Composed, map[string]string, error) {
|
|
node := c.env.Node
|
|
c.mu.Lock()
|
|
placement, placed := c.placements[node]
|
|
modules := maps.Clone(c.modules)
|
|
inOwn := c.inOwnOf(placement)
|
|
c.mu.Unlock()
|
|
if !placed {
|
|
return nil, nil, errors.New("this node's key in the seat's placement state is gone: it composes nothing of its own")
|
|
}
|
|
fps := shadowBuilds(running, placement, modules, inOwn)
|
|
// A module the laid candidate changes is composed at the candidate's build, though the node runs another.
|
|
for module := range fps {
|
|
if mod, ok := modules[module]; ok && laid[module] {
|
|
fps[module] = mod.FP2
|
|
}
|
|
}
|
|
kept := map[string]string{}
|
|
for module, fp := range fps {
|
|
if mod, ok := modules[module]; ok && mod.FP2 == fp {
|
|
continue
|
|
}
|
|
kept[module] = fp
|
|
}
|
|
wants := map[string]delivery.Want{agent.NodeModule: {}}
|
|
for module, fp := range fps {
|
|
wants[module] = delivery.Want{FP: fp}
|
|
}
|
|
c.mu.Lock()
|
|
c.wants = wants
|
|
c.mu.Unlock()
|
|
scope := frombus.Scope{Node: node, KeptAt: kept, InOwn: inOwn, Running: running, SameValue: c.sameValue}
|
|
if input != nil {
|
|
input(scope)
|
|
}
|
|
comps, _, held, err := frombus.Compose(ctx, reader{c: c}, c.env.Mesh, scope, fps)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
for key, comp := range comps {
|
|
comps[key] = c.keepRunning(comp, running)
|
|
}
|
|
return comps, held, nil
|
|
}
|