From b9fc09c37539ffda8d0f9af3751c3093ed617c38 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 01:23:44 +0200 Subject: [PATCH] Derive the terminal's settings from what a module serves and which files it trusts; a found directory is its own condition The third review of #170 (hq issue 339): a setting overrides any key a provider serves, so any caller of the settings verb could move a database's port, a registry's port or an issuer to a listener of its own and collect what consumers present. TerminalKeys now derives from the manifest: places, accesses, every served key and every setting a served value asks for, and every setting a file marked `trusted` asks for. `trusted` is the catalogue's word, taken out before the declaration; `module check` warns of a file that asks for a setting without saying, and refuses it from 2026-10-30. The hand list is gone. A directory used as found is now its own condition kind, the operator's, never urgent, and the gate exempts it where it exempts a relogin. --- cmd/mesh-controller/check.go | 35 ++++++ cmd/mesh-controller/found_wait_test.go | 52 ++++++++ cmd/mesh-controller/gate.go | 10 +- cmd/mesh-controller/module_health.go | 67 ++++++++++- cmd/mesh-controller/modules.go | 18 ++- cmd/mesh-controller/plain_words.go | 8 ++ cmd/mesh-controller/terminal_settings_test.go | 50 +++++--- internal/catalogue/declaration.go | 1 + internal/catalogue/placement.go | 21 ---- internal/catalogue/settings.go | 1 + internal/catalogue/terminal_keys.go | 112 ++++++++++++++++++ internal/catalogue/terminal_settings_test.go | 51 ++++++-- 12 files changed, 368 insertions(+), 58 deletions(-) create mode 100644 internal/catalogue/terminal_keys.go diff --git a/cmd/mesh-controller/check.go b/cmd/mesh-controller/check.go index 31c0eec8..d53d3a82 100644 --- a/cmd/mesh-controller/check.go +++ b/cmd/mesh-controller/check.go @@ -65,6 +65,13 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error { } // A definition names no installation (novox/hq ADR 0112, ADR 0155): judged here, in the // catalogue-wide test, and at registration, which refuses in the same words. + if wrong := catalogue.TrustProblems(m); len(wrong) > 0 { + for _, p := range wrong { + fmt.Fprintf(out, "%s: %s\n", path, p) + } + failed += len(wrong) + faulted[m.Module] = true + } if named := catalogue.InstallationProblems(m); len(named) > 0 { for _, p := range named { fmt.Fprintf(out, "%s: %s\n", path, p) @@ -130,6 +137,25 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error { } } + // **Every file that asks for a setting says whether it is trusted** (novox/hq issue 339): warned until the + // date, refused from it, and counted for the catalogue's merge check like the resources without health. + unsaid := 0 + trustRequired := !checkNow().Before(catalogue.TrustRequiredFrom) + for _, name := range names { + missing := catalogue.UnsaidTrust(shelf[name]) + unsaid += len(missing) + if trustRequired { + for _, id := range missing { + fmt.Fprintf(out, "%s: the file %s asks for a setting and does not say whether it is trusted: say "+ + "%q true or false (novox/hq issue 339)\n", name, id, catalogue.TrustedField) + } + if len(missing) > 0 { + failed += len(missing) + faulted[name] = true + } + } + } + for _, name := range names { m := shelf[name] if faulted[name] { @@ -171,6 +197,10 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error { if len(checks) > 0 { fmt.Fprintf(out, ", ready: %s", strings.Join(checks, "; ")) } + if missing := catalogue.UnsaidTrust(m); len(missing) > 0 { + fmt.Fprintf(out, "; WARNING: %s ask(s) for a setting and say(s) not whether it is trusted, refused from "+ + "%s (novox/hq issue 339)", strings.Join(missing, ", "), catalogue.TrustRequiredFrom.Format("2006-01-02")) + } if missing := catalogue.Undeclared(m); len(missing) > 0 { fmt.Fprintf(out, "; WARNING: %s stay(s) up and say(s) not how it is ready — judged by liveness alone, "+ "refused from %s (ADR 0240 rule 8)", strings.Join(missing, ", "), catalogue.HealthRequiredFrom.Format("2006-01-02")) @@ -179,6 +209,7 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error { } // The count the catalogue keeps (ADR 0240 rule 8), in a line its merge check reads. fmt.Fprintf(out, "%s %d\n", UndeclaredHealthLine, undeclared) + fmt.Fprintf(out, "%s %d\n", UnsaidTrustLine, unsaid) if failed > 0 { return fmt.Errorf("%d problem(s) in %d manifest(s)", failed, len(paths)) } @@ -193,6 +224,10 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error { // `health` in, over the manifests given: the catalogue's merge check compares it with the number it keeps. const UndeclaredHealthLine = "long-running resources without health:" +// UnsaidTrustLine starts the line `module check` says the count of files that ask for a setting and do not say +// whether it is trusted (novox/hq issue 339). +const UnsaidTrustLine = "files asking for a setting without saying whether it is trusted:" + // checkNow is the clock `module check` judges the date by; a test sets it. var checkNow = time.Now diff --git a/cmd/mesh-controller/found_wait_test.go b/cmd/mesh-controller/found_wait_test.go index 10d7b5dd..e383194b 100644 --- a/cmd/mesh-controller/found_wait_test.go +++ b/cmd/mesh-controller/found_wait_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) @@ -65,6 +66,12 @@ func TestAFixGoesThroughPastADirectoryFoundBefore(t *testing.T) { backlogFacts := gatherGateFacts gatherGateFacts = func(ctx context.Context, open *stores, component string) (gateFacts, error) { f, err := backlogFacts(ctx, open, component) + // The used-as-found condition, raised after the send (its second statement): its own kind, never a + // fault the gate reads as the build's. + f.judged, f.openErr = true, nil + f.open = append(f.open, conditions.Condition{Key: usedAsFoundKey("app", "anchor"), Kind: kindUsedAsFound, + Subject: conditions.Subject{Scope: conditions.ScopeModule, ID: "app.anchor", Machine: "anchor"}, + Raised: time.Now()}) f.health = map[string]inventory.NodeHealth{} for _, n := range []string{"anchor", "laptop"} { f.health[n] = inventory.NodeHealth{Node: n, HeardAt: time.Now(), Resources: []inventory.ResourceHealth{ @@ -102,3 +109,48 @@ func TestAFixGoesThroughPastADirectoryFoundBefore(t *testing.T) { }) } } + +// The condition says the wait in its own kind: the operator's, never urgent, and cleared once handed over. +func TestADirectoryUsedAsFoundIsItsOwnCondition(t *testing.T) { + k, _ := withConditionsInMemory(t) + ctx := t.Context() + rs := map[string][]inventory.ResourceHealth{"notes": {foundDirectory("notes", time.Now().Add(-time.Hour))}} + for i := 0; i < 2; i++ { + if err := judgeModuleHealth(ctx, nil, k, "laptop", rs, map[string]int{"notes": i + 1}, time.Now()); err != nil { + t.Fatal(err) + } + } + open, _ := k.Open(ctx) + var got *conditions.Condition + for i, c := range open { + if c.Key == usedAsFoundKey("notes", "laptop") { + got = &open[i] + } + if c.Kind == kindModuleUnhealthy { + t.Fatalf("raised as a fault: %+v", c) + } + } + if got == nil || got.Resolver != conditions.ResolverOperator || got.Severity == conditions.Urgent || + !strings.Contains(got.Summary, "notes.data") { + t.Fatalf("the condition: %+v", got) + } + // Long open is still not urgent: only a person can hand it over, and nothing is broken by the wait. + if err := judgeModuleHealth(ctx, nil, k, "laptop", rs, map[string]int{"notes": 3}, time.Now().Add(48*time.Hour)); err != nil { + t.Fatal(err) + } + open, _ = k.Open(ctx) + for _, c := range open { + if c.Key == usedAsFoundKey("notes", "laptop") && c.Severity == conditions.Urgent { + t.Fatal("a directory used as found became urgent") + } + } + if err := judgeModuleHealth(ctx, nil, k, "laptop", map[string][]inventory.ResourceHealth{}, nil, time.Now()); err != nil { + t.Fatal(err) + } + open, _ = k.Open(ctx) + for _, c := range open { + if c.Key == usedAsFoundKey("notes", "laptop") { + t.Fatal("not cleared once handed over") + } + } +} diff --git a/cmd/mesh-controller/gate.go b/cmd/mesh-controller/gate.go index d9d71bcc..f47929cf 100644 --- a/cmd/mesh-controller/gate.go +++ b/cmd/mesh-controller/gate.go @@ -216,9 +216,10 @@ 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 is the module's reading, not a fault raised since the send: the - // gate reads it from the statement below (ADR 0254). - if c.Source == gateProbe || c.Raised.Before(since) || c.Kind == kindReloginNeeded { + // 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.Raised.Before(since) || c.Kind == kindReloginNeeded || c.Kind == kindUsedAsFound { continue } onIt := c.Subject.Machine == machine || slices.Contains(c.Subject.Also, machine) || @@ -329,7 +330,8 @@ func aboutTheMachine(machine string, moved []string, since time.Time, f gateFact for _, c := range f.open { aboutIt := c.Subject.Scope == conditions.ScopeMachine && (c.Subject.ID == machine || c.Subject.Machine == machine || slices.Contains(c.Subject.Also, machine)) - if !aboutIt || c.Source == gateProbe || c.Raised.Before(since) { + // A directory used as found waits for a person, whatever the send did (novox/hq issue 339). + if !aboutIt || c.Source == gateProbe || c.Raised.Before(since) || c.Kind == kindUsedAsFound { kept = append(kept, c) continue } diff --git a/cmd/mesh-controller/module_health.go b/cmd/mesh-controller/module_health.go index 25717cab..5a6759ce 100644 --- a/cmd/mesh-controller/module_health.go +++ b/cmd/mesh-controller/module_health.go @@ -135,7 +135,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.Subject.Machine == node { + if (c.Kind == kindModuleUnhealthy || c.Kind == kindReloginNeeded || c.Kind == kindUsedAsFound) && + c.Subject.Machine == node { standing[c.Key] = c } } @@ -158,6 +159,21 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi heldOn := map[string]string{} providers := map[catalogue.Chosen]bool{} for _, m := range modules { + // **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. + if said, waits := foundWait(m, node, unhealthy[m]); waits { + o := usedAsFoundObservation(m, node, said, unhealthy[m]) + seen[o.Key()] = true + became[m] = kindUsedAsFound + 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 wait for a person's new login is said as that** (novox/hq ADR 0254): one plain sentence to the // operator, never urgent, cleared on the first statement that no longer says it. if said, waits := personWait(m, node, unhealthy[m]); waits { @@ -209,6 +225,9 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi if c.Kind == kindReloginNeeded { why = fmt.Sprintf("%s says %s no longer waits for a new login", node, module) } + if c.Kind == kindUsedAsFound { + why = fmt.Sprintf("%s says no directory of %s is used as found any more", 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) } @@ -617,3 +636,49 @@ func addsAccountGroups(from catalogue.Manifest, hadFrom bool, to catalogue.Manif } return false } + +// kindUsedAsFound is a module's condition while the node-engine uses one of its directories as found (novox/hq +// issue 339): its own kind, the operator's, never urgent, and never read by the gate as a fault of a build. +const kindUsedAsFound = "directory-used-as-found" + +// usedAsFoundKey is a module's used-as-found condition on a machine. +func usedAsFoundKey(module, node string) string { + return conditions.Key(conditions.ScopeModule, module+"."+node, kindUsedAsFound) +} + +// foundWait is whether everything unhealthy of a module on a machine is a directory used as found, and that in +// one sentence. Anything else unhealthy beside it is judged as a fault, with the directory among its resources. +func foundWait(module, node string, rs []inventory.ResourceHealth) (string, bool) { + var ids, why []string + for _, r := range rs { + if r.Module != module || r.State != link.StateUnhealthy { + continue + } + if !usedAsFound(r) { + return "", false + } + ids = append(ids, r.Resource) + why = append(why, strings.TrimSpace(strings.TrimPrefix(r.Reason, link.ReasonUsedAsFound))) + } + if len(ids) == 0 { + return "", false + } + return fmt.Sprintf("%s on %s uses %s as found: %s", module, node, strings.Join(ids, ", "), + strings.Join(why, "; ")), true +} + +// usedAsFoundObservation is a module whose directory the node-engine uses as found, in words: the operator's, a +// warning however long it stays, its summary naming the directories and their owners; the paths are evidence. +func usedAsFoundObservation(module, node, said string, rs []inventory.ResourceHealth) conditions.Observation { + o := moduleUnhealthyObservation(module, node, rs) + o.Token, o.Kind, o.Resolver, o.Severity, o.Summary = kindUsedAsFound, kindUsedAsFound, conditions.ResolverOperator, + conditions.Warning, said + o.Headline = fmt.Sprintf("%s waits for a directory on %s", module, node) + o.Explanation = fmt.Sprintf("A directory of %s was already on %s, with another owner or mode than %s declares. "+ + "The mesh left it as it was rather than hand it to an account, so %s may not be able to use it.", + module, node, module, module) + o.Needs = fmt.Sprintf("on %s, run mesh-host hand-over with the directory's path as root.", node) + o.Resolved = fmt.Sprintf("%s's directory on %s is the mesh's", module, node) + o.Actions = nil + return o +} diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index 352d6227..442d1b47 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -445,7 +445,7 @@ func settingsCommand(ctx context.Context, args []string) error { if err != nil { return err } - if err := refuseTerminalSettingsThroughAVerb(before, values, positionals[0], where); err != nil { + if err := refuseTerminalSettingsThroughAVerb(ctx, inv, before, values, positionals[0], where); err != nil { return err } added, changed, removed := settingsChange(before, values) @@ -603,7 +603,7 @@ func settingsCommand(ctx context.Context, args []string) error { if err != nil { return err } - if err := refuseTerminalSettingsThroughAVerb(before, nil, positionals[0], where); err != nil { + if err := refuseTerminalSettingsThroughAVerb(ctx, inv, before, nil, positionals[0], where); err != nil { return err } if err := inv.ClearSettings(ctx, *node, positionals[0]); err != nil { @@ -998,6 +998,9 @@ func whereItComesFrom(repository, ref, commit, path string, self bool) (inventor // definition that got past the check — written elsewhere, or checked by nobody — is refused here // in the same words. A name meant on purpose is declared with its reason and passes. func namesNoInstallation(m catalogue.Manifest) error { + if problems := catalogue.TrustProblems(m); len(problems) > 0 { + return fmt.Errorf("%s", strings.Join(problems, "; ")) + } named := catalogue.InstallationProblems(m) if len(named) == 0 { return nil @@ -1080,12 +1083,19 @@ func throughAVerb() (string, bool) { // refuseTerminalSettingsThroughAVerb refuses a layer change through a verb that would add, change or remove // places or accesses; a change that leaves both as they were is not refused. -func refuseTerminalSettingsThroughAVerb(before, after map[string]any, module, where string) error { +func refuseTerminalSettingsThroughAVerb(ctx context.Context, inv *inventory.Inventory, before, after map[string]any, + module, where string) error { verb, through := throughAVerb() if !through { return nil } - for _, key := range catalogue.TerminalKeys(module) { + // Judged against the module's definition as the catalogue holds it: what it serves and which of its files + // are trusted. A catalogue that cannot be read refuses rather than judging against nothing. + shelf, err := inv.Catalogue(ctx) + if err != nil { + return fmt.Errorf("which settings of %s are the terminal's cannot be read, so nothing was changed: %w", module, err) + } + for _, key := range catalogue.TerminalKeys(shelf[module]) { was, _ := json.Marshal(before[key]) now, _ := json.Marshal(after[key]) if string(was) == string(now) { diff --git a/cmd/mesh-controller/plain_words.go b/cmd/mesh-controller/plain_words.go index 81a568eb..faec2bbb 100644 --- a/cmd/mesh-controller/plain_words.go +++ b/cmd/mesh-controller/plain_words.go @@ -204,6 +204,14 @@ var plainWordings = map[string]func(conditions.Observation) words{ } return reloginWords(orModule(module), machineOr(o, "a machine"), false) }), + kindUsedAsFound: worded(func(o conditions.Observation) words { + module := "" + if o.Scope == conditions.ScopeModule && o.Machine != "" { + module = strings.TrimSuffix(o.ID, "."+o.Machine) + } + 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} + }), kindProviderFailing: worded(func(o conditions.Observation) words { thing, consumer := conditions.ThingWords(o), idPart(o, 2) if consumer == "" { diff --git a/cmd/mesh-controller/terminal_settings_test.go b/cmd/mesh-controller/terminal_settings_test.go index 79e6461f..849979a6 100644 --- a/cmd/mesh-controller/terminal_settings_test.go +++ b/cmd/mesh-controller/terminal_settings_test.go @@ -151,22 +151,33 @@ func TestPlacesAndAccessesAreRefusedThroughEveryVerb(t *testing.T) { } } -// The mesh's trust anchors are set at the terminal alone (novox/hq issue 339): through the settings verb, a caller -// could replace the internal authority's root every consumer trusts, or the issuer every login is checked against. -func TestATrustAnchorIsRefusedThroughAVerb(t *testing.T) { +// What a provider serves is set at the terminal alone (novox/hq issue 339): through the settings verb, a caller +// could move a database's port to a listener of its own and collect every consumer's credentials, or point every +// login at an issuer of its own. +func TestAServedKeyIsRefusedThroughAVerb(t *testing.T) { open := aMesh(t) ctx := t.Context() - register(t, open, catalogue.Manifest{Module: "step-ca", Version: "1", - Provides: catalogue.FromAnywhere("acme-ca"), - Serves: map[string]map[string]any{"acme-ca": {"root": "", "path": "/acme/acme/directory"}}, - Resources: []map[string]any{{"id": "rc", "type": "file", "path": "/etc/step.conf", "mode": "0644", - "content": "x = ${setting:x}\n"}}}) + register(t, open, catalogue.Manifest{Module: "store", Version: "1", + Provides: catalogue.FromAnywhere("database"), + Serves: map[string]map[string]any{"database": {"port": 5432.0}}, + Resources: []map[string]any{{"id": "rc", "type": "file", "path": "/etc/store.conf", "mode": "0644", + "trusted": false, "content": "x = ${setting:x}\n"}}}) register(t, open, catalogue.Manifest{Module: "keycloak", Version: "1", Provides: catalogue.FromAnywhere("oidc-client"), Serves: map[string]map[string]any{"oidc-client": {"issuer": "${setting:issuer}"}}, Resources: []map[string]any{{"id": "rc", "type": "file", "path": "/etc/kc.conf", "mode": "0644", - "content": "issuer = ${setting:issuer}\nx = ${setting:x}\n"}}}) - for _, m := range []string{"step-ca", "keycloak"} { + "trusted": false, "content": "x = ${setting:x}\n"}}}) + register(t, open, catalogue.Manifest{Module: "power", Version: "1", + Resources: []map[string]any{{"id": "logind", "type": "file", "path": "/etc/systemd/logind.conf.d/power.conf", + "mode": "0644", "trusted": true, "content": "HandleLidSwitch=${setting:lid}\nx=${setting:x}\n"}}}) + if err := atTheTerminal(t, "settings", "set", "keycloak", `{"issuer":"https://id.example/realms/mesh","x":0}`, + "--node", "anchor"); err != nil { + t.Fatalf("the issuer at the terminal: %v", err) + } + if err := atTheTerminal(t, "settings", "set", "power", `{"lid":"suspend","x":0}`, "--node", "anchor"); err != nil { + t.Fatal(err) + } + for _, m := range []string{"store", "keycloak", "power"} { if _, err := assign(ctx, open, "anchor", m); err != nil { t.Fatal(err) } @@ -177,20 +188,23 @@ func TestATrustAnchorIsRefusedThroughAVerb(t *testing.T) { t.Fatalf("%s: %v", what, err) } } - if err := atTheTerminal(t, "settings", "set", "keycloak", `{"issuer":"https://id.example/realms/mesh","x":0}`, - "--node", "anchor"); err != nil { - t.Fatalf("the issuer at the terminal: %v", err) - } + refused("a served port through the verb", throughVerb(t, "settings", map[string]any{"module": "store", + "node": "anchor", "values": `{"port":6543,"x":0}`})) + refused("a served port, mesh-wide, through the verb", throughVerb(t, "settings", + map[string]any{"module": "store", "values": `{"port":6543}`})) refused("the issuer through the verb", throughVerb(t, "settings", map[string]any{"module": "keycloak", "node": "anchor", "values": `{"issuer":"https://evil.example/realms/mesh","x":0}`})) - refused("the authority's root path through the verb", throughVerb(t, "settings", map[string]any{"module": "step-ca", - "node": "anchor", "values": `{"path":"/evil","x":0}`})) - refused("the authority's root path, mesh-wide, through the verb", throughVerb(t, "settings", - map[string]any{"module": "step-ca", "values": `{"path":"/evil"}`})) refused("clearing the issuer through the verb", throughVerb(t, "settings", map[string]any{"module": "keycloak", "node": "anchor", "clear": "true"})) if err := throughVerb(t, "settings", map[string]any{"module": "keycloak", "node": "anchor", "values": `{"issuer":"https://id.example/realms/mesh","x":1}`}); err != nil { t.Fatalf("another key through the verb, the issuer kept: %v", err) } + refused("a setting a trusted file asks for, through the verb", throughVerb(t, "settings", map[string]any{ + "module": "power", "node": "anchor", "values": `{"lid":"ignore","x":0}`})) + // And `trusted` is the catalogue's word: it never reaches the machine, whose engine parses strictly. + plan := printed(t, func() error { return atTheTerminal(t, "plan", "anchor", "--json") }) + if strings.Contains(plan, `"trusted"`) { + t.Fatal("the declaration carries the catalogue's `trusted`") + } } diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 85a57b24..9e8b5cc7 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -913,6 +913,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string, // such field, and the reason is for a reader of the manifest. delete(copied, SecretsInEnvironment) delete(copied, NamesOnPurpose) + delete(copied, TrustedField) // **An operator's value, from the assignment** (novox/hq ADR 0112, ADR 0155): what a // definition may not carry because it is true of one installation only. Filled from // the same layers a mergeable file takes, and refused when no layer set it. diff --git a/internal/catalogue/placement.go b/internal/catalogue/placement.go index 24781dd0..b552ba30 100644 --- a/internal/catalogue/placement.go +++ b/internal/catalogue/placement.go @@ -438,24 +438,3 @@ func namesOfAccessIDs(accesses map[string]string) []string { sort.Strings(names) return names } - -// trustAnchors are the settings a provider serves its consumers as what they trust, by module (novox/hq issue -// 339): set through a verb, any caller could point every consumer at an authority or an issuer of its own. -// -// - step-ca, the mesh's internal ACME authority: `root`, the root a consumer is handed to trust (the one -// setting that may hold lines, settingsHoldOneLine); `roots` and `path`, where a consumer fetches the roots -// and the ACME directory from, which a setting may override as it may any served fact. -// - keycloak, the identity provider: `issuer`, the issuer every OIDC consumer checks a login's token against. -// -// Named here, not in the manifests, because no manifest field says "this is trusted" yet; the catalogue was read -// for every served fact and every ${setting:…} on 2026-10-09, and these are the ones a consumer trusts. -var trustAnchors = map[string][]string{ - rootModule: {rootSetting, "roots", "path"}, - "keycloak": {"issuer"}, -} - -// TerminalKeys are the settings keys of a module that are set at the controller's terminal alone, never through -// a verb (novox/hq issue 339): places and accesses for every module, and a provider's trust anchors. -func TerminalKeys(module string) []string { - return append([]string{PlacesSetting, AccessesSetting}, trustAnchors[module]...) -} diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go index 2fab2177..534c7424 100644 --- a/internal/catalogue/settings.go +++ b/internal/catalogue/settings.go @@ -90,6 +90,7 @@ func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, err out["content"] = string(rendered) + "\n" delete(out, "merge") delete(out, "protected") + delete(out, TrustedField) return out, nil } diff --git a/internal/catalogue/terminal_keys.go b/internal/catalogue/terminal_keys.go new file mode 100644 index 00000000..cd294c0c --- /dev/null +++ b/internal/catalogue/terminal_keys.go @@ -0,0 +1,112 @@ +package catalogue + +import ( + "fmt" + "sort" + "time" +) + +// Which settings are the controller's terminal's alone (novox/hq issue 339). +// +// A setting is the operator's word on how a module is configured, and the `settings` verb writes it for any +// caller allowed to call verbs — agents among them. Most settings change only the module itself. Some change +// what root or another module trusts, and those are said at the terminal alone, never through a verb: +// +// 1. `places` and `accesses`: where the node-engine creates and, as root, owns a module's directories, and +// which of the machine's paths are mounted into its container. +// 2. **Every key a provider serves**, and every setting a served value asks for. A setting overrides a served +// key (Settle), and what is served is what every consumer of the provision connects to and believes: a +// database's port, an object store's scheme, a registry's port, an identity provider's issuer and token +// path. Through a verb, any caller could point every consumer at a listener of its own and collect the +// credentials they present. +// 3. **Every setting a file marked `trusted` asks for**: a file root or a consumer trusts — a logind drop-in, +// an env file that says which uid a container runs as, a script run as root. The manifest says so on the +// file (TrustedField), and `module check` names a file that asks for a setting without saying. +// +// Derived from the manifest, never listed by hand, so a provider or a trusted file added tomorrow is covered. + +// TrustedField is the key a file resource carries to say whether the settings it asks for are trusted: true +// makes each a terminal key; false says, out loud, that none changes what root or a consumer trusts. Said in the +// catalogue, never on the machine: the composer takes it out before the node-engine, which parses strictly. +const TrustedField = "trusted" + +// TrustRequiredFrom is when `module check` refuses a file that asks for a setting and does not say whether it is +// trusted. Until then it is warned and counted: the catalogue's files get the field in their own change, which +// can only land once a controller that takes the field out of the declaration runs. +var TrustRequiredFrom = time.Date(2026, 10, 30, 0, 0, 0, 0, time.UTC) + +// TerminalKeys are the settings keys of a module that are set at the controller's terminal alone: places and +// accesses, every key its provisions serve and every setting a served value asks for, and every setting a file +// marked trusted asks for. Places and accesses first, then the rest sorted. +func TerminalKeys(m Manifest) []string { + keys := map[string]bool{} + for _, served := range m.Serves { + for key, value := range served { + keys[key] = true + if s, ok := value.(string); ok { + for _, asked := range settingsUsed(s) { + keys[asked] = true + } + } + } + } + for _, r := range m.Resources { + if trusted, _ := r[TrustedField].(bool); !trusted || fmt.Sprint(r["type"]) != "file" { + continue + } + if content, ok := r["content"].(string); ok { + for _, asked := range settingsUsed(content) { + keys[asked] = true + } + } + } + delete(keys, PlacesSetting) + delete(keys, AccessesSetting) + rest := make([]string, 0, len(keys)) + for k := range keys { + rest = append(rest, k) + } + sort.Strings(rest) + return append([]string{PlacesSetting, AccessesSetting}, rest...) +} + +// UnsaidTrust is every file of a module that asks for a setting and does not say whether it is trusted, by id. +func UnsaidTrust(m Manifest) []string { + var out []string + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "file" { + continue + } + content, _ := r["content"].(string) + if len(settingsUsed(content)) == 0 { + continue + } + if _, said := r[TrustedField]; !said { + out = append(out, fmt.Sprint(r["id"])) + } + } + sort.Strings(out) + return out +} + +// TrustProblems are the ways a manifest states `trusted` wrongly: anything but true or false, or on anything but +// a file. Refused at registration and by `module check`. +func TrustProblems(m Manifest) []string { + var out []string + for _, r := range m.Resources { + v, said := r[TrustedField] + if !said { + continue + } + if fmt.Sprint(r["type"]) != "file" { + out = append(out, fmt.Sprintf("%s: %v is a %v and says %q; only a file says whether the settings it "+ + "asks for are trusted (novox/hq issue 339)", m.Module, r["id"], r["type"], TrustedField)) + continue + } + if _, ok := v.(bool); !ok { + out = append(out, fmt.Sprintf("%s: %v says %q as %v; it is true or false (novox/hq issue 339)", + m.Module, r["id"], TrustedField, v)) + } + } + return out +} diff --git a/internal/catalogue/terminal_settings_test.go b/internal/catalogue/terminal_settings_test.go index a8a722f2..79950ff7 100644 --- a/internal/catalogue/terminal_settings_test.go +++ b/internal/catalogue/terminal_settings_test.go @@ -128,17 +128,48 @@ func TestTheRuntimesDataAndAnyHomesSSHAreTheMachinesOwn(t *testing.T) { } } -// A trust anchor the mesh hands its consumers is the terminal's too (novox/hq issue 339): the authority's root, -// where its roots and directory are, and the identity provider's issuer. -func TestTrustAnchorsAreTerminalKeys(t *testing.T) { - for module, keys := range map[string][]string{ - "step-ca": {"places", "accesses", "root", "roots", "path"}, - "keycloak": {"places", "accesses", "issuer"}, - "mailu": {"places", "accesses"}, +// What a provider serves its consumers is the terminal's (novox/hq issue 339): any key under its `serves`, and any +// setting a served value asks for, is set at the terminal alone — a verb that could change a port could point every +// consumer at a listener of the caller's own. So is any setting a file marked `trusted` asks for. +func TestTerminalKeysAreDerived(t *testing.T) { + postgres := Manifest{Module: "postgres", Serves: map[string]map[string]any{"postgres-database": {"port": 5432.0}}} + keycloak := Manifest{Module: "keycloak", Serves: map[string]map[string]any{"oidc-client": { + "issuer": "${setting:issuer}", "token-path": "/protocol/openid-connect/token"}}} + power := Manifest{Module: "power", Resources: []map[string]any{ + {"id": "logind", "type": "file", "path": "/etc/systemd/logind.conf.d/power.conf", "trusted": true, + "content": "HandleLidSwitch=${setting:handle-lid-switch}\n"}, + {"id": "note", "type": "file", "path": "/var/lib/power/note", "trusted": false, "content": "${setting:greeting}\n"}}} + for _, c := range []struct { + m Manifest + want string + }{ + {postgres, "places,accesses,port"}, + {keycloak, "places,accesses,issuer,token-path"}, + {power, "places,accesses,handle-lid-switch"}, + {Manifest{Module: "plain"}, "places,accesses"}, } { - got := TerminalKeys(module) - if strings.Join(got, ",") != strings.Join(keys, ",") { - t.Errorf("%s: %v; want %v", module, got, keys) + if got := strings.Join(TerminalKeys(c.m), ","); got != c.want { + t.Errorf("%s: %s; want %s", c.m.Module, got, c.want) + } + } +} + +// A file that asks for a setting says whether what it asks is trusted (novox/hq issue 339): `trusted` is a +// boolean, on a file alone, and a file asking for a setting without it is named. +func TestAFileSaysWhetherItsSettingsAreTrusted(t *testing.T) { + m := Manifest{Module: "power", Resources: []map[string]any{ + {"id": "said", "type": "file", "path": "/etc/a", "trusted": true, "content": "${setting:a}"}, + {"id": "unsaid", "type": "file", "path": "/etc/b", "content": "${setting:b}"}, + {"id": "no-setting", "type": "file", "path": "/etc/c", "content": "plain"}}} + if got := strings.Join(UnsaidTrust(m), ","); got != "unsaid" { + t.Errorf("unsaid: %s; want unsaid", got) + } + for _, bad := range []map[string]any{ + {"id": "x", "type": "file", "path": "/etc/x", "trusted": "yes", "content": "${setting:a}"}, + {"id": "y", "type": "directory", "trusted": true}, + } { + if problems := TrustProblems(Manifest{Module: "power", Resources: []map[string]any{bad}}); len(problems) == 0 { + t.Errorf("%v was taken", bad) } } }