diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 6e677a2..4351529 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -551,9 +551,9 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, sess } 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 { - 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 { running func() []delivery.Composed unseal func(string) ([]byte, error) - refused func(string) + refused func(why string, held map[string]string) held func(module string) (delivery.Seals, []string, error) } diff --git a/go.mod b/go.mod index cb0509c..8fc3983 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 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.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/go.sum b/go.sum index e02d94c..4412310 100644 --- a/go.sum +++ b/go.sum @@ -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.20261028130759-f387581b6bfb/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +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.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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index e802476..9b8195b 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -24,6 +24,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -162,7 +163,10 @@ type Agent struct { said map[string]string // notComposed is the modules whose have says the node could not compose them: why, and the have before (NotComposed). 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 // transient asks for every transiently failed module at once. stopped is closed when Run returns. 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 // 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. -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() - var modules []string - if why != "" { - for module, w := range a.wants { - if w.Present && !w.Want.Removed && module != NodeModule && a.notComposed[module].why != why { - modules = append(modules, module) - } + if why == "" { + a.heldAlone = make(map[string]bool, len(held)) + for m := range held { + a.heldAlone[m] = true } - } 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) } } a.mu.Unlock() sort.Strings(modules) for _, module := range modules { + why := whyOf[module] if ctx.Err() != nil { 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) for _, m := range names { 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 } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 115befc..096a355 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -838,6 +838,37 @@ func TestTheNodesOwnApplyHoldsOutAModuleThatMovedToItsOwnComposition(t *testing. 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) } + + // 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 @@ -1134,6 +1165,20 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( 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. _, craw2, dig2 := unsigned("web", fp, file("web", "x", "2")) b.comps.put(ck, craw2) diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 713bf3b..3671e69 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -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 // 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) + // Refused is told why the node cannot compose its modules (why, the node refused whole), or, of a node that composes, + // each module it holds and why (held: refused alone, the rest composed and applied, frombus.Compose), and both empty + // 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 // 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 @@ -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} 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) // 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). @@ -509,7 +511,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { } if ctx.Err() == nil { 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) } } @@ -521,7 +523,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { if err != nil { // 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.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). c.owed.Store(BusFailed(err)) return nil @@ -539,8 +541,19 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { return nil } c.owed.Store(false) - c.refused("") - c.sayOnce("") + // A module refused alone is held: it keeps what it runs, its have says why, and every other module is applied (issue + // 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() defer c.mu.Unlock() 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}) } 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] { c.rev++ delete(c.handed, key) @@ -583,14 +601,29 @@ 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) { +// heldModules is the modules of held, sorted. +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() - same := c.refusal == why - c.refusal = why + same := c.refusal == said + c.refusal = said c.mu.Unlock() if !same && c.env.Refused != nil { - c.env.Refused(why) + c.env.Refused(why, held) } } diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index aeb2101..6df31cf 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "os" "slices" "strconv" "strings" @@ -249,7 +250,12 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { var refusals []string e := b.env() 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) c.mu.Lock() 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)) { 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. 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)) || @@ -589,7 +646,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { return nil, fmt.Errorf("not sealed to n1: %s", box) } 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) consumer := []compose.CredentialKey{{Provision: "pg", Consumer: "n1", ConsumerModule: "web", Provider: "n2"}} 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) } // 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") - pc.refused("") + pc.refused("a stand-in for web's password", nil) + pc.refused("a stand-in for web's password", nil) + pc.refused("", nil) if len(refusals) != 2 || refusals[0] == "" || refusals[1] != "" { 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 } // 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"}}}, 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 { diff --git a/internal/selfcompose/testdata/de-spiegel-e25e1beb.module.json b/internal/selfcompose/testdata/de-spiegel-e25e1beb.module.json new file mode 100644 index 0000000..b4c4bd2 --- /dev/null +++ b/internal/selfcompose/testdata/de-spiegel-e25e1beb.module.json @@ -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" + } +}