A module the node holds alone marks that module, keeps what runs of it, and never refuses the node (issue 1126)
mesh/delivery merged: source.main moved to novox/mesh-host@9a8b3870bad0
mesh/delivery-group group fix/1126-apply-culprit merged: every member is merged
mesh/check pass 24c23a1d066f on a548a61e86d7 | unit 2667 run, 590 kept, 36 skipped | integration 2676 run, 633 kept, 29 skipped | over bound: unit (sl…

frombus.Compose now holds a module refused alone (mesh-sdk 0de3006, the rule
its answer follows too): composed as what runs of it, with what depends on
how it is held held too. The composer hands every other module, keeps what it
handed of a held one, and tells the agent which modules it holds and why;
agent.NotComposedEach marks those alone and clears the rest. Only a refusal
of no single module still marks every module (NotComposed).

heldOut also holds out, of the node's own composition as it runs, the
resources of each module held alone, until a composition of its own is
handed: the node's own apply never removes a module leaving the fold with its
names unset (review of the apply, round 1; issue 1945 by a new path).

Pins mesh-sdk fix/1126-apply-culprit at 0de3006 (a group).
This commit is contained in:
2026-10-28 17:23:26 +01:00
parent a548a61e86
commit 24c23a1d06
8 changed files with 368 additions and 35 deletions
+3 -3
View File
@@ -551,9 +551,9 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, sess
} }
return got.SealedAt, got.Sealed, nil return got.SealedAt, got.Sealed, nil
}, },
refused: func(why string) { refused: func(why string, held map[string]string) {
if now := own.get(); now != nil && run.Err() == nil { if now := own.get(); now != nil && run.Err() == nil {
now.NotComposed(run, why) now.NotComposedEach(run, why, held) // a module held alone marks that module, never the others (issue 1126)
} }
}, },
} }
@@ -883,7 +883,7 @@ func applyContext(ctx context.Context) (context.Context, context.CancelFunc) {
type composerHooks struct { type composerHooks struct {
running func() []delivery.Composed running func() []delivery.Composed
unseal func(string) ([]byte, error) unseal func(string) ([]byte, error)
refused func(string) refused func(why string, held map[string]string)
held func(module string) (delivery.Seals, []string, error) held func(module string) (delivery.Seals, []string, error)
} }
+1 -1
View File
@@ -3,7 +3,7 @@ module github.com/novox/mesh-host
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028130759-f387581b6bfb git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028162235-0de30064156a
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028130759-f387581b6bfb h1:RioCDIPNJCv09/UJ3z1ocK0zFAf+Yuk47ICkfPfpEYo= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028162235-0de30064156a h1:BYJrXqPfeNSF0eypX3UnczLq/geXNkfJbiUK9JHlHNA=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028130759-f387581b6bfb/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028162235-0de30064156a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+79 -10
View File
@@ -24,6 +24,7 @@ import (
"time" "time"
"git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/delivery"
) )
@@ -162,7 +163,10 @@ type Agent struct {
said map[string]string said map[string]string
// notComposed is the modules whose have says the node could not compose them: why, and the have before (NotComposed). // notComposed is the modules whose have says the node could not compose them: why, and the have before (NotComposed).
notComposed map[string]notComposedMark notComposed map[string]notComposedMark
retry chan []string // heldAlone is the modules the composer holds alone (NotComposedEach): what runs of each in the node's own
// composition is held out of the node's own apply until a composition of its own is handed (heldOut).
heldAlone map[string]bool
retry chan []string
// tries counts the transient failures in a row of each module; again carries a retry whose wait ran out, and // tries counts the transient failures in a row of each module; again carries a retry whose wait ran out, and
// transient asks for every transiently failed module at once. stopped is closed when Run returns. // transient asks for every transiently failed module at once. stopped is closed when Run returns.
tries map[string]int tries map[string]int
@@ -364,23 +368,45 @@ var readAgain = time.Second
// fingerprint, the digest and the sealed values the agent runs, or, before it ran anything since its start, those its // fingerprint, the digest and the sealed values the agent runs, or, before it ran anything since its start, those its
// last have said (a refusal at the first compose after a start). Called with "" once the node composes again: each // last have said (a refusal at the first compose after a start). Called with "" once the node composes again: each
// module so marked has its have back as it was, unless an apply wrote it since. // module so marked has its have back as it was, unless an apply wrote it since.
func (a *Agent) NotComposed(ctx context.Context, why string) { func (a *Agent) NotComposed(ctx context.Context, why string) { a.NotComposedEach(ctx, why, nil) }
// NotComposedEach is NotComposed for the node refused whole (why), or, with why empty, for the modules the node holds
// alone (held, each with its own why: refused alone, the rest composed and applied, issue 1126): only those are marked,
// each naming its own why, and every other module marked before has its have back. Both empty, every mark is cleared.
func (a *Agent) NotComposedEach(ctx context.Context, why string, held map[string]string) {
a.mu.Lock() a.mu.Lock()
var modules []string if why == "" {
if why != "" { a.heldAlone = make(map[string]bool, len(held))
for module, w := range a.wants { for m := range held {
if w.Present && !w.Want.Removed && module != NodeModule && a.notComposed[module].why != why { a.heldAlone[m] = true
modules = append(modules, module)
}
} }
} else { }
for module := range a.notComposed { whyOf := map[string]string{} // the mark each module is to carry; a marked module not in it is cleared
for module, w := range a.wants {
if !w.Present || w.Want.Removed || module == NodeModule {
continue
}
if why != "" {
whyOf[module] = why
} else if h, ok := held[module]; ok && h != "" {
whyOf[module] = h
}
}
var modules []string
for module, w := range whyOf {
if a.notComposed[module].why != w {
modules = append(modules, module)
}
}
for module := range a.notComposed {
if _, still := whyOf[module]; !still && why == "" { // the node refused whole marks; it clears none
modules = append(modules, module) modules = append(modules, module)
} }
} }
a.mu.Unlock() a.mu.Unlock()
sort.Strings(modules) sort.Strings(modules)
for _, module := range modules { for _, module := range modules {
why := whyOf[module]
if ctx.Err() != nil { if ctx.Err() != nil {
return // the run ended: what it would mark or clear is the next run's to say (issue 1126) return // the run ended: what it would mark or clear is the next run's to say (issue 1126)
} }
@@ -806,6 +832,49 @@ func (a *Agent) heldOut(except string) []delivery.Composed {
sort.Strings(names) sort.Strings(names)
for _, m := range names { for _, m := range names {
out = append(out, waiting[m].C) out = append(out, waiting[m].C)
held[m] = true
}
// A module the composer holds alone keeps what runs of it in the node's own composition, until a composition of its
// own is handed: held out as one of its own, so the node's own apply never removes it (issue 1126, review of the
// apply, round 1: de-spiegel leaving the fold with its names unset; issue 1945).
if len(a.heldAlone) == 0 {
return out
}
var own *delivery.Composed
for _, c := range a.others("\x00") {
if c.Module == "" {
own = &c
break
}
}
alone := make([]string, 0, len(a.heldAlone))
for m := range a.heldAlone {
if !held[m] && m != except && own != nil {
alone = append(alone, m)
}
}
sort.Strings(alone)
for _, m := range alone {
var rs []json.RawMessage
for _, raw := range own.Resources {
var head struct {
ID string `json:"id"`
Module string `json:"module"`
}
if json.Unmarshal(raw, &head) != nil {
continue
}
owner := head.Module
if owner == "" {
owner, _ = declaration.ModuleGuess(head.ID)
}
if owner == m {
rs = append(rs, raw)
}
}
if len(rs) > 0 {
out = append(out, delivery.Composed{Node: a.env.Node, Module: m, Resources: rs})
}
} }
return out return out
} }
+45
View File
@@ -838,6 +838,37 @@ func TestTheNodesOwnApplyHoldsOutAModuleThatMovedToItsOwnComposition(t *testing.
if !strings.Contains(strings.Join(names, ","), "systemd-resolved") { if !strings.Contains(strings.Join(names, ","), "systemd-resolved") {
t.Fatalf("the others of the node's own apply were %v: systemd-resolved's resources would be removed", names) t.Fatalf("the others of the node's own apply were %v: systemd-resolved's resources would be removed", names)
} }
// Issue 1126, review of the apply, round 1: a module the composer holds alone (de-spiegel leaving the fold with its
// names unset) has no composition of its own in the bucket: what runs of it in the node's own is held out of the
// node's own apply until one is handed.
b2 := newBed(t)
spiegel := file("de-spiegel", "server-env", "A=1")
var bare map[string]any
_ = json.Unmarshal(spiegel, &bare)
delete(bare, "module")
spiegel, _ = json.Marshal(bare)
b2.env.Kept = func() map[string][]json.RawMessage { return map[string][]json.RawMessage{NodeModule: {spiegel}} }
nk2, nraw2, ndig2 := composition(t, NodeModule, "", false)
nwraw2, _ := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig2})
b2.comps.before(nk2, nraw2)
b2.wants.before(nwk, nwraw2)
ctx2, cancel2 := context.WithCancel(context.Background())
b2.ag = New(b2.env)
b2.ag.NotComposedEach(ctx2, "", map[string]string{"de-spiegel": "de-spiegel says ${config:praktijk-name}, and nothing sets it"})
done2 := make(chan struct{})
go func() { defer close(done2); _ = b2.ag.Run(ctx2) }()
defer func() { cancel2(); <-done2 }()
eventually(t, "the node's own not applied beside a held module", func() bool { return len(b2.appliedList()) == 1 })
b2.mu.Lock()
defer b2.mu.Unlock()
heldOut := false
for _, c := range b2.others[0] {
heldOut = heldOut || (c.Module == "de-spiegel" && len(c.Resources) == 1)
}
if !heldOut {
t.Errorf("the others of the node's own apply were %+v: held de-spiegel's resources would be removed", b2.others[0])
}
} }
// A module the assigner removed holds nothing back, its compositions still in the bucket or not: its resources are its // A module the assigner removed holds nothing back, its compositions still in the bucket or not: its resources are its
@@ -1134,6 +1165,20 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
t.Errorf("%s's have once the node composes again: %+v", row.module, h) t.Errorf("%s's have once the node composes again: %+v", row.module, h)
} }
} }
// Issue 1126, the applying path: a module the node holds alone (its own refusal: de-spiegel's unset route names on
// novox) is marked alone, naming its own words; web, which composes, keeps running, unmarked. Composed again, idle's
// have is what it runs.
b.ag.NotComposedEach(context.Background(), "", map[string]string{"idle": "idle says ${config:name}, and nothing sets it"})
if h := b.haves.last("idle"); h.Phase != string(delivery.PhaseFailed) || h.Composed != idleDig || !strings.Contains(h.Detail, "${config:name}") {
t.Errorf("idle's have while it alone is held: %+v", h)
}
if h := b.haves.last("web"); h.Phase != string(delivery.PhaseRunning) || h.Composed != dig {
t.Errorf("web, which composes, is marked beside idle: %+v", h)
}
b.ag.NotComposedEach(context.Background(), "", nil)
if h := b.haves.last("idle"); h.Phase != string(delivery.PhaseRunning) || h.Composed != idleDig {
t.Errorf("idle's have once it composes again: %+v", h)
}
// A revision at the same build (a config value changed) is applied. // A revision at the same build (a config value changed) is applied.
_, craw2, dig2 := unsigned("web", fp, file("web", "x", "2")) _, craw2, dig2 := unsigned("web", fp, file("web", "x", "2"))
b.comps.put(ck, craw2) b.comps.put(ck, craw2)
+46 -13
View File
@@ -52,9 +52,11 @@ type Env struct {
// Hear subscribes a subject: here the vault's word that a secret changed (VaultSecretChanged), on which the node // 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. // 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) 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 // Refused is told why the node cannot compose its modules (why, the node refused whole), or, of a node that composes,
// have says it (agent.NotComposed): a refusal is loud where haves are read, not one journal line. Nil tells nobody. // each module it holds and why (held: refused alone, the rest composed and applied, frombus.Compose), and both empty
Refused func(why string) // once it composes every module again, so each module's have says it (agent.NotComposedEach): a refusal is loud where
// haves are read, not one journal line, and one module's never marks the others (issue 1126). Nil tells nobody.
Refused func(why string, held map[string]string)
// Running is what the node runs now (agent.Everything), and Unseal opens a value sealed to this machine: a secret // 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 at the same slot (the same field of // the vault seals again for each ask is kept as the box the node already runs at the same slot (the same field of
// the same resource of the same module, delivery.KeepSeals) when that box opens to the same value, so neither the // the same resource of the same module, delivery.KeepSeals) when that box opens to the same value, so neither the
@@ -497,7 +499,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
} }
scope := frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn, Running: running} scope := frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn, Running: running}
missed := c.missed.Load() missed := c.missed.Load()
compositions, _, err := frombus.Compose(ctx, r, c.env.Mesh, scope, fps) compositions, _, held, err := frombus.Compose(ctx, r, c.env.Mesh, scope, fps)
err = c.missedIn(missed, err) err = c.missedIn(missed, err)
// A bus that does not answer for a moment (a reload of its accounts pauses it a few seconds) is no refusal: the pass is // A bus that does not answer for a moment (a reload of its accounts pauses it a few seconds) is no refusal: the pass is
// tried again after a growing wait before anything is marked (issue 1126). // tried again after a growing wait before anything is marked (issue 1126).
@@ -509,7 +511,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
} }
if ctx.Err() == nil { if ctx.Err() == nil {
missed = c.missed.Load() missed = c.missed.Load()
compositions, _, err = frombus.Compose(ctx, r, c.env.Mesh, scope, fps) compositions, _, held, err = frombus.Compose(ctx, r, c.env.Mesh, scope, fps)
err = c.missedIn(missed, err) err = c.missedIn(missed, err)
} }
} }
@@ -521,7 +523,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
if err != nil { if err != nil {
// Said before the agent marks the modules: the marks read each have first, which a paused bus delays. // Said before the agent marks the modules: the marks read each have first, which a paused bus delays.
c.sayOnce(fmt.Sprintf("the node cannot compose its modules, and keeps running what it runs: %v", err)) c.sayOnce(fmt.Sprintf("the node cannot compose its modules, and keeps running what it runs: %v", err))
c.refused(fmt.Sprintf("%v", err)) c.refused(fmt.Sprintf("%v", err), nil)
// A bus that did not answer past every try is no answer: composed again after a bound (issue 1126, 2026-10-28). // A bus that did not answer past every try is no answer: composed again after a bound (issue 1126, 2026-10-28).
c.owed.Store(BusFailed(err)) c.owed.Store(BusFailed(err))
return nil return nil
@@ -539,8 +541,19 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
return nil return nil
} }
c.owed.Store(false) c.owed.Store(false)
c.refused("") // A module refused alone is held: it keeps what it runs, its have says why, and every other module is applied (issue
c.sayOnce("") // 1126: one module's refusal froze the whole node).
c.refused("", held)
if len(held) == 0 {
c.sayOnce("")
} else {
var lines []string
for _, m := range heldModules(held) {
lines = append(lines, m+": "+held[m])
}
c.sayOnce("the node composes its modules but holds " + strings.Join(heldModules(held), ", ") +
", which keep running what they run: " + strings.Join(lines, "; "))
}
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
var out []agent.Update var out []agent.Update
@@ -574,6 +587,11 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
out = append(out, agent.Update{Key: key, Value: raw, Revision: c.rev}) out = append(out, agent.Update{Key: key, Value: raw, Revision: c.rev})
} }
for key := range c.handed { for key := range c.handed {
if _, module, _, _, err := delivery.ParseComposedKey(key); err == nil && module != "" {
if _, isHeld := held[module]; isHeld {
continue // held: what was handed of it stays, so the node keeps running it
}
}
if !now[key] { if !now[key] {
c.rev++ c.rev++
delete(c.handed, key) delete(c.handed, key)
@@ -583,14 +601,29 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
return out return out
} }
// refused tells the agent why the node cannot compose, or "" that it composes again, once per change. // heldModules is the modules of held, sorted.
func (c *Composer) refused(why string) { func heldModules(held map[string]string) []string {
out := make([]string, 0, len(held))
for m := range held {
out = append(out, m)
}
sort.Strings(out)
return out
}
// refused tells the agent why the node cannot compose (why), or which modules it holds (held), or both empty that it
// composes every module again, once per change.
func (c *Composer) refused(why string, held map[string]string) {
said := why
for _, m := range heldModules(held) {
said += "\x00" + m + "\x00" + held[m]
}
c.mu.Lock() c.mu.Lock()
same := c.refusal == why same := c.refusal == said
c.refusal = why c.refusal = said
c.mu.Unlock() c.mu.Unlock()
if !same && c.env.Refused != nil { if !same && c.env.Refused != nil {
c.env.Refused(why) c.env.Refused(why, held)
} }
} }
+63 -6
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -249,7 +250,12 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
var refusals []string var refusals []string
e := b.env() e := b.env()
e.Get = get e.Get = get
e.Refused = func(why string) { refusals = append(refusals, why) } e.Refused = func(why string, held map[string]string) {
for _, m := range heldModules(held) {
why += "held " + m + ": " + held[m]
}
refusals = append(refusals, why)
}
c2 := New(e) c2 := New(e)
c.mu.Lock() c.mu.Lock()
for k, v := range c.wants { for k, v := range c.wants {
@@ -307,6 +313,57 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
Transient(fmt.Errorf("the revisions of it2.mailer.domain: %w", context.DeadlineExceeded)) { Transient(fmt.Errorf("the revisions of it2.mailer.domain: %w", context.DeadlineExceeded)) {
t.Error("Transient misjudges a bus that does not answer, or a refusal") t.Error("Transient misjudges a bus that does not answer, or a refusal")
} }
// Issue 1126, the applying path (review of the seed): one module's own refusal holds that module alone. On novox,
// de-spiegel's unset route names (its manifest at the indexer's commit e25e1beb, captured) refused the whole node and
// marked all its modules not composed. Now lemurs is handed; de-spiegel is held, and proxy with it, which receives its
// routes: what was handed of de-spiegel stays handed, so the node keeps running it, and the agent is told those two
// alone, de-spiegel in its config form's words.
spiegel, err := os.ReadFile("testdata/de-spiegel-e25e1beb.module.json")
if err != nil {
t.Fatal(err)
}
proxy := `{"module":"proxy","version":"1","capabilities":["systemd"],"provides":[{"name":"route","scope":"mesh"},{"name":"smtp","scope":"mesh"}],` +
`"resources":[{"id":"service","type":"service","unit":"proxy.service","state":"running","boot":"enabled"}]}`
c3, told := fresh(env.Get)
for m, raw := range map[string]string{"de-spiegel": string(spiegel), "proxy": proxy} {
dig := delivery.ManifestDigest([]byte(raw))
enc, _ := delivery.EncodeManifest(delivery.Manifest{Module: m, JSON: raw})
k, _ := delivery.ManifestsKey(dig)
b.mu.Lock()
b.kv[delivery.ManifestsBucket+"/"+k] = enc
b.mu.Unlock()
mfp := delivery.SourcePrefix2 + strings.Repeat(map[string]string{"de-spiegel": "d", "proxy": "e"}[m], 64)
c3.modules[m] = delivery.Module{FP2: mfp, Manifest: dig, Repository: "novox/" + m, Commit: "c"}
c3.wants[m] = delivery.Want{FP: mfp, Self: true}
}
p3 := c3.placements["n1"]
p3.Modules = append(append([]delivery.PlacedModule{}, p3.Modules...), delivery.PlacedModule{Module: "proxy"}, delivery.PlacedModule{Module: "de-spiegel"})
c3.placements["n1"] = p3
r3 := c3.records["n1"]
r3.Capabilities = []string{"systemd", "container-runtime"}
c3.records["n1"] = r3
spiegelKey, _ := delivery.ComposedKey("n1", "de-spiegel", c3.wants["de-spiegel"].FP)
proxyKey, _ := delivery.ComposedKey("n1", "proxy", c3.wants["proxy"].FP)
c3.handed[spiegelKey] = "sha256:" + strings.Repeat("5", 64) // what the node runs of it, handed before
handed3 := map[string]agent.Update{}
for _, u := range c3.compose(ctx) {
handed3[u.Key] = u
}
lemursKey3, _ := delivery.ComposedKey("n1", "lemurs", c3.wants["lemurs"].FP)
if u, ok := handed3[lemursKey3]; !ok || u.Deleted {
t.Errorf("lemurs, which composes, is not handed beside de-spiegel's refusal: %v (told %q)", handed3, *told)
}
if u, ok := handed3[proxyKey]; ok {
t.Errorf("proxy is handed without held de-spiegel's routes: %+v", u)
}
if u, ok := handed3[spiegelKey]; ok {
t.Errorf("held de-spiegel is handed %+v, want what was handed of it kept", u)
}
if len(*told) != 1 || !strings.HasPrefix((*told)[0], "held de-spiegel: ") || !strings.Contains((*told)[0], "${config:praktijk-name}") ||
!strings.Contains((*told)[0], "held proxy: ") || strings.Contains((*told)[0], "held lemurs") {
t.Errorf("the agent was told %q, want de-spiegel held in its config form's words, and proxy", *told)
}
// A read the bus did not answer, timed out or not, is tried again later; the run's own end and a refusal are not. // A read the bus did not answer, timed out or not, is tried again later; the run's own end and a refusal are not.
if !BusFailed(fmt.Errorf("the revisions of it2.mailer.domain: %w", context.DeadlineExceeded)) || !BusFailed(fmt.Errorf("x: %w", nats.ErrNoResponders)) || if !BusFailed(fmt.Errorf("the revisions of it2.mailer.domain: %w", context.DeadlineExceeded)) || !BusFailed(fmt.Errorf("x: %w", nats.ErrNoResponders)) ||
!BusFailed(fmt.Errorf("the have of web: %w", nats.ErrTimeout)) || BusFailed(fmt.Errorf("x: %w", context.Canceled)) || !BusFailed(fmt.Errorf("the have of web: %w", nats.ErrTimeout)) || BusFailed(fmt.Errorf("x: %w", context.Canceled)) ||
@@ -589,7 +646,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
return nil, fmt.Errorf("not sealed to n1: %s", box) return nil, fmt.Errorf("not sealed to n1: %s", box)
} }
var refusals []string var refusals []string
pairEnv.Refused = func(why string) { refusals = append(refusals, why) } pairEnv.Refused = func(why string, held map[string]string) { refusals = append(refusals, why) }
pc := New(pairEnv) pc := New(pairEnv)
consumer := []compose.CredentialKey{{Provision: "pg", Consumer: "n1", ConsumerModule: "web", Provider: "n2"}} consumer := []compose.CredentialKey{{Provision: "pg", Consumer: "n1", ConsumerModule: "web", Provider: "n2"}}
provider := []compose.CredentialKey{{Provision: "s3", Consumer: "n3", ConsumerModule: "files", Provider: "n1"}} provider := []compose.CredentialKey{{Provision: "s3", Consumer: "n3", ConsumerModule: "files", Provider: "n1"}}
@@ -607,9 +664,9 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
t.Errorf("web's credential composed as %s, want the box that runs", got) t.Errorf("web's credential composed as %s, want the box that runs", got)
} }
// A node that cannot compose tells the agent once, and once again when it composes (the agent's haves say it). // A node that cannot compose tells the agent once, and once again when it composes (the agent's haves say it).
pc.refused("a stand-in for web's password") pc.refused("a stand-in for web's password", nil)
pc.refused("a stand-in for web's password") pc.refused("a stand-in for web's password", nil)
pc.refused("") pc.refused("", nil)
if len(refusals) != 2 || refusals[0] == "" || refusals[1] != "" { if len(refusals) != 2 || refusals[0] == "" || refusals[1] != "" {
t.Errorf("the agent was told %q", refusals) t.Errorf("the agent was told %q", refusals)
} }
@@ -640,7 +697,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
return json.RawMessage(`{"node":"n1","key":"sk","own":[]}`), nil // nothing issued for keeper: it is not placed here return json.RawMessage(`{"node":"n1","key":"sk","own":[]}`), nil // nothing issued for keeper: it is not placed here
} }
// What n1 runs: lemurs and its own composition, as its composer composes them from the trunk. // What n1 runs: lemurs and its own composition, as its composer composes them from the trunk.
running, _, err := frombus.Compose(ctx, fixedReader{placements: map[string]delivery.Placement{"n1": trunk}, running, _, _, err := frombus.Compose(ctx, fixedReader{placements: map[string]delivery.Placement{"n1": trunk},
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}, records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}},
modules: mods, manifests: map[string][]byte{digest: []byte(lemurs), kdigest: []byte(keeper)}}, frombus.Mesh{}, frombus.Scope{Node: "n1"}, map[string]string{"lemurs": fp}) modules: mods, manifests: map[string][]byte{digest: []byte(lemurs), kdigest: []byte(keeper)}}, frombus.Mesh{}, frombus.Scope{Node: "n1"}, map[string]string{"lemurs": fp})
if err != nil { if err != nil {
@@ -0,0 +1,129 @@
{
"module": "de-spiegel",
"version": "1",
"slug": "spiegel",
"capabilities": [
"container-runtime"
],
"requires": [
"route",
"smtp"
],
"contributes": {
"route": {
"site": {
"label": "de-spiegel",
"port": 35621
},
"praktijk": {
"name": "${config:praktijk-name}",
"port": 35621
},
"praktijk-www": {
"name": "${config:praktijk-www-name}",
"redirect": "https://${config:praktijk-name}"
},
"psychologie": {
"name": "${config:psychologie-name}",
"port": 35621
},
"psychologie-www": {
"name": "${config:psychologie-www-name}",
"redirect": "https://${config:psychologie-name}"
}
},
"smtp": {
"account": "de-spiegel"
}
},
"binds": {
"route": "/var/lib/de-spiegel/route.json",
"smtp": "/var/lib/de-spiegel/smtp.json"
},
"config": {
"praktijk-name": {
"type": "string",
"description": "The public name the practice's site is served on; the operator's, with no default (novox/hq ADR 0112).",
"trusted": true,
"required": true
},
"praktijk-www-name": {
"type": "string",
"description": "The public name that redirects to the practice's site (its www form); the operator's, with no default (novox/hq ADR 0112).",
"trusted": true,
"required": true
},
"psychologie-name": {
"type": "string",
"description": "The public name the psychology practice's site is served on; the operator's, with no default (novox/hq ADR 0112).",
"trusted": true,
"required": true
},
"psychologie-www-name": {
"type": "string",
"description": "The public name that redirects to the psychology practice's site (its www form); the operator's, with no default (novox/hq ADR 0112).",
"trusted": true,
"required": true
}
},
"listens": [
{
"port": 35621,
"protocol": "tcp",
"from": "mesh",
"why": "the de-spiegel site and its /contact endpoint over http; the practice's three public names are route grants, and the proxy reaches every one of them here"
}
],
"resources": [
{
"id": "state",
"type": "directory",
"path": "/var/lib/de-spiegel",
"mode": "0700"
},
{
"id": "server-env",
"type": "file",
"path": "/var/lib/de-spiegel/server.env",
"mode": "0600",
"content": "SMTP_HOST=${bound:smtp:at}\nSMTP_PORT=${bound:smtp:port}\nSMTP_AUTH_USER=de-spiegel@${bound:smtp:domain}\nSMTP_AUTH_PASS=${secret:smtp}\n"
},
{
"id": "net",
"type": "network",
"name": "de-spiegel"
},
{
"id": "server",
"type": "container",
"name": "de-spiegel",
"artifact": "site",
"network": "de-spiegel",
"env-file": [
"/var/lib/de-spiegel/server.env"
],
"ports": [
"35621"
],
"secrets-in-environment": "the application's own code reads SMTP_AUTH_USER/PASS from the environment (server/index.js); converting is this repository's change"
}
],
"build": {
"on": [
{
"arg": "NODE_BASE",
"image": "node@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9"
}
],
"artifacts": [
{
"name": "site",
"kind": "image",
"from": "Dockerfile"
}
]
},
"secrets": {
"smtp": "/var/lib/de-spiegel/smtp.secret"
}
}