303 lines
13 KiB
Go
303 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
|
"git.novox.be/novox/mesh-sdk/go/seatlist"
|
|
"git.novox.be/novox/mesh-sdk/go/views"
|
|
)
|
|
|
|
// aBus is the states claude-code reads, as a test holds them: by read, key and value.
|
|
type aBus map[string]map[string][]byte
|
|
|
|
func (b aBus) Keys(read string) ([]string, error) {
|
|
if b[read] == nil {
|
|
return nil, errors.New("no such state")
|
|
}
|
|
var keys []string
|
|
for k := range b[read] {
|
|
keys = append(keys, k)
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
func (b aBus) Get(read, key string) ([]byte, bool, error) {
|
|
v, ok := b[read][key]
|
|
return v, ok, nil
|
|
}
|
|
|
|
// someSeats are two seats as the seats' contract says them: one held once for the mesh, one on every machine.
|
|
func someSeats() []seatdef.Definition {
|
|
return []seatdef.Definition{
|
|
{Seat: "mesh-module-assigner", Domain: "Placement", Scope: "mesh", Verbs: []seatdef.Verb{
|
|
{Name: "placement", Description: "Read placement: one node's key.", Replaces: []string{"hostnamectl", "uptime"}},
|
|
{Name: "propose", Description: "Propose a change."}}},
|
|
{Seat: "node-service-manager", Domain: "Placement", Scope: "node", Verbs: []seatdef.Verb{
|
|
{Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot.", Replaces: []string{"systemctl status", "systemctl is-active"}},
|
|
{Name: "journal", Description: "The last lines of one unit's journal.", Replaces: []string{"journalctl"}}}},
|
|
}
|
|
}
|
|
|
|
// aMesh is two machines, anchor (with a public domain) and laptop, docker and zsh placed on anchor, docker's manifest
|
|
// saying what its tool replaces, as the seat mesh-module-assigner and the indexer keep them on the bus.
|
|
func aMesh(t *testing.T) aBus {
|
|
t.Helper()
|
|
b := aBus{PlacementRead: {}, ModulesRead: {}, ManifestsRead: {}}
|
|
for _, p := range []delivery.Placement{
|
|
{V: 1, Node: "anchor", Decided: delivery.NodeDecided{PublicDomain: "Anchor.Example"},
|
|
Modules: []delivery.PlacedModule{{Module: "docker"}, {Module: "zsh"}}},
|
|
{V: 1, Node: "laptop"},
|
|
} {
|
|
values, err := delivery.PlacementValues(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for k, v := range values {
|
|
b[PlacementRead][k] = v
|
|
}
|
|
}
|
|
for module, manifest := range map[string]string{
|
|
"docker": `{"module":"docker","replaces":{"docker_logs":["docker logs"]}}`,
|
|
"zsh": `{"module":"zsh"}`,
|
|
} {
|
|
digest := fmt.Sprintf("%x", sha256.Sum256([]byte(manifest)))
|
|
mkey, _ := delivery.ManifestsKey(digest)
|
|
raw, err := json.Marshal(delivery.Manifest{V: 1, Module: module, JSON: manifest})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b[ManifestsRead][mkey] = raw
|
|
key, _ := delivery.ModulesKey(module)
|
|
b[ModulesRead][key], _ = json.Marshal(delivery.Module{V: 1, None: "nothing is built of it", Manifest: digest, Repository: "novox/mesh-catalog", Commit: "c"})
|
|
}
|
|
return b
|
|
}
|
|
|
|
func resolving(name string) []string {
|
|
return map[string][]string{"anchor": {"10.10.0.1"}, "anchor.internal": {"10.10.0.1"}, "anchor.example": {"192.0.2.10"},
|
|
"laptop": {"127.0.0.1", "10.10.0.4"}}[name]
|
|
}
|
|
|
|
// Issue 1126 B13: what the mesh says of its tools and machines is read from the bus whole, never asked of the
|
|
// controller: the seats' verbs, the placed modules' own tools, every machine with its domains and addresses — a
|
|
// loopback address never one of them.
|
|
func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) {
|
|
m, err := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var addresses []string
|
|
for _, r := range m.Replacements {
|
|
addresses = append(addresses, r.Address)
|
|
}
|
|
if got := strings.Join(addresses, ","); got != "mesh-module-assigner.placement,node-service-manager.status,node-service-manager.journal,docker.docker_logs" {
|
|
t.Errorf("replacements: %s", got)
|
|
}
|
|
t.Run("the again tool answers the operator and the healers only", func(t *testing.T) {
|
|
for caller, refused := range map[string]bool{"person.jochen": false, "shanks.mesh-healers": false, "shanks.mesh-config": true} {
|
|
if err := seatlist.Refusal("claude_code_seat_list_again", seatListAgainCallers, caller); (err != nil) != refused {
|
|
t.Errorf("%s: refused=%v, err %v", caller, refused, err)
|
|
}
|
|
}
|
|
})
|
|
t.Run("the seats are the seat list read from the bus", func(t *testing.T) {
|
|
// A table made from what the seat mesh-catalog answers (ADR 0635 §1): no compiled list, so before the reader
|
|
// has started nothing replaces a command, and after it the seats' verbs are the seat's own.
|
|
seatListMu.Lock()
|
|
seatList = nil
|
|
seatListMu.Unlock()
|
|
if got := definedSeats(); len(got) != 0 {
|
|
t.Fatalf("seats before the reader started: %v", got)
|
|
}
|
|
startSeatList(listBus{"mesh-module-assigner": someSeats()[0], "node-service-manager": someSeats()[1]})
|
|
defer func() { seatListMu.Lock(); seatList = nil; seatListMu.Unlock() }()
|
|
// The reader's first read ends when Ready closes (no sleeping).
|
|
select {
|
|
case <-seatList.Ready():
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatalf("the seat list was not read: %+v", seatList.Status())
|
|
}
|
|
got, err := ReadMeshToolsFrom(aMesh(t), definedSeats(), resolving)
|
|
var addrs []string
|
|
for _, r := range got.Replacements {
|
|
addrs = append(addrs, r.Address)
|
|
}
|
|
// The foundation's bootstrapped seats are held beside the two the seat answered, in reading order.
|
|
if err != nil || !slices.Contains(addrs, "mesh-module-assigner.placement") || !slices.Contains(addrs, "node-service-manager.journal") {
|
|
t.Fatalf("replacements from the bus: %v, %v", addrs, err)
|
|
}
|
|
})
|
|
if len(m.Machines) != 2 || strings.Join(m.Machines[0].Domains, ",") != "anchor.internal,anchor.example" ||
|
|
strings.Join(m.Machines[0].Addresses, ",") != "10.10.0.1,192.0.2.10" || strings.Join(m.Machines[1].Addresses, ",") != "10.10.0.4" {
|
|
t.Errorf("machines: %+v", m.Machines)
|
|
}
|
|
if m.Replacements[3].Does != "logs" || !m.Replacements[2].NodeScoped || m.Replacements[0].NodeScoped {
|
|
t.Errorf("the rows: %+v", m.Replacements)
|
|
}
|
|
}
|
|
|
|
// **The table is generated, not written** (novox/hq ADR 0245): what the records say a verb replaces is a row,
|
|
// the machines' seats first, and nothing is rendered where the records say nothing.
|
|
func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) {
|
|
m, _ := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving)
|
|
table := InsteadOf(&m)
|
|
for _, want := range []string{
|
|
"| `<node>/node-service-manager.` | `status` — `systemctl status`, `systemctl is-active` · `journal` — `journalctl` |",
|
|
"| `<node>/docker.` | `docker_logs` — `docker logs` |",
|
|
"| `mesh-module-assigner.` | `placement` — `hostnamectl`, `uptime` |",
|
|
"Never `ssh` to a mesh machine",
|
|
} {
|
|
if !strings.Contains(table, want) {
|
|
t.Errorf("the table lacks %q:\n%s", want, table)
|
|
}
|
|
}
|
|
if strings.Index(table, "node-service-manager.") > strings.Index(table, "docker.") ||
|
|
strings.Index(table, "docker.") > strings.Index(table, "`mesh-module-assigner.`") {
|
|
t.Errorf("not the machines' seats, then modules, then the mesh's:\n%s", table)
|
|
}
|
|
// What the guard here named most comes first: its row, and in its row its verb.
|
|
m.Refused = map[string]int{"node-service-manager.journal": 3, "mesh-module-assigner.placement": 1}
|
|
ranked := InsteadOf(&m)
|
|
if !strings.Contains(ranked, "| `<node>/node-service-manager.` | `journal` — `journalctl` · `status`") ||
|
|
strings.Index(ranked, "`mesh-module-assigner.`") > strings.Index(ranked, "`<node>/docker.`") {
|
|
t.Errorf("not ranked by the guard's record:\n%s", ranked)
|
|
}
|
|
m.Refused = nil
|
|
if InsteadOf(nil) != "" || InsteadOf(&MeshTools{}) != "" {
|
|
t.Error("a table from nothing")
|
|
}
|
|
// Into the instructions after the mesh's own text, before what was registered.
|
|
out := RenderWithMesh(Facts{Node: "w", Console: "x"}, Settings{}, nil, "/h", nil,
|
|
Config{Mesh: []Item{{Kind: KindInstructions, Name: "conventions", Scope: ScopeMesh, Files: map[string]string{"conventions.md": "Be brief."}}}}, &m)
|
|
md := out["CLAUDE.md"]
|
|
if i, j, k := strings.Index(md, "## Conventions"), strings.Index(md, "## Instead of a shell command"), strings.Index(md, "### conventions"); !(i < j && j < k && i >= 0) {
|
|
t.Errorf("the table is not between the mesh's text and the registered sections:\n%s", md)
|
|
}
|
|
if os.Getenv("SHOW_TABLE") != "" {
|
|
t.Log(table)
|
|
}
|
|
// A long list is cut, and says how the rest is found.
|
|
var many MeshTools
|
|
for i := 0; i < InsteadOfRows+3; i++ {
|
|
many.Replacements = append(many.Replacements, Replacement{Address: "s" + string(rune('a'+i)) + ".v", Seat: true, Replaces: []string{"c"}})
|
|
}
|
|
if t2 := InsteadOf(&many); strings.Count(t2, "| `s") != InsteadOfRows || !strings.Contains(t2, "3 more seats and modules") {
|
|
t.Errorf("not cut to %d rows:\n%s", InsteadOfRows, t2)
|
|
}
|
|
}
|
|
|
|
// Asked again with the same answer, nothing is written and nothing renders.
|
|
func TestTheMeshsToolsAreKeptOnlyWhenTheyChange(t *testing.T) {
|
|
p := Paths{State: t.TempDir()}
|
|
m, _ := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving)
|
|
if !KeepMeshTools(p, m) {
|
|
t.Fatal("the first answer was not kept")
|
|
}
|
|
if KeepMeshTools(p, m) {
|
|
t.Fatal("the same answer was kept again")
|
|
}
|
|
m.Machines = m.Machines[:1]
|
|
if !KeepMeshTools(p, m) || len(ReadMeshTools(p).Machines) != 1 {
|
|
t.Fatal("a changed answer was not kept")
|
|
}
|
|
d := GuardDataOf(Paths{State: p.State, Node: "laptop"}, ReadMeshTools(p))
|
|
if d.Node != "laptop" || len(d.Replacements) != 4 || !strings.HasSuffix(d.Log, "guard.log") {
|
|
t.Errorf("the guard's data: %+v", d)
|
|
}
|
|
}
|
|
|
|
// Where a module runs is read from the seat mesh-module-assigner's placement (issue 1126 B13), never the controller's answer.
|
|
func TestTheMachinesRunningAModuleAreReadFromPlacement(t *testing.T) {
|
|
b := aMesh(t)
|
|
if got, err := NodesRunning(b, "zsh"); err != nil || strings.Join(got, ",") != "anchor" {
|
|
t.Errorf("zsh runs on %v (%v)", got, err)
|
|
}
|
|
if got, err := NodesRunning(b, "claude-code"); err != nil || got != nil {
|
|
t.Errorf("a module that runs nowhere: %v (%v)", got, err)
|
|
}
|
|
if _, err := NodesRunning(aBus{}, "zsh"); err == nil {
|
|
t.Error("an unreadable placement was read as no machine")
|
|
}
|
|
}
|
|
|
|
// ADR 0615 §1: the tools and the placement come from the owners' first views and their views events, never their state.
|
|
// A reader whose first views are answered from the fixture reads what the state read gave, and a views event changes
|
|
// what it reads (CAT-CC3).
|
|
func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) {
|
|
bus := aMesh(t)
|
|
r := newMeshViews()
|
|
for _, name := range []string{ModulesRead, ManifestsRead, PlacementRead} {
|
|
state := strings.TrimPrefix(name, strings.Split(name, ".")[0]+".")
|
|
values := map[string]json.RawMessage{}
|
|
for k, v := range bus[name] {
|
|
values[k] = v
|
|
}
|
|
f := r.Follow(strings.Split(name, ".")[0], state, "x")
|
|
f.Ask = func(context.Context, string) (views.Answer, error) {
|
|
return views.Answer{V: views.Version, Owner: f.Owner, State: state, Revision: 5, Created: time.Unix(1, 0), Values: values, At: time.Now()}, nil
|
|
}
|
|
if err := f.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
want, err := ReadMeshToolsFrom(bus, someSeats(), resolving)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := ReadMeshToolsFrom(viewStates{r}, someSeats(), resolving)
|
|
if err != nil || !reflect.DeepEqual(got, want) || len(got.Replacements) == 0 {
|
|
t.Fatalf("from the views: %+v, %v\nfrom the state: %+v", got, err, want)
|
|
}
|
|
// A reader not yet answered says so, and reads nothing.
|
|
if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, someSeats(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") {
|
|
t.Fatalf("an unanswered first view: %v", err)
|
|
}
|
|
}
|
|
|
|
// Issue 1126 B13 AC1 (ADR 0590): over the seats the mesh defines, the rendered instructions, their table among them,
|
|
// name no address of the controller, which is not placed: each job is named by the seat that owns it now.
|
|
func TestTheRenderedInstructionsNameNoControllerAddress(t *testing.T) {
|
|
m, err := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
md := RenderWithMesh(Facts{Node: "w", Console: "x"}, Settings{}, nil, "/h", nil, Config{}, &m)["CLAUDE.md"]
|
|
if !strings.Contains(md, "## Instead of a shell command") {
|
|
t.Fatalf("no table rendered:\n%s", md)
|
|
}
|
|
if strings.Contains(md, "mesh-controller") {
|
|
t.Errorf("the instructions name the controller:\n%s", md)
|
|
}
|
|
}
|
|
|
|
// listBus answers the seat mesh-catalog's seat list from the definitions it is given.
|
|
type listBus map[string]seatdef.Definition
|
|
|
|
func (b listBus) Ask(key string, body any) (json.RawMessage, error) {
|
|
if q, _ := body.(map[string]any); q != nil && q["seat"] != nil {
|
|
d := b[q["seat"].(string)]
|
|
d.Format, d.Grain, d.Decision = seatdef.Format, seatdef.SeatList, "ADR 0635"
|
|
raw, _ := json.Marshal(d)
|
|
return json.Marshal(seatlist.Answer{Seat: d.Seat, Found: true, Entry: &seatlist.Entry{Seat: d.Seat, Source: "trunk", Digest: "d-" + d.Seat, Definition: raw}})
|
|
}
|
|
var l seatlist.Listing
|
|
for n := range b {
|
|
l.Seats = append(l.Seats, seatlist.Entry{Seat: n, Source: "trunk", Digest: "d-" + n})
|
|
}
|
|
return json.Marshal(l)
|
|
}
|
|
|
|
func (listBus) Subscribe(string, func(string, json.RawMessage) error) error { return nil }
|