Merge pull request 'A take is a comparison: the preview, its refusals, the strays, and the policy said at build (hq ADR 0163)' (#201) from feat/a-take-is-a-comparison into main
This commit was merged in pull request #201.
This commit is contained in:
@@ -109,10 +109,10 @@ var (
|
|||||||
|
|
||||||
func TestTakingAModuleNotOnTheNodeIsRefused(t *testing.T) {
|
func TestTakingAModuleNotOnTheNodeIsRefused(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(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)
|
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)
|
t.Fatalf("taking on a converged node gave %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ func TestTheFlipIsRefusedWhileAFoundContainerIsHeld(t *testing.T) {
|
|||||||
func TestTakingNamesWhatItReplaces(t *testing.T) {
|
func TestTakingNamesWhatItReplaces(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
reportsHolding(t, open, heldContainer, heldFile)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -156,7 +156,7 @@ func TestTakingNamesWhatItReplaces(t *testing.T) {
|
|||||||
func TestConvergingPreviewsThenChangesAndAdoptingKeepsWhatWasTaken(t *testing.T) {
|
func TestConvergingPreviewsThenChangesAndAdoptingKeepsWhatWasTaken(t *testing.T) {
|
||||||
open, sent := anAdoptedAnchor(t)
|
open, sent := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reportsHolding(t, open, heldFile)
|
reportsHolding(t, open, heldFile)
|
||||||
@@ -330,7 +330,7 @@ func digestIn(t *testing.T, preview string) string {
|
|||||||
func TestTheFlipActsOnlyOnThePreviewTheOperatorSaw(t *testing.T) {
|
func TestTheFlipActsOnlyOnThePreviewTheOperatorSaw(t *testing.T) {
|
||||||
open, sent := anAdoptedAnchor(t)
|
open, sent := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reportsHolding(t, open, heldFile)
|
reportsHolding(t, open, heldFile)
|
||||||
@@ -396,7 +396,7 @@ func TestTheFlipActsOnlyOnThePreviewTheOperatorSaw(t *testing.T) {
|
|||||||
func TestTheFlipHoldsTheNodeWhileItSends(t *testing.T) {
|
func TestTheFlipHoldsTheNodeWhileItSends(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reportsHolding(t, open, heldFile)
|
reportsHolding(t, open, heldFile)
|
||||||
@@ -441,7 +441,7 @@ func TestTheFlipHoldsTheNodeWhileItSends(t *testing.T) {
|
|||||||
func TestThePreviewNamesEveryHeldKind(t *testing.T) {
|
func TestThePreviewNamesEveryHeldKind(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
since := time.Now()
|
since := time.Now()
|
||||||
@@ -484,7 +484,7 @@ func TestThePreviewNamesEveryHeldKind(t *testing.T) {
|
|||||||
func TestTheFlipIsRefusedOnAnAccountNamingNothingReachable(t *testing.T) {
|
func TestTheFlipIsRefusedOnAnAccountNamingNothingReachable(t *testing.T) {
|
||||||
open, sent := anAdoptedAnchor(t)
|
open, sent := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Only a loopback listener: nothing off the machine, which is the same silence.
|
// 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) {
|
func TestAssigningWaitsForWhateverIsConvergingTheNode(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reportsHolding(t, open, heldFile)
|
reportsHolding(t, open, heldFile)
|
||||||
|
|||||||
+193
-15
@@ -62,6 +62,15 @@ func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node
|
|||||||
if h.Kept != "" {
|
if h.Kept != "" {
|
||||||
fmt.Printf(" %-17s original kept at %s\n", "", 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))
|
fmt.Printf(" as of %s\n", said.At.Local().Format(time.DateTime))
|
||||||
return nil
|
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
|
// 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
|
// has moved. From the next push its resources converge there like any other, replacing what the
|
||||||
// node found and holds for it.
|
// 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
|
inv := open.inventory
|
||||||
assigned, err := inv.Assigned(ctx, node)
|
assigned, err := inv.Assigned(ctx, node)
|
||||||
if err != nil {
|
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 {
|
// The comparison first (novox/hq ADR 0163): every held thing the module would replace, beside
|
||||||
return "", err
|
// what the module declares, and the differences that refuse unless named.
|
||||||
}
|
|
||||||
said := fmt.Sprintf("%s is taken on %s", module, node)
|
|
||||||
reported, err := inv.AdoptionOf(ctx, node)
|
reported, err := inv.AdoptionOf(ctx, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
var replaces []string
|
preview, refusals := comparisonOf(reported.Held, module, opts)
|
||||||
for _, h := range reported.Held {
|
if len(refusals) > 0 {
|
||||||
if h.Module == module {
|
return "", fmt.Errorf("taking %s on %s is refused:\n %s\n%s", module, node,
|
||||||
replaces = append(replaces, " "+heldLine(h))
|
strings.Join(refusals, "\n "), preview)
|
||||||
}
|
}
|
||||||
|
if !opts.Yes {
|
||||||
|
return preview + fmt.Sprintf("\nnothing taken; `take %s %s --yes` cuts it over as previewed", node, module), nil
|
||||||
}
|
}
|
||||||
if len(replaces) > 0 {
|
if err := inv.Take(ctx, node, module); err != nil {
|
||||||
said += "; the next push replaces what the node found and holds for it:\n" +
|
return "", err
|
||||||
strings.Join(replaces, "\n")
|
}
|
||||||
|
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
|
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
|
// 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.
|
// variable so a test can age a report without waiting.
|
||||||
var reportFreshFor = 15 * time.Minute
|
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.
|
// takeCommand, convergeCommand and adoptCommand are the command line's adapters to the acts above.
|
||||||
func takeCommand(ctx context.Context, args []string) error {
|
func takeCommand(ctx context.Context, args []string) error {
|
||||||
if len(args) != 2 {
|
set := flag.NewFlagSet("take", flag.ContinueOnError)
|
||||||
return errors.New("take <node> <module>")
|
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 <node> <module> [--yes] [--downgrade] [--replace <path>]...")
|
||||||
|
}
|
||||||
|
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 {
|
func convergeCommand(ctx context.Context, args []string) error {
|
||||||
set := flag.NewFlagSet("converge", flag.ContinueOnError)
|
set := flag.NewFlagSet("converge", flag.ContinueOnError)
|
||||||
yes := set.String("yes", "", "do it, naming the digest the preview printed; without it, only "+
|
yes := set.String("yes", "", "do it, naming the digest the preview printed; without it, only "+
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func commands(who Authenticator) http.Handler {
|
|||||||
}))
|
}))
|
||||||
// Adoption (novox/hq ADR 0100): the same acts as `take`, `converge` and `adopt`.
|
// 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) {
|
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) {
|
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)
|
return converge(ctx, open, in.Node, in.Yes, in.Digest, in.Filter)
|
||||||
|
|||||||
@@ -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",
|
fmt.Printf("\n%s %s, built on %s from %s\n",
|
||||||
manifest.Module, manifest.Version, result.On, short(result.Commit))
|
manifest.Module, manifest.Version, result.On, short(result.Commit))
|
||||||
|
saysWhenThePolicyActs(ctx, open.inventory, manifest.Module)
|
||||||
fmt.Printf(" run `assign <node> %s` to put it somewhere\n", manifest.Module)
|
fmt.Printf(" run `assign <node> %s` to put it somewhere\n", manifest.Module)
|
||||||
return nil
|
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
|
// 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
|
// 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.
|
// result reaches the catalogue whether or not the asker was still listening.
|
||||||
|
|||||||
@@ -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",
|
fmt.Printf("%s: %s %s registered, built on %s from %s\n",
|
||||||
result.ID, manifest.Module, manifest.Version, result.On, short(result.Commit))
|
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, "")
|
planBuilt(ctx, b.inv, manifest.Module, result.Commit, "")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,6 +164,18 @@ type Held struct {
|
|||||||
Since time.Time `json:"since"`
|
Since time.Time `json:"since"`
|
||||||
Changed string `json:"changed,omitempty"`
|
Changed string `json:"changed,omitempty"`
|
||||||
Kept string `json:"kept,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.
|
// 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
|
Held []Held
|
||||||
Firewall string
|
Firewall string
|
||||||
Reachable []Reach
|
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 is when it said so; zero when it never has.
|
||||||
At time.Time
|
At time.Time
|
||||||
}
|
}
|
||||||
@@ -189,6 +203,16 @@ type Adoption struct {
|
|||||||
// question is the machine as it is now.
|
// question is the machine as it is now.
|
||||||
func (i *Inventory) RecordAdoption(ctx context.Context, node string, held []Held, firewall string,
|
func (i *Inventory) RecordAdoption(ctx context.Context, node string, held []Held, firewall string,
|
||||||
reachable []Reach) error {
|
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))
|
heldRaw, err := json.Marshal(nonNil(held))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -198,9 +222,9 @@ func (i *Inventory) RecordAdoption(ctx context.Context, node string, held []Held
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = i.store.Pool().Exec(ctx,
|
_, 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()
|
adoption_reported = now(), last_seen = now()
|
||||||
where id = $1`, node, heldRaw, firewall, reachRaw)
|
where id = $1`, node, heldRaw, firewall, reachRaw, straysRaw)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,12 +237,12 @@ func nonNil[T any](s []T) []T {
|
|||||||
|
|
||||||
// AdoptionOf is what a node last reported about adoption.
|
// AdoptionOf is what a node last reported about adoption.
|
||||||
func (i *Inventory) AdoptionOf(ctx context.Context, name string) (Adoption, error) {
|
func (i *Inventory) AdoptionOf(ctx context.Context, name string) (Adoption, error) {
|
||||||
var heldRaw, reachRaw []byte
|
var heldRaw, reachRaw, straysRaw []byte
|
||||||
var firewall *string
|
var firewall *string
|
||||||
var at *time.Time
|
var at *time.Time
|
||||||
err := i.store.Pool().QueryRow(ctx,
|
err := i.store.Pool().QueryRow(ctx,
|
||||||
`select held, firewall, reachable, adoption_reported from node where name = $1`, name).
|
`select held, firewall, reachable, adoption_reported, strays from node where name = $1`, name).
|
||||||
Scan(&heldRaw, &firewall, &reachRaw, &at)
|
Scan(&heldRaw, &firewall, &reachRaw, &at, &straysRaw)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Adoption{}, fmt.Errorf("%w: %s", ErrNoSuchNode, name)
|
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
|
return Adoption{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(straysRaw) > 0 {
|
||||||
|
if err := json.Unmarshal(straysRaw, &out.Strays); err != nil {
|
||||||
|
return Adoption{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(reachRaw) > 0 {
|
if len(reachRaw) > 0 {
|
||||||
if err := json.Unmarshal(reachRaw, &out.Reachable); err != nil {
|
if err := json.Unmarshal(reachRaw, &out.Reachable); err != nil {
|
||||||
return Adoption{}, err
|
return Adoption{}, err
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -398,7 +398,7 @@ func (i *Inventory) AcceptSecretForModule(ctx context.Context, node, module, nam
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, own := m.OwnSecrets[name]; !own {
|
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 <node> [--local <name>]`, the value the running service already uses (novox/hq ADR 0163)", module, name, declaresOwn(m))
|
||||||
}
|
}
|
||||||
key, err := i.SealingKeyOf(ctx, node)
|
key, err := i.SealingKeyOf(ctx, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -546,7 +546,7 @@ func (i *Inventory) RotateModuleSecret(ctx context.Context, node, module, name s
|
|||||||
}
|
}
|
||||||
own, declared := m.OwnSecrets[name]
|
own, declared := m.OwnSecrets[name]
|
||||||
if !declared {
|
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 <node> [--local <name>]`, the value the running service already uses (novox/hq ADR 0163)", module, name, declaresOwn(m))
|
||||||
}
|
}
|
||||||
switch own.Taken {
|
switch own.Taken {
|
||||||
case catalogue.TakenAtStart:
|
case catalogue.TakenAtStart:
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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.
|
// 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))
|
held := make([]inventory.Held, 0, len(report.Held))
|
||||||
for _, h := range report.Held {
|
for _, h := range report.Held {
|
||||||
held = append(held, inventory.Held{ID: h.ID, Module: h.Module, Kind: h.Kind,
|
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))
|
reachable := make([]inventory.Reach, 0, len(report.Reachable))
|
||||||
for _, r := range report.Reachable {
|
for _, r := range report.Reachable {
|
||||||
reachable = append(reachable, inventory.Reach{Protocol: r.Protocol, Address: r.Address,
|
reachable = append(reachable, inventory.Reach{Protocol: r.Protocol, Address: r.Address,
|
||||||
Port: r.Port, By: r.By, Published: r.Published, ContainerPort: r.ContainerPort})
|
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
|
return false, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ type Report struct {
|
|||||||
// not see coming.
|
// not see coming.
|
||||||
Host string `json:"host,omitempty"`
|
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
|
// 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
|
// 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
|
// 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"`
|
Changed string `json:"changed,omitempty"`
|
||||||
// Kept is where a file's original was kept.
|
// Kept is where a file's original was kept.
|
||||||
Kept string `json:"kept,omitempty"`
|
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.
|
// Reach is one thing reachable on the machine: a listening socket, or a published container port.
|
||||||
|
|||||||
Reference in New Issue
Block a user