A take acts on the preview it showed; a setting is judged where it is stored; a kept network and a minted secret are said (hq ADR 0163)
take ends its preview with a digest and --yes names it, as the flip does; a changed preview or an account older than the flip allows is refused. A module the machine holds nothing for has nothing to compare, and --yes suffices. A published port's reach is said as the machine reported it. Every secret the module holds on the machine is listed with where it came from, and one the mesh minted for a service whose data was found refuses unless --mint names it. One judgement of a module's settings against its definition, in the catalogue: settings set refuses what cannot compose or reaches nothing, naming node, module, layer and key; Compose leaves out a module whose definition moved under a stored setting, the envelope says so (left_out), plan and push say it by name, and the machine is told everything else. A stray setting no longer refuses the whole machine where it is read (issue 096). The per-machine setting networks keeps a found network for a taken container, on an adopted machine only; the container's declaration carries it and the preview names it (rule 4).
This commit is contained in:
@@ -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()
|
||||
|
||||
+209
-20
@@ -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 <digest>` 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
|
||||
// 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 <node> %s %s`", module, sec.Name)
|
||||
if !sec.Own() {
|
||||
what = "secret from " + sec.Provider
|
||||
accept = fmt.Sprintf("`secret accept <node> %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 <node>` with {%q: {<container>: [%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 <node> <module> [--yes] [--downgrade] [--replace <path>]...")
|
||||
if len(positionals) < 2 || len(positionals) > 3 || (len(positionals) == 3 && !*yes) {
|
||||
return errors.New("take <node> <module> [--yes <digest>] [--downgrade] [--replace <path>]... [--mint <secret>]...")
|
||||
}
|
||||
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) })
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -180,7 +180,8 @@ func usage() {
|
||||
api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here
|
||||
assign <node> <module> put a module on a node
|
||||
unassign <node> <module> take it off
|
||||
take <node> <module> cut a module over on an adopted node, once its data has moved
|
||||
take <node> <module> preview a module's cutover on an adopted node: what runs beside
|
||||
what it declares; --yes <digest> cuts it over as previewed
|
||||
converge <node> [--yes <digest>] [--filter nftables] preview, then make, an adopted node converged
|
||||
adopt <node> return a converged node to adopted; what was taken stays taken
|
||||
settings set <module> <file> what a module's config should say, for the whole mesh
|
||||
|
||||
+49
-15
@@ -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
|
||||
}
|
||||
@@ -405,7 +397,32 @@ func declarationWith(ctx context.Context, open *stores, node string,
|
||||
return sendable{}, err
|
||||
}
|
||||
return sendable{Resources: composed.Resources, Adoption: adoption,
|
||||
Received: composed.Received, Mesh: with.Mesh}, nil
|
||||
Received: composed.Received, Mesh: with.Mesh,
|
||||
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.
|
||||
@@ -445,7 +462,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
|
||||
@@ -1000,6 +1020,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])
|
||||
|
||||
@@ -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{}
|
||||
@@ -454,7 +458,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,
|
||||
@@ -666,6 +674,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 {
|
||||
|
||||
@@ -31,6 +31,14 @@ type sendable struct {
|
||||
// the same composition as its received files, and every machine's private-network address.
|
||||
Received map[string]map[string][]catalogue.Contribution
|
||||
Mesh []string
|
||||
// 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
|
||||
@@ -51,6 +59,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".
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,15 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// 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{
|
||||
// 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,
|
||||
@@ -20,10 +22,24 @@ func TestATakePreviewsTheComparisonAndRefusesWhatIsNotNamed(t *testing.T) {
|
||||
{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"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
preview, refusals := comparisonOf(held, "forge", takeOptions{})
|
||||
|
||||
// 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) {
|
||||
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 <node> 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 <node> 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,13 +249,32 @@ type Composed struct {
|
||||
Resources []map[string]any
|
||||
Owner map[string]string
|
||||
Received map[string]map[string][]Contribution
|
||||
// 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{}
|
||||
received := map[string]map[string][]Contribution{}
|
||||
resources, err := r.compose(with, owner, received)
|
||||
leftOut := map[string]string{}
|
||||
resources, err := r.compose(with, owner, received, leftOut)
|
||||
if err != nil {
|
||||
return Composed{}, err
|
||||
}
|
||||
@@ -267,7 +286,7 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||
"sealed": with.BusMembership, "mode": "0600",
|
||||
})
|
||||
}
|
||||
return Composed{Resources: resources, Owner: owner, Received: received}, nil
|
||||
return Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut}, nil
|
||||
}
|
||||
|
||||
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
|
||||
@@ -277,7 +296,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,
|
||||
received map[string]map[string][]Contribution) ([]map[string]any, error) {
|
||||
received map[string]map[string][]Contribution, 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
|
||||
@@ -706,6 +743,10 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
// 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 {
|
||||
@@ -715,6 +756,16 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
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
|
||||
}
|
||||
@@ -960,8 +1011,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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user