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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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`")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]...)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user