diff --git a/cmd/mesh-controller/gate.go b/cmd/mesh-controller/gate.go index 27ccd9db..235a53b7 100644 --- a/cmd/mesh-controller/gate.go +++ b/cmd/mesh-controller/gate.go @@ -125,6 +125,9 @@ type gateFacts struct { // groupsAdded is, per module, whether the move judged puts an account in a group its previous build did // not (issue 318 review): the only move whose wait for a new login is excused. groupsAdded map[string]bool + // waits is what the controller holds to check a module's wait for the operator (novox/hq ADR 0283): the + // manifest of each module's build judged, and the secrets given on each machine. + waits operatorWaitFacts // sent is, per machine, the declaration the gate's own send carried there (novox/hq issue 352): a // report is held against it, never against the send made last. sentBuilds is what each machine was // last sent of every module, and judged the commit of each module this gate judges: a machine last @@ -257,10 +260,11 @@ func judgeHealth(module, component string, m catalogue.Manifest, machine string, firstLine(f.openErr.Error()) } for _, c := range f.open { - // A wait for a person's new login, or for a directory used as found to be handed over, is the module's - // reading, not a fault raised since the send: the gate reads it from the statement below (ADR 0254, - // novox/hq issue 339). - if c.Source == gateProbe || c.OpenAt(since) || c.Kind == kindReloginNeeded || c.Kind == kindUsedAsFound { + // A wait for a person's new login, for a directory used as found to be handed over, or for the + // operator's secret or setting, is the module's reading, not a fault raised since the send: the gate + // reads it from the statement below (ADR 0254, novox/hq issue 339, ADR 0283). + if c.Source == gateProbe || c.OpenAt(since) || c.Kind == kindReloginNeeded || c.Kind == kindUsedAsFound || + c.Kind == kindNeedsOperator { continue } onIt := c.Subject.Machine == machine || slices.Contains(c.Subject.Also, machine) || @@ -477,6 +481,7 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs return "", err } facts.groupsAdded = movesAddingGroups(ctx, open.inventory, g, pairs, shelf) + facts.waits = gateWaitFacts(ctx, open.inventory, g, pairs, shelf, facts.health) facts.sent = g.Sent facts.commits, facts.sentBuilds = judgedCommits(g, pairs), map[string]map[string]string{} // A module this gate put back at once (putBackBroken) was sent its earlier build by the gate itself: @@ -751,6 +756,47 @@ func movesAddingGroups(ctx context.Context, inv *inventory.Inventory, g *invento return out } +// gateWaitFacts reads what checks the waits for the operator of the modules a gate judges (novox/hq ADR 0283): the +// manifest of the build judged — the one it moves to, else the catalogue's — and the secrets given on each machine +// that says a module of them waits. +func gateWaitFacts(ctx context.Context, inv *inventory.Inventory, g *inventory.PlanGate, pairs []judged, + shelf map[string]catalogue.Manifest, health map[string]inventory.NodeHealth) operatorWaitFacts { + var f operatorWaitFacts + judgedManifests := map[string]catalogue.Manifest{} + byMachine := map[string][]string{} + for _, j := range pairs { + waiting := false + for _, r := range health[j.node].Resources { + if r.Module == j.module && r.State == link.StateWaiting { + waiting = true + } + } + if !waiting { + continue + } + byMachine[j.node] = append(byMachine[j.node], j.module) + if _, done := judgedManifests[j.module]; done { + continue + } + to := g.To + for _, c := range g.Carried { + if c.Module == j.module { + to = c.To + break + } + } + if m, found, err := inv.ManifestAt(ctx, j.module, to); err == nil && found { + judgedManifests[j.module] = m + } else if m, known := shelf[j.module]; known { + judgedManifests[j.module] = m + } + } + for machine, modules := range byMachine { + readWaitFacts(ctx, inv, machine, modules, judgedManifests, &f) + } + return f +} + // decide sets a gate's verdict. func decide(g *inventory.PlanGate, verdict, why string, now time.Time) { g.Verdict, g.Why, g.JudgedAt = verdict, why, &now diff --git a/cmd/mesh-controller/module_health.go b/cmd/mesh-controller/module_health.go index 5b1b3bb5..11555243 100644 --- a/cmd/mesh-controller/module_health.go +++ b/cmd/mesh-controller/module_health.go @@ -74,9 +74,21 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke kept := inventory.ResourceHealth{Module: r.Module, Resource: r.Resource, Kind: r.Kind, Target: r.Target, State: r.State, Reason: r.Reason, Since: r.Since, Streak: r.Streak, Restarts: r.Restarts, Check: r.Check, Needs: r.Needs, Account: r.Account, Root: r.Root} + for _, w := range r.Waits { + kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Secret: w.Secret, Setting: w.Setting, What: w.What}) + } resources = append(resources, kept) - if r.State == link.StateUnhealthy && r.Module != "" { - unhealthy[r.Module] = append(unhealthy[r.Module], kept) + } + // **A wait for the operator is checked before it is excused** (novox/hq ADR 0283): a waiting resource whose + // wait does not check out is judged unhealthy, saying why; one that does is kept beside the unhealthy ones, so + // judgeModuleHealth can say it as needs-operator. What is stored is what the machine said. + var wf operatorWaitFacts + if mods := waitingModules(resources); len(mods) > 0 { + readWaitFacts(ctx, inv, node, mods, nil, &wf) + } + for _, r := range checkWaiting(node, resources, wf) { + if (r.State == link.StateUnhealthy || r.State == link.StateWaiting) && r.Module != "" { + unhealthy[r.Module] = append(unhealthy[r.Module], r) } } streaks := map[string]int{} @@ -135,7 +147,8 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi } standing := map[string]conditions.Condition{} for _, c := range open { - if (c.Kind == kindModuleUnhealthy || c.Kind == kindReloginNeeded || c.Kind == kindUsedAsFound) && + if (c.Kind == kindModuleUnhealthy || c.Kind == kindReloginNeeded || c.Kind == kindUsedAsFound || + c.Kind == kindNeedsOperator) && c.Subject.Machine == node { standing[c.Key] = c } @@ -159,6 +172,20 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi heldOn := map[string]string{} providers := map[catalogue.Chosen]bool{} for _, m := range modules { + // **A part that waits for the operator is said as that** (novox/hq ADR 0283): its waits already checked, + // the operator's, never urgent, its words naming the act. + if waits, waiting := operatorWait(m, unhealthy[m]); waiting { + o := needsOperatorObservation(m, node, waits, unhealthy[m]) + seen[o.Key()] = true + became[m] = kindNeedsOperator + if _, isOpen := standing[o.Key()]; streaks[m] < moduleUnhealthyAfter && !isOpen { + continue + } + if _, err := k.Observe(ctx, o); err != nil { + problems = append(problems, err.Error()) + } + continue + } // **A directory used as found is said as that** (novox/hq issue 339): the operator's to hand over at the // machine, never urgent — nothing is broken by the wait that a person was not told of — and its own kind, // so the gate never reads it as a fault of the build that happened to be sent beside it. @@ -228,6 +255,9 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi if c.Kind == kindUsedAsFound { why = fmt.Sprintf("%s says no directory of %s is used as found any more", node, module) } + if c.Kind == kindNeedsOperator { + why = fmt.Sprintf("%s says %s no longer waits for the operator", node, module) + } if on, held := heldOn[key]; held { why = fmt.Sprintf("what %s finds on %s waits on %s, which is unhealthy: held under its condition", module, node, on) } @@ -238,6 +268,12 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi case c.Kind == kindModuleUnhealthy && became[module] == kindReloginNeeded: why = fmt.Sprintf("%s on %s now waits only for a new login", module, node) resolved = fmt.Sprintf("%s on %s now waits only for a new login", module, node) + case c.Kind == kindModuleUnhealthy && became[module] == kindNeedsOperator: + why = fmt.Sprintf("%s on %s now only waits for the operator", module, node) + resolved = fmt.Sprintf("%s on %s now only waits for you", module, node) + case c.Kind == kindNeedsOperator && became[module] == kindModuleUnhealthy: + why = fmt.Sprintf("%s on %s no longer only waits for the operator, and is not healthy", module, node) + resolved = fmt.Sprintf("What %s on %s waited for is given, and it still does not work", module, node) case c.Kind == kindReloginNeeded && became[module] == kindModuleUnhealthy: why = fmt.Sprintf("%s on %s no longer waits for a new login, and is not healthy", module, node) resolved = fmt.Sprintf("The new login on %s is done, and %s still does not work", node, module) @@ -420,6 +456,11 @@ func reasonWords(r inventory.ResourceHealth) string { case "": return "is unhealthy" } + // A wait for the operator that did not check out is said as the controller found it (ADR 0283): names of + // secrets and settings only, never what the check itself said. + if strings.HasPrefix(r.Reason, waitRefusedPrefix) { + return r.Reason + } // What a declared check found says an endpoint, a path or an address: evidence, never the summary the // operator's channel carries (ADR 0234 §6). The summary names the check. if r.Check != "" { @@ -511,7 +552,9 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea if h.HeardAt.Before(since) { return healthNotYet, fmt.Sprintf("%s has not said how what %s runs is since it was sent", machine, module) } - wait, waits := personWait(module, machine, h.Resources) + // **A wait for the operator is checked first** (novox/hq ADR 0283): one that does not check out is unhealthy. + resources := checkWaiting(machine, h.Resources, f.waits) + wait, waits := personWait(module, machine, resources) // **Only a build whose own send put the account in a new group is excused** (issue 318 review): read from // what the controller sent, never from when the machine says the wait began — that time is the engine's // memory, reset by its restart and moved by a change of words. A build that adds no account group cannot @@ -520,10 +563,17 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea waits = false } var found []string - for _, r := range h.Resources { + var forOperator []inventory.Wait + for _, r := range resources { if r.Module != module { continue } + // **Any build is excused while its wait for the operator checks out** (ADR 0283 decision 4): a secret not + // given is owed by every build alike, so it is no fault of this one, and the verdict carries it. + if r.State == link.StateWaiting { + forOperator = append(forOperator, r.Waits...) + continue + } if waits && r.State == link.StateUnhealthy { continue } @@ -550,11 +600,14 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea reasonAfter(r.Reason)) } } - if waits || len(found) > 0 { + if waits || len(found) > 0 || len(forOperator) > 0 { var said []string if waits { said = append(said, wait) } + if len(forOperator) > 0 { + said = append(said, operatorWaitSaid(module, machine, forOperator)) + } if len(found) > 0 { said = append(said, fmt.Sprintf("on %s, %s uses %s as found and waits for the operator to hand it over "+ "(`nox node hand-over %s ` on the control-node)", machine, module, strings.Join(found, ", "), machine)) diff --git a/cmd/mesh-controller/operator_wait.go b/cmd/mesh-controller/operator_wait.go new file mode 100644 index 00000000..0357a4ce --- /dev/null +++ b/cmd/mesh-controller/operator_wait.go @@ -0,0 +1,261 @@ +package main + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/conditions" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// A part that waits for the operator's secret or setting (novox/hq ADR 0283, issue 386). +// +// **A module's tool check may say it waits**: nothing of it is wrong but a part that cannot work until the operator +// gives one of its own secrets or one of its settings. The node-engine states such a resource `waiting`, with what +// it waits for. A module saying so is an assertion, so **the controller checks each wait before it excuses it**: +// +// - a wait for a secret names an own secret the module's manifest declares — by its name, or as a member of a +// secret family — said `"issued-by": "outside"`, and the store holds no value a person gave for it on that +// machine; +// - a wait for a setting names a setting the manifest declares (checked by name only: the controller cannot tell +// whether a free-form value covers a part, and the needs-operator condition is where a false one shows). +// +// An excused wait is read by the first-node gate as *waits for a person* (ADR 0254), a pass carried in the verdict, +// for any build of the module — a secret not given is owed by every build alike. It is said to the operator as +// `module...needs-operator`, naming the act. A wait that fails the check is judged unhealthy, saying +// why, and raises the module's `unhealthy` condition. + +// kindNeedsOperator is a module's condition while a part of it waits for the operator's secret or setting. +const kindNeedsOperator = "needs-operator" + +// needsOperatorKey is a module's needs-operator condition on a machine. +func needsOperatorKey(module, node string) string { + return conditions.Key(conditions.ScopeModule, module+"."+node, kindNeedsOperator) +} + +// operatorWaitFacts is what the controller holds to check a module's waits: the manifest judged per module, and per +// "@" the own secrets a person gave there, with when. A module or a machine absent is not known, +// and no wait of it is excused. +type operatorWaitFacts struct { + manifests map[string]catalogue.Manifest + given map[string]map[string]time.Time +} + +// checkWait is nil when a wait is excused, and otherwise why not, in words. Pure. +func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) error { + m, known := f.manifests[module] + if !known { + return fmt.Errorf("says it waits for %s, and the mesh holds no manifest of %s to check it against", waitNames(w), module) + } + switch { + case w.Secret != "" && w.Setting != "", w.Secret == "" && w.Setting == "": + return fmt.Errorf("says it waits, naming %s, where a wait names one secret or one setting", waitNames(w)) + case w.Setting != "": + if _, declared := m.Settings[w.Setting]; !declared { + return fmt.Errorf("says it waits for the setting %s, which %s does not declare", w.Setting, module) + } + return nil + } + own, _, declared := m.OwnSecrets.Lookup(w.Secret) + if !declared { + return fmt.Errorf("says it waits for the secret %s, which %s does not declare", w.Secret, module) + } + if own.IssuedBy != catalogue.IssuedOutside { + return fmt.Errorf("says it waits for the secret %s, which the mesh makes itself: only a secret issued outside "+ + "the mesh waits for the operator", w.Secret) + } + given, readable := f.given[module+"@"+machine] + if !readable { + return fmt.Errorf("says it waits for the secret %s, and what was given on %s could not be read", w.Secret, machine) + } + if at, was := given[w.Secret]; was { + return fmt.Errorf("says it waits for the secret %s, which was given at %s", w.Secret, + at.UTC().Format("2006-01-02 15:04 MST")) + } + return nil +} + +// waitRefusedPrefix opens every reason checkWait gives, so the words of a refused wait are told from a check's own. +const waitRefusedPrefix = "says it waits" + +// waitNames is what a wait names, as "the secret x" or "the setting y". +func waitNames(w inventory.Wait) string { + switch { + case w.Secret != "" && w.Setting != "": + return "the secret " + w.Secret + " and the setting " + w.Setting + case w.Secret != "": + return "the secret " + w.Secret + case w.Setting != "": + return "the setting " + w.Setting + } + return "nothing" +} + +// checkWaiting reads one machine's resources against the facts: every waiting resource whose waits all check out is +// kept as said; one with a wait that does not, or with no wait at all, is answered as unhealthy with why. Pure; the +// statement as kept is not changed. +func checkWaiting(machine string, rs []inventory.ResourceHealth, f operatorWaitFacts) []inventory.ResourceHealth { + out := make([]inventory.ResourceHealth, 0, len(rs)) + for _, r := range rs { + if r.State == link.StateWaiting { + var why error + if len(r.Waits) == 0 { + why = fmt.Errorf("says it waits, and names nothing it waits for") + } + for _, w := range r.Waits { + if why == nil { + why = checkWait(r.Module, machine, w, f) + } + } + if why != nil { + r.State, r.Reason = link.StateUnhealthy, why.Error() + } + } + out = append(out, r) + } + return out +} + +// operatorWait is whether everything not healthy of a module on a machine is waiting with its waits checked +// (checkWaiting already applied), and those waits. A module with anything unhealthy, starting or unknown beside it +// does not wait: it is judged as before. +func operatorWait(module string, rs []inventory.ResourceHealth) ([]inventory.Wait, bool) { + var waits []inventory.Wait + for _, r := range rs { + if r.Module != module { + continue + } + switch r.State { + case link.StateHealthy: + case link.StateWaiting: + waits = append(waits, r.Waits...) + default: + return nil, false + } + } + return waits, len(waits) > 0 +} + +// operatorWaitSaid is a module's wait for the operator in one sentence, for the gate's verdict and the condition's +// summary: what the operator gives and what it names, and for a secret the line that opens the desk prompt. +func operatorWaitSaid(module, machine string, waits []inventory.Wait) string { + var parts []string + for _, w := range waits { + part := fmt.Sprintf("%s (%s", w.What, waitNames(w)) + if w.Secret != "" { + part += fmt.Sprintf(", given with `nox secret ask %s %s %s`", machine, module, w.Secret) + } + parts = append(parts, part+")") + } + return fmt.Sprintf("%s on %s waits for the operator: %s", module, machine, strings.Join(parts, "; ")) +} + +// needsOperatorObservation is a module whose only parts not healthy wait for the operator (ADR 0283): the operator's, +// a warning however long it stands, its plain words naming the act and never saying there is nothing to do. +func needsOperatorObservation(module, node string, waits []inventory.Wait, rs []inventory.ResourceHealth) conditions.Observation { + o := moduleUnhealthyObservation(module, node, rs) + o.Token, o.Kind, o.Resolver, o.Severity = kindNeedsOperator, kindNeedsOperator, conditions.ResolverOperator, conditions.Warning + o.Summary = operatorWaitSaid(module, node, waits) + w := needsOperatorWords(module, node, waits) + o.Headline, o.Explanation, o.Needs, o.Resolved, o.Actions = w.Headline, w.Explanation, w.Needs, w.Resolved, nil + return o +} + +// needsOperatorWords is what the operator reads of a module waiting for them (ADR 0253, ADR 0283): the act, for a +// secret typed at the machine's desk prompt and for a setting approved when an agent proposes it. The secret's and +// the setting's names, and the line, are in the summary for whoever looks closer. +func needsOperatorWords(module, node string, waits []inventory.Wait) words { + var acts []string + seen := map[string]bool{} + secret := false + for _, w := range waits { + var act string + switch { + case w.Secret != "": + act, secret = fmt.Sprintf("type %s at %s's desk prompt", w.What, node), true + case w.Setting != "": + act = fmt.Sprintf("approve %s of %s on %s when it is proposed to you", w.Setting, module, node) + } + if act != "" && !seen[act] { + seen[act] = true + acts = append(acts, act) + } + } + needs := strings.Join(acts, "; and ") + "." + // Plain words hold one sentence of at most conditions.NeedsMax characters: several acts are named in the + // summary instead. + if len(acts) == 0 || len(needs) > conditions.NeedsMax { + needs = fmt.Sprintf("give what %s waits for on %s; the details name each secret and setting.", module, node) + } + explanation := fmt.Sprintf("Part of %s on %s cannot work until you give what it waits for.", module, node) + if secret { + explanation += " A hidden prompt opens at the desk when the secret is asked for, and what you type there " + + "is sealed to the machine." + } + explanation += " Its update is in place and nothing was undone; it carries on by itself once it is given." + return words{ + Headline: fmt.Sprintf("%s waits for you on %s", module, node), + Needs: needs, + Explanation: explanation, + Resolved: fmt.Sprintf("%s on %s no longer waits for you", module, node), + } +} + +// readWaitFacts reads what the controller holds to check the waits of the modules named on one machine: the +// manifests (the catalogue's, or those given) and the secrets given there. A read that fails leaves that module +// unknown, so none of its waits is excused. +func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string, modules []string, + manifests map[string]catalogue.Manifest, f *operatorWaitFacts) { + if f.manifests == nil { + f.manifests = map[string]catalogue.Manifest{} + } + if f.given == nil { + f.given = map[string]map[string]time.Time{} + } + if inv == nil { + return + } + var shelf map[string]catalogue.Manifest + sort.Strings(modules) + for _, module := range modules { + if _, has := f.manifests[module]; !has { + if m, given := manifests[module]; given { + f.manifests[module] = m + } else { + if shelf == nil { + var err error + if shelf, err = inv.Catalogue(ctx); err != nil { + shelf = map[string]catalogue.Manifest{} + } + } + if m, known := shelf[module]; known { + f.manifests[module] = m + } + } + } + if _, read := f.given[module+"@"+machine]; read { + continue + } + if given, err := inv.GivenOwnSecrets(ctx, machine, module); err == nil { + f.given[module+"@"+machine] = given + } + } +} + +// waitingModules is every module with a waiting resource in a statement. +func waitingModules(rs []inventory.ResourceHealth) []string { + seen := map[string]bool{} + var out []string + for _, r := range rs { + if r.State == link.StateWaiting && r.Module != "" && !seen[r.Module] { + seen[r.Module] = true + out = append(out, r.Module) + } + } + return out +} diff --git a/cmd/mesh-controller/operator_wait_test.go b/cmd/mesh-controller/operator_wait_test.go new file mode 100644 index 00000000..6443a3f7 --- /dev/null +++ b/cmd/mesh-controller/operator_wait_test.go @@ -0,0 +1,264 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/conditions" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// A part that waits for the operator's secret or setting (novox/hq ADR 0283, issue 386). + +var mountsManifest = catalogue.Manifest{Module: "mounts", Version: "1", + Settings: map[string]catalogue.SettingDeclaration{"smb-users": {}, "sources": {}}, + OwnSecrets: catalogue.OwnSecrets{ + "smb-password-*": {Path: "/s/smb-password-*.secret", IssuedBy: catalogue.IssuedOutside}, + "broker": {Path: "/s/broker"}, + "made": {Path: "/s/made", Taken: catalogue.TakenAtStart}, + "licence": {Path: "/s/licence", IssuedBy: catalogue.IssuedOutside}, + }} + +var passwordWait = inventory.Wait{Part: "the source games", Secret: "smb-password-games", + What: "the password of the source games"} + +var usernameWait = inventory.Wait{Part: "the source games", Setting: "smb-users", What: "the username of the source games"} + +func waitingResource(waits ...inventory.Wait) inventory.ResourceHealth { + return inventory.ResourceHealth{Module: "mounts", Resource: "mounts.watch", Kind: "process", + Target: "mesh-mounts-watch.service", State: link.StateWaiting, Check: "tool", + Reason: "the source games waits for its password", Waits: waits} +} + +func factsGiven(given map[string]time.Time) operatorWaitFacts { + return operatorWaitFacts{manifests: map[string]catalogue.Manifest{"mounts": mountsManifest}, + given: map[string]map[string]time.Time{"mounts@workstation": given}} +} + +// Rule 3: a wait is excused only when what it names is the module's, issued outside the mesh, and not given there. +func TestAWaitIsExcusedOnlyWhenItChecksOut(t *testing.T) { + at := time.Date(2026, 10, 10, 15, 8, 0, 0, time.UTC) + for _, c := range []struct { + name string + w inventory.Wait + f operatorWaitFacts + says string // "" when excused + }{ + {"a member of an outside family, not given", passwordWait, factsGiven(nil), ""}, + {"an outside secret by name, not given", inventory.Wait{Part: "p", Secret: "licence", What: "w"}, factsGiven(nil), ""}, + {"a declared setting", usernameWait, factsGiven(nil), ""}, + {"a member given", passwordWait, factsGiven(map[string]time.Time{"smb-password-games": at}), "was given at 2026-10-10 15:08"}, + {"a secret not declared", inventory.Wait{Part: "p", Secret: "smb-credentials", What: "w"}, factsGiven(nil), "does not declare"}, + {"a secret the mesh makes", inventory.Wait{Part: "p", Secret: "made", What: "w"}, factsGiven(nil), "mesh makes itself"}, + {"the bus account", inventory.Wait{Part: "p", Secret: "broker", What: "w"}, factsGiven(nil), "mesh makes itself"}, + {"a setting not declared", inventory.Wait{Part: "p", Setting: "logins", What: "w"}, factsGiven(nil), "does not declare"}, + {"both", inventory.Wait{Part: "p", Secret: "licence", Setting: "smb-users", What: "w"}, factsGiven(nil), "one secret or one setting"}, + {"neither", inventory.Wait{Part: "p", What: "w"}, factsGiven(nil), "one secret or one setting"}, + {"no manifest known", passwordWait, operatorWaitFacts{}, "no manifest"}, + {"what was given cannot be read", passwordWait, + operatorWaitFacts{manifests: map[string]catalogue.Manifest{"mounts": mountsManifest}}, "could not be read"}, + } { + err := checkWait("mounts", "workstation", c.w, c.f) + switch { + case c.says == "" && err != nil: + t.Errorf("%s: refused: %v", c.name, err) + case c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)): + t.Errorf("%s: %v; want a refusal saying %q", c.name, err, c.says) + } + } +} + +// A waiting resource whose wait does not check out, or that names nothing, is judged unhealthy, saying why. +func TestAWaitThatFailsItsCheckIsUnhealthy(t *testing.T) { + given := factsGiven(map[string]time.Time{"smb-password-games": time.Now()}) + got := checkWaiting("workstation", []inventory.ResourceHealth{waitingResource(passwordWait)}, given) + if got[0].State != link.StateUnhealthy || !strings.Contains(got[0].Reason, "was given") { + t.Fatalf("a wait for a secret given: %+v", got[0]) + } + got = checkWaiting("workstation", []inventory.ResourceHealth{waitingResource()}, factsGiven(nil)) + if got[0].State != link.StateUnhealthy || !strings.Contains(got[0].Reason, "names nothing") { + t.Fatalf("a wait naming nothing: %+v", got[0]) + } + got = checkWaiting("workstation", []inventory.ResourceHealth{waitingResource(passwordWait, usernameWait)}, factsGiven(nil)) + if got[0].State != link.StateWaiting { + t.Fatalf("two waits that check out: %+v", got[0]) + } +} + +// Rule 4: the gate passes a module whose only parts not healthy wait for the operator, carrying the wait; anything +// else beside it is judged as before; and any build is excused, not only one that added something. +func TestTheGatePassesAWaitForTheOperatorCarriedAlong(t *testing.T) { + now := time.Now() + since := now.Add(-time.Minute) + healthy := inventory.ResourceHealth{Module: "mounts", Resource: "mounts.apply", Kind: "process", + Target: "mesh-mounts-apply.service", State: link.StateHealthy} + f := gateFacts{now: now, waits: factsGiven(nil), groupsAdded: map[string]bool{"mounts": false}, + health: map[string]inventory.NodeHealth{"workstation": {Node: "workstation", HeardAt: now, + Resources: []inventory.ResourceHealth{healthy, waitingResource(passwordWait)}}}} + h, why := moduleHealthWord("mounts", "workstation", since, f) + if h != healthPerson || !strings.Contains(why, "waits for the operator: the password of the source games") || + !strings.Contains(why, "nox secret ask workstation mounts smb-password-games") { + t.Fatalf("an excused wait reads %v %q; want a wait for a person naming the act", h, why) + } + // A second resource unhealthy beside it: not yet, as before. + down := healthy + down.State, down.Reason = link.StateUnhealthy, "down" + f.health["workstation"] = inventory.NodeHealth{Node: "workstation", HeardAt: now, + Resources: []inventory.ResourceHealth{down, waitingResource(passwordWait)}} + if h, why := moduleHealthWord("mounts", "workstation", since, f); h != healthNotYet { + t.Fatalf("a resource down beside the wait reads %v %q", h, why) + } + // The password given and the module still saying it waits: not excused. + f.waits = factsGiven(map[string]time.Time{"smb-password-games": now}) + f.health["workstation"] = inventory.NodeHealth{Node: "workstation", HeardAt: now, + Resources: []inventory.ResourceHealth{healthy, waitingResource(passwordWait)}} + if h, why := moduleHealthWord("mounts", "workstation", since, f); h != healthNotYet || !strings.Contains(why, "was given") { + t.Fatalf("a wait for a secret given reads %v %q", h, why) + } + // Facts never read (no manifest): never excused. + f.waits = operatorWaitFacts{} + if h, _ := moduleHealthWord("mounts", "workstation", since, f); h != healthNotYet { + t.Fatalf("a wait nothing could check reads %v", h) + } +} + +func needsOperatorOpen(t *testing.T, k *conditions.Keeper) (*conditions.Condition, []conditions.Condition) { + t.Helper() + open, err := k.Open(t.Context()) + if err != nil { + t.Fatal(err) + } + for i, c := range open { + if c.Key == needsOperatorKey("mounts", "workstation") { + return &open[i], open + } + } + return nil, open +} + +// Rule 5: two statements of an excused wait raise needs-operator, the operator's, a warning however long, naming the +// act; a statement without it clears it. +func TestTheNeedsOperatorConditionNamesTheAct(t *testing.T) { + k, _ := withConditionsInMemory(t) + ctx := t.Context() + rs := map[string][]inventory.ResourceHealth{"mounts": {waitingResource(passwordWait)}} + if err := judgeModuleHealth(ctx, nil, k, "workstation", rs, map[string]int{"mounts": 1}, time.Now()); err != nil { + t.Fatal(err) + } + if got, _ := needsOperatorOpen(t, k); got != nil { + t.Fatal("raised on one statement") + } + if err := judgeModuleHealth(ctx, nil, k, "workstation", rs, map[string]int{"mounts": 2}, time.Now()); err != nil { + t.Fatal(err) + } + got, open := needsOperatorOpen(t, k) + if got == nil { + t.Fatalf("not raised on two statements: %+v", open) + } + for _, c := range open { + if c.Kind == kindModuleUnhealthy { + t.Fatalf("raised as a fault too: %+v", c) + } + } + if got.Kind != kindNeedsOperator || got.Resolver != conditions.ResolverOperator || got.Severity != conditions.Warning { + t.Fatalf("the condition: %+v", got) + } + if !strings.Contains(got.Needs, "type the password of the source games at workstation's desk prompt") || + !strings.Contains(got.Explanation, "hidden prompt opens at the desk") { + t.Fatalf("its needs do not name the act: %q", got.Needs) + } + if strings.Contains(strings.ToLower(got.Explanation), "nothing for you") || !strings.Contains(got.Explanation, "nothing was undone") { + t.Fatalf("its explanation: %q", got.Explanation) + } + if !strings.Contains(got.Summary, "smb-password-games") || !strings.Contains(got.Summary, "nox secret ask workstation mounts smb-password-games") { + t.Fatalf("its summary does not name the secret and the line: %q", got.Summary) + } + // Long open is still a warning: only the operator can end it. + if err := judgeModuleHealth(ctx, nil, k, "workstation", rs, map[string]int{"mounts": 3}, time.Now().Add(48*time.Hour)); err != nil { + t.Fatal(err) + } + if got, _ := needsOperatorOpen(t, k); got == nil || got.Severity == conditions.Urgent { + t.Fatalf("after two days: %+v", got) + } + // Given: the next statement does not say it, and it clears. + if err := judgeModuleHealth(ctx, nil, k, "workstation", map[string][]inventory.ResourceHealth{}, nil, time.Now()); err != nil { + t.Fatal(err) + } + if got, _ := needsOperatorOpen(t, k); got != nil { + t.Fatal("not cleared once given") + } +} + +func TestASettingsWaitAsksForTheApproval(t *testing.T) { + k, _ := withConditionsInMemory(t) + ctx := t.Context() + rs := map[string][]inventory.ResourceHealth{"mounts": {waitingResource(usernameWait)}} + for i := 1; i <= 2; i++ { + if err := judgeModuleHealth(ctx, nil, k, "workstation", rs, map[string]int{"mounts": i}, time.Now()); err != nil { + t.Fatal(err) + } + } + got, _ := needsOperatorOpen(t, k) + if got == nil || !strings.Contains(got.Needs, "approve smb-users of mounts on workstation when it is proposed to you") { + t.Fatalf("the condition: %+v", got) + } +} + +// A wait that fails its check is the module's own fault: unhealthy, with why, and no needs-operator. +func TestAWaitThatFailsItsCheckRaisesUnhealthy(t *testing.T) { + k, _ := withConditionsInMemory(t) + ctx := t.Context() + checked := checkWaiting("workstation", []inventory.ResourceHealth{waitingResource(passwordWait)}, + factsGiven(map[string]time.Time{"smb-password-games": time.Now()})) + rs := map[string][]inventory.ResourceHealth{"mounts": checked} + for i := 1; i <= 2; i++ { + if err := judgeModuleHealth(ctx, nil, k, "workstation", rs, map[string]int{"mounts": i}, time.Now()); err != nil { + t.Fatal(err) + } + } + got, open := needsOperatorOpen(t, k) + if got != nil { + t.Fatalf("a wait for a secret given raised needs-operator: %+v", got) + } + unhealthy := false + for _, c := range open { + unhealthy = unhealthy || c.Key == moduleUnhealthyKey("mounts", "workstation") + } + if !unhealthy { + t.Fatalf("not raised as unhealthy: %+v", open) + } +} + +// A module that waited and is then broken says so when the wait clears, and the other way round. +func TestANeedsOperatorThatBecameUnhealthySaysSo(t *testing.T) { + k, _ := withConditionsInMemory(t) + ctx := t.Context() + waiting := map[string][]inventory.ResourceHealth{"mounts": {waitingResource(passwordWait)}} + for i := 1; i <= 2; i++ { + if err := judgeModuleHealth(ctx, nil, k, "workstation", waiting, map[string]int{"mounts": i}, time.Now()); err != nil { + t.Fatal(err) + } + } + broken := waitingResource() + broken.State, broken.Reason, broken.Waits = link.StateUnhealthy, "the source games refused its login", nil + for i := 3; i <= 4; i++ { + if err := judgeModuleHealth(ctx, nil, k, "workstation", map[string][]inventory.ResourceHealth{"mounts": {broken}}, + map[string]int{"mounts": i}, time.Now()); err != nil { + t.Fatal(err) + } + } + got, open := needsOperatorOpen(t, k) + if got != nil { + t.Fatal("needs-operator still open after it became a fault") + } + found := false + for _, c := range open { + found = found || c.Key == moduleUnhealthyKey("mounts", "workstation") + } + if !found { + t.Fatalf("the fault is not raised: %+v", open) + } +} diff --git a/cmd/mesh-controller/plain_words.go b/cmd/mesh-controller/plain_words.go index 38ddc897..9fe68c35 100644 --- a/cmd/mesh-controller/plain_words.go +++ b/cmd/mesh-controller/plain_words.go @@ -221,6 +221,16 @@ var plainWordings = map[string]func(conditions.Observation) words{ w := usedAsFoundObservation(orModule(module), machineOr(o, "a machine"), o.Summary, nil) return words{Headline: w.Headline, Explanation: w.Explanation, Needs: w.Needs, Resolved: w.Resolved} }), + kindNeedsOperator: worded(func(o conditions.Observation) words { + // The observation carries the act itself (ADR 0283); these are its words when only the kind is known. + module := "" + if o.Scope == conditions.ScopeModule && o.Machine != "" { + module = strings.TrimSuffix(o.ID, "."+o.Machine) + } + node := machineOr(o, "a machine") + w := needsOperatorWords(orModule(module), node, nil) + return w + }), kindProviderFailing: worded(func(o conditions.Observation) words { thing, consumer := conditions.ThingWords(o), idPart(o, 2) if consumer == "" { diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index a7149f4f..82f0fbb4 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -664,7 +664,33 @@ func renderingFor(ctx context.Context, open *stores, node string, needed := map[string]map[string]string{} foreseen := map[string]map[string]bool{} for _, m := range plan.Modules { - for name := range m.OwnSecrets { + // **A secret family is never made** (novox/hq ADR 0283): each member a person gave on this machine is + // placed, and one not given is nothing — the module says it waits for it. + for _, family := range m.OwnSecrets.Families() { + members, err := inv.GivenMembers(ctx, node, m.Module, family) + if err != nil { + return catalogue.Rendering{}, inventory.Node{}, err + } + for _, g := range members { + if _, fam, ok := m.OwnSecrets.Lookup(g.Name); !ok || fam != family { + continue // a longer family's member, or a name no longer of this family + } + if !g.Current { + if choosing == Allocating { + return catalogue.Rendering{}, inventory.Node{}, fmt.Errorf( + "%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+ + "since generated a new sealing key. The mesh cannot make another; give it again", + m.Module, node, g.Name, node) + } + continue + } + if needed[m.Module] == nil { + needed[m.Module] = map[string]string{} + } + needed[m.Module][g.Name] = g.Sealed + } + } + for name := range m.OwnSecrets.Plain() { // Minted on the send path and only read on every other. Making one is an insert, and // a question that writes is a question that can block against the machine it is about. var sealed string diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 7d7f1e5e..4b191182 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -606,7 +606,18 @@ func (r Resolution) compose(with Rendering, owner map[string]string, "mode": "0600", }) } - for _, name := range sortedKeys(m.OwnSecrets) { + // **A secret family's members, as given** (novox/hq ADR 0283): one file each at the family's path, and + // nothing for a member not given — the mesh never makes one, and the module says it waits for it. + for _, name := range sortedKeys(with.Needed[m.Module]) { + own, family, ok := m.OwnSecrets.Lookup(name) + if !ok || family == "" || with.Needed[m.Module][name] == "" { + continue + } + first = append(first, ownedBy(m.SecretsOwner, map[string]any{ + "id": NeedID(name), "type": "file", "path": own.Path, "sealed": with.Needed[m.Module][name], + })) + } + for _, name := range sortedKeys(m.OwnSecrets.Plain()) { sealed := with.Needed[m.Module][name] if sealed == "" && with.Foreseen[m.Module][name] { // Not made, and the next send makes it: composed with a stand-in so that whatever @@ -2385,7 +2396,7 @@ func secretsReadBy(resource map[string]any, m Manifest) []string { mentioned = append(mentioned, stringsIn(resource[key])...) } var out []string - for _, name := range sortedKeys(m.OwnSecrets) { + for _, name := range sortedKeys(m.OwnSecrets.Plain()) { path := m.OwnSecrets[name].Path if path == "" { continue diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 56c391bf..be0e5871 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -1942,6 +1942,37 @@ func ParseManifest(raw []byte) (Manifest, error) { problems = append(problems, whileStoppedProblems(m, r, hasSchedule(r))...) } for name, own := range m.OwnSecrets { + // **A family is issued outside the mesh, and lands one file per member** (novox/hq ADR 0283): the mesh + // never makes a member, so a family the mesh may make would be one nothing ever fills. + if IsFamily(name) { + if !memberRest.MatchString(strings.TrimSuffix(FamilyPrefix(name), "-")) { + problems = append(problems, fmt.Sprintf( + "%s declares the secret family %q, whose prefix is not a name", m.Module, name)) + } + if own.IssuedBy != IssuedOutside { + problems = append(problems, fmt.Sprintf( + "%s declares the secret family %q without \"issued-by\": %q; the mesh never makes a "+ + "member of a family, so only a party outside the mesh can fill one (novox/hq ADR 0283)", + m.Module, name, IssuedOutside)) + } + if strings.Count(own.Path, "*") != 1 { + problems = append(problems, fmt.Sprintf( + "%s keeps the secret family %q at %q, which does not hold exactly one *: each member lands "+ + "where the * is replaced by its name (novox/hq ADR 0283)", m.Module, name, own.Path)) + } + for other := range m.OwnSecrets { + if other != name && !IsFamily(other) { + if rest := strings.TrimPrefix(other, FamilyPrefix(name)); rest != other && memberRest.MatchString(rest) { + problems = append(problems, fmt.Sprintf( + "%s declares the secret %q, which is also a member of its family %q — a name names one", + m.Module, other, name)) + } + } + } + } else if strings.Contains(name, "*") { + problems = append(problems, fmt.Sprintf( + "%s declares the secret %q: a * only ends a family's name, as \"-*\"", m.Module, name)) + } if !placedOrAbsolute(own.Path) { problems = append(problems, fmt.Sprintf( "%s needs %q at %q, which is neither an absolute path nor a placed one", m.Module, name, own.Path)) @@ -2549,6 +2580,69 @@ func (o OwnSecrets) MarshalJSON() ([]byte, error) { return json.Marshal(entries) } +// A secret family (novox/hq ADR 0283): an own secret declared under a name ending in FamilySuffix is one +// member per part the module's settings name — `smb-password-*` holds `smb-password-games`, +// `smb-password-library` — each given by its full name, placed at the family's path with its one `*` replaced +// by what follows the prefix. The mesh never makes a member: a family is issued outside the mesh, and a member +// not given is no file. +const FamilySuffix = "-*" + +// memberRest is what may follow a family's prefix: a name's characters. +var memberRest = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +// IsFamily says an own secret's declared name is a family's. +func IsFamily(name string) bool { return strings.HasSuffix(name, FamilySuffix) } + +// FamilyPrefix is what every member of a family begins with: the declared name without its `*`. +func FamilyPrefix(family string) string { return strings.TrimSuffix(family, "*") } + +// Lookup resolves an own secret by the name it is given under: declared by that name, or a member of a family +// (the longest prefix that fits), with the family's path filled for the member. family is the family's +// declared name, or "" for a secret declared by name. +func (o OwnSecrets) Lookup(name string) (s OwnSecret, family string, ok bool) { + if s, ok := o[name]; ok && !IsFamily(name) { + return s, "", true + } + for declared, f := range o { + if !IsFamily(declared) { + continue + } + prefix := FamilyPrefix(declared) + rest := strings.TrimPrefix(name, prefix) + if !strings.HasPrefix(name, prefix) || !memberRest.MatchString(rest) { + continue + } + if family == "" || len(declared) > len(family) { + s, family, ok = OwnSecret{Path: strings.Replace(f.Path, "*", rest, 1), Taken: f.Taken, IssuedBy: f.IssuedBy}, declared, true + } + } + return s, family, ok +} + +// Plain is every own secret declared by its own name: what the mesh makes when not given, and places by +// name. A family is not one, and is never made. +func (o OwnSecrets) Plain() OwnSecrets { + out := make(OwnSecrets, len(o)) + for name, s := range o { + if !IsFamily(name) { + out[name] = s + } + } + return out +} + +// Families is every family's declared name, sorted. +func (o OwnSecrets) Families() []string { + var out []string + for name := range o { + if IsFamily(name) { + out = append(out, name) + } + } + sort.Strings(out) + return out +} + // Paths is each own secret's path by name — the shape every placement and file walk reads. func (o OwnSecrets) Paths() map[string]string { out := make(map[string]string, len(o)) diff --git a/internal/catalogue/secret_family_test.go b/internal/catalogue/secret_family_test.go new file mode 100644 index 00000000..506456da --- /dev/null +++ b/internal/catalogue/secret_family_test.go @@ -0,0 +1,100 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A secret family (novox/hq ADR 0283): one own secret per part the settings name, issued outside the mesh, each +// given by its full name, never made by the mesh. + +func familyManifest(t *testing.T, ownSecrets string) (Manifest, error) { + t.Helper() + return ParseManifest([]byte(`{"module":"mounts","version":"1","own-secrets":{` + ownSecrets + `}}`)) +} + +func TestAFamilyIsDeclaredIssuedOutsideWithOneStar(t *testing.T) { + m, err := familyManifest(t, `"smb-password-*":{"path":"/var/lib/mounts/smb-password-*.secret","issued-by":"outside"}`) + if err != nil { + t.Fatalf("a well-formed family was refused: %v", err) + } + if got := m.OwnSecrets.Families(); len(got) != 1 || got[0] != "smb-password-*" { + t.Fatalf("families: %v", got) + } + if len(m.OwnSecrets.Plain()) != 0 { + t.Fatalf("a family counted as a secret declared by name: %v", m.OwnSecrets.Plain()) + } +} + +func TestAMalformedFamilyIsRefused(t *testing.T) { + for name, c := range map[string]struct{ own, says string }{ + "made by the mesh": {`"smb-password-*":{"path":"/s/smb-password-*.secret","taken":"at-start"}`, "issued-by"}, + "no star in its path": {`"smb-password-*":{"path":"/s/smb-password.secret","issued-by":"outside"}`, + "exactly one *"}, + "two stars in its path": {`"smb-password-*":{"path":"/s/*/smb-password-*.secret","issued-by":"outside"}`, + "exactly one *"}, + "a star inside a name": {`"smb*password":{"path":"/s/x","issued-by":"outside"}`, "only ends a family"}, + "a name that is also a member": {`"smb-password-*":{"path":"/s/p-*","issued-by":"outside"}, + "smb-password-games":{"path":"/s/games","issued-by":"outside"}`, "also a member"}, + } { + if _, err := familyManifest(t, c.own); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v; want a refusal saying %q", name, err, c.says) + } + } +} + +func TestAMemberIsFoundByItsFullNameAndLandsWhereTheStarIs(t *testing.T) { + o := OwnSecrets{ + "smb-password-*": {Path: "/s/smb-password-*.secret", IssuedBy: IssuedOutside}, + "smb-password-big-*": {Path: "/big/*.secret", IssuedBy: IssuedOutside}, + "token": {Path: "/s/token", IssuedBy: IssuedOutside}, + } + s, family, ok := o.Lookup("smb-password-games") + if !ok || family != "smb-password-*" || s.Path != "/s/smb-password-games.secret" || s.IssuedBy != IssuedOutside { + t.Fatalf("a member: %+v %q %v", s, family, ok) + } + if s, family, ok := o.Lookup("smb-password-big-one"); !ok || family != "smb-password-big-*" || s.Path != "/big/one.secret" { + t.Fatalf("the longest family that fits: %+v %q %v", s, family, ok) + } + if s, family, ok := o.Lookup("token"); !ok || family != "" || s.Path != "/s/token" { + t.Fatalf("a secret declared by name: %+v %q %v", s, family, ok) + } + for _, name := range []string{"smb-password-*", "smb-password-", "smb-password-../etc", "smb-password-a/b", + "smb-password-a.b", "smb-password-Games", "smb-password--x", "other"} { + if _, _, ok := o.Lookup(name); ok { + t.Errorf("%q was found as a secret", name) + } + } +} + +// A member given is one file at its path; one not given is nothing, and the machine still composes — the mesh never +// makes a member. +func TestAMemberGivenIsPlacedAndOneNotGivenIsNothing(t *testing.T) { + m, err := familyManifest(t, `"smb-password-*":{"path":"/var/lib/mounts/smb-password-*.secret","issued-by":"outside"}`) + if err != nil { + t.Fatal(err) + } + r := Resolution{Node: "workstation", Modules: []Manifest{m}} + out, err := r.Declaration(Rendering{Needed: map[string]map[string]string{"mounts": {"smb-password-games": "sealed"}}}) + if err != nil { + t.Fatalf("a module with one member given did not compose: %v", err) + } + var paths []string + for _, res := range out { + if res["sealed"] != nil { + paths = append(paths, res["path"].(string)) + } + } + if len(paths) != 1 || paths[0] != "/var/lib/mounts/smb-password-games.secret" { + t.Fatalf("placed: %v", paths) + } + out, err = r.Declaration(Rendering{}) + if err != nil { + t.Fatalf("a module with no member given did not compose: %v", err) + } + for _, res := range out { + if res["sealed"] != nil { + t.Fatalf("a member nobody gave was placed: %v", res) + } + } +} diff --git a/internal/catalogue/secrets_into_files.go b/internal/catalogue/secrets_into_files.go index 5b1eb97b..106d7898 100644 --- a/internal/catalogue/secrets_into_files.go +++ b/internal/catalogue/secrets_into_files.go @@ -56,7 +56,7 @@ func secretsUsed(content string) []string { // name would end that, to save writing a file. func sealedFor(m Manifest, needs []Needed, with Rendering) (map[string]string, error) { sealed := map[string]string{} - for name := range m.OwnSecrets { + for name := range m.OwnSecrets.Plain() { if value := with.Needed[m.Module][name]; value != "" { sealed[name] = value } diff --git a/internal/inventory/given.go b/internal/inventory/given.go index fb71c695..fac8574b 100644 --- a/internal/inventory/given.go +++ b/internal/inventory/given.go @@ -155,7 +155,7 @@ func (i *Inventory) ReplaceGivenAfterStart(ctx context.Context, node, declared s } // The definition is asked again now, not only when the value was given: one that has since // said the value is an outside party's, or applied, keeps it as given, and the mark stays gone. - if own, ok := m.OwnSecrets[d.name]; !ok || !own.MeshMayMake() { + if own, _, ok := m.OwnSecrets.Lookup(d.name); !ok || !own.MeshMayMake() { continue } if err := i.remakeOwn(ctx, d.nodeID, key, m, d.module, d.name); err != nil { diff --git a/internal/inventory/health.go b/internal/inventory/health.go index 8f3797a7..1ee5a829 100644 --- a/internal/inventory/health.go +++ b/internal/inventory/health.go @@ -34,6 +34,17 @@ type ResourceHealth struct { // Root is "never" on an account verdict that judged whether the account can become root without a // person (novox/hq ADR 0266). Root string `json:"root,omitempty"` + // Waits is what a waiting resource waits for the operator to give (novox/hq ADR 0283). + Waits []Wait `json:"waits,omitempty"` +} + +// Wait is one thing a waiting resource waits for the operator to give: exactly one of Secret and Setting +// (novox/hq ADR 0283), as link.Wait carries it. +type Wait struct { + Part string `json:"part"` + Secret string `json:"secret,omitempty"` + Setting string `json:"setting,omitempty"` + What string `json:"what"` } // NodeHealth is a machine's newest statement, as kept. diff --git a/internal/inventory/secret_family_test.go b/internal/inventory/secret_family_test.go new file mode 100644 index 00000000..1021dc63 --- /dev/null +++ b/internal/inventory/secret_family_test.go @@ -0,0 +1,76 @@ +package inventory + +import ( + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// A member of a secret family is given by its full name, at the desk too, and a name outside the family is refused +// (novox/hq ADR 0283). +func TestAMemberIsGivableAtTheDeskAndANameOutsideTheFamilyIsNot(t *testing.T) { + m := catalogue.Manifest{Module: "mounts", OwnSecrets: catalogue.OwnSecrets{ + "smb-password-*": {Path: "/s/smb-password-*.secret", IssuedBy: catalogue.IssuedOutside}, + }} + if err := GivableAtDesk(m, "smb-password-games"); err != nil { + t.Fatalf("a member was refused: %v", err) + } + for _, name := range []string{"smb-password-*", "smb-password-", "smb-password-a/b", "smb-password-A", "smb-credentials"} { + err := GivableAtDesk(m, name) + if err == nil { + t.Errorf("%q was givable", name) + } else if !strings.Contains(err.Error(), "smb-password-") { + t.Errorf("%q: the refusal does not say the family's form: %v", name, err) + } + } +} + +// A member given is read back as given, sealed to the machine's key; the mesh makes none, so nothing else is. +func TestAMemberGivenIsReadAsGivenAndNothingElseIs(t *testing.T) { + inv := fresh(t) + ctx := t.Context() + node, err := inv.AddNode(ctx, "workstation") + if err != nil { + t.Fatal(err) + } + key, _ := aSealingKey(t) + if err := inv.RecordSealingKey(ctx, node.ID, key); err != nil { + t.Fatal(err) + } + m := catalogue.Manifest{Module: "mounts", Version: "1", OwnSecrets: catalogue.OwnSecrets{ + "smb-password-*": {Path: "/s/smb-password-*.secret", IssuedBy: catalogue.IssuedOutside}, + "token": {Path: "/s/token", IssuedBy: catalogue.IssuedOutside}, + }} + if err := inv.RegisterModule(ctx, m, Source{}); err != nil { + t.Fatal(err) + } + if err := inv.AcceptSecretForModule(ctx, "workstation", "mounts", "smb-password-games", "hunter2"); err != nil { + t.Fatalf("a member was refused: %v", err) + } + if err := inv.AcceptSecretForModule(ctx, "workstation", "mounts", "smb-pass", "x"); err == nil { + t.Fatal("a name of no family was accepted") + } + members, err := inv.GivenMembers(ctx, "workstation", "mounts", "smb-password-*") + if err != nil { + t.Fatal(err) + } + if len(members) != 1 || members[0].Name != "smb-password-games" || !members[0].Current || members[0].Sealed == "" || + strings.Contains(members[0].Sealed, "hunter2") { + t.Fatalf("members: %+v", members) + } + given, err := inv.GivenOwnSecrets(ctx, "workstation", "mounts") + if err != nil { + t.Fatal(err) + } + if _, was := given["smb-password-games"]; !was || len(given) != 1 { + t.Fatalf("given: %v", given) + } + // A value the mesh made is not one a person gave. + if _, err := inv.SecretForModule(ctx, "workstation", "mounts", "token"); err != nil { + t.Fatal(err) + } + if given, _ := inv.GivenOwnSecrets(ctx, "workstation", "mounts"); len(given) != 1 { + t.Fatalf("a value the mesh made counted as given: %v", given) + } +} diff --git a/internal/inventory/secrets.go b/internal/inventory/secrets.go index 32534e70..80f002b5 100644 --- a/internal/inventory/secrets.go +++ b/internal/inventory/secrets.go @@ -7,6 +7,7 @@ import ( "slices" "sort" "strings" + "time" "github.com/jackc/pgx/v5" @@ -446,7 +447,7 @@ func (i *Inventory) acceptOwn(ctx context.Context, node, module, name, value str if err != nil { return false, err } - own, declared := m.OwnSecrets[name] + own, _, declared := m.OwnSecrets.Lookup(name) if !declared { return false, fmt.Errorf("%s does not declare %q as an own secret; %s — a secret it requires from a provider is accepted with `--provider [--local ]`, the value the running service already uses (novox/hq ADR 0163)", module, name, declaresOwn(m)) } @@ -583,7 +584,7 @@ const BrokerSecret = "broker" // in place of a value given (catalogue.OwnSecret.MeshMayMake). The desk takes only what a person holds and // the mesh cannot make — a bot's token — so nobody is asked to type the mesh's own credential into a prompt. func GivableAtDesk(m catalogue.Manifest, name string) error { - own, ok := m.OwnSecrets[name] + own, _, ok := m.OwnSecrets.Lookup(name) if !ok { return fmt.Errorf("%s does not declare %q as an own secret; %s", m.Module, name, declaresOwn(m)) } @@ -602,7 +603,79 @@ func declaresOwn(m catalogue.Manifest) string { if len(m.OwnSecrets) == 0 { return "it declares no own secrets" } - return "it declares: " + strings.Join(sortedNames(m.OwnSecrets.Paths()), ", ") + names := sortedNames(m.OwnSecrets.Plain().Paths()) + // A family is said as its members are given (novox/hq ADR 0283): one per part, by its full name. + for _, f := range m.OwnSecrets.Families() { + names = append(names, catalogue.FamilyPrefix(f)+" (one per part, issued outside the mesh)") + } + return "it declares: " + strings.Join(names, ", ") +} + +// GivenMember is one member of a secret family given on a machine (novox/hq ADR 0283): its full name, its value +// sealed to the machine, and whether it is sealed to the key the machine holds now. +type GivenMember struct { + Name string + Sealed string + Current bool +} + +// GivenMembers is every member of a module's secret family given on a machine, by name. The mesh never makes a +// member, so only a value a person gave is one; a machine with no sealing key holds none. +func (i *Inventory) GivenMembers(ctx context.Context, node, module, family string) ([]GivenMember, error) { + key, err := i.SealingKeyOf(ctx, node) + if err != nil || key == "" { + return nil, err + } + record, err := i.NodeByName(ctx, node) + if err != nil { + return nil, err + } + prefix := catalogue.FamilyPrefix(family) + rows, err := i.store.Pool().Query(ctx, + `select name, sealed, node_key from module_secret + where node = $1 and module = $2 and origin = 'accepted' and left(name, length($3)) = $3 + order by name`, record.ID, module, prefix) + if err != nil { + return nil, err + } + defer rows.Close() + var out []GivenMember + for rows.Next() { + var g GivenMember + var against string + if err := rows.Scan(&g.Name, &g.Sealed, &against); err != nil { + return nil, err + } + g.Current = against == key + out = append(out, g) + } + return out, rows.Err() +} + +// GivenOwnSecrets is every own secret of a module a person gave on a machine, by name, with when (novox/hq ADR +// 0283): what the controller checks a module's wait for a secret against. A value the mesh made is not one. +func (i *Inventory) GivenOwnSecrets(ctx context.Context, node, module string) (map[string]time.Time, error) { + record, err := i.NodeByName(ctx, node) + if err != nil { + return nil, err + } + rows, err := i.store.Pool().Query(ctx, + `select name, coalesce(made_at, now()) from module_secret where node = $1 and module = $2 and origin = 'accepted'`, + record.ID, module) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]time.Time{} + for rows.Next() { + var name string + var at time.Time + if err := rows.Scan(&name, &at); err != nil { + return nil, err + } + out[name] = at + } + return out, rows.Err() } func sortedNames(of map[string]string) []string { @@ -645,7 +718,7 @@ func (i *Inventory) RotateModuleSecret(ctx context.Context, node, module, name s if err != nil { return err } - own, declared := m.OwnSecrets[name] + own, _, declared := m.OwnSecrets.Lookup(name) if !declared { return fmt.Errorf("%s does not declare %q as an own secret; %s — a secret it requires from a provider is accepted with `--provider [--local ]`, the value the running service already uses (novox/hq ADR 0163)", module, name, declaresOwn(m)) } diff --git a/internal/link/protocol.go b/internal/link/protocol.go index 7ce043de..b64a9a72 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -556,8 +556,21 @@ const ( StateStarting = "starting" StateHeld = "held" StateUnknown = "unknown" + // StateWaiting is a resource whose module's tool check says it waits for the operator: a named own secret + // or setting not given yet (novox/hq ADR 0283). Not a fault; the controller checks the wait before it + // excuses it. + StateWaiting = "waiting" ) +// Wait is one thing a waiting resource waits for the operator to give: exactly one of Secret and Setting, the +// part that waits and what the operator gives, in words (novox/hq ADR 0283; mesh-sdk go/health's shape). +type Wait struct { + Part string `json:"part"` + Secret string `json:"secret,omitempty"` + Setting string `json:"setting,omitempty"` + What string `json:"what"` +} + // ResourceHealth is one long-running resource's state. type ResourceHealth struct { Module string `json:"module"` @@ -581,6 +594,9 @@ type ResourceHealth struct { // without a person (novox/hq ADR 0266): the engine judged that too, and a healthy verdict says it cannot. // Empty from an engine older than RootContract, and on every other verdict. Root string `json:"root,omitempty"` + // Waits is what a resource in StateWaiting waits for (novox/hq ADR 0283). Empty otherwise, and from an + // engine older than that. + Waits []Wait `json:"waits,omitempty"` } // HealthSaid is the health event's body: the machine and its statement. The machine is read from the