Files
mesh-host/internal/compose/resolution/handed_secrets_test.go
T
jschoubben 421e6b0771 Hand a moved module by its manifest, its build and the machine, never by the vault's reading of its plan, so a node with no plan yet makes the plan that hands it (issue 1966)
The vault judged a module handed only when its assignment already carried a handing plan, and the node-engine followed
that word: a module with no plan was composed the old way, refused for a key only the provider's helper answers
(forge-account's ssh_alias), and so never got its plan. resolution.Handing is now the manifest, its build and the
machine alone; the vault's word is read only to say where the two disagree. A refusal of a file or an own secret the
provisioner's client should fill now says "handed module not handed" and why, never only the keys a binding lacks or a
secret to make.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 02:30:57 +01:00

91 lines
4.2 KiB
Go

package resolution
import (
"errors"
"reflect"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
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"}}]}`
const 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) []mcat.Built {
return []mcat.Built{{Name: "files", Kind: mcat.ArtifactArchive,
Reference: mcat.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 mcat.Rendering) mcat.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[mcat.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 := []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 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 not handed, with no plan yet, is refused: %v", err)
}
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 {
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"])
}
}
}