Take a module, converge a node after a preview, and return it to adopted, from the command line and the API (hq ADR 0100)

This commit is contained in:
2026-09-22 17:27:35 +02:00
parent 28894fa5bd
commit 1ea84f8b8f
6 changed files with 683 additions and 39 deletions
+255
View File
@@ -0,0 +1,255 @@
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"reflect"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
"github.com/novox/mesh-controller/internal/overlay"
)
// novox/hq ADR 0100: taking a module is its cutover; converging a node is one act, previewed, and
// refused while a found container is held; returning to adopted keeps what was taken.
// anAdoptedAnchor is aMesh with the anchor adopted, running a served module the predecessor also
// runs and a module with only a file, and a filter module in the catalogue.
func anAdoptedAnchor(t *testing.T) (*stores, *[]string) {
t.Helper()
open := aMesh(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "hello-web", Version: "1",
Listens: []catalogue.Listening{{Port: 8080, From: catalogue.FromEverywhere}},
Resources: []map[string]any{
{"id": "page", "type": "file", "path": "/var/lib/hello-web/index.html", "content": "hi"},
{"id": "server", "type": "container", "name": "hello-web", "ports": []any{"8080:80"},
"image": "registry.example/hello@sha256:" + strings.Repeat("a", 64)},
}})
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
Resources: []map[string]any{
{"id": "conf", "type": "file", "path": "/etc/notes.conf", "content": "x"},
}})
register(t, open, catalogue.Manifest{Module: "nftables", Version: "1",
Filtering: &catalogue.Filtering{Into: "/etc/nftables.conf"},
Resources: []map[string]any{{"id": "load", "type": "service", "unit": "mesh-filter.service",
"state": "running", "restart-on": []any{"filtering"}}}})
if err := open.inventory.SetAdopted(ctx, "anchor", true); err != nil {
t.Fatal(err)
}
for _, m := range []string{"hello-web", "notes"} {
if _, err := assign(ctx, open, "anchor", m); err != nil {
t.Fatal(err)
}
}
sent := &[]string{}
saved := sendNodes
sendNodes = func(_ context.Context, _ *stores, names []string) error {
*sent = append(*sent, names...)
return nil
}
t.Cleanup(func() { sendNodes = saved })
return open, sent
}
// reportsHolding has the anchor report, on what it was last sent, holding what is given.
func reportsHolding(t *testing.T, open *stores, held ...link.Held) {
t.Helper()
ctx := t.Context()
body, err := composed(t, open, "anchor").Body()
if err != nil {
t.Fatal(err)
}
record, err := open.inventory.NodeByName(ctx, "anchor")
if err != nil {
t.Fatal(err)
}
if err := open.inventory.RecordSent(ctx, record.ID, digestOf(body)); err != nil {
t.Fatal(err)
}
if err := (link.Enrolment{Inventory: open.inventory}).Heard(ctx, link.Report{
Node: "anchor", Applied: []string{"hello-web.x"}, Declared: digestOf(body),
Firewall: "ufw", Held: held,
Reachable: []link.Reach{
{Protocol: "tcp", Address: "0.0.0.0", Port: 22, By: "sshd"},
{Protocol: "tcp", Address: "0.0.0.0", Port: 8080, By: "hello-web", Published: true,
ContainerPort: 80},
{Protocol: "tcp", Address: "0.0.0.0", Port: 5000, By: "predecessor-registry",
Published: true, ContainerPort: 5000},
{Protocol: "tcp", Address: "127.0.0.1", Port: 15672, By: "mesh-broker",
Published: true, ContainerPort: 15672},
},
}); err != nil {
t.Fatal(err)
}
}
var (
heldContainer = link.Held{ID: "hello-web.server", Module: "hello-web", Kind: "container",
Target: "hello-web", Since: time.Now()}
heldFile = link.Held{ID: "notes.conf", Module: "notes", Kind: "file",
Target: "/etc/notes.conf", Since: time.Now(), Kept: "/var/lib/mesh-host/kept/abc-notes.conf"}
)
func TestTakingAModuleNotOnTheNodeIsRefused(t *testing.T) {
open, _ := anAdoptedAnchor(t)
if _, err := take(t.Context(), open, "anchor", "nftables"); !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) {
t.Fatalf("taking on a converged node gave %v", err)
}
}
func TestConvergingIsRefusedOnAPreviewThatWouldBeStale(t *testing.T) {
open, sent := anAdoptedAnchor(t)
_, err := converge(t.Context(), open, "anchor", false, "")
if err == nil || !strings.Contains(err.Error(), "has not reported") {
t.Fatalf("a node that never reported was previewed: %v", err)
}
if len(*sent) != 0 {
t.Fatal("a refused converge sent something")
}
}
func TestTheFlipIsRefusedWhileAFoundContainerIsHeld(t *testing.T) {
open, sent := anAdoptedAnchor(t)
reportsHolding(t, open, heldContainer, heldFile)
_, err := converge(t.Context(), open, "anchor", true, "")
if err == nil || !strings.Contains(err.Error(), "take anchor hello-web once its data has moved") {
t.Fatalf("the flip was not refused while hello-web holds its found container: %v", err)
}
if n, _ := open.inventory.NodeByName(t.Context(), "anchor"); !n.Adopted || len(*sent) != 0 {
t.Fatal("a refused flip changed something")
}
}
func TestTakingNamesWhatItReplaces(t *testing.T) {
open, _ := anAdoptedAnchor(t)
reportsHolding(t, open, heldContainer, heldFile)
said, err := take(t.Context(), open, "anchor", "hello-web")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(said, "container hello-web (hello-web.server)") ||
!strings.Contains(said, "push anchor") {
t.Fatalf("taking did not say what it replaces and what to run:\n%s", said)
}
}
func TestConvergingPreviewsThenChangesAndAdoptingKeepsWhatWasTaken(t *testing.T) {
open, sent := anAdoptedAnchor(t)
ctx := t.Context()
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
t.Fatal(err)
}
reportsHolding(t, open, heldFile)
preview, err := converge(ctx, open, "anchor", false, "")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"tcp/8080 hello-web (published, container port 80)",
"declared by hello-web (from anywhere)",
"WILL CLOSE — no module assigned here declares it",
"ssh is never closed",
"notes\n replacing the found file /etc/notes.conf (original kept at",
"assigns nftables",
"the found firewall (ufw) is disabled, never flushed",
} {
if !strings.Contains(preview, want) {
t.Errorf("the preview does not say %q:\n%s", want, preview)
}
}
if strings.Contains(preview, "15672") {
t.Errorf("a loopback listener is in the preview:\n%s", preview)
}
for _, line := range strings.Split(preview, "\n") {
if strings.Contains(line, "5000") && !strings.Contains(line, "WILL CLOSE") {
t.Errorf("an undeclared published port is not said to close: %s", line)
}
}
if n, _ := open.inventory.NodeByName(ctx, "anchor"); !n.Adopted || len(*sent) != 0 {
t.Fatal("the preview changed something")
}
if _, err := converge(ctx, open, "anchor", true, ""); err != nil {
t.Fatal(err)
}
n, _ := open.inventory.NodeByName(ctx, "anchor")
if n.Adopted {
t.Fatal("converge --yes left the node adopted")
}
taken, _ := open.inventory.Taken(ctx, "anchor")
if !reflect.DeepEqual(taken, []string{"hello-web", overlay.Name, "nftables", "notes"}) {
t.Fatalf("the flip took %v", taken)
}
if !reflect.DeepEqual(*sent, []string{"anchor"}) {
t.Fatalf("the flip sent %v", *sent)
}
declared := composed(t, open, "anchor")
if declared.Adoption != nil {
t.Fatal("a converged node is still sent an adoption envelope")
}
if !hasID(declared.Resources, "nftables.filtering") {
t.Fatal("the converged node is not declared the mesh's filter")
}
if _, err := adopt(ctx, open, "anchor"); err != nil {
t.Fatal(err)
}
if _, err := adopt(ctx, open, "anchor"); err == nil {
t.Fatal("adopting an adopted node was not refused")
}
again, _ := open.inventory.Taken(ctx, "anchor")
if !reflect.DeepEqual(again, taken) {
t.Fatalf("returning to adopted lost what was taken: %v", again)
}
declared = composed(t, open, "anchor")
if declared.Adoption == nil || len(declared.Adoption.Untaken) != 0 {
t.Fatalf("returned to adopted, the envelope is %+v", declared.Adoption)
}
if hasID(declared.Resources, "nftables.filtering") || hasID(declared.Resources, "nftables.load") {
t.Fatal("returned to adopted, the mesh's filter is still declared")
}
}
func hasID(resources []map[string]any, id string) bool {
for _, r := range resources {
if r["id"] == id {
return true
}
}
return false
}
// The command API refuses a flip exactly as the command line does, in the same words.
func TestTheApiRefusesTheFlipInTheCommandLinesWords(t *testing.T) {
open, _ := anAdoptedAnchor(t)
reportsHolding(t, open, heldContainer)
_, direct := converge(t.Context(), open, "anchor", true, "")
if direct == nil {
t.Fatal("the flip was not refused")
}
got := asking(t, letIn{}, "POST", "/converge", `{"node":"anchor","yes":true}`)
if got.Code != http.StatusConflict {
t.Fatalf("got %d: %s", got.Code, got.Body.String())
}
var said map[string]any
if err := json.Unmarshal(got.Body.Bytes(), &said); err != nil {
t.Fatal(err)
}
if said["refused"] != direct.Error() {
t.Fatalf("the API said %q and the command line %q", said["refused"], direct.Error())
}
if got := asking(t, letIn{}, "POST", "/take", `{"node":"anchor"}`); got.Code != http.StatusBadRequest {
t.Fatalf("a take naming no module got %d", got.Code)
}
}