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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user