diff --git a/cmd/mesh-controller/adopting_test.go b/cmd/mesh-controller/adopting_test.go index a517214..b0a2c04 100644 --- a/cmd/mesh-controller/adopting_test.go +++ b/cmd/mesh-controller/adopting_test.go @@ -109,10 +109,10 @@ var ( func TestTakingAModuleNotOnTheNodeIsRefused(t *testing.T) { open, _ := anAdoptedAnchor(t) - if _, err := take(t.Context(), open, "anchor", "nftables"); !errors.Is(err, inventory.ErrNotAssigned) { + if _, err := take(t.Context(), open, "anchor", "nftables", takeOptions{Yes: true}); !errors.Is(err, inventory.ErrNotAssigned) { t.Fatalf("taking an unassigned module gave %v", err) } - if _, err := take(t.Context(), open, "laptop", "network"); !errors.Is(err, inventory.ErrNotAdopted) { + if _, err := take(t.Context(), open, "laptop", "network", takeOptions{Yes: true}); !errors.Is(err, inventory.ErrNotAdopted) { t.Fatalf("taking on a converged node gave %v", err) } } @@ -143,7 +143,7 @@ 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") + said, err := take(t.Context(), open, "anchor", "hello-web", takeOptions{Yes: true}) if err != nil { t.Fatal(err) } @@ -156,7 +156,7 @@ func TestTakingNamesWhatItReplaces(t *testing.T) { func TestConvergingPreviewsThenChangesAndAdoptingKeepsWhatWasTaken(t *testing.T) { open, sent := anAdoptedAnchor(t) ctx := t.Context() - if _, err := take(ctx, open, "anchor", "hello-web"); err != nil { + if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil { t.Fatal(err) } reportsHolding(t, open, heldFile) @@ -330,7 +330,7 @@ func digestIn(t *testing.T, preview string) string { func TestTheFlipActsOnlyOnThePreviewTheOperatorSaw(t *testing.T) { open, sent := anAdoptedAnchor(t) ctx := t.Context() - if _, err := take(ctx, open, "anchor", "hello-web"); err != nil { + if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil { t.Fatal(err) } reportsHolding(t, open, heldFile) @@ -396,7 +396,7 @@ func TestTheFlipActsOnlyOnThePreviewTheOperatorSaw(t *testing.T) { func TestTheFlipHoldsTheNodeWhileItSends(t *testing.T) { open, _ := anAdoptedAnchor(t) ctx := t.Context() - if _, err := take(ctx, open, "anchor", "hello-web"); err != nil { + if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil { t.Fatal(err) } reportsHolding(t, open, heldFile) @@ -441,7 +441,7 @@ func TestTheFlipHoldsTheNodeWhileItSends(t *testing.T) { func TestThePreviewNamesEveryHeldKind(t *testing.T) { open, _ := anAdoptedAnchor(t) ctx := t.Context() - if _, err := take(ctx, open, "anchor", "hello-web"); err != nil { + if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil { t.Fatal(err) } since := time.Now() @@ -484,7 +484,7 @@ func TestThePreviewNamesEveryHeldKind(t *testing.T) { func TestTheFlipIsRefusedOnAnAccountNamingNothingReachable(t *testing.T) { open, sent := anAdoptedAnchor(t) ctx := t.Context() - if _, err := take(ctx, open, "anchor", "hello-web"); err != nil { + if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil { t.Fatal(err) } // Only a loopback listener: nothing off the machine, which is the same silence. @@ -512,7 +512,7 @@ func TestTheFlipIsRefusedOnAnAccountNamingNothingReachable(t *testing.T) { func TestAssigningWaitsForWhateverIsConvergingTheNode(t *testing.T) { open, _ := anAdoptedAnchor(t) ctx := t.Context() - if _, err := take(ctx, open, "anchor", "hello-web"); err != nil { + if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil { t.Fatal(err) } reportsHolding(t, open, heldFile) diff --git a/cmd/mesh-controller/adoption.go b/cmd/mesh-controller/adoption.go index baf9da8..e3e40dc 100644 --- a/cmd/mesh-controller/adoption.go +++ b/cmd/mesh-controller/adoption.go @@ -62,6 +62,15 @@ func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node if h.Kept != "" { fmt.Printf(" %-17s original kept at %s\n", "", h.Kept) } + for _, f := range comparisonLines(h) { + fmt.Printf(" %-17s %s\n", "", f) + } + } + if len(said.Strays) > 0 { + fmt.Printf(" strays %d container(s) the mesh neither wrote nor holds:\n", len(said.Strays)) + for _, s := range said.Strays { + fmt.Printf(" %-17s %s (%s)\n", "", s.Name, s.Detail) + } } fmt.Printf(" as of %s\n", said.At.Local().Format(time.DateTime)) return nil @@ -136,7 +145,14 @@ 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. -func take(ctx context.Context, open *stores, node, module string) (string, error) { +// takeOptions is what a take was told about the differences it may pass (novox/hq ADR 0163). +type takeOptions struct { + Yes bool + Downgrade bool + Replace map[string]bool +} + +func take(ctx context.Context, open *stores, node, module string, opts takeOptions) (string, error) { inv := open.inventory assigned, err := inv.Assigned(ctx, node) if err != nil { @@ -150,27 +166,170 @@ func take(ctx context.Context, open *stores, node, module string) (string, error } } } - if err := inv.Take(ctx, node, module); err != nil { - return "", err - } - said := fmt.Sprintf("%s is taken on %s", module, node) + // 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) if err != nil { return "", err } - var replaces []string - for _, h := range reported.Held { - if h.Module == module { - replaces = append(replaces, " "+heldLine(h)) - } + preview, refusals := comparisonOf(reported.Held, module, 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) } - if len(replaces) > 0 { - said += "; the next push replaces what the node found and holds for it:\n" + - strings.Join(replaces, "\n") + if !opts.Yes { + return preview + fmt.Sprintf("\nnothing taken; `take %s %s --yes` cuts it over as previewed", node, module), nil + } + if err := inv.Take(ctx, node, module); err != nil { + return "", err + } + said := fmt.Sprintf("%s is taken on %s", module, node) + if preview != "" { + 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 } +// 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) { + var b strings.Builder + var refusals []string + for _, h := range held { + if h.Module != module { + continue + } + 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) { + fmt.Fprintf(&b, " %s\n", line) + } + f := factsOf(h) + if f.downgrade && !opts.Downgrade { + refusals = append(refusals, fmt.Sprintf("%s: the module's image (%s, made %s) is older than the one running (%s, made %s) — "+ + "a service that migrated its data forward may not start on it; `--downgrade` to take it anyway", + h.Target, f.declaredImage, day(f.declaredCreated), f.image, day(f.imageCreated))) + } + if f.differs && !opts.Replace[h.Target] && !opts.Replace["*"] { + refusals = append(refusals, fmt.Sprintf("%s: the module's content differs from the file found; the lines above "+ + "marked - are lost by taking it; `--replace %s` to replace it anyway, or declare the file partially", + h.Target, h.Target)) + } + } + return b.String(), refusals +} + +// facts is a held thing's facts as the preview reads them. +type facts struct { + image, imageCreated, declaredImage, declaredCreated string + downgrade, differs bool + networks map[string][]string + mounts, ports, declaredPorts, declaredVolumes []string + difference []string +} + +func factsOf(h inventory.Held) facts { + var f facts + if h.Facts == nil { + return f + } + str := func(k string) string { s, _ := h.Facts[k].(string); return s } + list := func(k string) []string { + var out []string + if raw, ok := h.Facts[k].([]any); ok { + for _, x := range raw { + if s, ok := x.(string); ok { + out = append(out, s) + } + } + } + return out + } + f.image, f.imageCreated = str("image"), str("image_created") + f.declaredImage, f.declaredCreated = str("declared_image"), str("declared_image_created") + f.downgrade, _ = h.Facts["downgrade"].(bool) + f.differs, _ = h.Facts["differs"].(bool) + f.mounts, f.ports = list("mounts"), list("ports") + f.declaredPorts, f.declaredVolumes, f.difference = list("declared_ports"), list("declared_volumes"), list("difference") + if raw, ok := h.Facts["networks"].(map[string]any); ok { + f.networks = map[string][]string{} + for name, members := range raw { + var out []string + if ms, ok := members.([]any); ok { + for _, m := range ms { + if s, ok := m.(string); ok { + out = append(out, s) + } + } + } + f.networks[name] = out + } + } + return f +} + +// comparisonLines says a held thing's facts the way a person weighs them. +func comparisonLines(h inventory.Held) []string { + f := factsOf(h) + var out []string + if f.image != "" || f.declaredImage != "" { + line := fmt.Sprintf("runs %s", orNone(f.image)) + if f.imageCreated != "" { + line += " (made " + day(f.imageCreated) + ")" + } + line += "; the module declares " + orNone(f.declaredImage) + switch { + case f.declaredCreated != "": + line += " (made " + day(f.declaredCreated) + ")" + case f.declaredImage != "": + line += " (not on the machine yet, so its age is unknown)" + } + if f.downgrade { + line += " — DOWNGRADE" + } + out = append(out, line) + } + names := make([]string, 0, len(f.networks)) + for n := range f.networks { + names = append(names, n) + } + 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", + n, strings.Join(members, ", "))) + } + } + 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, " ")))) + } + if len(f.mounts) > 0 || len(f.declaredVolumes) > 0 { + out = append(out, fmt.Sprintf("mounts %s; the module declares %s", + orNone(strings.Join(f.mounts, " ")), orNone(strings.Join(f.declaredVolumes, " ")))) + } + if f.differs { + out = append(out, "the declared content differs from the file found (- lost, + new):") + for _, d := range f.difference { + out = append(out, " "+d) + } + } + return out +} + +// day is a timestamp as a person reads it in a preview: its date. +func day(stamp string) string { + if len(stamp) >= 10 { + return stamp[:10] + } + return stamp +} + // reportFreshFor is how old a node's account of itself may be for the flip to act on it. A // variable so a test can age a report without waiting. var reportFreshFor = 15 * time.Minute @@ -596,12 +755,31 @@ 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 { - if len(args) != 2 { - return errors.New("take ") + 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") + downgrade := set.Bool("downgrade", false, "take it although the module's image is older than the one running") + var replace stringList + set.Var(&replace, "replace", "a found file's path whose content the module may replace although it differs (repeatable; * for every one)") + positionals, err := parseAround(set, args) + if err != nil { + return err } - return runAct(ctx, func(open *stores) (string, error) { return take(ctx, open, args[0], args[1]) }) + if len(positionals) != 2 { + return errors.New("take [--yes] [--downgrade] [--replace ]...") + } + opts := takeOptions{Yes: *yes, Downgrade: *downgrade, Replace: map[string]bool{}} + for _, r := range replace { + opts.Replace[r] = true + } + return runAct(ctx, func(open *stores) (string, error) { return take(ctx, open, positionals[0], positionals[1], opts) }) } +// stringList is a repeatable flag. +type stringList []string + +func (l *stringList) String() string { return strings.Join(*l, ",") } +func (l *stringList) Set(v string) error { *l = append(*l, v); return nil } + func convergeCommand(ctx context.Context, args []string) error { set := flag.NewFlagSet("converge", flag.ContinueOnError) yes := set.String("yes", "", "do it, naming the digest the preview printed; without it, only "+ diff --git a/cmd/mesh-controller/api.go b/cmd/mesh-controller/api.go index b1b6a25..fce5a83 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) + return take(ctx, open, in.Node, in.Module, takeOptions{Yes: true}) })) 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) diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index fef3b53..1b27345 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -469,10 +469,25 @@ func buildOne(ctx context.Context, source buildSource, path, ref string, wait ti } fmt.Printf("\n%s %s, built on %s from %s\n", manifest.Module, manifest.Version, result.On, short(result.Commit)) + saysWhenThePolicyActs(ctx, open.inventory, manifest.Module) fmt.Printf(" run `assign %s` to put it somewhere\n", manifest.Module) return nil } +// saysWhenThePolicyActs tells whoever built a module that its upgrade policy will send the +// result on at once (novox/hq issue 126, ADR 0163): a person choreographing a data move must +// know which module will not wait for them. +func saysWhenThePolicyActs(ctx context.Context, inv *inventory.Inventory, module string) { + if u, err := inv.UpgradeOf(ctx, module); err == nil && u.RollOut { + how := "one machine at a time" + if u.Together { + how = "every machine at once" + } + fmt.Printf(" %s rolls out on build: the machines running it are sent this now, %s — "+ + "`upgrade %s record` first if something must move before it does\n", module, how, module) + } +} + // takeIn is what the mesh does with a build's outcome, whoever hears it: the waiting command and // the daemon that follows the role's events both come here (novox/hq issue 176), so a build's // result reaches the catalogue whether or not the asker was still listening. diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index 41defbb..793479d 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -270,6 +270,7 @@ func (b builds) Built(ctx context.Context, result link.BuildResult) error { } fmt.Printf("%s: %s %s registered, built on %s from %s\n", result.ID, manifest.Module, manifest.Version, result.On, short(result.Commit)) + saysWhenThePolicyActs(ctx, b.inv, manifest.Module) planBuilt(ctx, b.inv, manifest.Module, result.Commit, "") return nil } diff --git a/cmd/mesh-controller/take_preview_test.go b/cmd/mesh-controller/take_preview_test.go new file mode 100644 index 0000000..a03a051 --- /dev/null +++ b/cmd/mesh-controller/take_preview_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "strings" + "testing" + + "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{ + {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{}) + 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", + "- 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) + } + } + if strings.Contains(preview, "other") { + t.Errorf("another module's held things are in the preview:\n%s", preview) + } + 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) + } + // Named, they pass. + if _, refusals := comparisonOf(held, "forge", 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 { + 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") { + t.Fatalf("a factless hold: %q %v", preview, refusals) + } +} diff --git a/internal/inventory/adoption.go b/internal/inventory/adoption.go index 1edd697..60531d2 100644 --- a/internal/inventory/adoption.go +++ b/internal/inventory/adoption.go @@ -164,6 +164,18 @@ type Held struct { Since time.Time `json:"since"` Changed string `json:"changed,omitempty"` Kept string `json:"kept,omitempty"` + // Facts is what a take compares (novox/hq ADR 0163), as the host reported it: for a found + // container its image and the image's date, the networks and their other members, mounts and + // ports, beside the declared image, ports and volumes, and whether the declared image is the + // older; for a found file whether the declared content differs and how. + Facts map[string]any `json:"facts,omitempty"` +} + +// A Stray is a container a machine runs that the mesh neither wrote nor holds (ADR 0163). +type Stray struct { + Kind string `json:"kind"` + Name string `json:"name"` + Detail string `json:"detail,omitempty"` } // Reach is one thing reachable on an adopted node: a listening socket or a published port. @@ -181,6 +193,8 @@ type Adoption struct { Held []Held Firewall string Reachable []Reach + // Strays is what the machine runs that nobody asked for, as last reported (ADR 0163). + Strays []Stray // At is when it said so; zero when it never has. At time.Time } @@ -189,6 +203,16 @@ type Adoption struct { // question is the machine as it is now. func (i *Inventory) RecordAdoption(ctx context.Context, node string, held []Held, firewall string, reachable []Reach) error { + return i.RecordAdoptionWithStrays(ctx, node, held, firewall, reachable, nil) +} + +// RecordAdoptionWithStrays is RecordAdoption with what the machine says strays on it (ADR 0163). +func (i *Inventory) RecordAdoptionWithStrays(ctx context.Context, node string, held []Held, firewall string, + reachable []Reach, strays []Stray) error { + straysRaw, err := json.Marshal(nonNil(strays)) + if err != nil { + return err + } heldRaw, err := json.Marshal(nonNil(held)) if err != nil { return err @@ -198,9 +222,9 @@ func (i *Inventory) RecordAdoption(ctx context.Context, node string, held []Held return err } _, err = i.store.Pool().Exec(ctx, - `update node set held = $2, firewall = nullif($3, ''), reachable = $4, + `update node set held = $2, firewall = nullif($3, ''), reachable = $4, strays = $5, adoption_reported = now(), last_seen = now() - where id = $1`, node, heldRaw, firewall, reachRaw) + where id = $1`, node, heldRaw, firewall, reachRaw, straysRaw) return err } @@ -213,12 +237,12 @@ func nonNil[T any](s []T) []T { // AdoptionOf is what a node last reported about adoption. func (i *Inventory) AdoptionOf(ctx context.Context, name string) (Adoption, error) { - var heldRaw, reachRaw []byte + var heldRaw, reachRaw, straysRaw []byte var firewall *string var at *time.Time err := i.store.Pool().QueryRow(ctx, - `select held, firewall, reachable, adoption_reported from node where name = $1`, name). - Scan(&heldRaw, &firewall, &reachRaw, &at) + `select held, firewall, reachable, adoption_reported, strays from node where name = $1`, name). + Scan(&heldRaw, &firewall, &reachRaw, &at, &straysRaw) if errors.Is(err, pgx.ErrNoRows) { return Adoption{}, fmt.Errorf("%w: %s", ErrNoSuchNode, name) } @@ -237,6 +261,11 @@ func (i *Inventory) AdoptionOf(ctx context.Context, name string) (Adoption, erro return Adoption{}, err } } + if len(straysRaw) > 0 { + if err := json.Unmarshal(straysRaw, &out.Strays); err != nil { + return Adoption{}, err + } + } if len(reachRaw) > 0 { if err := json.Unmarshal(reachRaw, &out.Reachable); err != nil { return Adoption{}, err diff --git a/internal/inventory/migrations/0053-a-machine-says-what-strays-on-it.sql b/internal/inventory/migrations/0053-a-machine-says-what-strays-on-it.sql new file mode 100644 index 0000000..d0e6604 --- /dev/null +++ b/internal/inventory/migrations/0053-a-machine-says-what-strays-on-it.sql @@ -0,0 +1,3 @@ +-- What runs on a machine that the mesh neither wrote nor holds, as the host reports it with every +-- apply (novox/hq ADR 0163): a container left behind by a cutover is seen the day it is left. +alter table node add column strays jsonb; diff --git a/internal/inventory/secrets.go b/internal/inventory/secrets.go index e30f380..ec8121f 100644 --- a/internal/inventory/secrets.go +++ b/internal/inventory/secrets.go @@ -398,7 +398,7 @@ func (i *Inventory) AcceptSecretForModule(ctx context.Context, node, module, nam return err } if _, own := m.OwnSecrets[name]; !own { - return fmt.Errorf("%s does not declare %q as an own secret; %s", module, name, declaresOwn(m)) + return fmt.Errorf("%s does not declare %q as an own secret; %s — a secret it requires from a provider is accepted with `--provider [--local ]`, the value the running service already uses (novox/hq ADR 0163)", module, name, declaresOwn(m)) } key, err := i.SealingKeyOf(ctx, node) if err != nil { @@ -546,7 +546,7 @@ func (i *Inventory) RotateModuleSecret(ctx context.Context, node, module, name s } own, declared := m.OwnSecrets[name] if !declared { - return fmt.Errorf("%s does not declare %q as an own secret; %s", module, name, declaresOwn(m)) + return fmt.Errorf("%s does not declare %q as an own secret; %s — a secret it requires from a provider is accepted with `--provider [--local ]`, the value the running service already uses (novox/hq ADR 0163)", module, name, declaresOwn(m)) } switch own.Taken { case catalogue.TakenAtStart: diff --git a/internal/link/enrolment.go b/internal/link/enrolment.go index 3878dde..b560596 100644 --- a/internal/link/enrolment.go +++ b/internal/link/enrolment.go @@ -286,18 +286,22 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err // schedule, when what it holds changes, not only after an apply — and never cleared by a // report that carries none, which is every bare word that the node is there. An adopted node // always names its firewall, so a report from one replaces all three, emptied held included. - if len(report.Held) > 0 || report.Firewall != "" || len(report.Reachable) > 0 { + if len(report.Held) > 0 || report.Firewall != "" || len(report.Reachable) > 0 || len(report.Strays) > 0 { held := make([]inventory.Held, 0, len(report.Held)) for _, h := range report.Held { held = append(held, inventory.Held{ID: h.ID, Module: h.Module, Kind: h.Kind, - Target: h.Target, Since: h.Since, Changed: h.Changed, Kept: h.Kept}) + Target: h.Target, Since: h.Since, Changed: h.Changed, Kept: h.Kept, Facts: h.Facts}) + } + strays := make([]inventory.Stray, 0, len(report.Strays)) + for _, s := range report.Strays { + strays = append(strays, inventory.Stray{Kind: s.Kind, Name: s.Name, Detail: s.Detail}) } reachable := make([]inventory.Reach, 0, len(report.Reachable)) for _, r := range report.Reachable { reachable = append(reachable, inventory.Reach{Protocol: r.Protocol, Address: r.Address, Port: r.Port, By: r.By, Published: r.Published, ContainerPort: r.ContainerPort}) } - if err := e.Inventory.RecordAdoption(ctx, node.ID, held, report.Firewall, reachable); err != nil { + if err := e.Inventory.RecordAdoptionWithStrays(ctx, node.ID, held, report.Firewall, reachable, strays); err != nil { return false, err } } diff --git a/internal/link/protocol.go b/internal/link/protocol.go index 34240ba..5e63894 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -194,6 +194,9 @@ type Report struct { // not see coming. Host string `json:"host,omitempty"` + // Strays is what runs on the machine that the mesh neither wrote nor holds (ADR 0163). + Strays []Stray `json:"strays,omitempty"` + // Profile is what the machine can do, detected again by this apply (novox/hq ADR 0161): the // same shape enrolment sends, so a machine that gained or lost a capability — switched its // network manager — is known at its next push and not at its next enrolment. Absent from a host @@ -270,6 +273,16 @@ type Held struct { Changed string `json:"changed,omitempty"` // Kept is where a file's original was kept. Kept string `json:"kept,omitempty"` + // Facts is the found thing beside what the module declares — what a take compares (novox/hq + // ADR 0163): the host's own shape, carried as data and read by the preview. + Facts map[string]any `json:"facts,omitempty"` +} + +// A Stray is a container a machine runs that the mesh neither wrote nor holds (ADR 0163). +type Stray struct { + Kind string `json:"kind"` + Name string `json:"name"` + Detail string `json:"detail,omitempty"` } // Reach is one thing reachable on the machine: a listening socket, or a published container port.