diff --git a/go/cmd/placement-seed/main_test.go b/go/cmd/placement-seed/main_test.go index 6dd4399..156b4f4 100644 --- a/go/cmd/placement-seed/main_test.go +++ b/go/cmd/placement-seed/main_test.go @@ -1,11 +1,14 @@ package main import ( + "encoding/json" "os" "path/filepath" "strings" "testing" + "git.novox.be/novox/mesh-sdk/go/compose" + "git.novox.be/novox/mesh-sdk/go/compose/frombus" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -39,6 +42,17 @@ func TestTheSeedWritesEachNodesPlacementFileOnce(t *testing.T) { if entries, _ := os.ReadDir(filepath.Join(other, "placement")); len(entries) != 0 { t.Errorf("a refused export wrote %v", entries) } + // What the controller's placement-export prints, frombus.Export per node, is what the seed takes: a node with no + // module placed included. + in := compose.Input{Nodes: []compose.Node{{Name: "solo", Place: compose.Place{Hub: true, Address: "10.77.0.9"}}}} + exported, err := frombus.Export(in, "solo") + if err != nil { + t.Fatal(err) + } + answer, _ := json.Marshal(map[string]any{"answer": map[string]delivery.Placement{"solo": exported}}) + if written, err := Seed(strings.NewReader(string(answer)), t.TempDir()); err != nil || len(written) != 1 { + t.Errorf("Export's answer %s was not seeded: %v, %v", answer, written, err) + } for _, bad := range []string{`[]`, `{}`, `{"ace":{"v":1,"node":"novox","modules":[]}}`} { if _, err := Seed(strings.NewReader(bad), t.TempDir()); err == nil { t.Errorf("%s was seeded", bad) diff --git a/go/compose/frombus/export.go b/go/compose/frombus/export.go index ad81a77..ee55f8b 100644 --- a/go/compose/frombus/export.go +++ b/go/compose/frombus/export.go @@ -24,7 +24,9 @@ func Export(in compose.Input, node string) (delivery.Placement, error) { if n == nil { return delivery.Placement{}, fmt.Errorf("frombus: the input has no node %q", node) } - p := delivery.Placement{Node: node, Decided: delivery.NodeDecided{ + // At the version the placement file is read at, with a list of modules even when none is placed: the export is the + // file the seed writes (cmd/placement-seed), and the reader refuses a value of version 0 or without its modules. + p := delivery.Placement{V: delivery.Version, Node: node, Modules: []delivery.PlacedModule{}, Decided: delivery.NodeDecided{ Endpoint: n.Place.Endpoint, Site: n.Place.Site, Hub: n.Place.Hub, Address: n.Place.Address, PublicDomain: n.PublicDomain, Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount, AgentHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}} diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go index 67f2c1e..4601762 100644 --- a/go/compose/frombus/frombus.go +++ b/go/compose/frombus/frombus.go @@ -221,8 +221,12 @@ func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, er return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node) } // Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5). - if in.OwnSecrets, err = r.OwnSecrets(ctx, s.Node); err != nil { - return compose.Input{}, fmt.Errorf("frombus: the own secrets of %s's modules: %w", s.Node, err) + // Asked only when a module placed on it declares one: a node none of whose modules has a secret composes + // whether or not the vault answers. + if declaresOwnSecrets(in, s.Node) { + if in.OwnSecrets, err = r.OwnSecrets(ctx, s.Node); err != nil { + return compose.Input{}, fmt.Errorf("frombus: the own secrets of %s's modules: %w", s.Node, err) + } } // The core's credentials travel as the vault's box sealed to the machine (ADR 0349 §16): the same values, which // the composition reads for a core module as its kept boxes. @@ -257,6 +261,25 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s return compose.CompositionsApplied(in, s.Node, fps) } +// declaresOwnSecrets says a module assigned to node declares an own secret, in the manifest the node composes it from. +func declaresOwnSecrets(in compose.Input, node string) bool { + for _, n := range in.Nodes { + if n.Name != node { + continue + } + for _, module := range n.Assigned { + m, ok := n.Kept[module] + if !ok { + m = in.Catalogue[module] + } + if len(m.OwnSecrets) > 0 { + return true + } + } + } + return false +} + // nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person // decided of it, and where its modules run). func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node { diff --git a/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go index d5ec534..1d7bbcc 100644 --- a/go/compose/frombus/frombus_test.go +++ b/go/compose/frombus/frombus_test.go @@ -27,6 +27,7 @@ type bus struct { values map[string]map[string]any // by node.module valuesRead []string own []compose.OwnSecretRow + ownErr error } func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) { @@ -41,7 +42,9 @@ func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) { } return raw, nil } -func (b *bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { return b.own, nil } +func (b *bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { + return b.own, b.ownErr +} func (b *bus) Credentials(context.Context, string, []compose.CredentialKey, []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) { return nil, nil, nil @@ -74,7 +77,7 @@ func TestInput(t *testing.T) { // A module in the settings form (plex and the media modules on one node, 2026-10-26): its config values are in its // layers, not mesh-config, so the placement carries them (MeshValues, Values) and mesh-config's are not read for it. media := []byte(`{"module":"media","version":"1","settings":{"lines":{"kind":"preference","default":0,"why":"kept"},` + - `"name":{"kind":"preference","default":"x","why":"shown"}},"resources":[{"id":"conf","type":"file",` + + `"name":{"kind":"preference","default":"x","why":"shown"}},"own-secrets":{"token":"/srv/media/token"},"resources":[{"id":"conf","type":"file",` + `"path":"/srv/media/conf","mode":"0644","content":"uid=${setting:lines} name=${setting:name}\n"}]}`) mediaDigest := delivery.ManifestDigest(media) keys := map[string]string{"anchor": publicKey(t), "laptop": publicKey(t)} @@ -102,7 +105,7 @@ func TestInput(t *testing.T) { manifests: map[string][]byte{digest: xterm, mediaDigest: media}, values: map[string]map[string]any{"laptop.xterm": {"font": "mono"}, "anchor.xterm": {"font": "serif"}, "laptop.media": {"lines": "not mesh-config's"}}, - own: []compose.OwnSecretRow{{Node: "laptop", Module: "xterm", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}}, + own: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}}, } } @@ -136,9 +139,9 @@ func TestInput(t *testing.T) { {From: "laptop", Values: map[string]any{"lines": float64(1000)}, Placement: map[string]any{"places": map[string]any{"data": "/srv/data"}}}}}}, }, Holdings: []catalogue.Held{{Claim: "mesh-private-network", Scope: "mesh", Node: "anchor", Module: overlay.Name}}, - OwnSecrets: []compose.OwnSecretRow{{Node: "laptop", Module: "xterm", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}}, + OwnSecrets: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}}, // The same value as a core credential's box, for a core module (ADR 0349 §16). - KeptCoreBoxes: []compose.CoreBox{{Node: "laptop", Module: "xterm", Name: "token", SealedTo: "sk", Box: json.RawMessage("{}")}}, + KeptCoreBoxes: []compose.CoreBox{{Node: "laptop", Module: "media", Name: "token", SealedTo: "sk", Box: json.RawMessage("{}")}}, Tunnels: map[string]compose.FoundTunnel{"anchor": {Tunnel: tun}}, Adopted: &compose.AdoptedTunnel{Tunnel: tun, Hub: "anchor", Adopted: true}, // The controller's order is by address; laptop enrolled under its peer's key, and a person named the other. @@ -201,6 +204,17 @@ func TestInput(t *testing.T) { t.Errorf("the check read %v", b.valuesRead) } + // A node none of whose modules declares an own secret composes without asking the vault (the review of L2: a node + // with no secret composed nothing while the vault did not answer); one whose module does, refuses without it. + quiet := mk() + quiet.ownErr = errors.New("no responders") + if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "anchor"}); err != nil { + t.Errorf("anchor, with no own secret, needed the vault: %v", err) + } + if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "laptop"}); err == nil { + t.Error("laptop's media, which declares an own secret, composed without the vault") + } + kept := mk() kept.manifests["old"] = xterm in, err := Input(ctx, kept, Mesh{}, Scope{Node: "laptop", Kept: map[string]string{"xterm": "old"}})