diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index 7b4be87..72b6d99 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -479,6 +479,12 @@ func buildOne(ctx context.Context, source buildSource, path, ref string, wait ti if source.Seat != "" { recorded.Repository, recorded.Seat = source.Repository, source.Seat } + // The build is kept; the module is not. A definition naming an installation is refused where + // it would enter the catalogue, and the build log says which build it was. + if err := namesNoInstallation(manifest); err != nil { + return fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w", + result.On, result.Repository, short(result.Commit), err) + } if err := inv.RegisterModule(ctx, manifest, recorded); err != nil { return err } diff --git a/cmd/mesh-controller/check.go b/cmd/mesh-controller/check.go index c0408ca..1b01554 100644 --- a/cmd/mesh-controller/check.go +++ b/cmd/mesh-controller/check.go @@ -51,8 +51,8 @@ func moduleCheck(paths []string, out io.Writer) error { failed++ continue } - // A definition names no installation (novox/hq ADR 0112, ADR 0155): judged here and in the - // catalogue-wide test, not yet at registration, while the declared exceptions shrink. + // A definition names no installation (novox/hq ADR 0112, ADR 0155): judged here, in the + // catalogue-wide test, and at registration, which refuses in the same words. if named := catalogue.InstallationProblems(m); len(named) > 0 { for _, p := range named { fmt.Fprintf(out, "%s: %s\n", path, p) diff --git a/cmd/mesh-controller/check_test.go b/cmd/mesh-controller/check_test.go index 5f98a8a..8947901 100644 --- a/cmd/mesh-controller/check_test.go +++ b/cmd/mesh-controller/check_test.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/novox/mesh-controller/internal/catalogue" "testing" ) @@ -67,3 +69,26 @@ func TestModuleCheckPassesTheCatalogue(t *testing.T) { t.Fatalf("the catalogue does not pass its own check: %v\n%s", err, out.String()) } } + +func TestRegistrationRefusesADefinitionNamingAnInstallation(t *testing.T) { + // novox/hq ADR 0155: the check moves to registration once the catalogue passes it. Both + // ways in — `module add` and a build's result — go through this, and a name declared on + // purpose passes with its reason. + named := catalogue.Manifest{Module: "idp", Resources: []map[string]any{ + {"id": "server", "type": "container", "image": "x@sha256:aa", + "env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}}, + }} + err := namesNoInstallation(named) + if err == nil || !strings.Contains(err.Error(), "login.mesh-one.be") || + !strings.Contains(err.Error(), catalogue.NamesOnPurpose) { + t.Fatalf("a definition naming an installation is refused with the name and the way out; got %v", err) + } + meant := catalogue.Manifest{Module: "site", Resources: []map[string]any{ + {"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc", + catalogue.NamesOnPurpose: map[string]any{ + "registry.mesh-one.be": "built outside the mesh until its repository is a build source here"}}, + }} + if err := namesNoInstallation(meant); err != nil { + t.Fatalf("a name declared on purpose passes; got %v", err) + } +} diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index 91ef197..8d678f9 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -113,6 +113,9 @@ func moduleCommand(ctx context.Context, args []string) error { if err != nil { return err } + if err := namesNoInstallation(m); err != nil { + return err + } if err := inv.RegisterModule(ctx, m, from); err != nil { return err } @@ -662,3 +665,18 @@ func whereItComesFrom(repository, ref, commit, path string, self bool) (inventor } return from, nil } + +// namesNoInstallation is the mesh refusing a definition that names an installation, at the moment +// it would enter the catalogue (novox/hq ADR 0112, ADR 0155). `module check` says the same thing +// earlier, where the author is; this is the last moment the mesh can still say no, and a +// definition that got past the check — written elsewhere, or checked by nobody — is refused here +// in the same words. A name meant on purpose is declared with its reason and passes. +func namesNoInstallation(m catalogue.Manifest) error { + named := catalogue.InstallationProblems(m) + if len(named) == 0 { + return nil + } + return fmt.Errorf("%s names an installation, and a definition names none — declare a name meant "+ + "on purpose under %s with its reason, or take it out:\n - %s", + m.Module, catalogue.NamesOnPurpose, strings.Join(named, "\n - ")) +} diff --git a/internal/catalogue/contributes_test.go b/internal/catalogue/contributes_test.go index 1065295..beb6ab6 100644 --- a/internal/catalogue/contributes_test.go +++ b/internal/catalogue/contributes_test.go @@ -197,6 +197,27 @@ func TestARouteCanBeSetPerMesh(t *testing.T) { } } +func TestASettingReachesAContributionOnlyWhereItDeclaresTheKey(t *testing.T) { + // novox/hq 04-ISSUES/173: the mail module's site name, set so its environment file could read + // it, arrived in every route it contributed. A setting overrides a key the contribution + // declares and adds none — the provider reads the contribution as a contract. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), World{}) + + out, err := got.Declaration(Rendering{Settings: SettingsBy{ + "board": {{From: "the mesh", Values: map[string]any{"host": "dashboard", "sitename": "Board"}}}, + }}) + if err != nil { + t.Fatal(err) + } + given := received(t, out) + if given[0].Values["host"] != "dashboard" { + t.Fatalf("the setting did not override the route's host: %v", given[0].Values) + } + if _, leaked := given[0].Values["sitename"]; leaked { + t.Fatalf("a setting the route never declared reached the proxy: %v", given[0].Values) + } +} + func TestReceivingWhatYouDoNotProvideIsRefused(t *testing.T) { // It would create a file nobody ever writes to, on a machine where nothing asked for it. _, err := ParseManifest([]byte(`{"module":"traefik","version":"1", diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 8145f1d..1dfede1 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -1161,7 +1161,10 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant, // module wrote another into its configuration. func (r Resolution) composed(m Manifest, to string, raw map[string]any, layers []Layer, what string) ( map[string]any, error) { - values, err := settle(raw, layers, nil, what) + // Overridden, not merged: a setting changes a key the contribution declares and adds none. + // The provider reads the contribution as a contract, and a setting made for one of this + // module's files is no part of it (novox/hq 04-ISSUES/173). + values, err := overridden(raw, layers, what) if err != nil { return nil, fmt.Errorf("%s: %w", what, err) } diff --git a/internal/catalogue/dir_into.go b/internal/catalogue/dir_into.go index da5ca61..ba2a4a5 100644 --- a/internal/catalogue/dir_into.go +++ b/internal/catalogue/dir_into.go @@ -20,6 +20,12 @@ import ( // declared with the path as the exception it is, and everything else in the module names it by // id — so moving it later is one line, not a search. // +// **The mesh's own files for a module are placed too** (novox/hq issue 174). What the mesh writes +// *for* a module — its sealed bus credential, its merged configuration, its bindings — is the +// mesh's plumbing, not the module's data, and sits under `/mesh/`. A directory +// saying `"place": "mesh"` is that place; the definition names the files beneath it by +// `${dir:}` and states no path. +// // **Resolved here, not on the machine.** The host receives concrete paths exactly as it always // has; nothing new reaches it and it learns no field. Which also means a resolved path changing // is a spec change like any other — and the spec comparison must see it (novox/hq issue 126). @@ -27,6 +33,15 @@ import ( // defaultDataRoot is where module data lands when a node states no root of its own. const defaultDataRoot = "/var/lib" +// The two places a pathless directory may name, beside its own id. +const ( + // placeOwn is the assignment's own root, / — to-be 27's one directory per + // assignment, which every other placed thing of the module sits beneath. + placeOwn = "." + // placeMesh is where the mesh keeps what it writes for the module, /mesh/. + placeMesh = "mesh" +) + // dirRef is how a module names one of its placed directories: ${dir:}. var dirRef = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9-]*)\}`) @@ -40,26 +55,46 @@ func dataRoot(with Rendering) string { // dirsFor is every placed directory of a module, id → the path it resolves to on this node. // -// A pathless directory saying `"place": "."` is the assignment's own root, / — -// to-be 27's one directory per assignment, which every other placed thing sits beneath. At most -// one makes sense; nothing enforces one, because two ids resolving to one path is a mistake the -// module's own files make visible immediately. +// A pathless directory saying `"place": "."` is the assignment's own root, /; one +// saying `"place": "mesh"` is the mesh's directory for the module, /mesh/; one +// saying neither is //. At most one of each place makes sense; nothing enforces +// one, because two ids resolving to one path is a mistake the module's own files make visible +// immediately. +// +// A stated path may itself begin with a placed reference — `${dir:mesh-state}/state` — and is +// filled after the directories it can name are resolved; one level, because a directory beneath +// a placed one is the whole of what an adopted layout needs (issue 174's `state` and `out`). func dirsFor(m Manifest, with Rendering) map[string]string { dirs := map[string]string{} + var beneath []map[string]any for _, r := range m.Resources { if fmt.Sprint(r["type"]) != "directory" { continue } id := fmt.Sprint(r["id"]) if path, stated := r["path"].(string); stated && path != "" { + if strings.HasPrefix(path, "${dir:") { + beneath = append(beneath, r) + continue + } dirs[id] = strings.TrimRight(path, "/") continue } - if place, said := r["place"].(string); said && place == "." { + switch place, _ := r["place"].(string); place { + case placeOwn: dirs[id] = dataRoot(with) + "/" + m.Module - continue + case placeMesh: + dirs[id] = dataRoot(with) + "/mesh/" + m.Module + default: + dirs[id] = dataRoot(with) + "/" + m.Module + "/" + id } - dirs[id] = dataRoot(with) + "/" + m.Module + "/" + id + } + for _, r := range beneath { + path := strings.TrimRight(r["path"].(string), "/") + // A reference to no directory is left as written and refused where the resource is + // placed (dirInto), with the message that names what exists. + filled, _ := dirFill(path, dirs, m.Module) + dirs[fmt.Sprint(r["id"])] = filled } return dirs } @@ -244,10 +279,11 @@ func (m Manifest) unknownDirRefs() []string { "%s states both path and place on %v — a stated path IS the placement", m.Module, r["id"])) } - if place != "." { + if place != placeOwn && place != placeMesh { problems = append(problems, fmt.Sprintf( - "%s says place %q on %v, and the only place is %q — the assignment's own root", - m.Module, place, r["id"], ".")) + "%s says place %q on %v, and the places are %q — the assignment's own root — and "+ + "%q — where the mesh keeps what it writes for the module", + m.Module, place, r["id"], placeOwn, placeMesh)) } } seen := map[string]bool{} diff --git a/internal/catalogue/dir_into_test.go b/internal/catalogue/dir_into_test.go index d990d26..9e13399 100644 --- a/internal/catalogue/dir_into_test.go +++ b/internal/catalogue/dir_into_test.go @@ -216,8 +216,48 @@ func TestPlaceIsValidatedAtTheManifest(t *testing.T) { wrong := Manifest{Module: "x", Resources: []map[string]any{ {"id": "d", "type": "directory", "place": "sub/dir"}, }} - if got := wrong.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], `the only place is "."`) { - t.Fatalf("a place that is not the root refuses; got %v", got) + if got := wrong.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], `the places are "."`) { + t.Fatalf("a place that is neither root refuses; got %v", got) + } +} + +func TestTheMeshsDirectoryForAModuleIsAPlace(t *testing.T) { + // novox/hq issue 174. What the mesh writes for a module — its bus credential, its bindings — + // is the mesh's plumbing under /mesh/, and the definition names it by id. + m := Manifest{Module: "umami", + Resources: []map[string]any{ + {"id": "mesh-state", "type": "directory", "place": "mesh"}, + {"id": "state", "type": "directory", "place": "."}, + {"id": "server", "type": "container", "image": "x@sha256:aa", + "volumes": []any{"${dir:mesh-state}/broker:/run/secrets/broker:ro"}}, + }, + OwnSecrets: map[string]string{"broker": "${dir:mesh-state}/broker"}, + Binds: map[string]string{"route": "${dir:state}/route.json"}, + } + if got := m.unknownDirRefs(); len(got) != 0 { + t.Fatalf("place %q is a place; got %v", "mesh", got) + } + dirs := dirsFor(m, Rendering{}) + if dirs["mesh-state"] != "/var/lib/mesh/umami" || dirs["state"] != "/var/lib/umami" { + t.Fatalf("the mesh's directory sits beside the module's, not in it; got %v", dirs) + } + dirs = dirsFor(m, Rendering{DataRoot: "/srv"}) + if dirs["mesh-state"] != "/srv/mesh/umami" { + t.Fatalf("a node's root moves the mesh's files with the module's; got %v", dirs) + } + placed, err := placedManifest(m, Rendering{}) + if err != nil { + t.Fatal(err) + } + if placed.OwnSecrets["broker"] != "/var/lib/mesh/umami/broker" { + t.Fatalf("own-secrets are placed under the mesh's directory; got %v", placed.OwnSecrets) + } + container := shallowCopy(m.Resources[2]) + if err := dirInto(container, dirsFor(m, Rendering{}), m.Module); err != nil { + t.Fatal(err) + } + if container["volumes"].([]any)[0] != "/var/lib/mesh/umami/broker:/run/secrets/broker:ro" { + t.Fatalf("the mount's host side is placed; got %v", container["volumes"]) } } @@ -250,3 +290,36 @@ func shallowCopy(resource map[string]any) map[string]any { } return copied } + +func TestADirectoryBeneathAPlacedOneIsPlacedWithIt(t *testing.T) { + // An adopted layout keeps a subdirectory the predecessor made under the mesh's directory + // (issue 174: a forge's runtime state, a manager's output). Stated as beneath the placed one, + // it moves with it — a node's root moves both, and the definition names no host path. + m := Manifest{Module: "gitea", Resources: []map[string]any{ + {"id": "mesh-state", "type": "directory", "place": "mesh"}, + {"id": "runtime-state", "type": "directory", "path": "${dir:mesh-state}/state"}, + {"id": "server", "type": "container", "image": "x@sha256:aa", + "volumes": []any{"${dir:runtime-state}:/data"}}, + }} + if got := m.unknownDirRefs(); len(got) != 0 { + t.Fatalf("a path beneath a placed directory is well formed; got %v", got) + } + dirs := dirsFor(m, Rendering{DataRoot: "/srv"}) + if dirs["runtime-state"] != "/srv/mesh/gitea/state" { + t.Fatalf("the subdirectory follows the placed one; got %v", dirs) + } + sub := shallowCopy(m.Resources[1]) + if err := dirInto(sub, dirs, m.Module); err != nil { + t.Fatal(err) + } + if sub["path"] != "/srv/mesh/gitea/state" { + t.Fatalf("the directory resource itself is resolved; got %v", sub["path"]) + } + container := shallowCopy(m.Resources[2]) + if err := dirInto(container, dirs, m.Module); err != nil { + t.Fatal(err) + } + if container["volumes"].([]any)[0] != "/srv/mesh/gitea/state:/data" { + t.Fatalf("a reference to the subdirectory resolves whole; got %v", container["volumes"]) + } +} diff --git a/internal/catalogue/placed_paths_test.go b/internal/catalogue/placed_paths_test.go index 1317b4f..2852546 100644 --- a/internal/catalogue/placed_paths_test.go +++ b/internal/catalogue/placed_paths_test.go @@ -16,7 +16,9 @@ import ( // // Two checkouts: MESH_CATALOGUE_BEFORE, the catalogue as it was, and MESH_CATALOGUE, as it is now. // Every module in both is resolved with the controller's own rule (dirsFor, dirFill) and compared -// whole — not only the directories, but every string a directory's id was written into. +// whole — not only the directories, but every string a directory's id was written into. Both +// sides are resolved, so a manifest converted in two steps (issue 119, then issue 174) is judged +// against the paths it named, not the text it used to name them with. func TestPlacedDirectoriesKeepTheirPaths(t *testing.T) { before, after := os.Getenv("MESH_CATALOGUE_BEFORE"), os.Getenv("MESH_CATALOGUE") if before == "" || after == "" { @@ -42,7 +44,11 @@ func TestPlacedDirectoriesKeepTheirPaths(t *testing.T) { t.Errorf("%s: %v", module, err) continue } - dirs := dirsFor(m, Rendering{}) + earlier, err := ParseManifest(old) + if err != nil { + t.Errorf("%s before: %v", module, err) + continue + } var was, is any if err := json.Unmarshal(old, &was); err != nil { t.Fatal(err) @@ -50,7 +56,10 @@ func TestPlacedDirectoriesKeepTheirPaths(t *testing.T) { if err := json.Unmarshal(now, &is); err != nil { t.Fatal(err) } - resolved := resolvedTree(is, dirs, module, t) + // Both sides resolved: the manifest before may itself already place some directories + // (issue 119's conversion), and what must not move is the path a machine sees. + was = resolvedTree(was, dirsFor(earlier, Rendering{}), module, t) + resolved := resolvedTree(is, dirsFor(m, Rendering{}), module, t) if !reflect.DeepEqual(was, resolved) { wasJSON, _ := json.MarshalIndent(was, "", " ") isJSON, _ := json.MarshalIndent(resolved, "", " ") diff --git a/internal/catalogue/setting_into.go b/internal/catalogue/setting_into.go index f63199f..751f3b1 100644 --- a/internal/catalogue/setting_into.go +++ b/internal/catalogue/setting_into.go @@ -91,19 +91,40 @@ func orNoSettings(layers []Layer) string { return "; set today: " + strings.Join(keys, ", ") } -// settingKeysUsedBy is every key a module's files ask for, so a setting that lands in one is not -// called stray. +// settingKeysUsedBy is every key a module's files, contributions and served facts ask for, so a +// setting that lands in one is not called stray. func settingKeysUsedBy(m Manifest) map[string]bool { used := map[string]bool{} + note := func(s string) { + for _, k := range settingsUsed(s) { + used[k] = true + } + } for _, r := range m.Resources { if fmt.Sprint(r["type"]) != "file" { continue } if content, ok := r["content"].(string); ok { - for _, k := range settingsUsed(content) { - used[k] = true + note(content) + } + } + inValues := func(values map[string]any) { + for _, v := range values { + if s, ok := v.(string); ok { + note(s) } } } + for _, values := range m.Contributes { + inValues(values) + } + for _, locals := range m.ContributesMany { + for _, values := range locals { + inValues(values) + } + } + for _, values := range m.Serves { + inValues(values) + } return used } diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go index 77586af..6ea87ce 100644 --- a/internal/catalogue/settings.go +++ b/internal/catalogue/settings.go @@ -85,17 +85,67 @@ func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, err return out, nil } -// Settle lays settings over a module's own values. Exported for what a provider serves, which is -// settled where the mesh is walked rather than where a node is declared. +// Settle lays settings over what a provider serves. Exported because a served fact is settled where +// the mesh is walked rather than where a node is declared. +// +// **A setting overrides a served key; it never adds one** (novox/hq 04-ISSUES/173). What a consumer +// is told is the provider's contract, and a setting made for one of the provider's files — a site +// name, a public address — is not part of it. Before this, every setting of a module reached every +// consumer of every provision it served. func Settle(base map[string]any, layers []Layer) (map[string]any, error) { - return settle(base, layers, nil, "what is served") + return overridden(base, layers, "what is served") +} + +// overridden lays settings over a map whose keys are its contract: a contribution, a served fact. +// Only the keys the map already declares are touched; the rest of a layer is somebody else's +// business (a file's, another destination's) and is left to reach it there. +// +// A declared value may itself be the operator's, `${setting:}` (ADR 0155): a mail provider +// serves its domain, an identity provider its issuer, and neither is the definition's to state. +// Filled from the layers after the overrides, and refused by name when nothing sets it — a literal +// placeholder handed to a consumer is a service configured against a string nobody meant. +func overridden(base map[string]any, layers []Layer, what string) (map[string]any, error) { + kept := make([]Layer, 0, len(layers)) + for _, layer := range layers { + values := map[string]any{} + for key, value := range layer.Values { + if _, declared := base[key]; declared { + values[key] = value + } + } + kept = append(kept, Layer{From: layer.From, Values: values}) + } + merged, err := settle(base, kept, nil, what) + if err != nil { + return nil, err + } + for key, value := range merged { + s, ok := value.(string) + if !ok { + continue + } + for _, asked := range settingsUsed(s) { + v, set := settingValue(layers, asked) + if !set { + return nil, fmt.Errorf( + "%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)) + } + s = strings.ReplaceAll(s, "${setting:"+asked+"}", plainly(v)) + } + merged[key] = s + } + return merged, nil } // settle lays the layers over a module's own values, in order. // // Shared by a file's content and a module's contributions, because they are the same act: the // module says what it means by default, and somebody says what it means here. A contribution that -// could not be settled would have to be edited to be reused anywhere else. +// could not be settled would have to be edited to be reused anywhere else. The two differ in one +// respect, and the caller decides it: a file takes keys it did not declare (a setting may add to a +// configuration), a contribution or served fact does not (overridden). func settle(base map[string]any, layers []Layer, protected map[string]bool, what string) ( map[string]any, error) { merged := deepCopy(base) @@ -149,23 +199,22 @@ func deepCopy(in map[string]any) map[string]any { return out } -// UnusedSettings names settings that reached no file. +// UnusedSettings names settings that reach nothing. // // Somebody who sets a key on a module with nothing mergeable, or misspells one, has changed // nothing — and would find out by the machine not behaving differently, which is the slowest // way there is. This is what makes that visible at the moment they set it. +// +// Where a key can land: any mergeable file takes any key; a file asking for `${setting:}` +// takes that key (ADR 0155); a contribution or a served fact takes a key it declares, and no other +// (novox/hq 04-ISSUES/173); and the mesh's own words — `expose`, `ports`, `reach`, `endpoints` — +// are read by the mesh. A key none of those takes is stray, and is said so rather than dropped. func UnusedSettings(m Manifest, layers []Layer) []string { for _, r := range m.Resources { if how, _ := r["merge"].(string); how != "" { return nil } } - // A contribution is a destination too. A route's hostname is exactly the kind of thing that - // differs between one mesh and the next, and calling it stray would refuse the one setting - // most modules that publish anything will have. - if len(m.Contributes) > 0 { - return nil - } // A computed module has no resources here to look at — they are worked out per node, and // whether a setting lands is not knowable until then. Silence rather than a wrong answer: // claiming every setting on the private network is stray would be worse than saying nothing. @@ -173,12 +222,28 @@ func UnusedSettings(m Manifest, layers []Layer) []string { return nil } - // A key a file's content asks for with ${setting:} is a destination too (ADR 0155). - asked := settingKeysUsedBy(m) + lands := settingKeysUsedBy(m) + for _, values := range m.Contributes { + for key := range values { + lands[key] = true + } + } + for _, locals := range m.ContributesMany { + for _, values := range locals { + for key := range values { + lands[key] = true + } + } + } + for _, served := range m.Serves { + for key := range served { + lands[key] = true + } + } var unused []string for _, layer := range layers { for key := range layer.Values { - if asked[key] { + if lands[key] { continue } // `expose` is a real destination for a module that listens: it overrides a port's @@ -203,8 +268,9 @@ func UnusedSettings(m Manifest, layers []Layer) []string { continue } unused = append(unused, fmt.Sprintf( - "%s sets %q, and %s has no file or contribution to merge it into", - layer.From, key, m.Module)) + "%s sets %q, and %s has no file that merges it, asks for no ${setting:%s}, and "+ + "declares no %q in what it contributes or serves", + layer.From, key, m.Module, key, key)) } } sort.Strings(unused) diff --git a/internal/catalogue/settings_test.go b/internal/catalogue/settings_test.go index 3bd6dcf..b2cc392 100644 --- a/internal/catalogue/settings_test.go +++ b/internal/catalogue/settings_test.go @@ -167,11 +167,45 @@ func TestSettingsThatReachNothingAreNamed(t *testing.T) { {"id": "conf", "type": "file", "path": "/etc/thing", "content": "plain"}, }} unused := UnusedSettings(m, []Layer{{From: "node", Values: map[string]any{"port": 1}}}) - if len(unused) != 1 || !strings.Contains(unused[0], "no file or contribution to merge it into") { + if len(unused) != 1 || !strings.Contains(unused[0], "no file that merges it") { t.Errorf("settings that reached nothing were not named: %v", unused) } } +func TestASettingLandsOnlyWhereSomethingDeclaresIt(t *testing.T) { + // novox/hq 04-ISSUES/173. A module that contributes a route and serves a provision takes a + // setting for a key either declares, and a setting for a key neither declares is stray — it + // would not reach the route or the served fact, so it must be said rather than dropped. + m := Manifest{Module: "mail", + Contributes: map[string]map[string]any{"reverse-proxy": {"host": "mail", "port": 8080}}, + Serves: map[string]map[string]any{"smtp": {"host": "mail", "port": 25}}, + Resources: []map[string]any{ + {"id": "env", "type": "file", "path": "/etc/mail.env", "content": "SITE=${setting:sitename}\n"}, + }} + layers := []Layer{{From: "the mesh", Values: map[string]any{ + "host": "post", "sitename": "Mail", "website": "https://www.example.tld"}}} + unused := UnusedSettings(m, layers) + if len(unused) != 1 || !strings.Contains(unused[0], `"website"`) { + t.Errorf("only website reaches nothing; named: %v", unused) + } +} + +func TestASettingOverridesAServedKeyAndAddsNone(t *testing.T) { + // What a consumer is told is the provider's contract. A setting made for one of the + // provider's files — its site name, its public address — is not part of it. + served, err := Settle(map[string]any{"host": "mail", "port": 25}, + []Layer{{From: "the mesh", Values: map[string]any{"host": "post", "sitename": "Mail"}}}) + if err != nil { + t.Fatal(err) + } + if served["host"] != "post" { + t.Errorf("the setting did not override the served host: %v", served) + } + if _, leaked := served["sitename"]; leaked { + t.Errorf("a setting for a file reached the consumers: %v", served) + } +} + func TestContentThatIsNotJSONIsRefusedWhereSomebodyIsLooking(t *testing.T) { // Rather than on the machine, at apply time, as a file the program cannot read. _, err := ApplySettings(file(`this is not json`), nil) @@ -179,3 +213,24 @@ func TestContentThatIsNotJSONIsRefusedWhereSomebodyIsLooking(t *testing.T) { t.Fatal("a module claiming to merge as JSON shipped something else and was accepted") } } + +func TestAServedValueMayBeTheOperators(t *testing.T) { + // A mail provider serves its domain and an identity provider its issuer; neither is the + // definition's to state (ADR 0155). Filled from the layers, refused by name when unset. + served, err := Settle(map[string]any{"port": 587, "domain": "${setting:domain}"}, + []Layer{{From: "the mesh", Values: map[string]any{"domain": "example.tld"}}}) + if err != nil { + t.Fatal(err) + } + if served["domain"] != "example.tld" { + t.Errorf("the operator's value did not fill the served key: %v", served) + } + _, err = Settle(map[string]any{"domain": "${setting:domain}"}, nil) + if err == nil || !strings.Contains(err.Error(), `"domain"`) { + t.Errorf("a served value nothing sets must be refused by name; got %v", err) + } + m := Manifest{Module: "mail", Serves: map[string]map[string]any{"smtp": {"domain": "${setting:domain}"}}} + if unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{"domain": "x"}}}); len(unused) != 0 { + t.Errorf("a setting a served fact asks for is not stray: %v", unused) + } +} diff --git a/module.json b/module.json index 05fab59..4dfaa22 100644 --- a/module.json +++ b/module.json @@ -18,13 +18,13 @@ } ], "own-secrets": { - "inventory": "/var/lib/mesh/mesh-controller/inventory", - "identity": "/var/lib/mesh/mesh-controller/identity", - "licences": "/var/lib/mesh/mesh-controller/licences", - "broker": "/var/lib/mesh/mesh-controller/broker", - "broker-management": "/var/lib/mesh/mesh-controller/broker-management", - "broker-address": "/var/lib/mesh/mesh-controller/broker-address", - "bus": "/var/lib/mesh/mesh-controller/bus" + "inventory": "${dir:mesh-state}/inventory", + "identity": "${dir:mesh-state}/identity", + "licences": "${dir:mesh-state}/licences", + "broker": "${dir:mesh-state}/broker", + "broker-management": "${dir:mesh-state}/broker-management", + "broker-address": "${dir:mesh-state}/broker-address", + "bus": "${dir:mesh-state}/bus" }, "secrets-owner": "65534:65534", "prepares": true, @@ -46,8 +46,8 @@ { "id": "mesh-state", "type": "directory", - "path": "/var/lib/mesh/mesh-controller", - "mode": "0700" + "mode": "0700", + "place": "mesh" }, { "id": "server", @@ -73,13 +73,13 @@ }, "volumes": [ "/var/lib/mesh-broker-tls:/broker-tls:ro", - "/var/lib/mesh/mesh-controller/inventory:/run/secrets/inventory:ro", - "/var/lib/mesh/mesh-controller/identity:/run/secrets/identity:ro", - "/var/lib/mesh/mesh-controller/licences:/run/secrets/licences:ro", - "/var/lib/mesh/mesh-controller/broker:/run/secrets/broker:ro", - "/var/lib/mesh/mesh-controller/bus:/run/secrets/bus:ro", - "/var/lib/mesh/mesh-controller/broker-management:/run/secrets/broker-management:ro", - "/var/lib/mesh/mesh-controller/broker-address:/run/secrets/broker-address:ro" + "${dir:mesh-state}/inventory:/run/secrets/inventory:ro", + "${dir:mesh-state}/identity:/run/secrets/identity:ro", + "${dir:mesh-state}/licences:/run/secrets/licences:ro", + "${dir:mesh-state}/broker:/run/secrets/broker:ro", + "${dir:mesh-state}/bus:/run/secrets/bus:ro", + "${dir:mesh-state}/broker-management:/run/secrets/broker-management:ro", + "${dir:mesh-state}/broker-address:/run/secrets/broker-address:ro" ], "artifact": "server", "restart-on": [