Merge pull request 'Say a part waiting for the operator as needs-operator and pass its gate; add secret families (issue 386, ADR 0283)' (#209) from feat/386-a-wait-for-the-operator into main
This commit was merged in pull request #209.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <directory>` on the control-node)", machine, module, strings.Join(found, ", "), machine))
|
||||
|
||||
@@ -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.<module>.<machine>.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
|
||||
// "<module>@<machine>" 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \"<prefix>-*\"", 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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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-<name>") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 <node> [--local <name>]`, 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)+"<name> (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 <node> [--local <name>]`, the value the running service already uses (novox/hq ADR 0163)", module, name, declaresOwn(m))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user