diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index de5b7c8d..0290447d 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -620,6 +620,11 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus marks := newMarker(run) heldFindings := &seatwait.HeldKeeper{Node: node, Observer: findingsModule, Writer: findings, Ctx: run, Say: say} hooks := composerHooks{ + entryNotFound: func(fp string, notFound bool) { + if err := seatwait.SayEntryNotFound(run, findings, node, findingsModule, fp, notFound, time.Now); err != nil { + say("the finding that the catalog half holds no entry of " + fp + " cannot be kept: " + err.Error()) + } + }, seatList: seats, seatListFailed: err != nil, running: func() []compositions.Composed { @@ -1111,7 +1116,9 @@ type composerHooks struct { unseal func(string) ([]byte, error) refused func(why string, held map[string]string) kept func(kept map[string][]string) - held func(module string) (compositions.Seals, []string, error) + // entryNotFound is selfcompose.Env's EntryNotFound. + entryNotFound func(fp string, notFound bool) + held func(module string) (compositions.Seals, []string, error) // vaultAuthority and certify are selfcompose.Env's VaultAuthority and Certify. vaultAuthority func() string certify func(ctx context.Context) error @@ -1173,7 +1180,7 @@ func composerEnv(session *link.Session, node string, say func(string), hooks com Hear: session.Hear, // And a moved module's templates archive, in the artifact store, as every archive is fetched (issue 1126). Fetch: apply.Fetch, - Running: hooks.running, Unseal: hooks.unseal, Refused: hooks.refused, Held: hooks.held, Kept: hooks.kept, + Running: hooks.running, Unseal: hooks.unseal, Refused: hooks.refused, Held: hooks.held, Kept: hooks.kept, EntryNotFound: hooks.entryNotFound, VaultAuthority: hooks.vaultAuthority, Certify: hooks.certify} if hooks.seatList != nil { env.SeatList, env.SeatListChanged = hooks.seatList.reader, hooks.seatList.changed diff --git a/cmd/mesh-host/seatlist.go b/cmd/mesh-host/seatlist.go index c06f5a9f..7461d625 100644 --- a/cmd/mesh-host/seatlist.go +++ b/cmd/mesh-host/seatlist.go @@ -125,7 +125,7 @@ func newFindingsOfTheNode(session *link.Session, node string, say func(string)) Module: findingsModule, Node: node, State: link.FindingsState{Session: session}, - Kinds: []string{seatwait.Kind, seatwait.HeldKind}, + Kinds: []string{seatwait.Kind, seatwait.HeldKind, seatwait.EntryNotFoundKind}, Asked: condseat.SubjectFindingsAsked, Logf: func(format string, args ...any) { say(fmt.Sprintf(format, args...)) }, Publish: session.PublishEvent, diff --git a/internal/seatwait/entrynotfound.go b/internal/seatwait/entrynotfound.go new file mode 100644 index 00000000..2f9e37ba --- /dev/null +++ b/internal/seatwait/entrynotfound.go @@ -0,0 +1,33 @@ +package seatwait + +import ( + "context" + "fmt" + "time" + + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// EntryNotFoundKind is the finding a part writes when the catalog half holds no entry of a fingerprint the part holds a want or a +// running for (design 47 G §614, A-15, A-117): scope module, its id the fingerprint escaped as a name. +const EntryNotFoundKind = "entry-not-found" + +// EntryNotFoundID is the finding's id for a fingerprint. +func EntryNotFoundID(fp string) string { return keys.EscapeName(fp) } + +// SayEntryNotFound writes the finding for fp when notFound, and clears it when no longer: the entry answered later, or nothing the +// node holds names the fingerprint any more. A write or a clear that fails is returned for the caller to say. +func SayEntryNotFound(ctx context.Context, w Writer, node, observer, fp string, notFound bool, now func() time.Time) error { + id := EntryNotFoundID(fp) + if !notFound { + return w.Clear(ctx, scopeModule, id, EntryNotFoundKind, "the entry is answered, or nothing the node holds names its fingerprint") + } + return w.Write(ctx, condseat.Finding{Observer: observer, Kind: EntryNotFoundKind, Scope: scopeModule, ID: id, + Severity: condseat.FindingSeverityWarning, Source: EntryNotFoundKind, + Subject: condseat.Subject{Scope: scopeModule, ID: observer, Machine: node}, + Summary: fmt.Sprintf("the catalog half holds no entry of the build %s, so %s composes nothing new of it", fp, node), + Headline: fmt.Sprintf("no entry of the build %s on %s", fp, node), + Explanation: "A want or a running names this build and the catalog half answered that it holds no entry of it. The finding clears when the entry is answered or nothing the node holds names the build.", + At: now()}) +} diff --git a/internal/seatwait/seatwait_test.go b/internal/seatwait/seatwait_test.go index 80f0a534..31f3a4a7 100644 --- a/internal/seatwait/seatwait_test.go +++ b/internal/seatwait/seatwait_test.go @@ -3,10 +3,12 @@ package seatwait import ( "context" "errors" + "strings" "testing" "time" condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/seatlist" ) @@ -214,6 +216,22 @@ func caseAHeldModuleIsAFindingClearedWhenItIsComposedOrNoLongerWanted(t *testing } } +// Design 47 G §614, A-117: the finding entry-not-found is of scope module with the fingerprint, escaped, as its id, and cleared by it. +func caseAnEntryNotFoundIsAFindingOfTheFingerprint(t *testing.T) { + w := &fakeFindings{} + fp := "src2:" + strings.Repeat("a", 64) + now := func() time.Time { return time.Unix(1, 0) } + if err := SayEntryNotFound(context.Background(), w, "n1", "mesh-host", fp, true, now); err != nil { + t.Fatal(err) + } + if len(w.put) != 1 || w.put[0].Kind != EntryNotFoundKind || w.put[0].Scope != "module" || w.put[0].ID != keys.EscapeName(fp) || strings.Contains(w.put[0].ID, ":") { + t.Fatalf("written %+v", w.put) + } + if err := SayEntryNotFound(context.Background(), w, "n1", "mesh-host", fp, false, now); err != nil || len(w.cleared) != 1 || w.cleared[0].ID != w.put[0].ID { + t.Fatalf("cleared %+v, %v", w.cleared, err) + } +} + // ADR 0636 §1, the node's finding of a seat list unread. func TestTheSeatListWait(t *testing.T) { for name, run := range map[string]func(*testing.T){ @@ -221,6 +239,7 @@ func TestTheSeatListWait(t *testing.T) { "a read within the bound writes nothing": caseAReadWithinTheBoundWritesNothing, "a finding that cannot be written is said and tried again": caseAFindingThatCannotBeWrittenIsSaidAndTriedAgain, "a finding of an earlier process is cleared at the first full read": caseAFindingOfAnEarlierProcessIsClearedAtTheFirstFullRead, + "an entry not found is a finding of the fingerprint": caseAnEntryNotFoundIsAFindingOfTheFingerprint, "a held module is a finding cleared when it is composed or no longer wanted": caseAHeldModuleIsAFindingClearedWhenItIsComposedOrNoLongerWanted, } { t.Run(name, run) diff --git a/internal/selfcompose/entries.go b/internal/selfcompose/entries.go index 171d0092..24e0ab1a 100644 --- a/internal/selfcompose/entries.go +++ b/internal/selfcompose/entries.go @@ -153,9 +153,32 @@ func (c *Composer) entryChanged(row meshcatalogclient.EntryRow) { c.mu.Unlock() c.keep(entryKey(row.Fingerprint), meshcatalog.EntryAnswer{Entry: *row.Entry}) } + c.tellNotFound(row.Fingerprint, row.State == meshcatalogclient.EntryNotFound) c.askRehand() } +// tellNotFound tells the finding entry-not-found once when fp's entry is not found, and once when it no longer is (design 47 G §614, A-117). +func (c *Composer) tellNotFound(fp string, notFound bool) { + c.mu.Lock() + was := c.notFound[fp] + if was == notFound { + c.mu.Unlock() + return + } + if c.notFound == nil { + c.notFound = map[string]bool{} + } + if notFound { + c.notFound[fp] = true + } else { + delete(c.notFound, fp) + } + c.mu.Unlock() + if c.env.EntryNotFound != nil { + c.env.EntryNotFound(fp, notFound) + } +} + // keep writes one value into the composition state; a state that cannot be written is said, and the value is kept again by // the next pass. func (c *Composer) keep(key string, v any) { @@ -236,6 +259,7 @@ func (c *Composer) syncEntries(ctx context.Context) { for _, row := range c.entries.Rows() { if _, named := need[row.Fingerprint]; !named { c.entries.Release(row.Fingerprint) + c.tellNotFound(row.Fingerprint, false) } } c.mu.Lock() diff --git a/internal/selfcompose/entries_test.go b/internal/selfcompose/entries_test.go index 7136b509..829659ae 100644 --- a/internal/selfcompose/entries_test.go +++ b/internal/selfcompose/entries_test.go @@ -58,6 +58,8 @@ func composeFromEntries(t *testing.T) { env := b.env() env.Entry, env.ComposedState = EntryCall(ask), state env.Refused = func(_ string, h map[string]string) { mu.Lock(); held = h; mu.Unlock() } + told := map[string]bool{} + env.EntryNotFound = func(fp string, notFound bool) { mu.Lock(); told[fp] = notFound; mu.Unlock() } c := New(env) out, err := c.Watch(ctx, "n1.>") if err != nil { @@ -97,6 +99,13 @@ func composeFromEntries(t *testing.T) { if !strings.HasPrefix(why, agent.EntryMissing) || !strings.Contains(why, ghost) || held["lemurs"] != "" { t.Errorf("held %q, want ghost held with %s naming %s", held, agent.EntryMissing, ghost) } + // And the finding entry-not-found is told for the fingerprint the half holds none of, and for no other (design 47 G §614, A-117). + mu.Lock() + nf, nfOther := told[ghost], told[fp] + mu.Unlock() + if !nf || nfOther { + t.Errorf("entry-not-found told %v for the ghost and %v for the entry held", nf, nfOther) + } // Each entry and want it composes from is kept in the composition state before the composition that uses them. state.mu.Lock() _, hasEntry := state.kv[entryKey(fp)] diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 91753a13..a87ec31a 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -61,6 +61,9 @@ const ResolvedBucket = "mesh-config_resolved" // Env is what the composer needs of the node. type Env struct { Node string + // EntryNotFound is told when the catalog half answered it holds no entry of a fingerprint (notFound), and when that is no longer so: + // the entry answered, or nothing the node holds names it (design 47 G §614, A-117). Nil tells nobody. + EntryNotFound func(fp string, notFound bool) // Watch follows one bucket's keys matching pattern, as the agent's watches do. Watch func(ctx context.Context, bucket, pattern string) (<-chan agent.Update, error) // Get reads one key's latest value; nil and no error when there is none. @@ -182,8 +185,10 @@ type Composer struct { // entries is the cache of entries asked by fingerprint, nil where the composer reads the indexer's states; keptEnt the entries // held from the composition state or an answer, keptWantVal the wants it keeps there and keptWants the modules whose want // stands from that state alone until the live read of the wants answers (entries.go). Under mu; keptWrite orders the writes. - entries *meshcatalogclient.Entries - keptEnt map[string]keptEntry + entries *meshcatalogclient.Entries + keptEnt map[string]keptEntry + // notFound is each fingerprint whose entry-not-found the composer told, so it is told once and cleared once. + notFound map[string]bool keptWantVal map[string]keptWant keptWants map[string]bool keptWrite sync.Mutex