Node-engine: hand a moved module by its manifest, its build and the machine, never by the vault's reading of its plan (issue 1966) #206

Merged
mesh-tools merged 1 commits from fix/handed-one-rule into main 2026-11-04 09:16:13 +00:00
7 changed files with 136 additions and 38 deletions
+4 -3
View File
@@ -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
}
+3 -2
View File
@@ -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
@@ -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
+7 -1
View File
@@ -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
+63 -11
View File
@@ -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
// <module>`.
const requiredByWords = "required by "
@@ -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)
}
}
}
@@ -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, &notMade) || !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 {