The binary in 419d662 came from a build into the worktree, not from the
change. node show says send, as the glossary does. A test now reconciles D1
beside a wait's needs-operator, which a wait raised under D1's source would fail.
260 lines
11 KiB
Go
260 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"github.com/novox/mesh-controller/internal/conditions"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// novox/hq issue 380: nfs-server was assigned to the home server for days and every send left it out — "nfs-server
|
|
// has a file that says ${setting:shares}, and nothing sets shares for it" — and nothing but `plan` said so. The
|
|
// manifest is the catalogue's own at the commit that added it (mesh-catalog 48fba44), which still says
|
|
// ${setting:shares}; the reason below is the one the live push printed.
|
|
|
|
// leftOutNFS is the resolution of a machine assigned that nfs-server, with the settings given.
|
|
func leftOutNFS(t *testing.T) catalogue.Resolution {
|
|
t.Helper()
|
|
raw, err := os.ReadFile("testdata/left-out/nfs-server.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m, err := catalogue.ParseManifest(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return catalogue.Resolution{Modules: []catalogue.Manifest{m}}
|
|
}
|
|
|
|
// sharesGiven is the operator's setting for it on the machine.
|
|
func sharesGiven(value string) catalogue.SettingsBy {
|
|
return catalogue.SettingsBy{"nfs-server": {{From: "anchor", Values: map[string]any{"shares": value}}}}
|
|
}
|
|
|
|
func TestAModuleLeftOutForASettingNobodyGaveNeedsTheOperatorNamingTheSettingAndTheCommand(t *testing.T) {
|
|
left := leftOutOf(leftOutNFS(t), nil, false)
|
|
if len(left) != 1 || left[0].Module != "nfs-server" || left[0].Setting != "shares" {
|
|
t.Fatalf("left out: %+v", left)
|
|
}
|
|
if !strings.Contains(left[0].Why, `nothing sets "shares" for it`) {
|
|
t.Fatalf("the reason is not the push's own: %s", left[0].Why)
|
|
}
|
|
obs := leftOutObservations("anchor", left)
|
|
if len(obs) != 1 {
|
|
t.Fatalf("raised %+v", obs)
|
|
}
|
|
o := obs[0]
|
|
if o.Key() != "module.nfs-server.anchor.left-out" || o.Kind != kindNeedsOperator || o.Machine != "anchor" ||
|
|
o.Severity != conditions.Warning || o.Resolver != conditions.ResolverOperator {
|
|
t.Fatalf("raised %s as %s, %s, by %s, on %q", o.Key(), o.Kind, o.Severity, o.Resolver, o.Machine)
|
|
}
|
|
for _, want := range []string{"nfs-server", "anchor", `"shares"`, "`settings set nfs-server '{\"shares\": …}' --node anchor`"} {
|
|
if !strings.Contains(o.Summary, want) {
|
|
t.Errorf("the summary does not name %s: %s", want, o.Summary)
|
|
}
|
|
}
|
|
if o.Said != left[0].Why {
|
|
t.Errorf("the evidence is not the reason the send gives: %s", o.Said)
|
|
}
|
|
plainExample(t, o, "nfs-server is not applied on anchor",
|
|
"Needs you: set shares for nfs-server on anchor, or approve it when it is proposed to you. nfs-server is assigned to "+
|
|
"anchor, and every send to anchor leaves it out of the declaration because nothing sets its setting shares. "+
|
|
"Nothing of it runs there until it is set; the rest of anchor is sent as usual.")
|
|
}
|
|
|
|
func TestAModuleLeftOutForAnotherCauseIsAWarningWithTheReasonAsEvidence(t *testing.T) {
|
|
// A setting stored that its manifest can no longer take: one with a line break (issue 339).
|
|
left := leftOutOf(leftOutNFS(t), sharesGiven("library=/srv/library\nmedia=/srv/media"), false)
|
|
if len(left) != 1 || left[0].Setting != "" {
|
|
t.Fatalf("left out: %+v", left)
|
|
}
|
|
obs := leftOutObservations("anchor", left)
|
|
if len(obs) != 1 {
|
|
t.Fatalf("raised %+v", obs)
|
|
}
|
|
o := obs[0]
|
|
if o.Key() != "module.nfs-server.anchor.left-out" || o.Kind != kindLeftOut || o.Severity != conditions.Warning {
|
|
t.Fatalf("raised %s as %s, %s", o.Key(), o.Kind, o.Severity)
|
|
}
|
|
if !strings.Contains(o.Said, "holds a line break") || strings.Contains(o.Summary, "/srv/") {
|
|
t.Fatalf("the reason is not the evidence, or the summary carries it to the channel:\n%s\n%s", o.Summary, o.Said)
|
|
}
|
|
plainExample(t, o, "nfs-server is not applied on anchor",
|
|
"Needs you: read why in the details, then change what is set for nfs-server or unassign it. nfs-server is assigned to "+
|
|
"anchor, and every send to anchor leaves it out of the declaration because what is set for it does not fit "+
|
|
"its manifest. Nothing of it changes there; the rest of anchor is sent as usual.")
|
|
}
|
|
|
|
// The self-check raises it, keeps it a warning however long it stands, and clears it when the module composes
|
|
// again or is no longer assigned; a machine's health statement neither clears nor raises it.
|
|
func TestALeftOutModulesConditionClearsWhenItComposesAgainOrIsUnassigned(t *testing.T) {
|
|
plan, settings := leftOutNFS(t), catalogue.SettingsBy(nil)
|
|
withProbes(t, probe{ID: probeLeftOutID, Asserts: "the test's", Kind: kindLeftOut, Phase: 1,
|
|
run: func(context.Context, *doctor) ([]conditions.Observation, error) {
|
|
return leftOutObservations("anchor", leftOutOf(plan, settings, false)), nil
|
|
}})
|
|
store := conditions.NewInMemory()
|
|
k := conditions.NewKeeper(t.Context(), conditions.Options{Store: store, History: store})
|
|
defer k.Close(context.Background())
|
|
d := &doctor{keeper: k, teller: &conditions.Told{}, host: "anchor"}
|
|
key := "module.nfs-server.anchor.left-out"
|
|
openOnes := func() map[string]conditions.Condition {
|
|
t.Helper()
|
|
open, err := k.Open(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := map[string]conditions.Condition{}
|
|
for _, c := range open {
|
|
out[c.Key] = c
|
|
}
|
|
return out
|
|
}
|
|
|
|
d.runOnce(t.Context(), "a test")
|
|
c, raised := openOnes()[key]
|
|
if !raised || c.Kind != kindNeedsOperator || c.Severity != conditions.Warning {
|
|
t.Fatalf("a module left out raised %+v", openOnes())
|
|
}
|
|
// A statement from the machine that says nothing of it — the module runs nothing there — leaves it open.
|
|
if err := judgeModuleHealth(t.Context(), nil, k, "anchor", map[string][]inventory.ResourceHealth{}, nil,
|
|
time.Now().Add(72*time.Hour)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, still := openOnes()[key]; !still {
|
|
t.Fatal("a health statement that says nothing of the module cleared its left-out condition")
|
|
}
|
|
d.runOnce(t.Context(), "a test")
|
|
if c := openOnes()[key]; c.Severity != conditions.Warning {
|
|
t.Fatalf("after a health statement and days, it is %+v", openOnes())
|
|
}
|
|
|
|
// The setting given: it composes, and the condition clears.
|
|
settings = sharesGiven("library=/srv/library")
|
|
d.runOnce(t.Context(), "a test")
|
|
if _, still := openOnes()[key]; still {
|
|
t.Fatalf("composed again, still open: %+v", openOnes())
|
|
}
|
|
|
|
// Left out again, then unassigned: no longer in the machine's set, and it clears.
|
|
settings = nil
|
|
d.runOnce(t.Context(), "a test")
|
|
if _, raised := openOnes()[key]; !raised {
|
|
t.Fatal("left out again and not raised")
|
|
}
|
|
plan = catalogue.Resolution{}
|
|
d.runOnce(t.Context(), "a test")
|
|
if _, still := openOnes()[key]; still {
|
|
t.Fatalf("unassigned, still open: %+v", openOnes())
|
|
}
|
|
}
|
|
|
|
func TestTheLeftOutProbeIsInTheRegistry(t *testing.T) {
|
|
for _, p := range probeRegistry {
|
|
if p.ID == probeLeftOutID {
|
|
if p.run == nil || !slices.Contains(p.Raises, kindLeftOut) || !slices.Contains(p.Raises, kindNeedsOperator) {
|
|
t.Fatalf("%+v", p)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("no probe %s: a module left out is said nowhere", probeLeftOutID)
|
|
}
|
|
|
|
// status and node show list it under "assigned, not applied" with the reason the condition says, and status is
|
|
// not well while there is one.
|
|
func TestStatusAndNodeListAModuleAssignedAndNotApplied(t *testing.T) {
|
|
left := map[string][]leftOutModule{"anchor": leftOutOf(leftOutNFS(t), nil, false)}
|
|
reason := leftOutObservations("anchor", left["anchor"])[0].Summary
|
|
asked := answers{leftOut: left}
|
|
if asked.well() {
|
|
t.Fatal("a mesh with a module assigned and not applied is called well")
|
|
}
|
|
shown := printed(t, func() error { return printStatus(asked) })
|
|
if !strings.Contains(shown, "1 module(s) assigned, not applied") || !strings.Contains(shown, "nfs-server") ||
|
|
!strings.Contains(shown, "`settings set nfs-server '{\"shares\": …}' --node anchor` sets it") {
|
|
t.Fatalf("status says:\n%s", shown)
|
|
}
|
|
if !strings.Contains(reason, left["anchor"][0].reason("anchor")) {
|
|
t.Fatalf("status and the condition say different reasons:\n%s\n%s", shown, reason)
|
|
}
|
|
body, err := statusAsJSON(asked)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var doc struct {
|
|
NotApplied []machineNotApplied `json:"not-applied"`
|
|
}
|
|
if err := json.Unmarshal(body, &doc); err != nil || len(doc.NotApplied) != 1 ||
|
|
doc.NotApplied[0].Setting != "shares" || doc.NotApplied[0].Node != "anchor" {
|
|
t.Fatalf("status --json: %v %s", err, body)
|
|
}
|
|
lines := strings.Join(notAppliedLines("anchor", left["anchor"]), "\n")
|
|
if !strings.Contains(lines, "assigned, not applied:") || !strings.Contains(lines, "nfs-server") ||
|
|
!strings.Contains(lines, `nothing sets its setting "shares"`) {
|
|
t.Fatalf("node show says:\n%s", lines)
|
|
}
|
|
}
|
|
|
|
// The skip is this probe's alone (review of #223): a part waiting for the operator (ADR 0283) keeps its own
|
|
// needs-operator condition, `module.<m>.<node>.needs-operator`, which still clears on the first statement that no
|
|
// longer names it — beside a left-out condition on the same machine, which stays.
|
|
func TestAWaitsNeedsOperatorStillClearsWhenItsStatementStopsNamingItBesideALeftOutOne(t *testing.T) {
|
|
store := conditions.NewInMemory()
|
|
k := conditions.NewKeeper(t.Context(), conditions.Options{Store: store, History: store})
|
|
defer k.Close(context.Background())
|
|
left := leftOutObservations("anchor", leftOutOf(leftOutNFS(t), nil, false))
|
|
if err := k.Reconcile(t.Context(), probeLeftOutID, left); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waiting := map[string][]inventory.ResourceHealth{"notes": {{Module: "notes", Resource: "server", State: link.StateWaiting,
|
|
Waits: []inventory.Wait{{Setting: "domain", What: "the domain it serves"}}}}}
|
|
if err := judgeModuleHealth(t.Context(), nil, k, "anchor", waiting, map[string]int{"notes": 2}, time.Now()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitKey, leftKey := needsOperatorKey("notes", "anchor"), "module.nfs-server.anchor.left-out"
|
|
open := func() map[string]conditions.Condition {
|
|
t.Helper()
|
|
list, err := k.Open(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := map[string]conditions.Condition{}
|
|
for _, c := range list {
|
|
out[c.Key] = c
|
|
}
|
|
return out
|
|
}
|
|
if c, raised := open()[waitKey]; !raised || c.Kind != kindNeedsOperator {
|
|
t.Fatalf("the wait raised %+v", open())
|
|
}
|
|
if c := open()[leftKey]; c.Kind != kindNeedsOperator {
|
|
t.Fatalf("the left-out condition is not open as needs-operator: %+v", open())
|
|
}
|
|
// D1 runs again and still finds nfs-server left out: its reconcile clears only what D1 raised, never the wait,
|
|
// which the machine's statement raised.
|
|
if err := k.Reconcile(t.Context(), probeLeftOutID, left); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, still := open()[waitKey]; !still {
|
|
t.Fatalf("D1's reconcile cleared the wait's needs-operator: %+v", open())
|
|
}
|
|
if err := judgeModuleHealth(t.Context(), nil, k, "anchor", map[string][]inventory.ResourceHealth{}, nil, time.Now()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, still := open()[waitKey]; still {
|
|
t.Fatalf("the statement no longer names the wait, and its needs-operator is still open: %+v", open())
|
|
}
|
|
if _, still := open()[leftKey]; !still {
|
|
t.Fatalf("the left-out condition was cleared by a health statement: %+v", open())
|
|
}
|
|
}
|