diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 1fbda4b..49292c5 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -98,9 +98,10 @@ type SignersReader interface { // HandedReader is a Reader that also says, of the composing node's modules, which the vault judged handed to the // provisioner when it answered OwnSecrets (the vault's `own`, its `handed`: catalogue.AssignmentHands of the plan in -// each module's assignment). The node's composition then hands exactly those (compose.Node.Handed, issue 774), so the -// vault and the node never judge one module two ways. Nil, or a Reader without it (a vault older than the field), -// composes by the manifest and the machine, as before. +// each module's assignment). Never the rule: the node hands by the manifest, its build and the machine +// (resolution.Handing, issue 1966), and the plans it composes are what the vault reads next; the vault's word is said +// where the two disagree (resolution.VaultDisagrees). Nil, or a Reader without it (a vault older than the field), says +// nothing. type HandedReader interface { Handed(ctx context.Context, node string) map[string]bool } diff --git a/internal/compose/input.go b/internal/compose/input.go index 2898d77..826e1e8 100644 --- a/internal/compose/input.go +++ b/internal/compose/input.go @@ -159,8 +159,9 @@ type Node struct { // Assigned is the modules assigned to it, in the order the controller reads them (inv.Assigned). Assigned []string // Handed is, by module, whether the vault judged it handed to the provisioner when it answered this node's own - // secrets (catalogue.AssignmentHands of the plan in its assignment): the node's own composition hands exactly those - // (catalogue.Rendering.Assigned, issue 774). Nil where the vault said nothing of it, as for the controller. + // secrets (catalogue.AssignmentHands of the plan in its assignment). Never the rule (resolution.Handing, issue 1966): + // read only to say where the vault and this composition disagree (resolution.VaultDisagrees). Nil where the vault said + // nothing of it, as for the controller. Handed map[string]bool // Capabilities are the ones present on it (inv.ProfileOf). Capabilities map[string]bool diff --git a/internal/compose/resolution/bound_into_files.go b/internal/compose/resolution/bound_into_files.go index d27401e..81fdcc4 100644 --- a/internal/compose/resolution/bound_into_files.go +++ b/internal/compose/resolution/bound_into_files.go @@ -102,7 +102,13 @@ func plainly(value any) string { // **An optional requirement left absent takes the default its placeholder names** (novox/hq ADR // 0330 §4), and one that names none is refused rather than rendered empty. The module check refuses // that already; this is the same rule where the file is made, for a manifest registered before it. -func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool) error { +// +// **A key the binding does not say is refused with why the node-engine fills the file** (issue 1966): filledHere +// (notHandedWhy). What a provider's helper answers reaches only a handed module's files, which the provisioner's client +// fills; a file the client should fill (handedFile) refused here is a handed module not handed, and says so, never only +// the keys it lacks. +func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool, + handedFile bool, filledHere string) error { if fmt.Sprint(resource["type"]) != "file" { return nil } @@ -135,10 +141,16 @@ func boundInto(resource map[string]any, known map[string]map[string]string, modu return token } value, said := values[key] + if !said && handedFile { + refused = fmt.Errorf( + "handed module not handed: %s asks its %s binding for %q, which only the provisioner's client fills, "+ + "and %s (issue 1966)", module, provision, key, filledHere) + return token + } if !said { refused = fmt.Errorf( - "%s asks its %s binding for %q, and what answers it says %s", - module, provision, key, orNothing(namesOfKeys(values))) + "%s asks its %s binding for %q, and what answers it says %s (%s)", + module, provision, key, orNothing(namesOfKeys(values)), filledHere) return token } return value diff --git a/internal/compose/resolution/declaration.go b/internal/compose/resolution/declaration.go index 3807e06..2eaae4c 100644 --- a/internal/compose/resolution/declaration.go +++ b/internal/compose/resolution/declaration.go @@ -415,6 +415,11 @@ func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string] // family's member (ADR 0283), so the module reads its absence as not given. continue } + if why := VaultDisagrees(with, m); sealed == "" && why != "" { + // The vault left it out of what it answered this node, its plan handing it: never a secret to make. + return nil, fmt.Errorf("handed module not handed: %s needs its own secret %q, which the vault answers "+ + "its provisioner's client alone, %s (issue 1966)", m.Module, name, why) + } if sealed == "" { // Declared and not made. Refused rather than skipped: a module whose own // credential is silently absent starts, fails to authenticate, and the reason is @@ -912,7 +917,8 @@ func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string] // **Substituted here, not on the machine.** A bound value is not secret — the mesh // holds it in the clear — so there is nothing for the host to be the only witness of, // and sending it already filled in means the host learns no new field. - if err := boundInto(copied, known, m.Module, absent); err != nil { + filledHere, handedFile := notHandedWhy(with, m, unsettled) + if err := boundInto(copied, known, m.Module, absent, handedFile, filledHere); err != nil { return nil, err } // And what the module could not have written: the machine it turned out to be diff --git a/internal/compose/resolution/handed.go b/internal/compose/resolution/handed.go index 10eba58..5328a8b 100644 --- a/internal/compose/resolution/handed.go +++ b/internal/compose/resolution/handed.go @@ -96,22 +96,74 @@ func renderingHanding(with mcat.Rendering, m mcat.Manifest) bool { } // Handing is whether a machine composed with this rendering hands the module to the provisioner, its files and -// its own secrets (design 57 P6, V4): a moved module there, assigned or only pulled in by another (issue 765), on a -// machine that can take it. The controller makes and seals none of such a module's secrets for that machine: its -// client asks the vault. +// its own secrets (design 57 P6, V4): a moved module there whose build kept what the client fills from +// (catalogue.Handed, written by Resolve), assigned or only pulled in by another (issue 765), on a machine that can take +// it (its node-engine writes a filled file on the client's word, and the client's account is named). The controller +// makes and seals none of such a module's secrets for that machine: its client asks the vault. // -// **Where the vault judged the module, its word is the rule** (Rendering.Assigned, issue 774): the vault leaves a -// module's own secrets out of `own` exactly when the plan in its assignment hands it (AssignmentHands), so a node -// composing itself hands exactly those, whatever its own reading of the manifest and the machine says. On 2026-10-31 -// postgres on novox was held: its plan handed its secrets, the vault answered none of them, and the node, judging by -// the manifest and the machine, composed them itself and refused for want of `reader`. Two readers, one rule. +// **The manifest, its build and the machine are the rule, never the plan** (issue 1966). The plan in a module's +// assignment is what a node's last composition made of it; when the vault's reading of it (Rendering.Assigned) was the +// rule, a module with no plan yet was judged not handed, composed the old way, refused there (its files ask the +// provider's helper for what only the helper answers), and so never got the plan that would hand it: on 2026-11-04 +// ace's three build modules and ssh-client, and shanks's mesh-build-image and ssh-client. The vault's word follows the +// plans this rule makes, one composition later, and is read only to say where the two disagree (VaultDisagrees). func Handing(with mcat.Rendering, m mcat.Manifest) bool { - if hands, judged := with.Assigned[m.Module]; judged { - return hands - } return m.Moved && m.Handed != nil && with.Hands && with.Provisioner != "" } +// machineWhy is why a machine does not take a moved module's handed files, "" where it does. +func machineWhy(with mcat.Rendering) string { + switch { + case !with.Hands: + return "this machine's node-engine has not stated that it writes a filled file on the provisioner client's word" + case with.Provisioner == "": + return "this machine's declaration names no account the provisioner's client runs as" + } + return "" +} + +// VaultDisagrees says why this composition does not hand a module the vault judged handed (Rendering.Assigned: the plan +// in its assignment hands it, so the vault left its own secrets out of what it answered this node), "" where they agree +// or the vault said nothing of it. Said where one of those secrets is then missing, as a handed module not handed, never +// as a secret to make (issue 1966). +func VaultDisagrees(with mcat.Rendering, m mcat.Manifest) string { + if !with.Assigned[m.Module] || Handing(with, m) { + return "" + } + switch { + case !m.Moved: + return "the vault judged it handed by its plan, and it is not moved" + case m.Handed == nil: + return "the vault judged it handed by its plan, and its build kept no templates" + } + return "the vault judged it handed by its plan, and " + machineWhy(with) +} + +// notHandedWhy says why a file of this module is composed here, filled by the node-engine, rather than by the +// provisioner's client: the module is not moved, its build kept nothing to hand, the machine cannot take it, its build +// kept no template for this file, or the file merges the layers' settings (begin). Said where a placeholder only a +// handed file can name is refused (boundInto); handedFile is whether the module's manifest and build hand this file, so +// that a refusal of it is a handed module not handed. +func notHandedWhy(with mcat.Rendering, m mcat.Manifest, r map[string]any) (why string, handedFile bool) { + id := fmt.Sprint(r["id"]) + switch { + case !m.Moved: + return m.Module + " is not moved, so its node-engine fills its files", false + case m.Handed == nil: + return m.Module + " is moved and its build kept no templates, so its node-engine fills its files", false + } + if _, kept := m.Handed.Templates[id]; !kept { + return fmt.Sprintf("its build kept no template for %s, so its node-engine fills that file", id), false + } + if mergesFile(r) { + return fmt.Sprintf("%s merges the layers' settings, which its node-engine does (issue 831)", id), false + } + if why := machineWhy(with); why != "" { + return why, true + } + return fmt.Sprintf("its build kept a template for %s and this composition filled it itself", id), true +} + // requiredByWords begins why a module only pulled in by another is on a machine (Resolution.Because): `required by // `. const requiredByWords = "required by " diff --git a/internal/compose/resolution/handed_bound_test.go b/internal/compose/resolution/handed_bound_test.go index 970662c..52eabcd 100644 --- a/internal/compose/resolution/handed_bound_test.go +++ b/internal/compose/resolution/handed_bound_test.go @@ -113,9 +113,10 @@ func TestAHandedModulesLocalCredentialsOfTheVaultAreNamedByLocalInItsPlan(t *tes // **Issue 969: a key its provider serves only at run time.** A moved module's file may name `${bound:…}` of a // provision whose provider tells it at run time (ssh-client's known_host of the forge). Where the machine takes -// handed files the client fills it and the module composes; where it does not the controller must fill it from what -// the provider serves statically, cannot, and refuses it with the very words an unmarked module is refused with -// (ADR 0349 §17: the old way, byte for byte). +// handed files the client fills it and the module composes, whatever the vault judged of a plan not made yet (issue +// 1966); where it does not the node-engine must fill it from what the provider serves statically, cannot, and refuses +// it as a handed module not handed, saying why the machine does not take it (issue 1966), where an unmarked module is +// refused with the keys the binding says and that it is not moved. func TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded(t *testing.T) { text := "Host ${bound:postgres-database:at}\n KnownHost ${bound:postgres-database:known_host}\n" moved := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, @@ -130,17 +131,30 @@ func TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded(t *testi t.Fatalf("handed, the module is filled by the client and the controller names nothing: %v %s", c.Filled, got) } - for name, with := range map[string]mcat.Rendering{ - "an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"}, - "no provisioner client on the machine": {Hands: true, ArtifactStore: "store:5000"}, + // Issue 1966: the vault judged it not handed (no plan yet); it is handed all the same. + noPlan := handingTo("store:5000") + noPlan.Assigned = map[string]bool{"bot": false} + if c, err := Compose(withDatabase(t, moved, text, true), noPlan); err != nil || len(c.Filled) == 0 { + t.Fatalf("with no plan yet, the module is not handed: %v", err) + } + + for name, with := range map[string]struct { + with mcat.Rendering + why string + }{ + "an engine that does not take a filled file alone": {mcat.Rendering{Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"}, + "has not stated that it writes a filled file"}, + "no provisioner client on the machine": {mcat.Rendering{Hands: true, ArtifactStore: "store:5000"}, + "names no account the provisioner's client runs as"}, } { - _, err := Compose(withDatabase(t, moved, text, true), sealedForBot(with)) - if err == nil || !strings.Contains(err.Error(), `asks its postgres-database binding for "known_host"`) { - t.Fatalf("%s: the key is not refused: %v", name, err) + _, err := Compose(withDatabase(t, moved, text, true), sealedForBot(with.with)) + if err == nil || !strings.Contains(err.Error(), `handed module not handed: bot asks its postgres-database binding for "known_host"`) || + !strings.Contains(err.Error(), with.why) { + t.Fatalf("%s: the key is not refused as a handed module not handed: %v", name, err) } - _, before := Compose(withDatabase(t, unmoved, text, false), sealedForBot(with)) - if before == nil || before.Error() != err.Error() { - t.Fatalf("%s: refused otherwise than the unmarked module: %v / %v", name, err, before) + _, before := Compose(withDatabase(t, unmoved, text, false), sealedForBot(with.with)) + if before == nil || !strings.Contains(before.Error(), `bot asks its postgres-database binding for "known_host", and what answers it says "as", "at" or "from" (bot is not moved`) { + t.Fatalf("%s: the unmarked module is refused %v", name, before) } } } diff --git a/internal/compose/resolution/handed_secrets_test.go b/internal/compose/resolution/handed_secrets_test.go index e9d4924..27eb6d1 100644 --- a/internal/compose/resolution/handed_secrets_test.go +++ b/internal/compose/resolution/handed_secrets_test.go @@ -1,6 +1,7 @@ package resolution import ( + "errors" "reflect" "strings" "testing" @@ -60,14 +61,25 @@ func TestAHandedModulesSecretsAreNamedAndNoValueIsSealed(t *testing.T) { if want := []mcat.PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(c.Plans["bot"].Secrets, want) { t.Fatalf("the plan names secrets %+v, want %+v", c.Plans["bot"].Secrets, want) } - // Issue 774 (postgres on novox, 2026-10-31): where the vault judged it handed, its secrets are the vault's even on a - // machine the node reads as not taking a filled file, and nothing is refused for a value the vault never answers. - judged, err := Compose(r, mcat.Rendering{ArtifactStore: "store:5000", Assigned: map[string]bool{"bot": true}}) + // Issue 1966 (ace, 2026-11-04): the vault judged it not handed, its assignment carrying no plan yet; the manifest, + // its build and the machine hand it all the same, so the plan that would hand it is made. + withoutPlan := handingTo("store:5000") + withoutPlan.Assigned = map[string]bool{"bot": false} + first, err := Compose(r, withoutPlan) if err != nil { - t.Fatalf("a module the vault judged handed is refused: %v", err) + t.Fatalf("a module the vault judged not handed, with no plan yet, is refused: %v", err) } - if want := []mcat.PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(judged.Plans["bot"].Secrets, want) { - t.Errorf("judged handed by the vault, the plan names secrets %+v, want %+v", judged.Plans["bot"].Secrets, want) + if want := []mcat.PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(first.Plans["bot"].Secrets, want) { + t.Errorf("with no plan yet, the plan names secrets %+v, want %+v", first.Plans["bot"].Secrets, want) + } + // Issue 774 (postgres on novox, 2026-10-31), as issue 1966 leaves it: where the vault judged it handed and the machine + // cannot take it, the vault answered none of its secrets, and the refusal says a handed module is not handed, never + // a secret to make. + _, err = Compose(r, mcat.Rendering{ArtifactStore: "store:5000", Assigned: map[string]bool{"bot": true}}) + var notMade *NotMadeError + if err == nil || errors.As(err, ¬Made) || !strings.Contains(err.Error(), "handed module not handed: bot needs its own secret \"token\"") || + !strings.Contains(err.Error(), "has not stated that it writes a filled file") { + t.Errorf("judged handed by the vault on a machine that cannot take it, the composition says %v", err) } // A process naming the token's file still restarts when its client writes it. for _, res := range c.Resources {