Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b832918df | ||
|
|
29be985c23 | ||
|
|
05d977666a | ||
|
|
e871991495 | ||
|
|
f9e19814eb | ||
|
|
af31315a5f | ||
|
|
474f68b34c | ||
|
|
1a724f20fe | ||
|
|
0da0bb2157 | ||
|
|
118e333ff8 |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 - "))
|
||||
}
|
||||
|
||||
@@ -40,7 +40,14 @@ type Consumer struct {
|
||||
AckWaitSeconds int
|
||||
// MaxDeliver before the message is dead-lettered; zero for the mesh's default.
|
||||
MaxDeliver int
|
||||
Why string
|
||||
// MaxAckPending is how many deliveries the server lets stand unacknowledged at once; zero for
|
||||
// the server's default, which is many. **One, for a consumer handled one at a time**
|
||||
// (novox/hq issue 175): a handler that builds for minutes keeps its own message alive with a
|
||||
// heartbeat, but everything handed over behind it times out unacknowledged and comes back —
|
||||
// and a merge that came back rebuilt what it had just built, five times over on 2026-09-30.
|
||||
// With one outstanding, the server holds the rest, and the heartbeat is keeping the message.
|
||||
MaxAckPending int
|
||||
Why string
|
||||
}
|
||||
|
||||
// seatStreamName is the stream holding a seat's inbound work. Named after the seat rather than
|
||||
|
||||
@@ -179,6 +179,7 @@ func (j *JetStream) EnsureConsumer(c Consumer) error {
|
||||
AckPolicy: nats.AckExplicitPolicy,
|
||||
AckWait: time.Duration(c.AckWaitSeconds) * time.Second,
|
||||
MaxDeliver: c.MaxDeliver,
|
||||
MaxAckPending: c.MaxAckPending,
|
||||
DeliverGroup: c.Queue,
|
||||
DeliverSubject: "",
|
||||
Description: c.Why,
|
||||
|
||||
@@ -244,8 +244,13 @@ func MeshConsumers() []Consumer {
|
||||
Push: true,
|
||||
AckWaitSeconds: 30,
|
||||
MaxDeliver: 5,
|
||||
Why: "the two events the mesh's own controller reacts to; after max-deliver it " +
|
||||
"dead-letters, because an announcement it cannot act on will not become actionable",
|
||||
// One at a time (novox/hq issue 175): acting on a merge builds for minutes, and an
|
||||
// announcement handed over behind it must wait on the server, not time out on the
|
||||
// client and come back to be acted on again.
|
||||
MaxAckPending: 1,
|
||||
Why: "the two events the mesh's own controller reacts to, one at a time; after " +
|
||||
"max-deliver it dead-letters, because an announcement it cannot act on will not " +
|
||||
"become actionable",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,3 +260,14 @@ func TestNoTwoConsumersDeliverOntoTheSameSubject(t *testing.T) {
|
||||
seen[subject] = c.Name + " on " + c.Stream
|
||||
}
|
||||
}
|
||||
|
||||
// The controller's events consumer is handed one announcement at a time (novox/hq issue 175): a
|
||||
// merge's handler builds for minutes, and what is queued behind it must wait on the server rather
|
||||
// than time out on the client and be acted on twice.
|
||||
func TestTheControllerTakesOneAnnouncementAtATime(t *testing.T) {
|
||||
for _, c := range MeshConsumers() {
|
||||
if c.Stream == "EVENTS" && c.Name == ControllerName && c.MaxAckPending != 1 {
|
||||
t.Fatalf("the events consumer may have %d outstanding; one announcement at a time", c.MaxAckPending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,24 @@ import (
|
||||
//
|
||||
// Not a fixture: the point is whether the manifests as written are accepted by the control plane that
|
||||
// will read them, and a copy of one manifest proves nothing about the other seventy-one.
|
||||
func TestEveryCatalogueManifestParses(t *testing.T) {
|
||||
root := os.Getenv("MESH_CATALOGUE")
|
||||
if root == "" {
|
||||
t.Skip("set MESH_CATALOGUE to a catalogue checkout to run this")
|
||||
// catalogueRoot is the catalogue these checks run over: MESH_CATALOGUE when set, else the checkout
|
||||
// beside this one, the way the main layout has it. A check that only ran when somebody remembered a
|
||||
// variable was a check nobody ran (novox/hq issue 134, 2026-09-30); it skips only when there is no
|
||||
// catalogue to be found at all.
|
||||
func catalogueRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
if root := os.Getenv("MESH_CATALOGUE"); root != "" {
|
||||
return root
|
||||
}
|
||||
sibling := filepath.Join("..", "..", "..", "mesh-catalog")
|
||||
if _, err := os.Stat(filepath.Join(sibling, "modules")); err != nil {
|
||||
t.Skip("no catalogue beside this checkout and MESH_CATALOGUE unset")
|
||||
}
|
||||
return sibling
|
||||
}
|
||||
|
||||
func TestEveryCatalogueManifestParses(t *testing.T) {
|
||||
root := catalogueRoot(t)
|
||||
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil || len(found) == 0 {
|
||||
t.Fatalf("no manifests under %s: %v", root, err)
|
||||
@@ -51,10 +64,7 @@ func TestEveryCatalogueManifestParses(t *testing.T) {
|
||||
// definition names a domain or a public address the mesh acts on, and every value that must for now
|
||||
// carries its reason (novox/hq ADR 0155, issue 134). The list it prints is the one that shrinks.
|
||||
func TestNoCatalogueManifestNamesAnInstallation(t *testing.T) {
|
||||
root := os.Getenv("MESH_CATALOGUE")
|
||||
if root == "" {
|
||||
t.Skip("set MESH_CATALOGUE to a catalogue checkout to run this")
|
||||
}
|
||||
root := catalogueRoot(t)
|
||||
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil || len(found) == 0 {
|
||||
t.Fatalf("no manifests under %s: %v", root, err)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 `<root>/mesh/<module>`. A directory
|
||||
// saying `"place": "mesh"` is that place; the definition names the files beneath it by
|
||||
// `${dir:<id>}` 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, <root>/<module> — 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, <root>/mesh/<module>.
|
||||
placeMesh = "mesh"
|
||||
)
|
||||
|
||||
// dirRef is how a module names one of its placed directories: ${dir:<id>}.
|
||||
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, <root>/<module> —
|
||||
// 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, <root>/<module>; one
|
||||
// saying `"place": "mesh"` is the mesh's directory for the module, <root>/mesh/<module>; one
|
||||
// saying neither is <root>/<module>/<id>. 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{}
|
||||
|
||||
@@ -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 <root>/mesh/<module>, 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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "", " ")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:<key>}` (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:<key>}`
|
||||
// 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:<key>} 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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+14
-1
@@ -5,7 +5,20 @@
|
||||
-- than for the mesh; ADR 0121 decided the rename and deferred it because a delivering seat that stops
|
||||
-- resolving mid-flight takes a provision away from every consumer. ADR 0122 removed that risk: a seat's
|
||||
-- former name is an alias that resolves to it forever, a held record follows the rename by cascade, and
|
||||
-- a claim written with the old name still holds. So the rename is one update and one alias.
|
||||
-- a claim written with the old name still holds.
|
||||
--
|
||||
-- **Both rows may exist when this runs.** A controller whose compiled defaults already carry the new
|
||||
-- name seeds it as a new seat the moment it can, and on the mesh this was written for that happened
|
||||
-- before the rename: the first form of this migration renamed into a duplicate key and the control
|
||||
-- node's prepare failed on every attempt (2026-09-30). So: if the new row is already there, the old
|
||||
-- row's holding moves to it and the old row goes; otherwise the old row is renamed. Either way the old
|
||||
-- name becomes an alias.
|
||||
update seat_holding set seat = 'mesh-artifact-store'
|
||||
where seat = 'the-artifact-store'
|
||||
and exists (select 1 from seat where name = 'mesh-artifact-store');
|
||||
delete from seat
|
||||
where name = 'the-artifact-store'
|
||||
and exists (select 1 from seat where name = 'mesh-artifact-store');
|
||||
update seat set name = 'mesh-artifact-store' where name = 'the-artifact-store';
|
||||
insert into seat_alias (alias, seat) values ('the-artifact-store', 'mesh-artifact-store')
|
||||
on conflict (alias) do update set seat = excluded.seat;
|
||||
|
||||
+16
-16
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user