Files
mesh-sdk/go/catalogue/handed_secrets_test.go
T
jschoubben c2999a4e13
mesh/delivery merged: source.main moved to novox/mesh-sdk@394fec8a63c1
mesh/delivery-group group fix/1126-held-as-runs merged: every member is merged
mesh/check pass c2999a4e13cc on d4c61ee8ffd0 | unit 3054 run, 234 kept, 36 skipped | integration 3075 run, 266 kept, 29 skipped | over bound: unit (sl…
A held module is no dependence of its neighbours: its hold stops spreading (issue 1126)
novox's seed p-20261028t231141-ec84b4 refused 11 modules "what it composes of ..., held, is not what runs of them":
de-spiegel's hold spread through mailu to postgres, nftables and route-proxy, and from postgres to every consumer, and
gitea, held so, refused the node: "its credential of postgres-database is handed to the provisioner and the module is
not: nothing would fill it". Three rounds each moved the error. The kind: a held module still took part in the
composition as a dependence, so its hold spread.

- The quick fix: let heldAsToday also follow dependsOnHeld's words, and skip the handed refusal for gitea. It moves the
  error once more and leaves every node's applying path holding consumers.
- The thorough fix, chosen: a held module composes only as what runs of it. composeEach no longer compares the
  composition with each held module as its stub (dependsOnHeld removed): a consumer or collector composes against what
  runs of the held module like any other module, and a real difference is still a named difference in the answer, a
  module's own refusal still its refusal. A stub (compose.Stub, catalogue.Manifest.Stubbed) leaves the credentials and
  own secrets it would hand the provisioner out of its plan as they run, never refused for want of a plan. Only what a
  held module's new manifest says to others in their own files is still held behind it (dependents: the provider of
  what it contributes to, the filter where it listens), which heldAsToday already follows.

Tests: rows added to existing tests (a held provider with a binding consumer, which the old code held in the seed's
words; a held feeder of a collector; a held module with a handed credential and own secret). No new test function.
2026-10-29 00:49:00 +01:00

269 lines
13 KiB
Go

