Merge pull request '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) from fix/handed-one-rule into main
This commit was merged in pull request #206.
This commit is contained in:
@@ -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
|
// 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
|
// 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
|
// each module's assignment). Never the rule: the node hands by the manifest, its build and the machine
|
||||||
// vault and the node never judge one module two ways. Nil, or a Reader without it (a vault older than the field),
|
// (resolution.Handing, issue 1966), and the plans it composes are what the vault reads next; the vault's word is said
|
||||||
// composes by the manifest and the machine, as before.
|
// where the two disagree (resolution.VaultDisagrees). Nil, or a Reader without it (a vault older than the field), says
|
||||||
|
// nothing.
|
||||||
type HandedReader interface {
|
type HandedReader interface {
|
||||||
Handed(ctx context.Context, node string) map[string]bool
|
Handed(ctx context.Context, node string) map[string]bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,8 +159,9 @@ type Node struct {
|
|||||||
// Assigned is the modules assigned to it, in the order the controller reads them (inv.Assigned).
|
// Assigned is the modules assigned to it, in the order the controller reads them (inv.Assigned).
|
||||||
Assigned []string
|
Assigned []string
|
||||||
// Handed is, by module, whether the vault judged it handed to the provisioner when it answered this node's own
|
// 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
|
// secrets (catalogue.AssignmentHands of the plan in its assignment). Never the rule (resolution.Handing, issue 1966):
|
||||||
// (catalogue.Rendering.Assigned, issue 774). Nil where the vault said nothing of it, as for the controller.
|
// 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
|
Handed map[string]bool
|
||||||
// Capabilities are the ones present on it (inv.ProfileOf).
|
// Capabilities are the ones present on it (inv.ProfileOf).
|
||||||
Capabilities map[string]bool
|
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
|
// **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
|
// 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.
|
// 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" {
|
if fmt.Sprint(resource["type"]) != "file" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -135,10 +141,16 @@ func boundInto(resource map[string]any, known map[string]map[string]string, modu
|
|||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
value, said := values[key]
|
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 {
|
if !said {
|
||||||
refused = fmt.Errorf(
|
refused = fmt.Errorf(
|
||||||
"%s asks its %s binding for %q, and what answers it says %s",
|
"%s asks its %s binding for %q, and what answers it says %s (%s)",
|
||||||
module, provision, key, orNothing(namesOfKeys(values)))
|
module, provision, key, orNothing(namesOfKeys(values)), filledHere)
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -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.
|
// family's member (ADR 0283), so the module reads its absence as not given.
|
||||||
continue
|
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 == "" {
|
if sealed == "" {
|
||||||
// Declared and not made. Refused rather than skipped: a module whose own
|
// Declared and not made. Refused rather than skipped: a module whose own
|
||||||
// credential is silently absent starts, fails to authenticate, and the reason is
|
// 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
|
// **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,
|
// 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.
|
// 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
|
return nil, err
|
||||||
}
|
}
|
||||||
// And what the module could not have written: the machine it turned out to be
|
// And what the module could not have written: the machine it turned out to be
|
||||||
|
|||||||
@@ -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
|
// 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
|
// its own secrets (design 57 P6, V4): a moved module there whose build kept what the client fills from
|
||||||
// machine that can take it. The controller makes and seals none of such a module's secrets for that machine: its
|
// (catalogue.Handed, written by Resolve), assigned or only pulled in by another (issue 765), on a machine that can take
|
||||||
// client asks the vault.
|
// 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
|
// **The manifest, its build and the machine are the rule, never the plan** (issue 1966). The plan in a module's
|
||||||
// module's own secrets out of `own` exactly when the plan in its assignment hands it (AssignmentHands), so a node
|
// assignment is what a node's last composition made of it; when the vault's reading of it (Rendering.Assigned) was the
|
||||||
// composing itself hands exactly those, whatever its own reading of the manifest and the machine says. On 2026-10-31
|
// rule, a module with no plan yet was judged not handed, composed the old way, refused there (its files ask the
|
||||||
// postgres on novox was held: its plan handed its secrets, the vault answered none of them, and the node, judging by
|
// provider's helper for what only the helper answers), and so never got the plan that would hand it: on 2026-11-04
|
||||||
// the manifest and the machine, composed them itself and refused for want of `reader`. Two readers, one rule.
|
// 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 {
|
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 != ""
|
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
|
// requiredByWords begins why a module only pulled in by another is on a machine (Resolution.Because): `required by
|
||||||
// <module>`.
|
// <module>`.
|
||||||
const requiredByWords = "required by "
|
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
|
// **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
|
// 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
|
// handed files the client fills it and the module composes, whatever the vault judged of a plan not made yet (issue
|
||||||
// the provider serves statically, cannot, and refuses it with the very words an unmarked module is refused with
|
// 1966); where it does not the node-engine must fill it from what the provider serves statically, cannot, and refuses
|
||||||
// (ADR 0349 §17: the old way, byte for byte).
|
// 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) {
|
func TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded(t *testing.T) {
|
||||||
text := "Host ${bound:postgres-database:at}\n KnownHost ${bound:postgres-database:known_host}\n"
|
text := "Host ${bound:postgres-database:at}\n KnownHost ${bound:postgres-database:known_host}\n"
|
||||||
moved := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`,
|
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)
|
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{
|
// Issue 1966: the vault judged it not handed (no plan yet); it is handed all the same.
|
||||||
"an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"},
|
noPlan := handingTo("store:5000")
|
||||||
"no provisioner client on the machine": {Hands: true, ArtifactStore: "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 {
|
||||||
_, err := Compose(withDatabase(t, moved, text, true), sealedForBot(with))
|
t.Fatalf("with no plan yet, the module is not handed: %v", err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
_, before := Compose(withDatabase(t, unmoved, text, false), sealedForBot(with))
|
|
||||||
if before == nil || before.Error() != err.Error() {
|
for name, with := range map[string]struct {
|
||||||
t.Fatalf("%s: refused otherwise than the unmarked module: %v / %v", name, err, before)
|
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.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.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
|
package resolution
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
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)
|
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
|
// Issue 1966 (ace, 2026-11-04): the vault judged it not handed, its assignment carrying no plan yet; the manifest,
|
||||||
// machine the node reads as not taking a filled file, and nothing is refused for a value the vault never answers.
|
// its build and the machine hand it all the same, so the plan that would hand it is made.
|
||||||
judged, err := Compose(r, mcat.Rendering{ArtifactStore: "store:5000", Assigned: map[string]bool{"bot": true}})
|
withoutPlan := handingTo("store:5000")
|
||||||
|
withoutPlan.Assigned = map[string]bool{"bot": false}
|
||||||
|
first, err := Compose(r, withoutPlan)
|
||||||
if err != nil {
|
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) {
|
if want := []mcat.PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(first.Plans["bot"].Secrets, want) {
|
||||||
t.Errorf("judged handed by the vault, the plan names secrets %+v, want %+v", judged.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.
|
// A process naming the token's file still restarts when its client writes it.
|
||||||
for _, res := range c.Resources {
|
for _, res := range c.Resources {
|
||||||
|
|||||||
Reference in New Issue
Block a user