The host refuses an empty body unless told the emptiness is meant (mesh-host#29). When a node's declaration composes to no resources — which #77 now sends rather than skips — Body() sets owns_nothing, so the node applies it and drops what it last held. A declaration with resources never carries the marker. One test.
380 lines
13 KiB
Go
380 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/overlay"
|
|
)
|
|
|
|
// novox/hq ADR 0100: every declaration an adopted node is sent says it is adopted and which modules
|
|
// were taken on it; a converged node's declaration is byte for byte what it was.
|
|
|
|
// helloWeb is a module the predecessor also runs: a page and a server under names it uses.
|
|
func helloWeb() catalogue.Manifest {
|
|
return catalogue.Manifest{Module: "hello-web", Version: "1",
|
|
Resources: []map[string]any{
|
|
{"id": "page", "type": "file", "path": "/var/lib/hello-web/index.html", "content": "hello"},
|
|
{"id": "server", "type": "container", "name": "hello-web",
|
|
"image": "registry.example/hello@sha256:" + strings.Repeat("a", 64)},
|
|
{"id": "served", "type": "directory", "path": "/var/lib/hello-web"},
|
|
}}
|
|
}
|
|
|
|
// composed is what node would be sent now, as push composes it.
|
|
func composed(t *testing.T, open *stores, node string) sendable {
|
|
t.Helper()
|
|
plan, settings, err := planFor(t.Context(), open, node)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
declared, err := declarationFor(t.Context(), open, node, plan, settings)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return declared
|
|
}
|
|
|
|
// convergedBefore is the envelope a converged node was sent before adoption existed, captured by
|
|
// running this same composition at the commit this branch left main (0a39b7d). The mesh here is
|
|
// aMesh's laptop with the private network taken off it, so nothing in the declaration is random:
|
|
// what changes this string is a change to what a converged machine is sent, which is the thing an
|
|
// older host would refuse.
|
|
const convergedBefore = `{"declaration":1,"resources":[{"content":"hello","id":"hello-web.page","path":"/var/lib/hello-web/index.html","type":"file"},{"hosts":["anchor.internal:10.77.0.1"],"id":"hello-web.server","image":"registry.example/hello@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","name":"hello-web","type":"container"},{"id":"hello-web.served","path":"/var/lib/hello-web","type":"directory"}]}`
|
|
|
|
func TestAConvergedDeclarationIsByteForByteWhatItWas(t *testing.T) {
|
|
open := aMesh(t)
|
|
ctx := t.Context()
|
|
register(t, open, helloWeb())
|
|
if _, err := unassign(ctx, open, "laptop", overlay.Name); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := assign(ctx, open, "laptop", "hello-web"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
declared := composed(t, open, "laptop")
|
|
if declared.Adoption != nil {
|
|
t.Fatal("a converged node was given an adoption envelope")
|
|
}
|
|
body, err := declared.Body()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(body) != convergedBefore {
|
|
t.Fatalf("a converged declaration changed; an older host parses this strictly:\n%s\n%s",
|
|
body, convergedBefore)
|
|
}
|
|
if bytes.Contains(body, []byte(`"adoption"`)) {
|
|
t.Fatal("a converged declaration names adoption; an older host would refuse it")
|
|
}
|
|
}
|
|
|
|
func TestAnAdoptedDeclarationCarriesItsModeAndWhatWasTaken(t *testing.T) {
|
|
open := aMesh(t)
|
|
ctx := t.Context()
|
|
register(t, open, helloWeb())
|
|
if err := open.inventory.SetAdopted(ctx, "anchor", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := assign(ctx, open, "anchor", "hello-web"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
declared := composed(t, open, "anchor")
|
|
if declared.Adoption == nil {
|
|
t.Fatal("an adopted node's declaration does not say it is adopted")
|
|
}
|
|
untaken := declared.Adoption.Untaken["hello-web"]
|
|
// Every kind — its directory too (novox/hq ADR 0103).
|
|
if !reflect.DeepEqual(untaken, []string{"hello-web.page", "hello-web.server",
|
|
"hello-web.served"}) {
|
|
t.Fatalf("hello-web's resources are not all named untaken: %v", declared.Adoption)
|
|
}
|
|
if len(declared.Adoption.Taken) != 0 {
|
|
t.Fatalf("nothing was taken, and the declaration says %v", declared.Adoption.Taken)
|
|
}
|
|
|
|
body, err := declared.Body()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var envelope map[string]any
|
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
adoption, _ := envelope["adoption"].(map[string]any)
|
|
if _, ok := adoption["taken"].([]any); !ok {
|
|
t.Fatalf("taken is not a list on the wire, even empty: %s", body)
|
|
}
|
|
|
|
// The digest the mesh compares is the digest of what is sent: status and push agree.
|
|
would, err := wouldSend(ctx, open, mustNodes(t, open))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if would["anchor"] != digestOf(body) {
|
|
t.Fatal("the digest the mesh compares is not of the declaration push sends")
|
|
}
|
|
|
|
// plan --json prints that same envelope.
|
|
printed := stdoutOf(t, func() error { return planCommand(ctx, []string{"anchor", "--json"}) })
|
|
var compact bytes.Buffer
|
|
if err := json.Compact(&compact, []byte(printed)); err != nil {
|
|
t.Fatalf("plan --json is not JSON: %v\n%s", err, printed)
|
|
}
|
|
if digestOf(compact.Bytes()) != digestOf(body) {
|
|
t.Fatalf("plan --json shows something other than what push sends:\n%s", printed)
|
|
}
|
|
|
|
// Taking the module moves its resources out of untaken.
|
|
if err := open.inventory.Take(ctx, "anchor", "hello-web"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
declared = composed(t, open, "anchor")
|
|
if _, still := declared.Adoption.Untaken["hello-web"]; still {
|
|
t.Fatalf("a taken module is still untaken: %v", declared.Adoption)
|
|
}
|
|
if !reflect.DeepEqual(declared.Adoption.Taken, []string{"hello-web"}) {
|
|
t.Fatalf("taken is %v", declared.Adoption.Taken)
|
|
}
|
|
}
|
|
|
|
func mustNodes(t *testing.T, open *stores) []inventory.Node {
|
|
t.Helper()
|
|
nodes, err := open.inventory.Nodes(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return nodes
|
|
}
|
|
|
|
// stdoutOf is what run printed.
|
|
func stdoutOf(t *testing.T, run func() error) string {
|
|
t.Helper()
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saved := os.Stdout
|
|
os.Stdout = w
|
|
done := make(chan string)
|
|
go func() {
|
|
all, _ := io.ReadAll(r)
|
|
done <- string(all)
|
|
}()
|
|
runErr := run()
|
|
os.Stdout = saved
|
|
w.Close()
|
|
out := <-done
|
|
if runErr != nil {
|
|
t.Fatalf("%v\n%s", runErr, out)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// novox/hq ADR 0100: a port a node was given for the store is where its consumers on other
|
|
// machines are told to reach it, and a port given for the whole mesh is refused.
|
|
func TestConsumersAreToldTheGivenPort(t *testing.T) {
|
|
open := aMesh(t)
|
|
ctx := t.Context()
|
|
register(t, open, catalogue.Manifest{Module: "store", Version: "1",
|
|
Provides: []catalogue.Offer{{Name: "database", Scope: catalogue.ScopeMesh}},
|
|
Listens: []catalogue.Listening{{Port: 5432, From: catalogue.FromMesh}},
|
|
Guards: []int{5432},
|
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store",
|
|
"ports": []any{"5432:5432"},
|
|
"image": "registry.example/pg@sha256:" + strings.Repeat("b", 64)}}})
|
|
register(t, open, catalogue.Manifest{Module: "app", Version: "1", Requires: []string{"database"}})
|
|
if _, err := assign(ctx, open, "anchor", "store"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := assign(ctx, open, "laptop", "app"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := open.inventory.SetSettings(ctx, "anchor", "store",
|
|
map[string]any{catalogue.PortsSetting: map[string]any{"5432": 5433}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
plan, _, err := planFor(ctx, open, "laptop")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var told any
|
|
for _, n := range plan.Needs {
|
|
if n.Name == "database" {
|
|
told = n.Serves["port"]
|
|
}
|
|
}
|
|
if told != 5433 {
|
|
t.Fatalf("the consumer is told the store is on %v", told)
|
|
}
|
|
for _, r := range composed(t, open, "anchor").Resources {
|
|
if r["id"] == "store.server" && !reflect.DeepEqual(r["ports"], []any{"5433:5432"}) {
|
|
t.Fatalf("the store publishes %v", r["ports"])
|
|
}
|
|
}
|
|
|
|
// A port for the whole mesh is refused where it is set, not stored to refuse every node's
|
|
// declaration afterwards.
|
|
if err := open.inventory.SetSettings(ctx, "", "store",
|
|
map[string]any{catalogue.PortsSetting: map[string]any{"5432": 5434}}); err == nil ||
|
|
!strings.Contains(err.Error(), "per node") {
|
|
t.Fatalf("a port given for the whole mesh was not refused: %v", err)
|
|
}
|
|
plan, settings, err := planFor(ctx, open, "anchor")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := declarationFor(ctx, open, "anchor", plan, settings); err != nil {
|
|
t.Fatalf("the refused mesh-wide layer was stored anyway: %v", err)
|
|
}
|
|
}
|
|
|
|
// novox/hq ADR 0103: the guard an adopted node is sent follows what was taken there. A store
|
|
// assigned but not taken is not guarded — its port may still be the predecessor's — and taking it
|
|
// guards it from the next declaration.
|
|
func TestTheGuardIsSentForTakenModulesOnly(t *testing.T) {
|
|
open := aMesh(t)
|
|
ctx := t.Context()
|
|
register(t, open, catalogue.Manifest{Module: "store", Version: "1",
|
|
Listens: []catalogue.Listening{{Port: 5432, From: catalogue.FromMesh}},
|
|
Guards: []int{5432},
|
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store",
|
|
"ports": []any{"5432:5432"},
|
|
"image": "registry.example/pg@sha256:" + strings.Repeat("b", 64)}}})
|
|
if err := open.inventory.SetAdopted(ctx, "anchor", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := assign(ctx, open, "anchor", "store"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hasID(composed(t, open, "anchor").Resources, catalogue.GuardID()) {
|
|
t.Fatal("an untaken store is guarded")
|
|
}
|
|
if err := open.inventory.Take(ctx, "anchor", "store"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, r := range composed(t, open, "anchor").Resources {
|
|
if r["id"] == catalogue.GuardID() {
|
|
if r["content"] != catalogue.AsGuard([]int{5432}) {
|
|
t.Fatalf("the taken store's guard is:\n%s", r["content"])
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatal("a taken store is not guarded")
|
|
}
|
|
|
|
// novox/hq ADR 0038 and 0100: a module may publish a port the long way — `2222:22`, because the
|
|
// machine's own ssh daemon holds 22 — and say it listens on the machine side of that mapping,
|
|
// which is the number anything reaching it dials. A node moves that port by naming it, and the
|
|
// number has to reach everything derived from it at once: what the runtime is handed, what an
|
|
// adopted node is told to open, what its guard refuses, and what a consumer elsewhere dials.
|
|
//
|
|
// It reached none of them. The setting was refused outright for naming the machine side — so a
|
|
// module's port could not be put back where the machine it replaces had it, and, worse, the
|
|
// node's every push failed for as long as the setting existed.
|
|
func TestTheMachineSideOfAMappingIsMovedEverywhereTheNumberIsUsed(t *testing.T) {
|
|
open := aMesh(t)
|
|
ctx := t.Context()
|
|
register(t, open, catalogue.Manifest{Module: "forge", Version: "1",
|
|
Provides: []catalogue.Offer{{Name: "git-over-ssh", Scope: catalogue.ScopeMesh}},
|
|
Serves: map[string]map[string]any{"git-over-ssh": {"port": 2222}},
|
|
Listens: []catalogue.Listening{{Port: 2222, From: catalogue.FromMesh}},
|
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "forge",
|
|
"ports": []any{"2222:22"},
|
|
"image": "registry.example/forge@sha256:" + strings.Repeat("c", 64)}}})
|
|
register(t, open, catalogue.Manifest{Module: "app", Version: "1",
|
|
Requires: []string{"git-over-ssh"}})
|
|
if err := open.inventory.SetAdopted(ctx, "anchor", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := assign(ctx, open, "anchor", "forge"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := assign(ctx, open, "laptop", "app"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := open.inventory.Take(ctx, "anchor", "forge"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := open.inventory.SetSettings(ctx, "anchor", "forge",
|
|
map[string]any{catalogue.PortsSetting: map[string]any{"2222": 222}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
resources := composed(t, open, "anchor").Resources
|
|
var container, opening map[string]any
|
|
for _, r := range resources {
|
|
switch r["id"] {
|
|
case "forge.server":
|
|
container = r
|
|
case catalogue.OpeningID("tcp", 222, catalogue.PathForwarded):
|
|
opening = r
|
|
}
|
|
if id, _ := r["id"].(string); strings.HasPrefix(id, "adoption.opening-tcp-2222-") {
|
|
t.Errorf("the adopted node is told to open the port the forge was moved off: %s", id)
|
|
}
|
|
}
|
|
if container == nil || !reflect.DeepEqual(container["ports"], []any{"222:22"}) {
|
|
t.Fatalf("the forge's container publishes %v", container["ports"])
|
|
}
|
|
if opening == nil || opening["to"] != 22 || opening["from"] != catalogue.OpeningFromMesh {
|
|
t.Fatalf("no opening for the port this node gave the forge: %v", opening)
|
|
}
|
|
var guard map[string]any
|
|
for _, r := range resources {
|
|
if r["id"] == catalogue.GuardID() {
|
|
guard = r
|
|
}
|
|
}
|
|
if guard == nil || guard["content"] != catalogue.AsGuard([]int{222}) {
|
|
t.Fatalf("the guard does not refuse the port the forge is on:\n%v", guard["content"])
|
|
}
|
|
|
|
// And the consumer on the other machine dials the same number.
|
|
plan, _, err := planFor(ctx, open, "laptop")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var told any
|
|
for _, n := range plan.Needs {
|
|
if n.Name == "git-over-ssh" {
|
|
told = n.Serves["port"]
|
|
}
|
|
}
|
|
if told != 222 {
|
|
t.Fatalf("the consumer is told the forge answers on %v", told)
|
|
}
|
|
}
|
|
|
|
func TestAnEmptyDeclarationSaysOwnsNothing(t *testing.T) {
|
|
// The host refuses an empty body unless told the emptiness is meant (novox/hq issue 127).
|
|
body, err := sendable{}.Body()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var env map[string]any
|
|
if err := json.Unmarshal(body, &env); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if env["owns_nothing"] != true {
|
|
t.Fatalf("an empty declaration must mark owns_nothing; got %v", env)
|
|
}
|
|
// A declaration with resources does not carry the marker.
|
|
body, _ = sendable{Resources: []map[string]any{{"id": "x"}}}.Body()
|
|
var env2 map[string]any
|
|
_ = json.Unmarshal(body, &env2)
|
|
if _, present := env2["owns_nothing"]; present {
|
|
t.Fatalf("a non-empty declaration must not mark owns_nothing; got %v", env)
|
|
}
|
|
}
|