package catalogue
import (
"reflect"
"slices"
"strings"
"testing"
)
// A moved module's own secrets come from the vault (novox/hq ADR 0349 clauses 7 and 11, design 57 V4, issue 647):
// where the module is handed, its declaration carries no sealed value — each file holding a secret alone is placed
// without one and filled by the client, and the plan names every secret its templates and those files need, by
// name. Where it is not handed, and for every module not moved, the composition is byte for byte what it was.
const (
handedBot = `{"module":"bot","version":"1",
"own-secrets":{"token":"${dir:state}/token"},
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
"resources":[
{"id":"state","type":"directory","mode":"0700","place":"."},
{"id":"conf","type":"file","path":"/etc/bot/bot.conf","mode":"0640","template":"files/bot.conf"},
{"id":"daemon","type":"process","name":"bot","run":["bot"],"env":{"BOT_TOKEN_FILE":"${dir:state}/token"}}]}`
botText = "token = ${secret:token}\nhome = ${machine:account-home}\n"
)
func botMoved() string {
return strings.Replace(handedBot, `"version":"1",`, `"version":"1","moved":true,`, 1)
}
func builtBot(text string) []Built {
return []Built{{Name: "files", Kind: ArtifactArchive,
Reference: ArtifactStoreScheme + "bot/files/blobs/sha256:" + strings.Repeat("e", 64),
Digest: "sha256:" + strings.Repeat("e", 64), Texts: map[string]string{"bot.conf": text}}}
}
// What the controller seals for the bot when it fills it the old way: a stand-in sealed value.
func sealedForBot(with Rendering) Rendering {
with.Needed = map[string]map[string]string{"bot": {"token": "sealed-to-the-node"}}
return with
}
func TestAHandedModulesSecretsAreNamedAndNoValueIsSealed(t *testing.T) {
r := notifierOn(t, held(t, botMoved(), builtBot(botText)))
c, got := composeWith(t, r, handingTo("store:5000"))
if strings.Contains(got, "sealed") || strings.Contains(got, "${secret:") {
t.Fatalf("a handed module's declaration carries a sealed value or a secret placeholder: %s", got)
}
var token map[string]any
for _, res := range c.Resources {
if res["id"] == "bot.needs-token" {
token = res
}
}
if token == nil {
t.Fatalf("the token's file is not declared: %s", got)
}
if token["path"] == nil || strings.Contains(token["path"].(string), "${") || token["mode"] != "0600" ||
token["owner-at-runtime"] != "owned" || token[HandedSecretField] != nil {
t.Fatalf("the token's file is declared %v: placed, 0600, owned at runtime, no value", token)
}
path := token["path"].(string)
if !reflect.DeepEqual(c.Filled, []string{path, "/etc/bot/bot.conf"}) {
t.Fatalf("the filled files are %v", c.Filled)
}
if want := []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)
}
// A process naming the token's file still restarts when its client writes it.
for _, res := range c.Resources {
if res["id"] == "bot.daemon" && !strings.Contains(strings.Join(stringsIn(res["restart-on"]), " "), "bot.needs-token") {
t.Errorf("the daemon does not restart on its token's file: %v", res["restart-on"])
}
}
}
// A secret only a template names is in the plan by name alone.
func TestASecretOnlyATemplateNamesIsPlannedByName(t *testing.T) {
raw := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"},`,
`"own-secrets":{"token":"${dir:state}/token","api-key":"${dir:state}/api-key"},`, 1)
r := notifierOn(t, held(t, raw, builtBot(botText+"key = ${secret:api-key}\n")))
c, _ := composeWith(t, r, handingTo("store:5000"))
names := []string{}
for _, s := range c.Plans["bot"].Secrets {
names = append(names, s.Name)
}
if strings.Join(names, " ") != "api-key token" {
t.Fatalf("the plan names %v", c.Plans["bot"].Secrets)
}
}
// Nothing changes for a module that has not moved, nor for a moved one where it is not handed: its secrets are
// sealed into the declaration as before.
func TestAMovedModulesSecretsAreSealedAsBeforeWhereItIsNotHanded(t *testing.T) {
_, before := composeWith(t, notifierOn(t, held(t, handedBot, builtBot(botText))),
sealedForBot(handingTo("store:5000")))
if !strings.Contains(before, "sealed-to-the-node") {
t.Fatalf("the unmoved module is not sealed the old way: %s", before)
}
for name, with := range map[string]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"},
} {
c, got := composeWith(t, notifierOn(t, held(t, botMoved(), builtBot(botText))), sealedForBot(with))
if got != before {
t.Errorf("%s: the moved module composes differently from its unmarked twin:\nbefore: %s\nnow: %s",
name, before, got)
}
if len(c.Filled) > 0 || c.Plans != nil {
t.Errorf("%s: something was handed: %v %v", name, c.Filled, c.Plans)
}
}
}
func TestWhatAHandedModuleCannotHaveFromTheVaultIsRefused(t *testing.T) {
// A template naming a credential the module requires: a binding's, which moves with its provider.
requires := strings.Replace(botMoved(), `"own-secrets":`, `"requires":["database"],"secrets":{"database":"/etc/bot/db"},"own-secrets":`, 1)
r, err := Resolve(shelf(held(t, requires, builtBot("db = ${secret:database}\n")),
Manifest{Module: "db", Version: "1", Provides: []Offer{{Name: "database", Scope: ScopeNode}}}),
[]string{"bot"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
r.Account = "op"
if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "moves with its provider") {
t.Errorf("a template naming a required credential: %v", err)
}
// A file with its text inline naming a secret, in a handed module.
inline := strings.Replace(botMoved(), `{"id":"daemon"`,
`{"id":"legacy","type":"file","path":"/etc/bot/legacy","content":"t = ${secret:token}\n"},{"id":"daemon"`, 1)
r = notifierOn(t, held(t, inline, builtBot(botText)))
if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "keep it in a template") {
t.Errorf("an inline file naming a secret in a handed module: %v", err)
}
// A family and a secret issued outside the mesh are not handed by name yet: the mark is refused.
for why, raw := range map[string]string{
"a family": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":"${dir:state}/token","key-*":"${dir:state}/keys"}`, 1),
"issued outside": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":{"path":"${dir:state}/token","issued-by":"outside"}}`, 1),
} {
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "design 57 V4") {
t.Errorf("a moved module with %s: %v", why, err)
}
}
}
// A credential of what a handed module requires that the vault keeps for it alone (issue 757): the forge's read
// token for one agent, issued by the forge's software per holder. Where the module is handed, its file is declared
// placed and without a value, and the plan names it with its provision and provider; anywhere else, and for a
// credential the mesh makes, the composition is what it was.
func forgeReader(t *testing.T, moved bool, issued bool) Resolution {
t.Helper()
raw := strings.Replace(handedBot, `"own-secrets":`,
`"requires":[{"name":"forge-read","optional":true}],"secrets":{"forge-read":"${dir:state}/forge.secret"},"own-secrets":`, 1)
if moved {
raw = strings.Replace(raw, `"version":"1",`, `"version":"1","moved":true,`, 1)
}
r := notifierOn(t, held(t, raw, builtBot(botText)))
r.Needs = append(r.Needs, Needed{Name: "forge-read", From: "novox", For: "bot", Module: "forge",
Sealed: "sealed-forge-read", IssuedToHolder: issued})
return r
}
func TestAHandedModulesCredentialIssuedForItIsNamedInItsPlanAndNoValueIsSealed(t *testing.T) {
c, got := composeWith(t, forgeReader(t, true, true), handingTo("store:5000"))
if strings.Contains(got, "sealed") || strings.Contains(got, HandedProvisionField) {
t.Fatalf("a handed module's declaration carries a sealed value or the marker: %s", got)
}
var file map[string]any
for _, res := range c.Resources {
if res["id"] == "bot."+SecretID("forge-read") {
file = res
}
}
if file == nil {
t.Fatalf("the credential's file is not declared: %s", got)
}
path, _ := file["path"].(string)
if path == "" || strings.Contains(path, "${") || file["mode"] != "0600" || file["owner-at-runtime"] != "owned" {
t.Fatalf("the credential's file is declared %v: placed, 0600, owned at runtime, no value", file)
}
if !strings.Contains(strings.Join(c.Filled, " "), path) {
t.Fatalf("the credential's file is not among the filled files %v", c.Filled)
}
want := []PlanProvision{{Name: "forge-read", Provision: "forge-read", Provider: "novox", ProviderModule: "forge",
Path: path, From: "novox", As: ConsumerIdentity("workstation", "bot")}}
if !reflect.DeepEqual(c.Plans["bot"].Provisions, want) {
t.Fatalf("the plan names %+v, want %+v", c.Plans["bot"].Provisions, want)
}
// Issue 1126 (novox's seed: "gitea: its credential of postgres-database is handed to the provisioner and the module
// is not"): held, the module is composed as its stub, with no template and no item, so nothing is handed. Its
// credential and its own secret are what runs of it, left out, and the composition is not refused.
r := forgeReader(t, true, true)
for i := range r.Modules {
if r.Modules[i].Module == "bot" {
h := *r.Modules[i].Handed
h.Templates = nil
r.Modules[i].Handed, r.Modules[i].Stubbed, r.Modules[i].Build = &h, true, nil
r.Modules[i].Resources = slices.DeleteFunc(r.Modules[i].Resources, func(x map[string]any) bool { return x["type"] != "directory" })
}
}
c, got = composeWith(t, r, handingTo("store:5000"))
if strings.Contains(got, SecretID("forge-read")) || strings.Contains(got, NeedID("token")) || c.Plans["bot"].Provisions != nil {
t.Fatalf("a held module's handed credential or own secret is composed: %s", got)
}
}
func TestACredentialIssuedForAHolderIsSealedAsBeforeWhereItsModuleIsNotHanded(t *testing.T) {
_, before := composeWith(t, forgeReader(t, false, false), sealedForBot(handingTo("store:5000")))
if !strings.Contains(before, "sealed-forge-read") {
t.Fatalf("the unmoved module's credential is not sealed the old way: %s", before)
}
for why, r := range map[string]Resolution{
"a module not moved": forgeReader(t, false, true),
"a moved module whose credential is made": forgeReader(t, true, false),
} {
c, got := composeWith(t, r, sealedForBot(handingTo("store:5000")))
if !strings.Contains(got, "sealed-forge-read") {
t.Errorf("%s: the credential is not sealed the old way: %s", why, got)
}
// Its provider is named (issue 760), and no credential for the client to fill.
if p, ok := c.Plans["bot"]; ok && slices.ContainsFunc(p.Provisions, func(pr PlanProvision) bool {
return pr.Name != "" || pr.Path != ""
}) {
t.Errorf("%s: the plan names a credential to fill: %+v", why, p.Provisions)
}
}
c, got := composeWith(t, forgeReader(t, true, true), sealedForBot(Rendering{Provisioner: "mesh-provisioner-client",
ArtifactStore: "store:5000"}))
if !strings.Contains(got, "sealed-forge-read") || c.Plans != nil {
t.Errorf("where the engine does not take a filled file alone, the credential is not sealed the old way: %s", got)
}
}
// A handed consumer's own login from a provider that gives each consumer one (issue 779): the same entry as for an
// issued credential, with the provider's form on it, and nothing sealed; where the consumer is not handed, the
// login is sealed as it was.
func TestAHandedConsumersOwnLoginFromAPerLoginProviderIsNamedInItsPlanAndNoValueIsSealed(t *testing.T) {
pair := func(handed bool) Resolution {
r := forgeReader(t, true, handed)
r.Needs[len(r.Needs)-1].CredentialForm = CredentialFormPair
return r
}
c, got := composeWith(t, pair(true), handingTo("store:5000"))
if strings.Contains(got, "sealed") || strings.Contains(got, HandedProvisionField) {
t.Fatalf("a handed consumer's declaration carries its login sealed or the marker: %s", got)
}
var path string
for _, res := range c.Resources {
if res["id"] == "bot."+SecretID("forge-read") {
path, _ = res["path"].(string)
if res["mode"] != "0600" || res["owner-at-runtime"] != "owned" || res["content"] != nil || res["sealed"] != nil {
t.Fatalf("the login's file is declared %v: placed, 0600, owned at runtime, no value", res)
}
}
}
if path == "" || !strings.Contains(strings.Join(c.Filled, " "), path) {
t.Fatalf("the login's file %q is not declared and filled: %v", path, c.Filled)
}
want := []PlanProvision{{Name: "forge-read", Provision: "forge-read", Provider: "novox", ProviderModule: "forge",
CredentialForm: CredentialFormPair, Path: path, From: "novox", As: ConsumerIdentity("workstation", "bot")}}
if !reflect.DeepEqual(c.Plans["bot"].Provisions, want) {
t.Fatalf("the plan names %+v, want %+v", c.Plans["bot"].Provisions, want)
}
// Not marked as the vault's, the login is sealed as before.
_, before := composeWith(t, pair(false), sealedForBot(handingTo("store:5000")))
if !strings.Contains(before, "sealed-forge-read") {
t.Fatalf("a consumer whose login the vault does not keep for it has it sealed no more: %s", before)
}
}