Compose a node's own modules from their builds and read what providers elsewhere serve, so a self-composing node hands moved modules and settles served settings as the controller does (issue 1126)

frombus composed every module from the indexer's raw manifest: no moved module
was handed, so the node stood in for own secrets the vault leaves to the
provisioner (the seed's refusals on all four nodes), and a provider elsewhere
had no config values, so mailu's served domain refused shanks.

- takein: the controller's take-in of a done key (artifacts, launchers, the
  templates archive's texts, Resolve) as one SDK rule; catalogue.Launchers holds
  the launcher rule buildstep now delegates to.
- frombus: each module placed on the composing node, and each kept at another
  build (Scope.KeptAt, a fingerprint), composes from its built manifest; another
  node's modules give the keys their served values fill (Reader.Served).
- buscompose: Declared.Served and Principal.ServedReads grant every node the read
  of exactly those keys of mesh-config_resolved.
- compose.ArtifactStoreAddress: the store as the node's composition routes to it.
This commit is contained in:
2026-10-27 21:11:20 +01:00
parent 9f92233a28
commit 04de3e5da2
16 changed files with 890 additions and 45 deletions
+25
View File
@@ -223,6 +223,31 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
if !slices.Contains(perms.Subscribe, VaultSecretChanged) {
t.Errorf("node one does not hear the vault's secret-changed: %v", perms.Subscribe)
}
// And what a module on another node serves from its settings, that key alone (mailu's smtp domain, issue 1126).
users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{
"two": {{Module: "mailer", Served: []string{"domain", "not.a.token"}}}}})
if err != nil {
t.Fatal(err)
}
get := func(key string) string {
return "$JS.API.DIRECT.GET.KV_" + ResolvedBucket + ".$KV." + ResolvedBucket + "." + key
}
for _, u := range users {
if u.Kind != KindNode {
continue
}
perms, err := PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
if u.Node == "one" && (!reflect.DeepEqual(u.ServedReads, []string{"two.mailer.domain"}) || !MayPublish(perms, get("two.mailer.domain")) ||
MayPublish(perms, get("two.mailer.sitename"))) {
t.Errorf("node one reads %v of two's resolved values", u.ServedReads)
}
if u.Node == "two" && len(u.ServedReads) != 0 {
t.Errorf("node two is granted its own served keys apart: %v", u.ServedReads)
}
}
})
t.Run("the controller's golden server configuration", func(t *testing.T) {
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
+10
View File
@@ -217,6 +217,10 @@ type Principal struct {
// ADR 0547 §2, ADR 0550 §5, issue 1126): its modules' own secrets sealed to its key, for the compositions it makes
// itself. Set while the vault's holder serves it.
AsksItsOwnSecrets bool
// ServedReads are, for a machine principal, the keys of mesh-config's resolved values that other nodes' modules serve
// from (`<node>.<module>.<key>`, catalogue.ServedSettings): what every consumer is told, and so what a node composing
// its own modules reads of a provider elsewhere (issue 1126), each key alone. Sorted, none twice.
ServedReads []string
// FollowsGrantsUserList is the machine principal of the node holding mesh-broker: its node-engine reads the grants
// part's sealed user list, one key and an object by name (novox/hq ADR 0547 clause 7, GrantsUserListFollows).
FollowsGrantsUserList bool
@@ -898,6 +902,12 @@ func PermissionsFor(p Principal) (Permissions, error) {
// manifests, the builder's builds (the manifest of a build its want names), and mesh-config's resolved values of
// its own node alone. Read only.
pub = append(pub, NodeComposeReads(p.Node)...)
// And, of the other nodes' resolved values, the keys their modules serve from (issue 1126), each one key.
served, err := ServedReadGrants(p.ServedReads)
if err != nil {
return Permissions{}, err
}
pub = append(pub, served...)
// And an object of the declaration bucket by its name, and nothing of the bucket's list or writing
// (novox/hq ADR 0486 §2, issue 1697).
fileReads, err := DeclarationFilesReads(p.Node)
+26
View File
@@ -325,3 +325,29 @@ func NodeComposeReads(node string) []string {
}
return append(out, readGrants(ResolvedBucket, node+".>")...)
}
// ServedReadGrants is the read of each key of mesh-config's resolved values named, `<node>.<module>.<key>`
// (Principal.ServedReads, issue 1126): a get and a watch of that key alone. Each part is checked as every subject token
// is, so a key cannot widen the grant.
func ServedReadGrants(keys []string) ([]string, error) {
var out []string
seen := map[string]bool{}
for _, k := range keys {
parts := strings.Split(k, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("%q is not <node>.<module>.<key>", k)
}
for _, part := range parts {
if !safeSubject.MatchString(part) {
return nil, fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", part)
}
}
for _, g := range readGrants(ResolvedBucket, k) {
if !seen[g] {
seen[g] = true
out = append(out, g)
}
}
}
return out, nil
}
+35 -1
View File
@@ -5,6 +5,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/announce"
"slices"
"sort"
"strings"
)
// Every user the composed file should contain, derived from what the mesh knows.
@@ -25,6 +26,9 @@ type Declared struct {
Emits []string
Consumes []string
Serves []string
// Served are the config keys its served values and zone name fill from its settings (catalogue.ServedSettings): what
// every node may read of its resolved values where it runs (Principal.ServedReads, issue 1126).
Served []string
// Holds are the seats this module claims, with the protocol each seat declares. A seat the
// mesh defines for itself declares no protocol, so holding one grants nothing on the bus —
// which is right: those seats are about who does a job, not about who may say what.
@@ -144,6 +148,7 @@ func Users(r Records) ([]Principal, error) {
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r),
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes)}}
served := servedReadsOf(r)
for _, node := range sortedCopy(r.Nodes) {
witness := false
var checks []string
@@ -160,7 +165,7 @@ func Users(r Records) ([]Principal, error) {
out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks,
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey),
AsksItsOwnSecrets: servesVerb(r, VaultOwn),
FollowsGrantsUserList: bus})
FollowsGrantsUserList: bus, ServedReads: servedOn(served, node)})
// **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one
// per machine that runs it, delivered to the module running it.
for _, d := range r.Assigned[node] {
@@ -459,3 +464,32 @@ func declaredStreamsOf(r Records) []string {
sort.Strings(out)
return out
}
// servedReadsOf is every key of mesh-config's resolved values a module assigned on a node serves from,
// `<node>.<module>.<key>`, sorted: each part a subject token, so a key that cannot be one is granted nothing (and the
// node composing a consumer of it refuses, naming it), never a wider read.
func servedReadsOf(r Records) []string {
var out []string
for node, declared := range r.Assigned {
for _, d := range declared {
for _, k := range d.Served {
if safeSubject.MatchString(node) && safeSubject.MatchString(d.Module) && safeSubject.MatchString(k) {
out = append(out, node+"."+d.Module+"."+k)
}
}
}
}
sort.Strings(out)
return slices.Compact(out)
}
// servedOn is, of the served keys, those of other nodes than node: its own slice it reads whole.
func servedOn(served []string, node string) []string {
var out []string
for _, k := range served {
if !strings.HasPrefix(k, node+".") {
out = append(out, k)
}
}
return out
}