Files
mesh-controller/internal/inventory/adoption_test.go
T
jschoubben 50734095b8 A repeat assignment says nothing changed (ADR 0115)
One assignment of a module per node is now the rule, not a limitation —
the operator dropped the multi-assignment requirement, and the schema's
(node, module) key has been the decision since migration 0005. What
changed: Assign reports whether the assignment was new, and the command
says 'already runs — one node runs one of each (ADR 0115); nothing
changed' instead of printing 'is assigned' for a no-op, which read as
an action that happened. Idempotence stays: a repeat is exit 0, because
a script stating what is already true is not wrong.
2026-09-26 19:02:35 +02:00

162 lines
4.7 KiB
Go

package inventory
import (
"errors"
"reflect"
"testing"
)
// novox/hq ADR 0100: a node is adopted or converged, and the controller records which.
func TestANodeAddedWithoutSayingIsConverged(t *testing.T) {
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "anchor"); err != nil {
t.Fatal(err)
}
n, err := inv.NodeByName(t.Context(), "anchor")
if err != nil {
t.Fatal(err)
}
if n.Adopted || !n.AdoptedSince.IsZero() {
t.Fatalf("a node nobody said anything about reads as adopted: %+v", n)
}
}
func TestAnAdoptedNodeRoundTripsThroughEveryReading(t *testing.T) {
inv := fresh(t)
made, err := inv.AddNodeAs(t.Context(), "anchor", true)
if err != nil {
t.Fatal(err)
}
if !made.Adopted || made.AdoptedSince.IsZero() {
t.Fatalf("added adopted, got %+v", made)
}
byName, err := inv.NodeByName(t.Context(), "anchor")
if err != nil {
t.Fatal(err)
}
all, err := inv.Nodes(t.Context())
if err != nil {
t.Fatal(err)
}
if !byName.Adopted || len(all) != 1 || !all[0].Adopted {
t.Fatalf("adoption did not survive reading back: %+v %+v", byName, all)
}
// And through a token: enrolment reads the node from the token it spends.
issued, err := inv.IssueToken(t.Context(), "anchor", 60e9)
if err != nil {
t.Fatal(err)
}
claimed, err := inv.Claim(t.Context(), issued.Secret, "a-key", false)
if err != nil {
t.Fatal(err)
}
if !claimed.Adopted {
t.Fatal("the node a token claims lost its mode")
}
}
func TestTakingIsRefusedOnAConvergedNodeAndForAnUnassignedModule(t *testing.T) {
inv := fresh(t)
if err := inv.RegisterModule(t.Context(), manifest("hello-web", nil, nil), Source{}); err != nil {
t.Fatal(err)
}
if _, err := inv.AddNode(t.Context(), "converged"); err != nil {
t.Fatal(err)
}
if _, err := inv.Assign(t.Context(), "converged", "hello-web"); err != nil {
t.Fatal(err)
}
if err := inv.Take(t.Context(), "converged", "hello-web"); !errors.Is(err, ErrNotAdopted) {
t.Fatalf("taking on a converged node gave %v", err)
}
if _, err := inv.AddNodeAs(t.Context(), "anchor", true); err != nil {
t.Fatal(err)
}
if err := inv.Take(t.Context(), "anchor", "hello-web"); !errors.Is(err, ErrNotAssigned) {
t.Fatalf("taking an unassigned module gave %v", err)
}
}
func TestATakenModuleOutlivesItsAssignmentAndReturningToAdopted(t *testing.T) {
inv := fresh(t)
for _, m := range []string{"hello-web", "postgres"} {
if err := inv.RegisterModule(t.Context(), manifest(m, nil, nil), Source{}); err != nil {
t.Fatal(err)
}
}
if _, err := inv.AddNodeAs(t.Context(), "anchor", true); err != nil {
t.Fatal(err)
}
for _, m := range []string{"hello-web", "postgres"} {
if _, err := inv.Assign(t.Context(), "anchor", m); err != nil {
t.Fatal(err)
}
}
if err := inv.Take(t.Context(), "anchor", "postgres"); err != nil {
t.Fatal(err)
}
if err := inv.Take(t.Context(), "anchor", "postgres"); err != nil {
t.Fatalf("taking twice is not an error: %v", err)
}
if err := inv.Unassign(t.Context(), "anchor", "postgres"); err != nil {
t.Fatal(err)
}
taken, err := inv.Taken(t.Context(), "anchor")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(taken, []string{"postgres"}) {
t.Fatalf("unassigning un-took it: %v", taken)
}
took, err := inv.Converge(t.Context(), "anchor")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(took, []string{"hello-web"}) {
t.Fatalf("converging took %v; it takes every assigned module not yet taken", took)
}
n, _ := inv.NodeByName(t.Context(), "anchor")
if n.Adopted {
t.Fatal("converged node still reads adopted")
}
if err := inv.SetAdopted(t.Context(), "anchor", true); err != nil {
t.Fatal(err)
}
taken, _ = inv.Taken(t.Context(), "anchor")
if !reflect.DeepEqual(taken, []string{"hello-web", "postgres"}) {
t.Fatalf("returning to adopted lost what was taken: %v", taken)
}
}
// Converging clears the whole of a node's account of itself: what it held, what was reachable, the
// firewall it found and when it said so. Keeping any of it would have `node show` report an
// adopted machine's account of a converged one.
func TestConvergingClearsTheAccountTheNodeGave(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
made, err := inv.AddNodeAs(ctx, "anchor", true)
if err != nil {
t.Fatal(err)
}
if err := inv.RecordAdoption(ctx, made.ID,
[]Held{{ID: "notes.conf", Module: "notes", Kind: "file", Target: "/etc/notes.conf"}},
"ufw", []Reach{{Protocol: "tcp", Address: "0.0.0.0", Port: 22, By: "sshd"}}); err != nil {
t.Fatal(err)
}
if _, err := inv.Converge(ctx, "anchor"); err != nil {
t.Fatal(err)
}
said, err := inv.AdoptionOf(ctx, "anchor")
if err != nil {
t.Fatal(err)
}
if len(said.Held) != 0 || len(said.Reachable) != 0 || said.Firewall != "" || !said.At.IsZero() {
t.Fatalf("converging kept the adopted machine's account: %+v", said)
}
}