Merge pull request 'The node-engine takes the indexer's verdict and never ends a read on a refused manifest (ADR 0578 §3)' (#192) from feat/judge-once into main
This commit was merged in pull request #192.
This commit is contained in:
@@ -3,7 +3,7 @@ module github.com/novox/mesh-host
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031230538-c28c645ca406
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031082917-2d1d8def7535 h1:Bgrm3YfWCxr+FEm03kEP5fDIe9hNbe+j0CDVatGAGKg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031082917-2d1d8def7535/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031083427-de8e82c06f54 h1:xITIabv8ujmwpaEKBiQpkOx6U/ICLTTzX9WWC3DGOPQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031083427-de8e82c06f54/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031230538-c28c645ca406 h1:Z4RcH5Ft4OAAG3F538NXIHP9F3pkyk3j0LtP9mqu1qQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031230538-c28c645ca406/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -39,8 +39,9 @@ func (itemsReader) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow,
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// dataItemsOf works out the data items of the scope's node (frombus.DataItems); a test sets what it answers.
|
||||
var dataItemsOf = frombus.DataItems
|
||||
// dataItemsOf works out the data items of the scope's node and, by module, why each module held for a manifest the
|
||||
// indexer refused is left out (frombus.DataItemsHeld, ADR 0578 §3); a test sets what it answers.
|
||||
var dataItemsOf = frombus.DataItemsHeld
|
||||
|
||||
// publishData keeps the data items of this node's modules in the engine's state data, when a pass asked it (dataDue).
|
||||
func (c *Composer) publishData(ctx context.Context) {
|
||||
@@ -57,19 +58,29 @@ func (c *Composer) publishData(ctx context.Context) {
|
||||
if scope.Node == "" {
|
||||
scope.Node = c.env.Node
|
||||
}
|
||||
items, unplaced, err := dataItemsOf(ctx, itemsReader{reader{c: c}}, c.env.Mesh, scope)
|
||||
items, unplaced, held, err := dataItemsOf(ctx, itemsReader{reader{c: c}}, c.env.Mesh, scope)
|
||||
if err != nil {
|
||||
c.sayData("the data items of this node's modules cannot be worked out, and what the engine's state " +
|
||||
delivery.EngineDataBucket + " holds stands; " + c.oweData() + ": " + err.Error())
|
||||
return
|
||||
}
|
||||
// A module held for a refused manifest declares nothing this pass can read: it is named, its other modules' items
|
||||
// are kept as ever, and its own key stands as it was until the manifest is accepted (ADR 0578 §3).
|
||||
if len(held) > 0 {
|
||||
var lines []string
|
||||
for _, m := range heldModules(held) {
|
||||
lines = append(lines, m+": "+held[m])
|
||||
}
|
||||
c.env.Say("data items of " + strings.Join(heldModules(held), ", ") + " not worked out, as the manifest is refused " +
|
||||
"and the module held (its key stands as it was): " + strings.Join(lines, "; "))
|
||||
}
|
||||
if len(unplaced) > 0 {
|
||||
c.env.Say("data items left out as not placeable: " + strings.Join(unplaced, "; "))
|
||||
}
|
||||
c.mu.Lock()
|
||||
digests := c.digestsByModule()
|
||||
c.mu.Unlock()
|
||||
wrote, removed, err := c.writeData(ctx, items, digests)
|
||||
wrote, removed, err := c.writeData(ctx, items, digests, held)
|
||||
if err != nil {
|
||||
c.sayData("the data items of this node's modules are not all kept in " + delivery.EngineDataBucket +
|
||||
"; " + c.oweData() + ": " + err.Error())
|
||||
@@ -130,7 +141,7 @@ func (c *Composer) digestsByModule() map[string]string {
|
||||
// writeData writes each module's key whose value differs from what the key holds, and deletes this node's keys of
|
||||
// modules with no items here now. The keys are read once per run (dataKept), so a restart that finds the same items
|
||||
// writes nothing.
|
||||
func (c *Composer) writeData(ctx context.Context, items map[string][]catalogue.PlacedDataItem, digests map[string]string) (wrote, removed int, err error) {
|
||||
func (c *Composer) writeData(ctx context.Context, items map[string][]catalogue.PlacedDataItem, digests map[string]string, held map[string]string) (wrote, removed int, err error) {
|
||||
store := c.env.Data
|
||||
if c.dataKept == nil {
|
||||
pattern, err := delivery.EngineDataNodeKeys(c.env.Node)
|
||||
@@ -198,7 +209,7 @@ func (c *Composer) writeData(ctx context.Context, items map[string][]catalogue.P
|
||||
}
|
||||
var gone []string
|
||||
for key := range c.dataKept {
|
||||
if _, still := want[key]; !still {
|
||||
if _, still := want[key]; !still && !heldKey(c.env.Node, key, held) {
|
||||
gone = append(gone, key)
|
||||
}
|
||||
}
|
||||
@@ -216,3 +227,13 @@ func (c *Composer) writeData(ctx context.Context, items map[string][]catalogue.P
|
||||
}
|
||||
return wrote, removed, nil
|
||||
}
|
||||
|
||||
// heldKey is whether key is the key of a module held for a refused manifest, which stands as it was.
|
||||
func heldKey(node, key string, held map[string]string) bool {
|
||||
for module := range held {
|
||||
if k, err := delivery.EngineDataKey(node, module); err == nil && k == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -77,15 +77,18 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) {
|
||||
var items map[string][]catalogue.PlacedDataItem
|
||||
var asked int
|
||||
var itemsErr error
|
||||
setFor(t, &dataItemsOf, func(context.Context, frombus.Reader, frombus.Mesh, frombus.Scope) (map[string][]catalogue.PlacedDataItem, []string, error) {
|
||||
var heldNow map[string]string
|
||||
setFor(t, &dataItemsOf, func(context.Context, frombus.Reader, frombus.Mesh, frombus.Scope) (map[string][]catalogue.PlacedDataItem, []string, map[string]string, error) {
|
||||
asked++
|
||||
return items, nil, itemsErr
|
||||
return items, nil, heldNow, itemsErr
|
||||
})
|
||||
var said []string
|
||||
env := Env{Node: "n1", Data: store, Say: func(s string) { said = append(said, s) }}
|
||||
// The engine's grant lets it read its own node's keys, as the listing at a run's first pass does, and no other's.
|
||||
if !grantAllows(t, "n1", delivery.EngineDataBucket, "n1.*") || grantAllows(t, "n1", delivery.EngineDataBucket, "n2.*") {
|
||||
t.Error("the node's grant reads other keys of the engine's state data than its own")
|
||||
}
|
||||
c := New(Env{Node: "n1", Data: store})
|
||||
c := New(env)
|
||||
webKey, _ := delivery.ComposedKey("n1", "web", delivery.SourcePrefix2+strings.Repeat("a", 64))
|
||||
nodeKey, _ := delivery.ComposedNodeKey("n1")
|
||||
for _, step := range []struct {
|
||||
@@ -94,8 +97,9 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) {
|
||||
items map[string][]catalogue.PlacedDataItem
|
||||
digests map[string]string // composed key to digest handed
|
||||
fail error
|
||||
refused error // what working out the items says
|
||||
owed time.Duration // the bound the loop waits before trying again; 0 when nothing is owed
|
||||
refused error // what working out the items says
|
||||
held map[string]string // modules held for a refused manifest, with why (ADR 0578 §3)
|
||||
owed time.Duration // the bound the loop waits before trying again; 0 when nothing is owed
|
||||
puts []string
|
||||
deletes []string
|
||||
asked int
|
||||
@@ -126,12 +130,19 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) {
|
||||
{name: "a module that left the node is deleted", due: true,
|
||||
items: map[string][]catalogue.PlacedDataItem{"oldie": {item("oldie", "x", "/srv/oldie")}},
|
||||
digests: map[string]string{nodeKey: "sha256:n1"}, deletes: []string{"n1.web"}, asked: 6},
|
||||
// ADR 0578 §3: one refused manifest never ends the read; its module is named, its key stands, the rest is kept.
|
||||
{name: "a module held for a refused manifest is named, its key stands and every other module's items are written", due: true,
|
||||
items: map[string][]catalogue.PlacedDataItem{"web": {item("web", "db", "/srv/web/db")}},
|
||||
held: map[string]string{"oldie": "the manifest of oldie is refused: a bar item is no icon"},
|
||||
digests: map[string]string{webKey: "sha256:w3", nodeKey: "sha256:n1"}, puts: []string{"n1.web"}, asked: 7,
|
||||
want: map[string]delivery.EngineData{
|
||||
"n1.web": {Module: "web", Digest: "sha256:w3", Items: []catalogue.PlacedDataItem{item("web", "db", "/srv/web/db")}}}},
|
||||
} {
|
||||
t.Run(step.name, func(t *testing.T) {
|
||||
store.mu.Lock()
|
||||
store.puts, store.deletes, store.fail = nil, nil, step.fail
|
||||
store.mu.Unlock()
|
||||
items, itemsErr = step.items, step.refused
|
||||
items, itemsErr, heldNow, said = step.items, step.refused, step.held, nil
|
||||
c.paid() // the pass before composed: nothing owed
|
||||
c.mu.Lock()
|
||||
c.handed = step.digests
|
||||
@@ -153,6 +164,14 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) {
|
||||
t.Errorf("%s holds %+v (%v), want %+v", key, got, err, want)
|
||||
}
|
||||
}
|
||||
for module, why := range step.held {
|
||||
if !slices.ContainsFunc(said, func(l string) bool { return strings.Contains(l, module) && strings.Contains(l, why) }) {
|
||||
t.Errorf("the held %s is not named with why in %q", module, said)
|
||||
}
|
||||
if store.kv["n1."+module] == nil {
|
||||
t.Errorf("the key of the held %s was deleted", module)
|
||||
}
|
||||
}
|
||||
if store.kv["n2.web"] == nil {
|
||||
t.Error("another node's key was deleted")
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ type Composer struct {
|
||||
// at a build of the old form): the node runs it inside its own composition, and composes it there again (issue 1126).
|
||||
inOwn map[string]bool
|
||||
modules map[string]delivery.Module
|
||||
told map[string]bool // lines said once each (sayOnceEach)
|
||||
values map[string]map[string]any // by module, this node's
|
||||
manifests map[string][]byte // by digest, read once
|
||||
archives map[string][]byte // templates archives by digest, read once
|
||||
@@ -1052,8 +1053,8 @@ func (c *Composer) Policy(ctx context.Context, module string) string {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parsed, err := catalogue.ParseManifestForInputs(raw)
|
||||
if err != nil {
|
||||
parsed, held, err := c.taken(module, m, raw)
|
||||
if err != nil || held != "" {
|
||||
return ""
|
||||
}
|
||||
policy, _, _ := catalogue.DerivedUpgrade(parsed)
|
||||
@@ -1074,13 +1075,51 @@ func (c *Composer) HoldsData(ctx context.Context, module string) (data, known bo
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
parsed, err := catalogue.ParseManifestForInputs(raw)
|
||||
if err != nil {
|
||||
parsed, held, err := c.taken(module, m, raw)
|
||||
if err != nil || held != "" {
|
||||
return false, false
|
||||
}
|
||||
return len(parsed.DataItems()) > 0, true
|
||||
}
|
||||
|
||||
// taken is a module's manifest as this engine reads it (novox/hq ADR 0578 §3): decoded leniently, judging nothing, with
|
||||
// the indexer's verdict from the module's value (a missing one is accepted). held is why the module is held, "" when it is
|
||||
// not: a manifest the indexer refused, or one not read past a field this engine does not know. A field set aside is said
|
||||
// once, and an error is returned only for bytes that are no manifest, which the caller says in its own words.
|
||||
func (c *Composer) taken(module string, m delivery.Module, raw []byte) (man catalogue.Manifest, held string, err error) {
|
||||
if v := m.VerdictOf(module); v != nil {
|
||||
if refused, _ := delivery.TakeManifestVerdict(v); refused {
|
||||
return catalogue.Manifest{}, v.Why(), nil
|
||||
}
|
||||
}
|
||||
man, err = catalogue.Decode(raw)
|
||||
if err != nil {
|
||||
c.sayOnceEach("the manifest of " + module + " does not decode, and the module is held: " + err.Error())
|
||||
return man, "", err
|
||||
}
|
||||
if aside := man.SetAside(); aside != "" {
|
||||
c.sayOnceEach("the manifest of " + module + " has a field this engine does not know, set aside: " + aside)
|
||||
if man.ReadNoFurther() {
|
||||
return man, aside, nil
|
||||
}
|
||||
}
|
||||
return man, "", nil
|
||||
}
|
||||
|
||||
// sayOnceEach says a line the first time only, apart from sayOnce's single current line.
|
||||
func (c *Composer) sayOnceEach(line string) {
|
||||
c.mu.Lock()
|
||||
if c.told == nil {
|
||||
c.told = map[string]bool{}
|
||||
}
|
||||
again := c.told[line]
|
||||
c.told[line] = true
|
||||
c.mu.Unlock()
|
||||
if !again {
|
||||
c.env.Say(line)
|
||||
}
|
||||
}
|
||||
|
||||
// reader is the composer's view of the bus as frombus reads it.
|
||||
type reader struct{ c *Composer }
|
||||
|
||||
@@ -1109,7 +1148,14 @@ func (c *Composer) certificateWanted(ctx context.Context, r reader, p delivery.P
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if parsed, err := catalogue.ParseManifestForInputs(raw); err == nil && parsed.Certificate != nil {
|
||||
c.mu.Lock()
|
||||
mod := c.modules[m.Module]
|
||||
c.mu.Unlock()
|
||||
parsed, held, err := c.taken(m.Module, mod, raw)
|
||||
if err != nil || held != "" {
|
||||
continue // held or not read: it asks none, and its refusal is named by the composition
|
||||
}
|
||||
if parsed.Certificate != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,7 +450,9 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
|
||||
b.kv[delivery.ManifestsBucket+"/"+powerKey] = powerEnc
|
||||
b.mu.Unlock()
|
||||
powerFP := delivery.SourcePrefix2 + strings.Repeat("c", 64)
|
||||
c4.modules["power"] = delivery.Module{FP2: powerFP, Manifest: powerDig, Repository: "novox/mesh-catalog", Commit: "ca8d6f33"}
|
||||
c4.modules["power"] = delivery.Module{FP2: powerFP, Manifest: powerDig, Repository: "novox/mesh-catalog", Commit: "ca8d6f33",
|
||||
// The indexer's verdict on the captured manifest (ADR 0578): the engine judges nothing, it takes this.
|
||||
Verdict: delivery.VerdictRefused, Problems: []string{`bar item "shows" is none of them`}}
|
||||
c4.wants["power"] = delivery.Want{FP: powerFP, Self: true}
|
||||
p4 := c4.placements["n1"]
|
||||
p4.Modules = append(append([]delivery.PlacedModule{}, p4.Modules...), delivery.PlacedModule{Module: "power"})
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestTheManifestDeclaresTheOperatorKeysFileFedByASetting(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := catalogue.ParseManifest(raw); err != nil {
|
||||
if _, err := catalogue.Decode(raw); err != nil {
|
||||
t.Fatalf("the manifest no longer parses: %v", err)
|
||||
}
|
||||
var m struct {
|
||||
@@ -71,7 +71,7 @@ func TestTheManifestConsumesTheBackupEvent(t *testing.T) {
|
||||
// 774: the acts bucket mesh-host_acts is not declared as state for now: a declared state of a module with no account
|
||||
// of its own is carried by every node's tool runner, which would be a second writer of the engine's own keys and
|
||||
// has the grants part refuse the whole user list (2026-10-30). It comes back once the runtime never carries the engine.
|
||||
parsed, err := catalogue.ParseManifest(raw)
|
||||
parsed, err := catalogue.Decode(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user