diff --git a/cmd/mesh-controller/adopting_test.go b/cmd/mesh-controller/adopting_test.go index b0a2c04..88443fd 100644 --- a/cmd/mesh-controller/adopting_test.go +++ b/cmd/mesh-controller/adopting_test.go @@ -143,7 +143,24 @@ func TestTheFlipIsRefusedWhileAFoundContainerIsHeld(t *testing.T) { func TestTakingNamesWhatItReplaces(t *testing.T) { open, _ := anAdoptedAnchor(t) reportsHolding(t, open, heldContainer, heldFile) - said, err := take(t.Context(), open, "anchor", "hello-web", takeOptions{Yes: true}) + ctx := t.Context() + // The machine holds something for the module, so the take acts on the preview the operator + // saw and names its digest (novox/hq ADR 0163). + preview, err := take(ctx, open, "anchor", "hello-web", takeOptions{}) + if err != nil { + t.Fatal(err) + } + saw := takeDigestIn(t, preview) + if !strings.Contains(preview, "nothing taken; `take anchor hello-web --yes "+saw+"`") { + t.Fatalf("the preview does not say how to act on it:\n%s", preview) + } + if taken, _ := open.inventory.Taken(ctx, "anchor"); len(taken) != 0 { + t.Fatal("the preview took something") + } + if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err == nil || !strings.Contains(err.Error(), "name its digest") { + t.Fatalf("--yes without the digest was not refused: %v", err) + } + said, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw}) if err != nil { t.Fatal(err) } @@ -153,6 +170,67 @@ func TestTakingNamesWhatItReplaces(t *testing.T) { } } +// takeDigestIn is the digest a take's preview printed. +func takeDigestIn(t *testing.T, preview string) string { + t.Helper() + for _, line := range strings.Split(preview, "\n") { + if fields := strings.Fields(line); len(fields) == 2 && fields[0] == "preview" { + return fields[1] + } + } + t.Fatalf("the preview printed no digest:\n%s", preview) + return "" +} + +// A take acts on the preview the operator saw, and on an account of the machine that is still the +// machine: a changed preview and a stale account refuse (novox/hq ADR 0163, rule 1). +func TestATakeIsRefusedOnAChangedPreviewOrAStaleAccount(t *testing.T) { + open, _ := anAdoptedAnchor(t) + ctx := t.Context() + reportsHolding(t, open, heldContainer, heldFile) + preview, err := take(ctx, open, "anchor", "hello-web", takeOptions{}) + if err != nil { + t.Fatal(err) + } + saw := takeDigestIn(t, preview) + + // The machine reports again, and what it holds has changed: the found container now carries + // facts the preview never showed. + changed := heldContainer + changed.Facts = map[string]any{"image": "hello:2", "declared_image": "registry.example/hello"} + reportsHolding(t, open, changed, heldFile) + _, err = take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw}) + if err == nil || !strings.Contains(err.Error(), "has changed since preview "+saw) { + t.Fatalf("a changed preview was acted on: %v", err) + } + if taken, _ := open.inventory.Taken(ctx, "anchor"); len(taken) != 0 { + t.Fatal("a refused take took something") + } + + // And an account older than the flip allows. + preview, err = take(ctx, open, "anchor", "hello-web", takeOptions{}) + if err != nil { + t.Fatal(err) + } + saw = takeDigestIn(t, preview) + saved := reportFreshFor + reportFreshFor = -time.Second + defer func() { reportFreshFor = saved }() + _, err = take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw}) + if err == nil || !strings.Contains(err.Error(), "a take acts only on an account newer than") { + t.Fatalf("a stale account was acted on: %v", err) + } + reportFreshFor = saved + if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw}); err != nil { + t.Fatal(err) + } + // A module the machine holds nothing for has nothing to compare: --yes alone suffices. + if _, err := take(ctx, open, "anchor", "notes", takeOptions{Yes: true}); err == nil { + // notes holds a file, so this one needs the digest too. + t.Fatal("notes holds a found file and was taken without a digest") + } +} + func TestConvergingPreviewsThenChangesAndAdoptingKeepsWhatWasTaken(t *testing.T) { open, sent := anAdoptedAnchor(t) ctx := t.Context() diff --git a/cmd/mesh-controller/adoption.go b/cmd/mesh-controller/adoption.go index 7e30353..de35605 100644 --- a/cmd/mesh-controller/adoption.go +++ b/cmd/mesh-controller/adoption.go @@ -156,11 +156,25 @@ const DefaultFilter = "nftables" // take is a module's cutover on an adopted node: the operator's act, done when that module's data // has moved. From the next push its resources converge there like any other, replacing what the // node found and holds for it. +// +// **Previewed, and the preview is a comparison** (novox/hq ADR 0163): for every held thing the +// module would replace, what runs beside what the module declares, and the difference; the +// module's secrets on the machine and where each came from; its settings on the machine. Without +// --yes the comparison is printed and nothing changes. `--yes ` cuts over exactly what was +// previewed, the way the flip is confirmed: the preview ends with a digest of what it said, and a +// take naming an older one, or acting on an account of the machine older than the flip allows, is +// refused. A module the machine holds nothing for has nothing to compare, and `--yes` suffices. // takeOptions is what a take was told about the differences it may pass (novox/hq ADR 0163). type takeOptions struct { - Yes bool + Yes bool + // Digest is the preview's, named with --yes; required whenever the machine holds something + // for the module. + Digest string Downgrade bool Replace map[string]bool + // Mint names the secrets the service shall take a new value for, although the mesh minted + // one and the service already has its own (rule 2). + Mint map[string]bool } func take(ctx context.Context, open *stores, node, module string, opts takeOptions) (string, error) { @@ -179,45 +193,128 @@ func take(ctx context.Context, open *stores, node, module string, opts takeOptio } // The comparison first (novox/hq ADR 0163): every held thing the module would replace, beside // what the module declares, and the differences that refuse unless named. - reported, err := inv.AdoptionOf(ctx, node) + c, err := comparisonFor(ctx, open, node, module) if err != nil { return "", err } - preview, refusals := comparisonOf(reported.Held, module, opts) + preview, refusals, saw := comparisonOf(module, c, opts) if len(refusals) > 0 { return "", fmt.Errorf("taking %s on %s is refused:\n %s\n%s", module, node, strings.Join(refusals, "\n "), preview) } + holds := len(heldOf(c.reported, module)) > 0 + if holds { + preview += "\n preview " + saw + } if !opts.Yes { - return preview + fmt.Sprintf("\nnothing taken; `take %s %s --yes` cuts it over as previewed", node, module), nil + if !holds { + return preview + fmt.Sprintf("\nnothing taken; `take %s %s --yes` declares it as the mesh's own", node, module), nil + } + return preview + fmt.Sprintf("\nnothing taken; `take %s %s --yes %s` cuts it over as previewed", node, module, saw), nil + } + if holds { + // The take acts on the preview the operator saw, and on an account of the machine that + // is still the machine: the same two refusals the flip makes. + if age := time.Since(c.reported.At); age > reportFreshFor { + return preview, fmt.Errorf("%s last said what it holds %s ago, and a take acts only on "+ + "an account newer than %s: run `push %s --wait 2m`, then preview again", + node, age.Round(time.Second), reportFreshFor, node) + } + if opts.Digest == "" { + return preview, fmt.Errorf("taking %s on %s acts on the preview you saw: name its digest, "+ + "`take %s %s --yes %s`, once you have read it", module, node, node, module, saw) + } + if opts.Digest != saw { + return preview, fmt.Errorf("what taking %s on %s would replace has changed since preview %s "+ + "(it is now %s): read the preview above, and run `take %s %s --yes %s` if it is "+ + "what you want", module, node, opts.Digest, saw, node, module, saw) + } } if err := inv.Take(ctx, node, module); err != nil { return "", err } said := fmt.Sprintf("%s is taken on %s", module, node) - if preview != "" { + if holds { said += "; the next push replaces what the node found and holds for it:\n" + preview } return said + fmt.Sprintf("\n run `push %s` to cut it over", node), nil } +// comparison is everything a take puts beside what the module declares: the machine's account of +// what it holds and what is reachable on it, the module's secrets on the machine, its settings +// there, and which found networks a setting keeps for each of its containers (by held id). +type comparison struct { + reported inventory.Adoption + secrets []inventory.SecretState + layers []catalogue.Layer + keeps map[string][]string + // settingsRefused is why the module's settings cannot compose with its definition, when + // they cannot — the module would be left out of the declaration (rule 6). + settingsRefused string +} + +func comparisonFor(ctx context.Context, open *stores, node, module string) (comparison, error) { + inv := open.inventory + var c comparison + var err error + if c.reported, err = inv.AdoptionOf(ctx, node); err != nil { + return c, err + } + if c.secrets, err = inv.SecretsOf(ctx, node, module); err != nil { + return c, err + } + if c.layers, err = inv.SettingsFor(ctx, node, module); err != nil { + return c, err + } + shelf, err := inv.Catalogue(ctx) + if err != nil { + return c, err + } + if m, known := shelf[module]; known && len(c.layers) > 0 { + if err := catalogue.JudgeSettings(m, c.layers, true); err != nil { + c.settingsRefused = err.Error() + } + if kept, err := catalogue.KeptNetworks(m, c.layers, true); err == nil && len(kept) > 0 { + c.keeps = map[string][]string{} + for id, networks := range kept { + c.keeps[module+"."+id] = networks + } + } + } + return c, nil +} + +// heldOf is what a node holds for one module. +func heldOf(reported inventory.Adoption, module string) []inventory.Held { + var out []inventory.Held + for _, h := range reported.Held { + if h.Module == module { + out = append(out, h) + } + } + return out +} + // comparisonOf is a take's preview: for every held thing of the module, what runs beside what the -// module declares, and the refusals the differences earn unless the take named them -// (novox/hq ADR 0163): an image older than the one running, a declared file that differs from the -// found one. A narrowed port and a shared network are said and not refused. -func comparisonOf(held []inventory.Held, module string, opts takeOptions) (string, []string) { +// module declares; its secrets and its settings on the machine; and the refusals the differences +// earn unless the take named them (novox/hq ADR 0163): an image older than the one running, a +// declared file that differs from the found one, a secret the mesh minted for a service whose data +// was found. A narrowed port and a shared network are said and not refused. The digest is of what +// the preview says, so anything in it changing changes the digest. +func comparisonOf(module string, c comparison, opts takeOptions) (preview string, refusals []string, digest string) { var b strings.Builder - var refusals []string + held := heldOf(c.reported, module) + foundData := false for _, h := range held { - if h.Module != module { - continue + if h.Kind == "container" || h.Kind == "directory" { + foundData = true } fmt.Fprintf(&b, " %s", heldLine(h)) if h.Kept != "" { fmt.Fprintf(&b, ", original kept at %s", h.Kept) } b.WriteString("\n") - for _, line := range comparisonLines(h) { + for _, line := range comparisonLinesWith(h, c.keeps[h.ID], c.reported) { fmt.Fprintf(&b, " %s\n", line) } f := factsOf(h) @@ -232,7 +329,52 @@ func comparisonOf(held []inventory.Held, module string, opts takeOptions) (strin h.Target, h.Target)) } } - return b.String(), refusals + // The module's secrets on the machine (rule 2 and 3): a service whose data was found already + // has a value for each, so one the mesh minted and nobody accepted refuses unless --mint says + // the service shall take a new one. + for _, sec := range c.secrets { + name := sec.Name + if sec.Local != "" { + name += " (" + sec.Local + ")" + } + what := "own secret" + accept := fmt.Sprintf("`secret accept %s %s`", module, sec.Name) + if !sec.Own() { + what = "secret from " + sec.Provider + accept = fmt.Sprintf("`secret accept %s %s --provider %s`", module, sec.Name, sec.Provider) + if sec.Local != "" { + accept = strings.TrimSuffix(accept, "`") + " --local " + sec.Local + "`" + } + } + switch { + case sec.Origin == inventory.OriginAccepted: + fmt.Fprintf(&b, " %s %s: accepted from a person, carried in as it is\n", what, name) + case opts.Mint[sec.Name]: + fmt.Fprintf(&b, " %s %s: minted by the mesh; the service takes the new value, as --mint said\n", what, name) + case foundData: + fmt.Fprintf(&b, " %s %s: MINTED by the mesh and not accepted — the running service already has one\n", what, name) + refusals = append(refusals, fmt.Sprintf("%s: the mesh minted a value and the service whose data was found "+ + "already uses its own; %s carries the existing value in, or `--mint %s` says the service shall take "+ + "the new one", name, accept, sec.Name)) + default: + fmt.Fprintf(&b, " %s %s: minted by the mesh\n", what, name) + } + } + // And its settings on this machine, composed against its definition (rule 1, rule 6). + for _, layer := range c.layers { + keys := make([]string, 0, len(layer.Values)) + for k := range layer.Values { + keys = append(keys, k) + } + sort.Strings(keys) + fmt.Fprintf(&b, " settings from %s: %s\n", layer.From, strings.Join(keys, ", ")) + } + if c.settingsRefused != "" { + fmt.Fprintf(&b, " SETTINGS DO NOT COMPOSE with the module's definition, so the push leaves it out: %s\n", c.settingsRefused) + } + preview = strings.TrimRight(b.String(), "\n") + sum := sha256.Sum256([]byte(preview)) + return preview, refusals, hex.EncodeToString(sum[:])[:12] } // facts is a held thing's facts as the preview reads them. @@ -286,6 +428,13 @@ func factsOf(h inventory.Held) facts { // comparisonLines says a held thing's facts the way a person weighs them. func comparisonLines(h inventory.Held) []string { + return comparisonLinesWith(h, nil, inventory.Adoption{}) +} + +// comparisonLinesWith is comparisonLines knowing which found networks this machine's setting keeps +// for the container (rule 4) and what the machine reports reachable, so a published port's reach +// is said beside the port (rule 1). +func comparisonLinesWith(h inventory.Held, keeps []string, reported inventory.Adoption) []string { f := factsOf(h) var out []string if f.image != "" || f.declaredImage != "" { @@ -311,14 +460,46 @@ func comparisonLines(h inventory.Held) []string { } sort.Strings(names) for _, n := range names { - if members := f.networks[n]; len(members) > 0 { - out = append(out, fmt.Sprintf("on the network %s with %s, which may reach it by name and will not once it moves to the module's own network", + members := f.networks[n] + if len(members) == 0 { + continue + } + if slices.Contains(keeps, n) { + out = append(out, fmt.Sprintf("on the network %s with %s — kept by this machine's setting, so they still reach it by name once taken", n, strings.Join(members, ", "))) + continue + } + out = append(out, fmt.Sprintf("on the network %s with %s, which may reach it by name and will not once it moves to the module's own network"+ + " (`settings set %s --node ` with {%q: {: [%q]}} keeps it)", + n, strings.Join(members, ", "), h.Module, catalogue.NetworksSetting, n)) + } + for _, n := range keeps { + if _, found := f.networks[n]; !found { + out = append(out, fmt.Sprintf("keeps the network %s by this machine's setting, which the found container is not on", n)) } } if len(f.ports) > 0 || len(f.declaredPorts) > 0 { out = append(out, fmt.Sprintf("publishes %s; the module declares %s", orNone(strings.Join(f.ports, " ")), orNone(strings.Join(f.declaredPorts, " ")))) + // How far each published port reaches now, as the machine reported it: the listener the + // runtime publishes for this container. The found firewall's and the guard's rules are + // not read; what they let through is said as what was reported reachable. + var reach []string + for _, r := range reported.Reachable { + if r.By == h.Target && r.Published { + reach = append(reach, fmt.Sprintf("%s:%d (%s, container port %d)", r.Address, r.Port, r.Protocol, r.ContainerPort)) + } + } + switch { + case len(reach) > 0: + line := "reachable now at " + strings.Join(reach, ", ") + if reported.Firewall != "" && reported.Firewall != "none" { + line += ", behind the found firewall (" + reported.Firewall + "), whose rules are not read" + } + out = append(out, line) + case len(f.ports) > 0 && len(reported.Reachable) > 0: + out = append(out, "not reported reachable on the machine") + } } if len(f.mounts) > 0 || len(f.declaredVolumes) > 0 { out = append(out, fmt.Sprintf("mounts %s; the module declares %s", @@ -767,21 +948,29 @@ func adopt(ctx context.Context, open *stores, node string) (string, error) { // takeCommand, convergeCommand and adoptCommand are the command line's adapters to the acts above. func takeCommand(ctx context.Context, args []string) error { set := flag.NewFlagSet("take", flag.ContinueOnError) - yes := set.Bool("yes", false, "cut over as previewed; without it the comparison is printed and nothing is taken") + yes := set.Bool("yes", false, "cut over as previewed, naming the digest the preview printed after it; "+ + "without it the comparison is printed and nothing is taken") downgrade := set.Bool("downgrade", false, "take it although the module's image is older than the one running") - var replace stringList + var replace, mint stringList set.Var(&replace, "replace", "a found file's path whose content the module may replace although it differs (repeatable; * for every one)") + set.Var(&mint, "mint", "a secret the service shall take the mesh's minted value for, although it already has its own (repeatable)") positionals, err := parseAround(set, args) if err != nil { return err } - if len(positionals) != 2 { - return errors.New("take [--yes] [--downgrade] [--replace ]...") + if len(positionals) < 2 || len(positionals) > 3 || (len(positionals) == 3 && !*yes) { + return errors.New("take [--yes ] [--downgrade] [--replace ]... [--mint ]...") + } + opts := takeOptions{Yes: *yes, Downgrade: *downgrade, Replace: map[string]bool{}, Mint: map[string]bool{}} + if len(positionals) == 3 { + opts.Digest = positionals[2] } - opts := takeOptions{Yes: *yes, Downgrade: *downgrade, Replace: map[string]bool{}} for _, r := range replace { opts.Replace[r] = true } + for _, m := range mint { + opts.Mint[m] = true + } return runAct(ctx, func(open *stores) (string, error) { return take(ctx, open, positionals[0], positionals[1], opts) }) } diff --git a/cmd/mesh-controller/api.go b/cmd/mesh-controller/api.go index fce5a83..c3fe583 100644 --- a/cmd/mesh-controller/api.go +++ b/cmd/mesh-controller/api.go @@ -102,7 +102,7 @@ func commands(who Authenticator) http.Handler { })) // Adoption (novox/hq ADR 0100): the same acts as `take`, `converge` and `adopt`. mux.HandleFunc("POST /take", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) { - return take(ctx, open, in.Node, in.Module, takeOptions{Yes: true}) + return take(ctx, open, in.Node, in.Module, takeOptions{Yes: in.Yes, Digest: in.Digest}) })) mux.HandleFunc("POST /converge", acting(who, false, func(ctx context.Context, open *stores, in request) (string, error) { return converge(ctx, open, in.Node, in.Yes, in.Digest, in.Filter) @@ -124,8 +124,8 @@ func commands(who Authenticator) http.Handler { type request struct { Node string `json:"node"` Module string `json:"module"` - // Yes, Digest and Filter are converge's: do it rather than preview it, the digest of the - // preview it acts on, and which module loads the mesh's filter. + // Yes, Digest and Filter are converge's and take's: do it rather than preview it, the digest + // of the preview it acts on, and (converge) which module loads the mesh's filter. Yes bool `json:"yes,omitempty"` Digest string `json:"digest,omitempty"` Filter string `json:"filter,omitempty"` diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index 7e48e76..ee30421 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -180,7 +180,8 @@ func usage() { api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here assign put a module on a node unassign take it off - take cut a module over on an adopted node, once its data has moved + take preview a module's cutover on an adopted node: what runs beside + what it declares; --yes cuts it over as previewed converge [--yes ] [--filter nftables] preview, then make, an adopted node converged adopt return a converged node to adopted; what was taken stays taken settings set what a module's config should say, for the whole mesh diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 0e74502..f7ae636 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -186,8 +186,12 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso // Settings for everything that resolved, including modules nobody assigned directly: a // requirement pulled in by something else is still configurable, and finding out that it is // not only when you try would be an arbitrary line nobody could predict. + // + // A setting that reaches nothing, or cannot compose with the definition it was stored for, + // no longer refuses the machine here: it is judged where it is stored, and a definition that + // moved under it costs that module its place in the declaration, said by name (novox/hq ADR + // 0163, rule 6 — see Compose). settings := catalogue.SettingsBy{} - var stray []string for _, m := range resolved.Modules { layers, err := inv.SettingsFor(ctx, nodeName, m.Module) if err != nil { @@ -197,18 +201,6 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso continue } settings[m.Module] = layers - stray = append(stray, catalogue.UnusedSettings(m, layers)...) - } - if len(stray) > 0 { - // Somebody set something that reaches no file. Said here rather than discovered by the - // machine not behaving differently, which is the slowest way there is. - // - // Marked like a set that will not compose, and for the same reason: it is a standing fact - // about this node's own configuration, not a question the mesh could not answer. A gatherer - // passes over it as it always did — one node's stray setting must not stop every other node - // being described (novox/hq 04-ISSUES/152). - return catalogue.Resolution{}, nil, notResolvable{fmt.Errorf( - "these settings reach nothing:\n - %s", strings.Join(stray, "\n - "))} } return resolved, settings, nil } @@ -404,7 +396,32 @@ func declarationWith(ctx context.Context, open *stores, node string, if err != nil { return sendable{}, err } - return sendable{Resources: composed.Resources, Adoption: adoption}, nil + return sendable{Resources: composed.Resources, Adoption: adoption, + LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut}, nil +} + +// sortedKeysOf is a map's keys, sorted — so what a declaration says it left out does not move +// for a reordering nobody made. +func sortedKeysOf(m map[string]string) []string { + if len(m) == 0 { + return nil + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// reportLeftOut says which of a machine's modules its declaration leaves out and why (novox/hq ADR +// 0163, rule 6), one line each: the machine is told everything else, and is told it was left out. +func reportLeftOut(node string, declared sendable) { + for _, m := range declared.LeftOut { + fmt.Printf("%s: %s left out — a setting stored for it cannot compose with its definition; "+ + "what the machine holds for it is kept and its containers are untouched. %s\n", + node, m, declared.leftOutWhy[m]) + } } // renderingFor is everything a node's declaration is composed with, and the node's record. @@ -444,7 +461,10 @@ func renderingFor(ctx context.Context, open *stores, node string, for _, m := range plan.Modules { g, err := catalogue.GivenPorts(m, settings[m.Module]) if err != nil { - return catalogue.Rendering{}, inventory.Node{}, err + // A given port its definition no longer publishes: the module is left out of the + // declaration, by name, when it is composed (novox/hq ADR 0163, rule 6) — never the + // machine refused here for it. + continue } if g != nil { given[m.Module] = g @@ -999,6 +1019,20 @@ func planCommand(ctx context.Context, args []string) error { return nil } + // Which modules a push would leave out, and why — said before the plan, since the plan is of + // what the machine would be told (novox/hq ADR 0163, rule 6). Judged, never composed: `plan` + // without --json allocates nothing. + if record, err := open.inventory.NodeByName(ctx, args[0]); err == nil { + left := plan.LeftOut(settings, record.Adopted) + reportLeftOut(args[0], sendable{LeftOut: sortedKeysOf(left), leftOutWhy: left}) + } + // And a setting that reaches nothing — refused where it is stored, and said here for one + // stored before its definition moved from under it. + for _, m := range plan.Modules { + for _, stray := range catalogue.UnusedSettings(m, settings[m.Module]) { + fmt.Printf("%s: a setting reaches nothing — %s\n", args[0], stray) + } + } fmt.Printf("%s would run:\n", args[0]) for _, m := range plan.Modules { fmt.Printf(" %-20s %s\n", m.Module, plan.Because[m.Module]) diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index 27d00f9..f1f962e 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -353,7 +353,11 @@ func pushCommand(ctx context.Context, args []string) error { // The private network is in here with everything else. It used to be composed separately // and prepended, which meant every machine with an address was on it and no machine could // be kept off. It is a module now, so it arrives the way a module does. - return declarationWith(held, open, node, plan, settings, gens, Allocating) + declared, err := declarationWith(held, open, node, plan, settings, gens, Allocating) + if err == nil { + reportLeftOut(node, declared) + } + return declared, err }) sentDigest := map[string]string{} @@ -458,7 +462,11 @@ func pushCommand(ctx context.Context, args []string) error { return sendable{}, err } reportUnhostable(node, plan) - return declarationWith(held, open, node, plan, settings, gens, Allocating) + declared, err := declarationWith(held, open, node, plan, settings, gens, Allocating) + if err == nil { + reportLeftOut(node, declared) + } + return declared, err }, func(s readyNode, body []byte) error { if err := link.Declare(ctx, server.Bus(), ident, s.node, body, @@ -670,6 +678,7 @@ func sendTo(ctx context.Context, open *stores, names []string) error { refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err)) continue } + reportLeftOut(name, declared) sending = append(sending, readyNode{name, declared}) } if len(refusals) > 0 { diff --git a/cmd/mesh-controller/sendable.go b/cmd/mesh-controller/sendable.go index f6a69d5..a2ecf8e 100644 --- a/cmd/mesh-controller/sendable.go +++ b/cmd/mesh-controller/sendable.go @@ -25,6 +25,14 @@ type sendable struct { // Adoption is nil for a converged node, and then the body is byte for byte what it was before // adoption existed: an older host parses the envelope strictly and would refuse the key. Adoption *adoptionEnvelope + // LeftOut is every module of the machine's set left out of this declaration because a stored + // setting cannot compose with its definition (novox/hq ADR 0163, rule 6), sorted. The host + // keeps that module's held things and touches none of its containers; a machine is told + // everything or nothing about what it IS told, and what it is not told is said. Absent from + // the body when empty, so a declaration that leaves nothing out is byte for byte what it was. + LeftOut []string + // leftOutWhy is why each was, for push and plan to say; never on the wire. + leftOutWhy map[string]string } // adoptionEnvelope is what an adopted node is told about its mode. Taken is every module taken on @@ -45,6 +53,9 @@ func (s sendable) Body() ([]byte, error) { if s.Sequence > 0 { envelope["sequence"] = s.Sequence } + if len(s.LeftOut) > 0 { + envelope["left_out"] = s.LeftOut + } // An empty declaration is deliberate here — the node owns nothing the mesh put there // (novox/hq issue 127) — and the host refuses an empty body unless it is told the emptiness // is meant, so a truncated or mis-composed body is never mistaken for "own nothing". diff --git a/cmd/mesh-controller/sendable_test.go b/cmd/mesh-controller/sendable_test.go index 8eb0e18..9b21869 100644 --- a/cmd/mesh-controller/sendable_test.go +++ b/cmd/mesh-controller/sendable_test.go @@ -382,3 +382,62 @@ func TestAnEmptyDeclarationSaysOwnsNothing(t *testing.T) { t.Fatalf("a non-empty declaration must not mark owns_nothing; got %v", env) } } + +// A setting is judged where it is stored, and an impossible one costs a module, not a machine +// (novox/hq ADR 0163, rule 6): stored while it composed, a setting whose definition then moved from +// under it leaves that module out of the declaration — said in the envelope, so the host keeps the +// module's things — and the machine is told everything else. +func TestADefinitionMovingUnderAStoredSettingLeavesThatModuleOutNotTheMachine(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + web := helloWeb() + web.Resources[1]["ports"] = []any{"8080"} + register(t, open, web) + register(t, open, catalogue.Manifest{Module: "notes", Version: "1", + Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/notes.conf", "content": "x"}}}) + for _, m := range []string{"hello-web", "notes"} { + if _, err := assign(ctx, open, "laptop", m); err != nil { + t.Fatal(err) + } + } + // Judged where it is stored: a port the module does not publish is refused by name. + err := open.inventory.SetSettings(ctx, "laptop", "hello-web", + map[string]any{catalogue.PortsSetting: map[string]any{"9999": 10000}}) + if err == nil || !strings.Contains(err.Error(), "hello-web on laptop") || !strings.Contains(err.Error(), "9999") { + t.Fatalf("an impossible setting was stored: %v", err) + } + if err := open.inventory.SetSettings(ctx, "laptop", "hello-web", + map[string]any{catalogue.PortsSetting: map[string]any{"8080": 10000}}); err != nil { + t.Fatal(err) + } + if declared := composed(t, open, "laptop"); len(declared.LeftOut) != 0 { + t.Fatalf("a setting that composes left a module out: %v", declared.LeftOut) + } + + // The definition moves: the container publishes another port now. + web.Version = "2" + web.Resources[1]["ports"] = []any{"9090"} + register(t, open, web) + declared := composed(t, open, "laptop") + if len(declared.LeftOut) != 1 || declared.LeftOut[0] != "hello-web" { + t.Fatalf("hello-web is not left out: %v", declared.LeftOut) + } + if !strings.Contains(declared.leftOutWhy["hello-web"], "no container of its publishes 8080") { + t.Fatalf("why it was left out is not said: %v", declared.leftOutWhy) + } + if hasID(declared.Resources, "hello-web.server") || !hasID(declared.Resources, "notes.conf") { + t.Fatalf("the machine was not told everything else: %v", declared.Resources) + } + body, err := declared.Body() + if err != nil { + t.Fatal(err) + } + var env map[string]any + if err := json.Unmarshal(body, &env); err != nil { + t.Fatal(err) + } + left, _ := env["left_out"].([]any) + if len(left) != 1 || left[0] != "hello-web" { + t.Fatalf("the envelope does not say what was left out: %v", env) + } +} diff --git a/cmd/mesh-controller/take_preview_test.go b/cmd/mesh-controller/take_preview_test.go index a03a051..c9f5fc5 100644 --- a/cmd/mesh-controller/take_preview_test.go +++ b/cmd/mesh-controller/take_preview_test.go @@ -4,26 +4,42 @@ import ( "strings" "testing" + "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/inventory" ) +// What the forge's take compares, as a machine would report it. +func aForgeComparison() comparison { + return comparison{reported: inventory.Adoption{ + Firewall: "ufw", + Held: []inventory.Held{ + {ID: "forge.server", Module: "forge", Kind: "container", Target: "forge", Facts: map[string]any{ + "image": "forge:1.27.3", "image_created": "2026-09-17T10:00:00Z", + "declared_image": "forge:1.22.6", "declared_image_created": "2026-08-20T10:00:00Z", "downgrade": true, + "networks": map[string]any{"predecessor_default": []any{"office", "db"}}, + "ports": []any{"3000/tcp>0.0.0.0:3000"}, "declared_ports": []any{"3000:3000"}, + }}, + {ID: "forge.config", Module: "forge", Kind: "file", Target: "/etc/forge/app.ini", Kept: "/var/lib/mesh/kept/app.ini", + Facts: map[string]any{"differs": true, "difference": []any{"- private scope: local", "+ upstream: public"}}}, + {ID: "other.server", Module: "other", Kind: "container", Target: "other"}, + }, + Reachable: []inventory.Reach{ + {Protocol: "tcp", Address: "0.0.0.0", Port: 3000, By: "forge", Published: true, ContainerPort: 3000}, + {Protocol: "tcp", Address: "0.0.0.0", Port: 22, By: "sshd"}, + }, + }} +} + // A take is a comparison (novox/hq ADR 0163): the preview puts what runs beside what the module // declares, and an older image or a differing file refuses unless named. func TestATakePreviewsTheComparisonAndRefusesWhatIsNotNamed(t *testing.T) { - held := []inventory.Held{ - {ID: "forge.server", Module: "forge", Kind: "container", Target: "forge", Facts: map[string]any{ - "image": "forge:1.27.3", "image_created": "2026-09-17T10:00:00Z", - "declared_image": "forge:1.22.6", "declared_image_created": "2026-08-20T10:00:00Z", "downgrade": true, - "networks": map[string]any{"predecessor_default": []any{"office", "db"}}, - "ports": []any{"3000/tcp>0.0.0.0:3000"}, "declared_ports": []any{"3000:3000"}, - }}, - {ID: "forge.config", Module: "forge", Kind: "file", Target: "/etc/forge/app.ini", Kept: "/var/lib/mesh/kept/app.ini", - Facts: map[string]any{"differs": true, "difference": []any{"- private scope: local", "+ upstream: public"}}}, - {ID: "other.server", Module: "other", Kind: "container", Target: "other"}, - } - preview, refusals := comparisonOf(held, "forge", takeOptions{}) + c := aForgeComparison() + preview, refusals, saw := comparisonOf("forge", c, takeOptions{}) for _, want := range []string{"runs forge:1.27.3 (made 2026-09-17)", "declares forge:1.22.6 (made 2026-08-20)", "DOWNGRADE", - "on the network predecessor_default with office, db", "publishes 3000/tcp>0.0.0.0:3000; the module declares 3000:3000", + "on the network predecessor_default with office, db", "will not once it moves to the module's own network", + "publishes 3000/tcp>0.0.0.0:3000; the module declares 3000:3000", + // How far the port reaches now, as the machine reported it (rule 1). + "reachable now at 0.0.0.0:3000 (tcp, container port 3000), behind the found firewall (ufw)", "- private scope: local", "original kept at /var/lib/mesh/kept/app.ini"} { if !strings.Contains(preview, want) { t.Errorf("the preview lacks %q:\n%s", want, preview) @@ -35,15 +51,93 @@ func TestATakePreviewsTheComparisonAndRefusesWhatIsNotNamed(t *testing.T) { if len(refusals) != 2 || !strings.Contains(refusals[0], "--downgrade") || !strings.Contains(refusals[1], "--replace /etc/forge/app.ini") { t.Fatalf("the downgrade and the differing file refuse, each naming its override: %v", refusals) } + if len(saw) != 12 { + t.Fatalf("the preview's digest is %q", saw) + } // Named, they pass. - if _, refusals := comparisonOf(held, "forge", takeOptions{Downgrade: true, Replace: map[string]bool{"/etc/forge/app.ini": true}}); len(refusals) != 0 { + if _, refusals, _ := comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"/etc/forge/app.ini": true}}); len(refusals) != 0 { t.Fatalf("named differences still refused: %v", refusals) } - if _, refusals := comparisonOf(held, "forge", takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}}); len(refusals) != 0 { + if _, refusals, _ := comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}}); len(refusals) != 0 { t.Fatalf("replace * did not cover the file: %v", refusals) } // A held thing with no facts yet — a host older than this — refuses nothing and says what it can. - if preview, refusals := comparisonOf(held, "other", takeOptions{}); len(refusals) != 0 || !strings.Contains(preview, "container other") { + if preview, refusals, _ := comparisonOf("other", c, takeOptions{}); len(refusals) != 0 || !strings.Contains(preview, "container other") { t.Fatalf("a factless hold: %q %v", preview, refusals) } + // The digest is of what the preview says: a fact changing changes it. + c.reported.Held[0].Facts["image"] = "forge:1.27.4" + if _, _, again := comparisonOf("forge", c, takeOptions{}); again == saw { + t.Fatal("the found image changed and the digest did not") + } +} + +// A secret the mesh minted for a service whose data was found refuses: the running service already +// has a value (rule 2). Accepted, it is carried in; `--mint` says the service shall take the new one. +func TestAMintedSecretForFoundDataRefusesUnlessAcceptedOrMinted(t *testing.T) { + c := aForgeComparison() + c.secrets = []inventory.SecretState{ + {Name: "admin", Origin: inventory.OriginMade}, + {Name: "postgres-database", Origin: inventory.OriginMade, Provider: "anchor"}, + {Name: "broker", Origin: inventory.OriginAccepted}, + } + preview, refusals, _ := comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}}) + for _, want := range []string{ + "own secret admin: MINTED by the mesh and not accepted", + "secret from anchor postgres-database: MINTED by the mesh and not accepted", + "own secret broker: accepted from a person, carried in as it is", + } { + if !strings.Contains(preview, want) { + t.Errorf("the preview lacks %q:\n%s", want, preview) + } + } + if len(refusals) != 2 { + t.Fatalf("two minted secrets refuse: %v", refusals) + } + if !strings.Contains(refusals[0], "`secret accept forge admin`") || !strings.Contains(refusals[0], "`--mint admin`") { + t.Errorf("the own secret's refusal names accepting it and minting it: %s", refusals[0]) + } + if !strings.Contains(refusals[1], "`secret accept forge postgres-database --provider anchor`") { + t.Errorf("the required secret's refusal names its provider: %s", refusals[1]) + } + preview, refusals, _ = comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}, + Mint: map[string]bool{"admin": true, "postgres-database": true}}) + if len(refusals) != 0 || !strings.Contains(preview, "admin: minted by the mesh; the service takes the new value, as --mint said") { + t.Fatalf("--mint did not pass the minted secrets: %v\n%s", refusals, preview) + } + // With no found data — only a file held — the service has no value of its own, and a minted + // secret is simply said. + c.reported.Held = c.reported.Held[1:2] + if _, refusals, _ := comparisonOf("forge", c, takeOptions{Replace: map[string]bool{"*": true}}); len(refusals) != 0 { + t.Fatalf("a minted secret refused with no data found: %v", refusals) + } +} + +// A found network a per-machine setting keeps is named in the preview (rule 4), and the module's +// settings are said with where each came from, composed or not (rules 1 and 6). +func TestTheKeptNetworkAndTheSettingsAreInThePreview(t *testing.T) { + c := aForgeComparison() + c.keeps = map[string][]string{"forge.server": {"predecessor_default"}} + c.layers = []catalogue.Layer{ + {From: catalogue.MeshWideLayer, Values: map[string]any{"site": "x"}}, + {From: "anchor", Values: map[string]any{catalogue.NetworksSetting: map[string]any{"server": []any{"predecessor_default"}}}}, + } + preview, _, _ := comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}}) + for _, want := range []string{ + "on the network predecessor_default with office, db — kept by this machine's setting, so they still reach it by name once taken", + "settings from the mesh: site", + "settings from anchor: networks", + } { + if !strings.Contains(preview, want) { + t.Errorf("the preview lacks %q:\n%s", want, preview) + } + } + if strings.Contains(preview, "will not once it moves") { + t.Errorf("a kept network is still said to be lost:\n%s", preview) + } + c.settingsRefused = "forge: ports is a { port: machine-port } map" + preview, _, _ = comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}}) + if !strings.Contains(preview, "SETTINGS DO NOT COMPOSE with the module's definition, so the push leaves it out: forge: ports") { + t.Errorf("settings that cannot compose are not said:\n%s", preview) + } } diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 41e77e5..351cf7f 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -244,12 +244,31 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { type Composed struct { Resources []map[string]any Owner map[string]string + // LeftOut is every module of this machine's set that was left out of its declaration, and + // why (novox/hq ADR 0163, rule 6): a setting stored for it that its definition can no longer + // compose. Its held things are kept and its containers untouched — the machine is told so — + // and it is told everything else. + LeftOut map[string]string +} + +// LeftOut is which of this machine's modules a declaration composed with these settings leaves +// out, and why (novox/hq ADR 0163, rule 6): each whose stored settings its definition can no longer +// compose. Empty when every module composes. The same judgement SetSettings makes before storing. +func (r Resolution) LeftOut(settings SettingsBy, adopted bool) map[string]string { + out := map[string]string{} + for _, m := range r.Modules { + if err := JudgeSettings(m, settings[m.Module], adopted); err != nil { + out[m.Module] = err.Error() + } + } + return out } // Compose is Declaration with the owner of every resource said. func (r Resolution) Compose(with Rendering) (Composed, error) { owner := map[string]string{} - resources, err := r.compose(with, owner) + leftOut := map[string]string{} + resources, err := r.compose(with, owner, leftOut) if err != nil { return Composed{}, err } @@ -261,7 +280,7 @@ func (r Resolution) Compose(with Rendering) (Composed, error) { "sealed": with.BusMembership, "mode": "0600", }) } - return Composed{Resources: resources, Owner: owner}, nil + return Composed{Resources: resources, Owner: owner, LeftOut: leftOut}, nil } // BusMembershipID names the resource carrying a machine's membership for the new bus, and @@ -270,7 +289,25 @@ func BusMembershipID() string { return "bus-membership" } const BusMembershipPath = "/var/lib/mesh/membership-next.json" -func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[string]any, error) { +func (r Resolution) compose(with Rendering, owner map[string]string, leftOut map[string]string) ([]map[string]any, error) { + // **A setting is judged where it is stored, and an impossible one costs a module, not a + // machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes + // this module uncomposable; it is left out of the declaration — its held things kept, its + // containers untouched, the machine told so by name — and the machine is told everything else. + // Before placing, because a placement is a setting too. + left := r.LeftOut(with.Settings, with.Adopted) + kept := make([]Manifest, 0, len(r.Modules)) + for _, m := range r.Modules { + if why, isLeft := left[m.Module]; isLeft { + if leftOut != nil { + leftOut[m.Module] = why + } + continue + } + kept = append(kept, m) + } + r.Modules = kept + // Every manifest is placed first (novox/hq ADR 0112): the maps naming where its bindings, // credentials and contributions land are resolved against this node's directories, so every // reader below — the binding files, the sealed secrets, the grant paths a contribution @@ -693,6 +730,10 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri // Which of this module's resources its preparation runs before, if it prepares anything. prepareBefore := preparationTarget(m) + // Which found networks this machine's setting keeps for each of its containers (novox/hq + // ADR 0163, rule 4); judged above, so an invalid one is not here. + keptNetworks, _ := KeptNetworks(m, with.Settings[m.Module], with.Adopted) + for _, unsettled := range resources { resource, err := ApplySettings(unsettled, with.Settings[m.Module]) if err != nil { @@ -702,6 +743,16 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri for k, v := range resource { copied[k] = v } + if networks, keeps := keptNetworks[fmt.Sprint(copied["id"])]; keeps { + // The container also joins the found network the setting names, so a neighbour + // that resolves it there keeps resolving it. Passed to the host as its own field, + // which it joins after the container is made. + joins := make([]any, 0, len(networks)) + for _, n := range networks { + joins = append(joins, n) + } + copied["networks"] = joins + } if err := refuseSecretsInEnvironment(copied, secretFiles, m.Module); err != nil { return nil, err } @@ -947,8 +998,15 @@ func (r Resolution) filtersHere() string { // computed for this machine, and each module's per-node exposure. The same answer whether the node // is adopted or converged — the one loads it as a filter, the other declares it as openings. func (r Resolution) Rules(with Rendering) ([]Rule, error) { + // A module whose settings cannot compose is left out of the declaration (novox/hq ADR 0163, + // rule 6), and out of the filter with it: nothing of it is declared, so nothing of it is let + // through. + left := r.LeftOut(with.Settings, with.Adopted) exposure := map[string]map[int]string{} for _, m := range r.Modules { + if _, isLeft := left[m.Module]; isLeft { + continue + } e, err := Exposure(m, with.Settings[m.Module]) if err != nil { return nil, err diff --git a/internal/catalogue/placement_test.go b/internal/catalogue/placement_test.go index 8cffa5e..1b02ca8 100644 --- a/internal/catalogue/placement_test.go +++ b/internal/catalogue/placement_test.go @@ -75,17 +75,26 @@ func TestAnAssignmentPlacesDirectoriesAndAccesses(t *testing.T) { } // An access declared by id and placed by nobody resolves to nowhere, and that is refused with the -// setting to write — not mounted as the literal, not skipped. +// setting to write — not mounted as the literal, not skipped. The refusal costs the module its +// place in the declaration, not the machine its declaration (novox/hq ADR 0163, rule 6). func TestAnUnplacedAccessIsRefusedByName(t *testing.T) { got, err := Resolve(shelf(placeable()), []string{"arr"}, workstation(), World{}) if err != nil { t.Fatal(err) } - _, err = got.Declaration(placedBy(map[string]any{ + with := placedBy(map[string]any{ AccessesSetting: map[string]any{"series": "/storage/media/series"}, - })) - if err == nil || !strings.Contains(err.Error(), `"spool"`) || !strings.Contains(err.Error(), AccessesSetting) { - t.Fatalf("an access nobody placed was not refused by name: %v", err) + }) + composed, err := got.Compose(with) + if err != nil { + t.Fatal(err) + } + why := composed.LeftOut["arr"] + if why == "" || !strings.Contains(why, `"spool"`) || !strings.Contains(why, AccessesSetting) { + t.Fatalf("an access nobody placed was not refused by name: %v", composed.LeftOut) + } + if _, declared := byID(composed.Resources)["arr.server"]; declared { + t.Fatal("the module with the unplaced access was declared anyway") } } diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go index cc418e0..1c5c03e 100644 --- a/internal/catalogue/settings.go +++ b/internal/catalogue/settings.go @@ -3,6 +3,7 @@ package catalogue import ( "encoding/json" "fmt" + "regexp" "sort" "strings" ) @@ -275,6 +276,11 @@ func UnusedSettings(m Manifest, layers []Layer) []string { if key == AccessesSetting && len(m.Accesses) > 0 { continue } + // `networks` keeps a found network for a taken container on one adopted machine + // (novox/hq ADR 0163). Validated in KeptNetworks, so not stray. + if key == NetworksSetting { + continue + } unused = append(unused, fmt.Sprintf( "%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", @@ -298,3 +304,125 @@ func stringsOf(v any) []string { } return out } + +// NetworksSetting is the settings key that keeps a found network for a taken container, on one +// adopted machine (novox/hq ADR 0163, rule 4): +// +// {"networks": {"server": ["predecessor_default"]}} +// +// has the module's container `server` also join `predecessor_default` once taken, so a neighbour +// that resolves it by name on that network keeps resolving it. Migration scaffolding in the sense +// of ADR 0104: assigned only on an adopted machine, reported while it stands, removed when the +// neighbours are taken. Keyed by the container's resource id; the value is the networks it keeps. +const NetworksSetting = "networks" + +// KeptNetworks reads which found networks each of a module's containers keeps, by container id. +// +// Refused from a mesh-wide layer — a found network is a fact about one machine — for an id the +// module declares no container under, for a name that is not a network's, and on a machine that +// is not adopted: the setting exists so neighbours the mesh has not taken yet keep reaching the +// container, and a converged machine has no such neighbours. +func KeptNetworks(m Manifest, layers []Layer, adopted bool) (map[string][]string, error) { + containers := map[string]bool{} + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) == "container" { + containers[fmt.Sprint(r["id"])] = true + } + } + out := map[string][]string{} + for _, layer := range layers { + raw, ok := layer.Values[NetworksSetting] + if !ok { + continue + } + if layer.From == MeshWideLayer { + return nil, fmt.Errorf("%s: %s is given per node — a found network is a fact about one "+ + "machine; set it with --node", m.Module, NetworksSetting) + } + if !adopted { + return nil, fmt.Errorf("%s: %s keeps a found network for neighbours the mesh has not taken "+ + "yet, and %s is converged — nothing on it is found; clear the setting", m.Module, + NetworksSetting, layer.From) + } + blocks, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s: %s is a { container: [network, …] } map, and %q set it to "+ + "something else", m.Module, NetworksSetting, layer.From) + } + for id, body := range blocks { + if !containers[id] { + return nil, fmt.Errorf("%s: %s names the container %q, which it does not declare — "+ + "the setting reaches nothing; it declares %s", m.Module, NetworksSetting, id, + orNothing(sortedKeys(containers))) + } + names := stringsOf(body) + if len(names) == 0 { + return nil, fmt.Errorf("%s: %s for %q is a list of network names, and %q set it to %v", + m.Module, NetworksSetting, id, layer.From, body) + } + for _, n := range names { + if !networkName.MatchString(n) { + return nil, fmt.Errorf("%s: %s for %q names %q, which is not a network name", + m.Module, NetworksSetting, id, n) + } + } + sort.Strings(names) + out[id] = names + } + } + if len(out) == 0 { + return nil, nil + } + return out, nil +} + +// networkName is what a container runtime accepts as a network's name. +var networkName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`) + +// JudgeSettings composes a module's settings against its definition and refuses the first thing +// that cannot work, naming the module, the layer and the key (novox/hq ADR 0163, rule 6). +// +// **The same judgement where a setting is stored and where a machine is declared.** Stored, a +// setting that cannot compose is refused before it is kept; composed later, a definition that has +// moved under a stored setting leaves that module out of the machine's declaration rather than +// the machine without one. Every reader of settings runs here: a port given, an exposure, a reach, +// an endpoint, a placement, an access, a kept network, a mergeable file's keys and a file's +// `${setting:…}`. A key that reaches nothing is not here: it cannot break a composition, so it is +// refused where it is stored (SetSettings, with UnusedSettings) and said where a plan is read, +// and never costs a module its place. +func JudgeSettings(m Manifest, layers []Layer, adopted bool) error { + // With no layers too: a definition may ask for a setting nobody made — an access placed by + // nobody, a file's ${setting:…} nothing sets — and that is the same statement, missing. + if _, err := GivenPorts(m, layers); err != nil { + return err + } + if _, err := Reaches(m, layers); err != nil { + return err + } + if _, err := Endpoints(m, layers); err != nil { + return err + } + if _, err := Places(m, layers); err != nil { + return err + } + if _, _, err := accessesFor(m, layers); err != nil { + return err + } + if _, err := KeptNetworks(m, layers, adopted); err != nil { + return err + } + for _, r := range m.Resources { + settled, err := ApplySettings(r, layers) + if err != nil { + return err + } + copied := map[string]any{} + for k, v := range settled { + copied[k] = v + } + if err := settingInto(copied, layers, m.Module); err != nil { + return err + } + } + return nil +} diff --git a/internal/catalogue/settings_judge_test.go b/internal/catalogue/settings_judge_test.go new file mode 100644 index 0000000..50d9622 --- /dev/null +++ b/internal/catalogue/settings_judge_test.go @@ -0,0 +1,127 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A setting is judged where it is stored, and an impossible one costs a module, not a machine +// (novox/hq ADR 0163, rule 6): the one judgement, used by SetSettings before storing and by +// Compose when a definition has moved under a stored setting. +func TestASettingThatCannotComposeIsRefusedByNameAndLeavesOnlyItsModuleOut(t *testing.T) { + web := Manifest{Module: "hello-web", + Listens: []Listening{{Port: 8080, From: FromEverywhere}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web", + "ports": []any{"8080"}}}} + for _, c := range []struct { + name string + layer map[string]any + refuse string + }{ + {"a port the module does not publish", map[string]any{PortsSetting: map[string]any{"9999": 10000}}, + "hello-web gives port 9999 a machine port, and no container of its publishes 9999"}, + {"a mesh-wide port", map[string]any{PortsSetting: map[string]any{"8080": 10000}}, + "a port is a fact about one machine"}, + } { + from := "anchor" + if c.name == "a mesh-wide port" { + from = MeshWideLayer + } + err := JudgeSettings(web, []Layer{{From: from, Values: c.layer}}, true) + if err == nil || !strings.Contains(err.Error(), c.refuse) { + t.Errorf("%s: judged %v, want %q", c.name, err, c.refuse) + } + } + if err := JudgeSettings(web, []Layer{{From: "anchor", Values: map[string]any{PortsSetting: map[string]any{"8080": 10000}}}}, true); err != nil { + t.Fatalf("a port the module publishes was refused: %v", err) + } + + // Composed, a module whose stored setting no longer works is left out by name; the rest of + // the machine is declared. + r := anAdoptedAnchor() + with := anchorRendering(false) + with.Settings = SettingsBy{"hello-web": {{From: "anchor", Values: map[string]any{PortsSetting: map[string]any{"9999": 10000}}}}} + composed, err := r.Compose(with) + if err != nil { + t.Fatal(err) + } + why, left := composed.LeftOut["hello-web"] + if !left || !strings.Contains(why, "no container of its publishes 9999") { + t.Fatalf("hello-web is not left out by name: %v", composed.LeftOut) + } + if len(composed.LeftOut) != 1 { + t.Fatalf("more than hello-web is left out: %v", composed.LeftOut) + } + got := byID(composed.Resources) + if _, declared := got["hello-web.server"]; declared { + t.Fatal("the left-out module's container is still declared") + } + if _, declared := got["distribution.store"]; !declared { + t.Fatal("the rest of the machine was not declared") + } + if left := r.LeftOut(with.Settings, false); len(left) != 1 || left["hello-web"] == "" { + t.Fatalf("the judgement a plan reads differs from what compose did: %v", left) + } +} + +// A taken container keeps a found network by a per-machine setting (novox/hq ADR 0163, rule 4): +// on an adopted machine only, for a container the module declares, and it reaches the container's +// declaration as the networks it also joins. +func TestAKeptNetworkReachesTheContainerOnAnAdoptedMachineOnly(t *testing.T) { + r := anAdoptedAnchor() + keep := SettingsBy{"hello-web": {{From: "anchor", + Values: map[string]any{NetworksSetting: map[string]any{"server": []any{"predecessor_default"}}}}}} + + with := anchorRendering(true) + with.Settings = keep + composed, err := r.Compose(with) + if err != nil { + t.Fatal(err) + } + if len(composed.LeftOut) != 0 { + t.Fatalf("a kept network left a module out: %v", composed.LeftOut) + } + server := byID(composed.Resources)["hello-web.server"] + networks, _ := server["networks"].([]any) + if len(networks) != 1 || networks[0] != "predecessor_default" { + t.Fatalf("the container does not join the kept network: %v", server) + } + if _, has := byID(composed.Resources)["distribution.store"]["networks"]; has { + t.Fatal("another container joins a network nobody kept for it") + } + + // Converged, the setting reaches nothing it was for, and the module is left out saying so. + with = anchorRendering(false) + with.Settings = keep + composed, err = r.Compose(with) + if err != nil { + t.Fatal(err) + } + if why := composed.LeftOut["hello-web"]; !strings.Contains(why, "anchor is converged") { + t.Fatalf("a kept network on a converged machine: %v", composed.LeftOut) + } + + web := r.Modules[4] + for _, c := range []struct { + name string + layer Layer + want string + }{ + {"mesh-wide", Layer{From: MeshWideLayer, Values: map[string]any{NetworksSetting: map[string]any{"server": []any{"x"}}}}, + "a found network is a fact about one machine"}, + {"an unknown container", Layer{From: "anchor", Values: map[string]any{NetworksSetting: map[string]any{"db": []any{"x"}}}}, + `names the container "db", which it does not declare`}, + {"not a list", Layer{From: "anchor", Values: map[string]any{NetworksSetting: map[string]any{"server": "x"}}}, + "is a list of network names"}, + {"not a network name", Layer{From: "anchor", Values: map[string]any{NetworksSetting: map[string]any{"server": []any{"a/b"}}}}, + "which is not a network name"}, + } { + _, err := KeptNetworks(web, []Layer{c.layer}, true) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: %v, want %q", c.name, err, c.want) + } + } + if kept, err := KeptNetworks(web, nil, false); err != nil || kept != nil { + t.Fatalf("no setting: %v %v", kept, err) + } +} diff --git a/internal/inventory/catalogue.go b/internal/inventory/catalogue.go index cbf8a90..b339509 100644 --- a/internal/inventory/catalogue.go +++ b/internal/inventory/catalogue.go @@ -605,6 +605,12 @@ func (i *Inventory) SetSettings(ctx context.Context, nodeName, module string, va if err != nil { return err } + // Judged here, against the module's current definition, before it is kept (novox/hq ADR 0163, + // rule 6): a setting that cannot compose is refused where it is set, naming the node, the + // module, the layer and the key — never stored to refuse the whole machine where it is read. + if err := i.judgeSettings(ctx, nodeName, module, values); err != nil { + return err + } if nodeName == "" { // A port is a fact about one machine (novox/hq ADR 0100). Refused here, in composition's // words: stored, it refuses every node running the module at composition, and the mesh @@ -661,6 +667,50 @@ func (i *Inventory) SetSettings(ctx context.Context, nodeName, module string, va return tx.Commit(ctx) } +// judgeSettings composes a layer somebody is about to store against the module's definition, with +// the mesh-wide layer under it when the layer is one node's, and refuses the first thing that +// cannot work (ADR 0163, rule 6). The same judgement composition makes; what passes here composes. +func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string, values map[string]any) error { + m, err := i.declared(ctx, module) + if err != nil { + return fmt.Errorf("%w: %s", ErrNoSuchModule, module) + } + where, from := "the mesh", catalogue.MeshWideLayer + adopted := false + var layers []catalogue.Layer + if nodeName != "" { + node, err := i.NodeByName(ctx, nodeName) + if err != nil { + return err + } + where, from, adopted = nodeName, nodeName, node.Adopted + var meshWide []byte + err = i.store.Pool().QueryRow(ctx, + `select values from settings where module = $1 and node is null`, module).Scan(&meshWide) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if len(meshWide) > 0 { + var under map[string]any + if err := json.Unmarshal(meshWide, &under); err != nil { + return err + } + layers = append(layers, catalogue.Layer{From: catalogue.MeshWideLayer, Values: under}) + } + } + layers = append(layers, catalogue.Layer{From: from, Values: values}) + if err := catalogue.JudgeSettings(m, layers, adopted); err != nil { + return fmt.Errorf("refused: %s on %s cannot compose with the layer %q — %w", module, where, from, err) + } + // And a key that reaches nothing, refused here where somebody can still fix the spelling: + // stored, it would be a setting somebody believes they made. + if stray := catalogue.UnusedSettings(m, layers[len(layers)-1:]); len(stray) > 0 { + return fmt.Errorf("refused: %s on %s — these settings reach nothing:\n - %s", module, where, + strings.Join(stray, "\n - ")) + } + return nil +} + // givenIn is the machine ports a node-level settings layer gives a module, software port → // machine port (novox/hq ADR 0100). Nothing when the layer gives none; what is not a port is left // for composition to refuse in its own words. diff --git a/internal/inventory/forget_test.go b/internal/inventory/forget_test.go index 108296f..b28e317 100644 --- a/internal/inventory/forget_test.go +++ b/internal/inventory/forget_test.go @@ -31,8 +31,11 @@ func TestRegisteringAModuleAgainKeepsWhatTheMeshHoldsForIt(t *testing.T) { if err := inv.RecordSealingKey(ctx, node.ID, key); err != nil { t.Fatal(err) } + // A mergeable file, so any setting composes (novox/hq ADR 0163, rule 6: a setting is judged + // where it is stored). m := catalogue.Manifest{Module: "step-ca", Version: "1", - Provides: catalogue.Offers("acme-ca"), OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password"}}} + Provides: catalogue.Offers("acme-ca"), OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password"}}, + Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/step-ca.json", "content": "{}", "merge": "json"}}} if err := inv.RegisterModule(ctx, m, Source{}); err != nil { t.Fatal(err) } @@ -229,5 +232,9 @@ func TestAModuleStillAssignedRefusesBeforeAnythingAboutWhatItHolds(t *testing.T) // declares (novox/hq 04-ISSUES/078). func withOwnSecret(m catalogue.Manifest, name string) catalogue.Manifest { m.OwnSecrets = catalogue.OwnSecrets{name: {Path: "/run/" + name}} + // And a mergeable file, so any setting these tests store composes (novox/hq ADR 0163, rule 6: + // a setting is judged where it is stored). + m.Resources = append(m.Resources, map[string]any{"id": "conf", "type": "file", + "path": "/etc/" + m.Module + ".json", "content": "{}", "merge": "json"}) return m } diff --git a/internal/inventory/ports_test.go b/internal/inventory/ports_test.go index 76f45ab..60f0665 100644 --- a/internal/inventory/ports_test.go +++ b/internal/inventory/ports_test.go @@ -15,9 +15,16 @@ func aNodeWithModules(t *testing.T, modules ...string) (*Inventory, string) { if _, err := inv.AddNode(ctx, "anchor"); err != nil { t.Fatal(err) } + // Each publishes the port these tests give it a machine port for: a port given for one the + // module does not publish is refused where it is stored (novox/hq ADR 0163, rule 6). + publishes := map[string]string{"postgres": "5432", "another-database": "5432", "cache": "6379", "web": "8080"} for _, m := range modules { - if err := inv.RegisterModule(ctx, - catalogue.Manifest{Module: m, Version: "1"}, Source{}); err != nil { + manifest := catalogue.Manifest{Module: m, Version: "1"} + if port, known := publishes[m]; known { + manifest.Resources = []map[string]any{{"id": "server", "type": "container", "name": m, + "image": "x", "ports": []any{port}}} + } + if err := inv.RegisterModule(ctx, manifest, Source{}); err != nil { t.Fatal(err) } } diff --git a/internal/inventory/secrets.go b/internal/inventory/secrets.go index ec8121f..53e8f00 100644 --- a/internal/inventory/secrets.go +++ b/internal/inventory/secrets.go @@ -820,3 +820,52 @@ func (i *Inventory) SharedHolders(ctx context.Context, provider, providerModule, sort.Strings(out) return out, nil } + +// SecretState is one secret a module holds on a machine, as a take compares it (novox/hq ADR +// 0163): its name, where it came from — made by the mesh or accepted from a person — and, for a +// credential the module requires from a provider, which node provides it and the local name it +// goes by where the module keeps several. +type SecretState struct { + Name string + // Local is the credential's name inside the module (ADR 0094); empty for an own secret or the + // ordinary one. + Local string + // Origin is OriginMade or OriginAccepted. + Origin string + // Provider is the node providing a required secret; empty for the module's own. + Provider string +} + +// Own says the secret is the module's own rather than one it requires from a provider. +func (s SecretState) Own() bool { return s.Provider == "" } + +// SecretsOf is every secret a module holds on a machine: its own, and each credential it requires +// from a provider — with where each value came from. What a take reads to refuse minting over a +// service that already has one (ADR 0163, rule 2). +func (i *Inventory) SecretsOf(ctx context.Context, node, module string) ([]SecretState, error) { + record, err := i.NodeByName(ctx, node) + if err != nil { + return nil, err + } + rows, err := i.store.Pool().Query(ctx, + `select name, '' as local, origin, '' as provider from module_secret + where node = $1 and module = $2 + union all + select s.name, s.local, s.origin, p.name from secret s + join node p on p.id = s.provider + where s.consumer = $1 and s.consumer_module = $2 + order by 4, 1, 2`, record.ID, module) + if err != nil { + return nil, err + } + defer rows.Close() + var out []SecretState + for rows.Next() { + var s SecretState + if err := rows.Scan(&s.Name, &s.Local, &s.Origin, &s.Provider); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} diff --git a/internal/inventory/secrets_test.go b/internal/inventory/secrets_test.go index d29b91c..b028300 100644 --- a/internal/inventory/secrets_test.go +++ b/internal/inventory/secrets_test.go @@ -934,3 +934,47 @@ func TestASharedCredentialIsOneValueSealedToEveryHolder(t *testing.T) { t.Fatalf("a consumer binding after an acceptance must be refused with the way out: %v", err) } } + +// SecretsOf is every secret a module holds on a machine with where each came from — what a take +// reads to refuse minting over a service that already has a value (novox/hq ADR 0163, rule 2). +func TestSecretsOfSaysEachSecretsOriginAndProvider(t *testing.T) { + inv, ctx := twoNodesWithKeys(t) + if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "forge", Version: "1", + Requires: []string{"secret", "postgres-database"}, + Secrets: map[string]string{"secret": "/run/secret", "postgres-database": "/run/pg"}, + OwnSecrets: catalogue.OwnSecrets{"admin": {Path: "/run/admin"}}}, Source{}); err != nil { + t.Fatal(err) + } + if _, err := inv.SecretForModule(ctx, "consumer", "forge", "admin"); err != nil { + t.Fatal(err) + } + if _, err := inv.SecretFor(ctx, "postgres-database", "consumer", "forge", "provider", ""); err != nil { + t.Fatal(err) + } + if err := inv.AcceptSecretForPair(ctx, "secret", "consumer", "forge", "provider", "", "hunter2"); err != nil { + t.Fatal(err) + } + got, err := inv.SecretsOf(ctx, "consumer", "forge") + if err != nil { + t.Fatal(err) + } + want := []SecretState{ + {Name: "admin", Origin: OriginMade}, + {Name: "postgres-database", Origin: OriginMade, Provider: "provider"}, + {Name: "secret", Origin: OriginAccepted, Provider: "provider"}, + } + if len(got) != len(want) { + t.Fatalf("got %+v", got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("secret %d: got %+v, want %+v", i, got[i], want[i]) + } + } + if !got[0].Own() || got[1].Own() { + t.Error("own and required are not told apart") + } + if other, _ := inv.SecretsOf(ctx, "consumer", "gitea"); len(other) != 0 { + t.Fatalf("another module's secrets: %+v", other) + } +} diff --git a/internal/inventory/settings_judged_test.go b/internal/inventory/settings_judged_test.go new file mode 100644 index 0000000..1563fa1 --- /dev/null +++ b/internal/inventory/settings_judged_test.go @@ -0,0 +1,69 @@ +package inventory + +import ( + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// A setting is judged where it is stored (novox/hq ADR 0163, rule 6): one that cannot compose with +// the module's definition is refused naming the node, the module, the layer and the key, and is not +// kept; one that reaches nothing is refused the same way. +func TestASettingIsJudgedWhereItIsStored(t *testing.T) { + inv := fresh(t) + ctx := t.Context() + if _, err := inv.AddNode(ctx, "anchor"); err != nil { + t.Fatal(err) + } + web := catalogue.Manifest{Module: "web", Version: "1", + Resources: []map[string]any{ + {"id": "server", "type": "container", "name": "web", "image": "x", "ports": []any{"8080"}}, + {"id": "conf", "type": "file", "path": "/etc/web.json", "content": "{}", "merge": "json"}, + }} + plain := catalogue.Manifest{Module: "plain", Version: "1", + Resources: []map[string]any{{"id": "server", "type": "container", "name": "plain", "image": "x"}}} + for _, m := range []catalogue.Manifest{web, plain} { + if err := inv.RegisterModule(ctx, m, Source{}); err != nil { + t.Fatal(err) + } + } + + err := inv.SetSettings(ctx, "anchor", "web", map[string]any{catalogue.PortsSetting: map[string]any{"9999": 10000}}) + for _, want := range []string{"refused: web on anchor", `layer "anchor"`, "9999"} { + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("a port the module does not publish: %v, want %q", err, want) + } + } + if layers, _ := inv.SettingsFor(ctx, "anchor", "web"); len(layers) != 0 { + t.Fatalf("the refused layer was stored: %v", layers) + } + // A key that reaches nothing is refused too, where the spelling can still be fixed; a module + // with a mergeable file takes any key. + err = inv.SetSettings(ctx, "", "plain", map[string]any{"colour": "blue"}) + if err == nil || !strings.Contains(err.Error(), "reach nothing") || !strings.Contains(err.Error(), `"colour"`) { + t.Fatalf("a stray key was stored: %v", err) + } + if err := inv.SetSettings(ctx, "", "web", map[string]any{"colour": "blue"}); err != nil { + t.Fatal(err) + } + // The mesh-wide layer is under the node's when the node's is judged. + if err := inv.SetSettings(ctx, "anchor", "web", map[string]any{catalogue.PortsSetting: map[string]any{"8080": 10000}}); err != nil { + t.Fatal(err) + } + // A kept network is for an adopted machine only (rule 4). + keep := map[string]any{catalogue.NetworksSetting: map[string]any{"server": []any{"predecessor_default"}}} + err = inv.SetSettings(ctx, "anchor", "plain", keep) + if err == nil || !strings.Contains(err.Error(), "anchor is converged") { + t.Fatalf("a kept network on a converged machine was stored: %v", err) + } + if err := inv.SetAdopted(ctx, "anchor", true); err != nil { + t.Fatal(err) + } + if err := inv.SetSettings(ctx, "anchor", "plain", keep); err != nil { + t.Fatal(err) + } + if err := inv.SetSettings(ctx, "anchor", "nothing", keep); err == nil { + t.Fatal("a setting for a module the mesh does not know was stored") + } +}