From 06742d0448ff45d964db457a140544b915f93817 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 13:09:45 +0100 Subject: [PATCH 1/3] =?UTF-8?q?A=20contribution's=20unset=20required=20val?= =?UTF-8?q?ue=20is=20refused=20in=20its=20module's=20own=20form's=20words,?= =?UTF-8?q?=20as=20a=20file's=20is=20(issue=201126,=20seed-8:=20de-spiegel?= =?UTF-8?q?=20in=20the=20config=20form=20was=20told=20${setting:=E2=80=A6}?= =?UTF-8?q?=20and=20settings=20set)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/catalogue/config_test.go | 13 +++++++++++++ go/catalogue/declaration.go | 10 +++++++++- go/catalogue/setting_into.go | 2 ++ go/catalogue/settings.go | 14 +++++++++++--- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/go/catalogue/config_test.go b/go/catalogue/config_test.go index 1168074..852d340 100644 --- a/go/catalogue/config_test.go +++ b/go/catalogue/config_test.go @@ -445,6 +445,19 @@ func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) { t.Fatalf("not typed as a value nobody gave: %v", err) } + // A contribution's value too (issue 1126, seed-8: de-spiegel's route names were refused with ${setting:…}). + withRoute := inConfigForm() + withRoute["contributes"] = map[string]any{"route": map[string]any{"site": map[string]any{"name": "${config:sender}", "port": 80}}} + routed, err := parseRaw(t, withRoute) + if err != nil { + t.Fatal(err) + } + _, err = Resolution{}.composed(routed, "route", routed.ContributesMany["route"]["site"], nil, "notifier contributing site to route") + if !errors.As(err, &unset) || unset.Module != "notifier" || !strings.Contains(err.Error(), + `notifier has a contribution site to route, in "name", that says ${config:sender}`) || strings.Contains(err.Error(), "${setting:") { + t.Fatalf("a contribution's unset value is not said in the config form's words: %v", err) + } + settingsForm := notifier() settingsForm.Resources[0]["content"] = "from = ${setting:domain}\n" err = unsetInConfigWords(settingInto(settingsForm.Resources[0], nil, "notifier"), settingsForm) diff --git a/go/catalogue/declaration.go b/go/catalogue/declaration.go index efa24a3..66c0660 100644 --- a/go/catalogue/declaration.go +++ b/go/catalogue/declaration.go @@ -10,6 +10,7 @@ package catalogue import ( "encoding/json" + "errors" "fmt" "sort" "strconv" @@ -1832,7 +1833,14 @@ func (r Resolution) composed(m Manifest, to string, raw map[string]any, layers [ // module's files is no part of it (novox/hq 04-ISSUES/173). values, err := overridden(raw, WithDefaults(m, layers), what) if err != nil { - return nil, fmt.Errorf("%s: %w", what, err) + // A required value nothing sets is said in the words of the module's form, as a file's, a unit's and an owner's + // are (issue 1126, seed-8: de-spiegel in the config form was refused with ${setting:…} and `settings set`). + var unset *UnsetSettingError + if errors.As(err, &unset) && unset.Module == "" { + unset.Module = m.Module + unset.where = fmt.Sprintf("a contribution%s, in %q,", strings.TrimPrefix(what, m.Module+" contributing"), unset.field) + } + return nil, unsetInConfigWords(fmt.Errorf("%s: %w", what, err), m) } reaches, err := Reaches(m, layers) if err != nil { diff --git a/go/catalogue/setting_into.go b/go/catalogue/setting_into.go index e16e28d..3ced407 100644 --- a/go/catalogue/setting_into.go +++ b/go/catalogue/setting_into.go @@ -51,6 +51,8 @@ type UnsetSettingError struct { // where says what asks for it — "a file", "a service whose unit", "a directory whose owner" — and // setToday which of the module's keys a layer sets, for saying it again in the config form's words. where, setToday string + // field is the key of a contribution whose value asks for it, where the contribution's words are made by its caller. + field string } func (e *UnsetSettingError) Error() string { return e.said } diff --git a/go/catalogue/settings.go b/go/catalogue/settings.go index 03efee3..4c57f07 100644 --- a/go/catalogue/settings.go +++ b/go/catalogue/settings.go @@ -5,6 +5,7 @@ import ( "crypto/x509" "encoding/json" "encoding/pem" + "errors" "fmt" "regexp" "sort" @@ -129,7 +130,12 @@ func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, err // contract. Until issue 696, a layer replaced any served key it named; influxdb on ace was the one // layer relying on it, and serves `${setting:org}` and `${setting:bucket}` since. func Settle(base map[string]any, layers []Layer) (map[string]any, error) { - return filledFromSettings(deepCopy(base), layers, "what is served") + out, err := filledFromSettings(deepCopy(base), layers, "what is served") + if err != nil { + // Said as it was, untyped: what is served names no module here to type it by. + return nil, errors.New(err.Error()) + } + return out, nil } // ServedSettings is every key a module's served values and its zone's name fill from its settings (`${setting:}` @@ -193,10 +199,12 @@ func filledFromSettings(merged map[string]any, layers []Layer, what string) (map for _, asked := range settingsUsed(s) { v, set := settingValue(layers, asked) if !set { - return nil, fmt.Errorf( + // Typed, its module and where left to the caller that knows them (Resolution.composed), so a module in + // the config form is refused in its own words (unsetInConfigWords; issue 1126, seed-8). + return nil, &UnsetSettingError{Setting: asked, field: key, setToday: orNoSettings(layers), said: fmt.Sprintf( "%s says ${setting:%s} for %q, and nothing sets %q — an operator's value is the "+ "assignment's, never the definition's (novox/hq ADR 0112)%s", - what, asked, key, asked, orNoSettings(layers)) + what, asked, key, asked, orNoSettings(layers))} } s = strings.ReplaceAll(s, "${setting:"+asked+"}", plainly(v)) } -- 2.54.0 From eda82b15988ef134b8bd073a270fc24d5dfa8ea3 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 13:09:56 +0100 Subject: [PATCH 2/3] A folded module owns its manifest's paths, as the controller tells node-files them, not every path it runs at (issue 1126, seed-8: f1-circuits, snake and txt-game shifted node-tools' owned paths on ace) --- go/catalogue/files_test.go | 11 ++- go/catalogue/manifest.go | 9 +- go/catalogue/seat_paths.go | 19 +++-- go/compose/composed.go | 29 ++++--- go/compose/frombus/answer_rows_test.go | 27 +++++- .../testdata/f1-circuits-c0ea2c94.module.json | 82 +++++++++++++++++++ 6 files changed, 149 insertions(+), 28 deletions(-) create mode 100644 go/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json diff --git a/go/catalogue/files_test.go b/go/catalogue/files_test.go index dcbf535..92de928 100644 --- a/go/catalogue/files_test.go +++ b/go/catalogue/files_test.go @@ -111,11 +111,14 @@ func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) { } } seats = compiled - // Issue 1126, C5: a module carried as it runs (compose.Carried) owns the paths it runs at, beside its resources'. - carried := Manifest{Module: "de-spiegel", Version: "1", RunsAt: []string{"/var/lib/de-spiegel/server.env"}} + // Issue 1126, C5 and seed-8: a module carried as it runs (compose.Carried) owns its manifest's paths (OwnsPaths), as + // the controller tells them, and not the paths its running resources are at (a directory the mesh placed). + carried := Manifest{Module: "de-spiegel", Version: "1", OwnsPaths: []string{"/var/lib/de-spiegel/server.env"}, + Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/de-spiegel"}}} w, err := Resolution{Node: "anchor", Modules: []Manifest{files, carried}}.filesWords(files, nil, Rendering{}) - if err != nil || !strings.Contains(w[FilesOwnedPaths], `"/var/lib/de-spiegel/server.env":"de-spiegel (its own tools)"`) { - t.Errorf("the paths a carried module runs at are not owned: %v %v", w[FilesOwnedPaths], err) + if err != nil || !strings.Contains(w[FilesOwnedPaths], `"/var/lib/de-spiegel/server.env":"de-spiegel (its own tools)"`) || + strings.Contains(w[FilesOwnedPaths], `"/var/lib/de-spiegel":`) { + t.Errorf("a carried module does not own its manifest's paths alone: %v %v", w[FilesOwnedPaths], err) } if w, _ := r.filesWords(app, nil, Rendering{}); w[FilesOwnedPaths] != "" { t.Error("a module not holding node-files is told the owned paths") diff --git a/go/catalogue/manifest.go b/go/catalogue/manifest.go index 2f0be1e..d91df7f 100644 --- a/go/catalogue/manifest.go +++ b/go/catalogue/manifest.go @@ -864,10 +864,11 @@ type Manifest struct { // that could only see its own ports would write a rule set that closed everything else. Filtering *Filtering `json:"filtering,omitempty"` - // RunsAt is, for a module composed from what runs of it (compose.Carried: folded into its node's own composition - // at a build of the old form, issue 1126, C5), the paths its running resources are at: paths it owns beside its - // resources' (declaredPaths), so the holder of node-files is told them. Never in a manifest's JSON. - RunsAt []string `json:"-"` + // OwnsPaths is, for a module composed from what runs of it (compose.Carried: folded into its node's own composition + // at a build of the old form, issue 1126, C5), the paths it owns: its manifest's (DeclaredPaths), as the controller + // tells the holder of node-files them, in place of its resources', which are what runs (a directory placed at a + // path its manifest leaves to the mesh, a binding). Nil for any other module. Never in a manifest's JSON. + OwnsPaths []string `json:"-"` // Jails are the fail2ban jails this module declares for its own service (novox/hq to-be 31). // Written into whichever node runs the module, the same way `listens` become that node's rules. diff --git a/go/catalogue/seat_paths.go b/go/catalogue/seat_paths.go index b80dd4d..a2a13af 100644 --- a/go/catalogue/seat_paths.go +++ b/go/catalogue/seat_paths.go @@ -123,6 +123,14 @@ func namesWithoutSteppingBack(s Seat, p string) bool { // as written would pass for a path nobody named. func declaredPaths(m Manifest) map[string]bool { out := map[string]bool{} + if m.OwnsPaths != nil { + // Carried: what its manifest declares, never what runs of it (issue 1126, seed-8: f1-circuits, snake and txt-game + // on ace owned their /var/lib paths on the node and none on the controller). + for _, p := range m.OwnsPaths { + out[p] = true + } + return out + } for _, r := range m.Resources { if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") { out[path.Clean(p)] = true @@ -133,14 +141,15 @@ func declaredPaths(m Manifest) map[string]bool { out[path.Clean(f.Path)] = true } } - for _, p := range m.RunsAt { - if strings.HasPrefix(p, "/") { - out[path.Clean(p)] = true - } - } return out } +// DeclaredPaths is every machine path a module's manifest declares it owns (declaredPaths), sorted and never nil: what +// a carried module owns (Manifest.OwnsPaths). +func DeclaredPaths(m Manifest) []string { + return append([]string{}, sortedKeys(declaredPaths(m))...) +} + // declaresPath says whether a module declares one path, as a resource or as a file the mesh renders. func declaresPath(m Manifest, p string) bool { return declaredPaths(m)[p] } diff --git a/go/compose/composed.go b/go/compose/composed.go index f6b9bb0..f89dec1 100644 --- a/go/compose/composed.go +++ b/go/compose/composed.go @@ -263,6 +263,10 @@ func NamesBuild(m catalogue.Manifest) bool { // and nothing else of its own is composed (issue 1126: nats's ${dir:mesh-state} on novox). A module refused in a // node's answer is composed so; a folded one the node runs is Carried. func Stub(m catalogue.Manifest) catalogue.Manifest { + // The paths it owns stay its manifest's, as the controller tells the holder of node-files them (issue 1126, seed-8). + if m.OwnsPaths == nil { + m.OwnsPaths = catalogue.DeclaredPaths(m) + } m.Build = nil var dirs []map[string]any for _, r := range m.Resources { @@ -276,19 +280,21 @@ func Stub(m catalogue.Manifest) catalogue.Manifest { // Carried is m, folded into its node's own composition at a build of the old form, as the node runs it (issue 1126, // C5): nothing to build; its containers and directories those the node's own composition (runs) carries of it, their -// module's word taken off their ids, with the manifest's directories it does not run; and the path of every resource it -// runs (catalogue.Manifest.RunsAt). So what other modules read of it is what runs: the machine ports its containers -// publish (the filter's rules, the routes reaching it: MachineSide) and the paths it owns (the holder of node-files is -// told them). Its other resources are not taken, since the composition makes some of them again from its manifest (its -// secrets' files) and one id twice is refused; none of them is composed into what is applied either way (compositions -// leaves a carried module's out, and carries the ones that run). What it provides, requires, listens on and grants is -// its manifest's. A module whose resources runs holds none of is its Stub, as before. +// module's word taken off their ids, with the manifest's directories it does not run. So what other modules read of it +// is what runs: the machine ports its containers publish (the filter's rules, the routes reaching it: MachineSide). The +// paths it owns, which the holder of node-files is told, are its manifest's, as for its Stub: the controller composes +// them from the manifest, not from every path a resource of it runs at (issue 1126, seed-8: f1-circuits, snake and +// txt-game on ace, whose manifests leave their directories to the mesh). Its other resources are not taken, since the +// composition makes some of them again from its manifest (its secrets' files) and one id twice is refused; none of them +// is composed into what is applied either way (compositions leaves a carried module's out, and carries the ones that +// run). What it provides, requires, listens on and grants is its manifest's. A module whose resources runs holds none +// of is its Stub, as before. func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catalogue.Manifest { if runs == nil { return Stub(m) } var taken []map[string]any - var at []string + runsAny := false ids := map[string]bool{} for _, raw := range runs.Resources { var r map[string]any @@ -303,9 +309,7 @@ func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catal if owner != module { continue } - if p, ok := r["path"].(string); ok && p != "" { - at = append(at, p) - } + runsAny = true if typ := r["type"]; typ != "container" && typ != "directory" { continue } @@ -314,7 +318,7 @@ func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catal ids[r["id"].(string)] = true taken = append(taken, r) } - if len(at) == 0 && len(taken) == 0 { + if !runsAny { return Stub(m) } stub := Stub(m) @@ -324,7 +328,6 @@ func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catal } } stub.Resources = taken - stub.RunsAt = at return stub } diff --git a/go/compose/frombus/answer_rows_test.go b/go/compose/frombus/answer_rows_test.go index d39da77..b1a463c 100644 --- a/go/compose/frombus/answer_rows_test.go +++ b/go/compose/frombus/answer_rows_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "os" "reflect" "slices" "strings" @@ -240,17 +241,39 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { if !strings.Contains(rules, "# spiegel — the site") || !strings.Contains(rules, "dport 20005 accept") || strings.Contains(rules, "dport 8080") { t.Errorf("the filter does not open the machine port spiegel runs at:\n%s", rules) } + // The paths a carried module owns are its manifest's, as the controller composes them, never what runs of it (issue + // 1126, seed-8 #542: on ace, the folded f1-circuits, snake and txt-game, whose manifests leave their directories and + // files to ${dir:…}, owned their /var/lib paths on the node and none on the controller, shifting node-tools' + // MESH_OWNED_PATHS). f1-circuits' manifest at the indexer's commit (c0ea2c94) is captured; what runs of it is + // composed from that manifest by the controller's rule (the directory at /var/lib/f1-circuits, its file, its container). for _, c := range running { - if c.Module == "" && !slices.Contains(compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).RunsAt, "/var/lib/spiegel/server.env") { - t.Errorf("spiegel carried does not own the path its running file is at") + if c.Module == "" && !reflect.DeepEqual(compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths, []string{"/var/lib/spiegel"}) { + t.Errorf("spiegel carried owns %v, want its manifest's /var/lib/spiegel alone", compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths) } } + f1raw, err := os.ReadFile("testdata/f1-circuits-c0ea2c94.module.json") + if err != nil { + t.Fatal(err) + } + f1, err := catalogue.ParseManifest(f1raw) + if err != nil { + t.Fatal(err) + } + f1runs := delivery.Composed{Node: "ace", Resources: []json.RawMessage{ + json.RawMessage(`{"id":"f1-circuits.state","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits","mode":"0700"}`), + json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`), + json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`), + json.RawMessage(`{"id":"f1-circuits.server","module":"f1-circuits","type":"container","name":"f1-circuits","image":"registry.example/f1@sha256:` + strings.Repeat("c", 64) + `"}`)}} + if got, want := catalogue.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), catalogue.DeclaredPaths(f1); !reflect.DeepEqual(got, want) { + t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want) + } var k1, k2 any _ = json.Unmarshal(keptComposed, &k1) _ = json.Unmarshal(keptFile, &k2) if !reflect.DeepEqual(k1, k2) { t.Errorf("the operator export is composed as %s, want the file that runs %s", keptComposed, keptFile) } + } // A module another node cannot run in this composition offers nothing in it: the refusal names it and why diff --git a/go/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json b/go/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json new file mode 100644 index 0000000..8f2b612 --- /dev/null +++ b/go/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json @@ -0,0 +1,82 @@ +{ + "module": "f1-circuits", + "slug": "f1", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "f1-circuits", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "listens": [ + { + "name": "web", + "port": 8000, + "protocol": "tcp", + "from": "mesh", + "why": "the track-layout CDN, the race posters and the Kometa metadata over http; its public name is a route grant and the proxy reaches it here. Kometa fetches the metadata and every poster by that public name" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, + { + "id": "cache", + "type": "directory", + "mode": "0700" + }, + { + "id": "site-env", + "type": "file", + "path": "${dir:state}/site.env", + "mode": "0644", + "content": "F1_CIRCUITS_BASE_URL=https://${bound:route:name}\n" + }, + { + "id": "server", + "type": "container", + "name": "f1-circuits", + "artifact": "cdn", + "ports": [ + "8000" + ], + "env-file": [ + "${dir:state}/site.env" + ], + "volumes": [ + "${dir:cache}:/app/cache" + ], + "restart-on": [ + "site-env" + ] + } + ], + "build": { + "on": [ + { + "arg": "PYTHON_BASE", + "image": "python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df" + } + ], + "artifacts": [ + { + "name": "cdn", + "kind": "image", + "from": "service/Dockerfile" + } + ] + } +} -- 2.54.0 From f387581b6bfba634c703a8a4bdc65611223585a8 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 13:09:56 +0100 Subject: [PATCH 3/3] A module's own refusal refuses that module, not the node: a stub whose contributions still fail the same is tried bare (issue 1126, seed-8: novox refused the whole candidate for de-spiegel's unset route names) --- go/compose/frombus/answer.go | 39 ++++-- go/compose/frombus/answer_rows_test.go | 36 ++++- .../testdata/de-spiegel-e25e1beb.module.json | 129 ++++++++++++++++++ 3 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 go/compose/frombus/testdata/de-spiegel-e25e1beb.module.json diff --git a/go/compose/frombus/answer.go b/go/compose/frombus/answer.go index 078dbbc..0f0bfe9 100644 --- a/go/compose/frombus/answer.go +++ b/go/compose/frombus/answer.go @@ -72,7 +72,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st } composed, skipped, stood, err := compose.CompositionsShadow(in, s.Node, fps) for err != nil { - module := culprit(in, s.Node, fps, failed, err) + module, bare := culprit(in, s.Node, fps, failed, err) if module == "" { // Not one module's: the node's own (a value served elsewhere, the mesh's network), said once as the node's. if len(failed) == 0 { @@ -83,7 +83,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st break } failed[module] = err.Error() - in = stubbed(in, s.Node, module) + in = stubbed(in, s.Node, module, bare) composed, skipped, stood, err = compose.CompositionsShadow(in, s.Node, fps) } // The node's own composition is judged only where the node runs one: one that runs none applies it only once a want @@ -246,8 +246,10 @@ func standsInFor(x, node string) (string, string) { // culprit is the module placed on node whose composition the error is of: the first, in placement order, whose stub // (compose.Stub) makes the composition succeed or fail otherwise; empty when none does (the error is not one module's). -// Modules already refused are passed over. -func culprit(in compose.Input, node string, fps map[string]string, failed map[string]string, err error) string { +// Modules already refused are passed over. A module whose stub still fails the same is tried bare (its stub contributing +// nothing, bare true): a refusal of what it contributes is its own, which the stub keeps (issue 1126, seed-8: de-spiegel's +// route names on novox, an unset required value, refused the whole node). +func culprit(in compose.Input, node string, fps map[string]string, failed map[string]string, err error) (string, bool) { var assigned []string for _, n := range in.Nodes { if n.Name == node { @@ -263,19 +265,32 @@ func culprit(in compose.Input, node string, fps map[string]string, failed map[st order = append(order, m) } } + changes := func(m string, bare bool) bool { + _, _, _, again := compose.CompositionsShadow(stubbed(in, node, m, bare), node, fps) + return again == nil || again.Error() != err.Error() + } for _, m := range order { if _, done := failed[m]; done { continue } - if _, _, _, again := compose.CompositionsShadow(stubbed(in, node, m), node, fps); again == nil || again.Error() != err.Error() { - return m + if changes(m, false) { + return m, false } } - return "" + for _, m := range order { + if _, done := failed[m]; done { + continue + } + if changes(m, true) { + return m, true + } + } + return "", false } -// stubbed is in with module composed as a stub on node alone (compose.Stub, as node's Kept). -func stubbed(in compose.Input, node, module string) compose.Input { +// stubbed is in with module composed as a stub on node alone (compose.Stub, as node's Kept); bare, a stub that also +// contributes nothing to what it requires. +func stubbed(in compose.Input, node, module string, bare bool) compose.Input { out := in out.Nodes = append([]compose.Node(nil), in.Nodes...) for i := range out.Nodes { @@ -292,7 +307,11 @@ func stubbed(in compose.Input, node, module string) compose.Input { for k, v := range out.Nodes[i].Kept { kept[k] = v } - kept[module] = compose.Stub(man) + stub := compose.Stub(man) + if bare { + stub.Contributes, stub.ContributesMany = nil, nil + } + kept[module] = stub out.Nodes[i].Kept = kept } return out diff --git a/go/compose/frombus/answer_rows_test.go b/go/compose/frombus/answer_rows_test.go index b1a463c..b90e2a1 100644 --- a/go/compose/frombus/answer_rows_test.go +++ b/go/compose/frombus/answer_rows_test.go @@ -111,7 +111,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { } // A refusal naming no module placed here is no module's: no compose is spent stubbing each in turn (issue 1126: ace's // answer took 7m46s, one compose per module), though here a stub would compose. - if m := culprit(in, "n1", fps, map[string]string{}, fmt.Errorf("nothing in this mesh provides \"artifact-store\"")); m != "" { + if m, _ := culprit(in, "n1", fps, map[string]string{}, fmt.Errorf("nothing in this mesh provides \"artifact-store\"")); m != "" { t.Errorf("a refusal naming no placed module was laid on %s", m) } ownKey, _ := delivery.ComposedNodeKey("n1") @@ -274,6 +274,40 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { t.Errorf("the operator export is composed as %s, want the file that runs %s", keptComposed, keptFile) } + // (D) Issue 1126, seed-8 (mesh-catalog #542): a module's own refusal refuses that module, never the node. novox runs + // de-spiegel folded, and its manifest at the indexer's commit (e25e1beb, captured) asks four required config values + // for its route names that nothing sets. novox answered the whole candidate refused ("de-spiegel contributing + // praktijk to route says ${setting:praktijk-name} ...": its stub still contributed the route). Only de-spiegel is + // refused, in the config form's words, and the rest is judged. + spiegel, err := os.ReadFile("testdata/de-spiegel-e25e1beb.module.json") + if err != nil { + t.Fatal(err) + } + proxy := []byte(`{"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"}]}`) + b = mk("lemurs", overlay.Name, "proxy", "de-spiegel") + for m, raw := range map[string][]byte{"de-spiegel": spiegel, "proxy": proxy} { + b.manifests[delivery.ManifestDigest(raw)] = raw + b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} + } + p = b.placements["n1"] + p.Decided.Endpoint = "n1.example:51820" + b.placements["n1"] = p + fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f")} + a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "h1", 3) + refused = nil + for _, r := range a.Refusals { + refused = append(refused, r.Module) + if r.Module == "de-spiegel" && (!strings.Contains(r.Why, "${config:praktijk-name}") || strings.Contains(r.Why, "${setting:")) { + t.Errorf("de-spiegel is refused in other words than its config form's: %s", r.Why) + } + } + if a.Refused != "" || !reflect.DeepEqual(refused, []string{"de-spiegel"}) { + t.Errorf("a module's own refusal: %+v, want de-spiegel refused alone, the node not", a) + } + if !slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "proxy" }) { + t.Errorf("proxy, which composes, is not judged beside de-spiegel's refusal: %+v", a) + } } // A module another node cannot run in this composition offers nothing in it: the refusal names it and why diff --git a/go/compose/frombus/testdata/de-spiegel-e25e1beb.module.json b/go/compose/frombus/testdata/de-spiegel-e25e1beb.module.json new file mode 100644 index 0000000..b4c4bd2 --- /dev/null +++ b/go/compose/frombus/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" + } +} -- 2.54.0