305 lines
11 KiB
Go
305 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
|
|
"github.com/novox/mesh-controller/internal/conditions"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// A consumer the mesh stops asking for is retired, not withdrawn, and deleted only by a person
|
|
// (novox/hq ADR 0230): what a provider says becomes a condition a person answers, and the verbs that
|
|
// answer it ask the provider — never anything else — for exactly what it said.
|
|
|
|
func waitingWord(module, node string, names ...string) link.Retirement {
|
|
r := link.Retirement{Module: module, Provider: "postgres-database", ProviderNode: node, Change: link.RetireWaiting,
|
|
Held: 7, Bound: "more than 3, or more than half of the 7 held", At: time.Now(), Since: time.Now()}
|
|
for _, n := range names {
|
|
r.Consumers = append(r.Consumers, link.RetiredConsumer{Consumer: n, Node: "laptop"})
|
|
}
|
|
return r
|
|
}
|
|
|
|
func openKeys(t *testing.T, k *conditions.Keeper) map[string]conditions.Condition {
|
|
t.Helper()
|
|
all, err := k.Open(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := map[string]conditions.Condition{}
|
|
for _, c := range all {
|
|
out[c.Key] = c
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestARetirementWaitingIsUrgentARejectionAWarningAndARetirementClears(t *testing.T) {
|
|
k, _ := withConditionsInMemory(t)
|
|
var recorded []link.HandAct
|
|
r := retirements{keeper: func() *conditions.Keeper { return k },
|
|
record: func(_ context.Context, a link.HandAct) error { recorded = append(recorded, a); return nil }}
|
|
ctx := t.Context()
|
|
waiting := waitingWord("postgres", "anchor", "a", "b", "c", "d")
|
|
if err := r.Retired(ctx, waiting); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
open := openKeys(t, k)
|
|
c, ok := open["provider.postgres.anchor.retire"]
|
|
if !ok || c.Kind != kindRetireWaiting || c.Severity != conditions.Urgent {
|
|
t.Fatalf("waiting is not an urgent retire-waiting condition: %+v", open)
|
|
}
|
|
for _, want := range []string{"a (laptop), b (laptop), c (laptop), d (laptop)", "retire approve anchor postgres",
|
|
"retire reject anchor postgres", "more than 3"} {
|
|
if !strings.Contains(c.Summary, want) {
|
|
t.Errorf("the condition does not say %q: %s", want, c.Summary)
|
|
}
|
|
}
|
|
|
|
// Rejected, through the controller: the urgent one becomes a warning, and nothing is recorded here —
|
|
// the verb recorded it before it asked.
|
|
rejected := waiting
|
|
rejected.Change, rejected.By, rejected.Why, rejected.Via = link.RetireRejected, "operator", "moving them", link.ViaController
|
|
if err := r.Retired(ctx, rejected); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
open = openKeys(t, k)
|
|
if _, still := open["provider.postgres.anchor.retire"]; still {
|
|
t.Fatal("a rejection left the provider waiting")
|
|
}
|
|
if c, ok := open["provider.postgres.anchor.retire-rejected"]; !ok || c.Severity != conditions.Warning ||
|
|
c.Kind != kindRetireRejected || !strings.Contains(c.Summary, "moving them") {
|
|
t.Fatalf("a rejection is not a warning saying why: %+v", open)
|
|
}
|
|
if len(recorded) != 0 {
|
|
t.Fatalf("an act through the controller was recorded twice: %+v", recorded)
|
|
}
|
|
|
|
// Approved afterwards, asked of the provider directly: both cleared, and recorded by hand here.
|
|
approved := waiting
|
|
approved.Change, approved.By, approved.Why, approved.Via = link.RetireApproved, "someone", "done moving", ""
|
|
if err := r.Retired(ctx, approved); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if open := openKeys(t, k); len(open) != 0 {
|
|
t.Fatalf("an approval left conditions open: %+v", open)
|
|
}
|
|
if len(recorded) != 1 || recorded[0].Verb != "retire approve" || recorded[0].By != "someone" ||
|
|
!strings.Contains(recorded[0].Why, "directly") || !slices.Contains(recorded[0].Args, "d") {
|
|
t.Fatalf("an approval outside the controller was not recorded: %+v", recorded)
|
|
}
|
|
|
|
// Waiting again, then the set settles (asked for again): cleared. And retired clears too.
|
|
for _, change := range []string{link.RetireSettled, link.RetireRetired} {
|
|
if err := r.Retired(ctx, waiting); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
done := waiting
|
|
done.Change = change
|
|
if err := r.Retired(ctx, done); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if open := openKeys(t, k); len(open) != 0 {
|
|
t.Fatalf("%s left conditions open: %+v", change, open)
|
|
}
|
|
}
|
|
|
|
// A deletion asked of the provider directly is recorded too.
|
|
deleted := link.Retirement{Module: "postgres", ProviderNode: "anchor", Change: link.RetireDeleted, By: "x",
|
|
Why: "gone for good", At: time.Now(), Consumers: []link.RetiredConsumer{{Consumer: "a"}}}
|
|
if err := r.Retired(ctx, deleted); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(recorded) != 2 || recorded[1].Verb != "cleanup delete" || recorded[1].Cause != kindCleanupWaiting {
|
|
t.Fatalf("a deletion outside the controller was not recorded: %+v", recorded)
|
|
}
|
|
}
|
|
|
|
func TestARetirementWordIsKeptAsItsConditionNamingTheEmitterFromTheSubject(t *testing.T) {
|
|
body, _ := json.Marshal(map[string]any{"provider": "oidc-client", "provider-node": "anchor", "change": "waiting",
|
|
"held": 2, "consumers": []map[string]any{{"consumer": "x"}, {"consumer": "y"}}, "module": "liar"})
|
|
r, err := link.ReadRetirement("mesh.mod.idp.event.provisioner.retirement", body)
|
|
if err != nil || r.Module != "idp" || len(r.Consumers) != 2 {
|
|
t.Fatalf("%+v %v", r, err)
|
|
}
|
|
if _, err := link.ReadRetirement("mesh.mod.idp.event.provisioner.retirement",
|
|
[]byte(`{"provider-node":"anchor","change":"vanished"}`)); err == nil {
|
|
t.Fatal("a change the mesh has no name for was read")
|
|
}
|
|
if _, err := link.ReadRetirement("mesh.mod.idp.event.provisioner.failing", body); err == nil {
|
|
t.Fatal("a failing word was read as a retirement")
|
|
}
|
|
k, _ := withConditionsInMemory(t)
|
|
if err := (retirements{keeper: func() *conditions.Keeper { return k }}).Retired(t.Context(), r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := openKeys(t, k)["provider.idp.anchor.retire"]; !ok {
|
|
t.Fatal("not kept under the emitter the subject names")
|
|
}
|
|
}
|
|
|
|
// fakeProvider answers the retirement tools on one machine, over a real bus, keeping what it was asked.
|
|
type fakeProvider struct {
|
|
mu sync.Mutex
|
|
state link.RetirementState
|
|
asked []map[string]any
|
|
deleted []string
|
|
approved []string
|
|
rejected []string
|
|
}
|
|
|
|
func (f *fakeProvider) serve(t *testing.T, conn *nats.Conn, module, node string) {
|
|
t.Helper()
|
|
answer := func(m *nats.Msg, result any, refusal string) {
|
|
body, _ := json.Marshal(map[string]any{"result": result, "error": refusal, "node": node})
|
|
_ = m.Respond(body)
|
|
}
|
|
names := func(args map[string]any) []string {
|
|
var out []string
|
|
for _, v := range args["consumers"].([]any) {
|
|
out = append(out, v.(string))
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
set := func(cs []link.RetiredConsumer) []string {
|
|
var out []string
|
|
for _, c := range cs {
|
|
out = append(out, c.Consumer)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
for _, tool := range []string{link.ToolRetirement, link.ToolRetireApprove, link.ToolRetireReject, link.ToolRetiredDelete} {
|
|
tool := tool
|
|
sub, err := conn.Subscribe(link.ModuleToolOn(module, tool, node), func(m *nats.Msg) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var args map[string]any
|
|
_ = json.Unmarshal(m.Data, &args)
|
|
f.asked = append(f.asked, map[string]any{"tool": tool, "args": args})
|
|
switch tool {
|
|
case link.ToolRetirement:
|
|
answer(m, f.state, "")
|
|
case link.ToolRetireApprove:
|
|
if f.state.Waiting == nil || !slices.Equal(names(args), set(f.state.Waiting.Consumers)) {
|
|
answer(m, nil, "not the set I wait with")
|
|
return
|
|
}
|
|
f.approved = names(args)
|
|
answer(m, map[string]any{"retired": f.approved}, "")
|
|
case link.ToolRetireReject:
|
|
if f.state.Waiting == nil || !slices.Equal(names(args), set(f.state.Waiting.Consumers)) {
|
|
answer(m, nil, "not the set I wait with")
|
|
return
|
|
}
|
|
f.rejected = names(args)
|
|
answer(m, map[string]any{"kept": f.rejected}, "")
|
|
case link.ToolRetiredDelete:
|
|
if args["confirm"] != args["consumer"] || args["why"] == "" || args["via"] != link.ViaController {
|
|
answer(m, nil, "confirm, why and via")
|
|
return
|
|
}
|
|
f.deleted = append(f.deleted, args["consumer"].(string))
|
|
answer(m, map[string]any{"deleted": args["consumer"], "freed_bytes": 1024}, "")
|
|
}
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
|
}
|
|
if err := conn.Flush(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func whyFlags(t *testing.T, why string) handActFlags {
|
|
t.Helper()
|
|
set := flag.NewFlagSet("t", flag.ContinueOnError)
|
|
f := addHandActFlags(set)
|
|
if err := set.Parse([]string{"--why", why}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return f
|
|
}
|
|
|
|
func handActsBy(t *testing.T, conn *nats.Conn, verb string) []link.HandAct {
|
|
t.Helper()
|
|
acts, err := link.HandActs(t.Context(), conn, time.Now().Add(-time.Minute))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out []link.HandAct
|
|
for _, a := range acts {
|
|
if a.Verb == verb {
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func retiredDaysAgo(name string, days int) link.RetiredConsumer {
|
|
size := int64(4096)
|
|
return link.RetiredConsumer{Consumer: name, Kind: "consumer", Why: "the mesh stopped asking for it",
|
|
RetiredAt: time.Now().Add(-time.Duration(days) * 24 * time.Hour).UTC().Format(time.RFC3339), SizeBytes: &size}
|
|
}
|
|
|
|
func TestTheRetireAndCleanupVerbsComposeTheirCommandLines(t *testing.T) {
|
|
for _, c := range []struct {
|
|
verb string
|
|
args map[string]any
|
|
want string
|
|
}{
|
|
{"retire", map[string]any{}, "retire --json"},
|
|
{"retire", map[string]any{"answer": "approve", "node": "anchor", "module": "postgres", "why": "moved"},
|
|
"retire approve anchor postgres --why moved"},
|
|
{"retire", map[string]any{"answer": "reject", "node": "anchor", "module": "postgres", "why": "no"},
|
|
"retire reject anchor postgres --why no"},
|
|
{"cleanup", map[string]any{}, "cleanup list --json"},
|
|
{"cleanup", map[string]any{"node": "anchor", "module": "postgres", "consumer": "x", "why": "gone"},
|
|
"cleanup delete anchor postgres x --why gone"},
|
|
{"cleanup", map[string]any{"older-than": "30", "why": "tidy"}, "cleanup delete --older-than 30 --why tidy"},
|
|
{"cleanup", map[string]any{"older-than": "30", "why": "tidy", "confirm": "true"},
|
|
"cleanup delete --older-than 30 --why tidy --confirm"},
|
|
} {
|
|
argv, err := argvFor(c.verb, c.args)
|
|
if err != nil || strings.Join(argv, " ") != c.want {
|
|
t.Errorf("%s %v: %q %v, want %q", c.verb, c.args, argv, err, c.want)
|
|
}
|
|
}
|
|
for _, args := range []map[string]any{
|
|
{"answer": "approve", "node": "anchor", "module": "postgres"}, // no why
|
|
{"answer": "maybe", "node": "anchor", "module": "postgres", "why": "x"},
|
|
} {
|
|
if _, err := argvFor("retire", args); err == nil {
|
|
t.Errorf("retire %v was composed", args)
|
|
}
|
|
}
|
|
for _, args := range []map[string]any{
|
|
{"node": "anchor", "module": "postgres", "consumer": "x"}, // no why
|
|
{"older-than": "30"},
|
|
{"consumer": "x", "older-than": "30", "node": "a", "module": "b", "why": "y"},
|
|
} {
|
|
if _, err := argvFor("cleanup", args); err == nil {
|
|
t.Errorf("cleanup %v was composed", args)
|
|
}
|
|
}
|
|
if repairingCommand([]string{"cleanup", "delete", "a", "b", "c"}) == "" ||
|
|
repairingCommand([]string{"retire", "approve", "a", "b"}) == "" || repairingCommand([]string{"retire"}) != "" {
|
|
t.Error("the generic verb would let a retirement or a deletion through without a why")
|
|
}
|
|
}
|
|
|
|
// A mark-only provider's retired consumer keeps its access; the listing and the approval say so
|
|
// rather than claiming it was disabled (ADR 0230).
|