Review of L2: a node that cannot compose says so in each wanted module's have, failed, while it keeps what it runs (H2); the composer keeps the box the node runs when it opens to the vault's value, so neither the switch nor a restart moves a composition (H1); pin mesh-sdk 3968849, whose node asks the vault only for a module declaring a secret (H8's failure) (issue 1126)
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: unit: ..test: ✔ a transient empty list for four passes retires nothing; five stable passes retire, data kept (290.408814ms) | 58de6…

This commit is contained in:
2026-10-26 20:09:40 +01:00
parent cdd3f3fd4f
commit 58de6aa10a
7 changed files with 207 additions and 15 deletions
+102 -8
View File
@@ -11,6 +11,7 @@
package selfcompose
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -45,6 +46,15 @@ type Env struct {
// Hear subscribes a subject: here the vault's word that a secret changed (VaultSecretChanged), on which the node
// composes again, so a rotated secret or credential reaches it at once (ADR 0547 §2). Nil hears nothing.
Hear func(subject string) (<-chan []byte, func(), error)
// Refused is told why the node cannot compose its modules, and "" once it composes again, so each wanted module's
// have says it (agent.NotComposed): a refusal is loud where haves are read, not one journal line. Nil tells nobody.
Refused func(why string)
// Running is what the node runs now (agent.Everything), and Unseal opens a value sealed to this machine: a secret
// the vault seals again for each ask is kept as the box the node already runs when that box opens to the same
// value, so neither the switch to composing its own nor a restart changes a composition (ADR 0547 §3). Either nil
// keeps the vault's box.
Running func() []delivery.Composed
Unseal func(sealed string) ([]byte, error)
}
// VaultSecretChanged is the vault's event that a secret changed: a name and its generation, never a value.
@@ -66,6 +76,7 @@ type Composer struct {
// is other bytes, so the one first had is kept while the generation stands, and the composition stays the same.
sealedOwn map[string]ownSealed
said string
refusal string
caught bool
rev uint64
}
@@ -351,7 +362,9 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
for _, module := range behind {
digest, err := c.builtManifest(ctx, module, fps[module])
if err != nil {
c.sayOnce(fmt.Sprintf("%s is wanted at %s, whose manifest cannot be read, so the node does not compose: %v", module, fps[module], err))
why := fmt.Sprintf("%s is wanted at %s, whose manifest cannot be read, so the node does not compose: %v", module, fps[module], err)
c.refused(why)
c.sayOnce(why)
return nil
}
kept[module] = digest
@@ -359,9 +372,11 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
compositions, _, err := frombus.Compose(ctx, r, c.env.Mesh, frombus.Scope{Node: c.env.Node, Kept: kept}, fps)
if err != nil {
c.refused(fmt.Sprintf("%v", err))
c.sayOnce(fmt.Sprintf("the node cannot compose its modules, and keeps running what it runs: %v", err))
return nil
}
c.refused("")
c.sayOnce("")
c.mu.Lock()
defer c.mu.Unlock()
@@ -405,6 +420,54 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
return out
}
// refused tells the agent why the node cannot compose, or "" that it composes again, once per change.
func (c *Composer) refused(why string) {
c.mu.Lock()
same := c.refusal == why
c.refusal = why
c.mu.Unlock()
if !same && c.env.Refused != nil {
c.env.Refused(why)
}
}
// runningBox is the box the node already runs that opens to the same value as box, or box: read before c.mu is taken,
// since the agent's lock is taken to read what runs.
func (c *Composer) runningBox(box string) string {
if c.env.Running == nil || c.env.Unseal == nil {
return box
}
value, err := c.env.Unseal(box)
if err != nil {
return box
}
for _, comp := range c.env.Running() {
for _, raw := range comp.Resources {
var r map[string]json.RawMessage
if json.Unmarshal(raw, &r) != nil {
continue
}
for _, field := range []string{"sealed", catalogue.VaultSealedField} {
f, ok := r[field]
if !ok {
continue
}
var held string
if json.Unmarshal(f, &held) != nil {
held = string(f) // a vault box is carried as its JSON
}
if held == "" || held == box {
continue
}
if v, err := c.env.Unseal(held); err == nil && bytes.Equal(v, value) {
return held
}
}
}
}
return box
}
func (c *Composer) sayOnce(line string) {
c.mu.Lock()
if c.said == line {
@@ -565,14 +628,25 @@ func (r reader) OwnSecrets(ctx context.Context, node string) ([]compose.OwnSecre
if a.Node != node {
return nil, fmt.Errorf("the vault answered %q's own secrets to %s", a.Node, node)
}
ids := make([]string, len(a.Own))
fresh := make([]string, len(a.Own))
for i, o := range a.Own {
ids[i] = o.Module + "/" + o.Name
if !r.c.keeps(ids[i], o.Generation, a.Key) {
fresh[i] = r.c.runningBox(string(o.Sealed))
}
}
r.c.mu.Lock()
defer r.c.mu.Unlock()
out := make([]compose.OwnSecretRow, 0, len(a.Own))
for _, o := range a.Own {
id := o.Module + "/" + o.Name
for i, o := range a.Own {
id := ids[i]
kept, ok := r.c.sealedOwn[id]
if !ok || kept.generation != o.Generation || kept.key != a.Key {
kept = ownSealed{generation: o.Generation, key: a.Key, sealed: string(o.Sealed)}
kept = ownSealed{generation: o.Generation, key: a.Key, sealed: fresh[i]}
if kept.sealed == "" {
kept.sealed = string(o.Sealed)
}
r.c.sealedOwn[id] = kept
}
out = append(out, compose.OwnSecretRow{Node: node, Module: o.Module, Name: o.Name, Origin: o.Origin,
@@ -617,16 +691,28 @@ func (r reader) Credentials(ctx context.Context, node string, asConsumer, asProv
if a.Node != node {
return nil, nil, fmt.Errorf("the vault answered %q's credentials to %s", a.Node, node)
}
ids := make([]string, len(a.Credentials))
fresh := make([]string, len(a.Credentials))
for i, c := range a.Credentials {
ids[i] = c.Role + "|" + c.Name + "|" + c.Provision + "|" + c.Consumer + "|" + c.ConsumerModule + "|" + c.Local
if !r.c.keeps(ids[i], c.Generation, a.Key) {
fresh[i] = r.c.runningBox(string(c.Sealed))
}
}
r.c.mu.Lock()
defer r.c.mu.Unlock()
var creds []compose.Credential
var issued []compose.Issued
for _, c := range a.Credentials {
// The value first had is kept while its generation stands, so sealing it again moves no composition.
id := c.Role + "|" + c.Name + "|" + c.Provision + "|" + c.Consumer + "|" + c.ConsumerModule + "|" + c.Local
for i, c := range a.Credentials {
// The value first had is kept while its generation stands, so sealing it again moves no composition; and the
// first had is the box the node runs when it opens to the same value (runningBox).
id := ids[i]
kept, ok := r.c.sealedOwn[id]
if !ok || kept.generation != c.Generation || kept.key != a.Key {
kept = ownSealed{generation: c.Generation, key: a.Key, sealed: string(c.Sealed)}
kept = ownSealed{generation: c.Generation, key: a.Key, sealed: fresh[i]}
if kept.sealed == "" {
kept.sealed = string(c.Sealed)
}
r.c.sealedOwn[id] = kept
}
k := c.CredentialKey
@@ -641,3 +727,11 @@ func (r reader) Credentials(ctx context.Context, node string, asConsumer, asProv
}
return creds, issued, nil
}
// keeps says the composer holds id's sealed value at this generation and key already.
func (c *Composer) keeps(id string, generation int, key string) bool {
c.mu.Lock()
defer c.mu.Unlock()
kept, ok := c.sealedOwn[id]
return ok && kept.generation == generation && kept.key == key
}