A node reports back after applying a declaration: it worked, some of it failed, or the whole thing was refused. A refusal or a failure moved last_seen and the reason went to a log line — so "which machine is not doing what it was told" had no answer the next morning, which is the question a mesh exists to answer. Refused and failed are kept as different things, because they are different situations with different remedies: refused means the machine is exactly as it was and what is wrong is in what was sent; failed means it is in a state nobody declared and what is wrong is on the machine. One word for both would make the record say less than the node did. One row per node, replaced. The question is the machine's current state — "this failed an hour ago and then succeeded" is not a machine anybody needs to look at, and a table of every report would bury the ones that matter under the ones that do not. `status` now answers three questions in the order somebody asks them: is anything broken, is anything not answering, is anything out of date. The first has consequences now, the third is a plan for later, and a status leading with the third would bury the first. A machine that has never spoken is reported as quiet rather than as broken — new, switched off and unreachable are not the same as tried and could not. The mapping from a report to an outcome had no test at all, which the injection caught: it is the code deciding which of those situations a machine is in. It has four now, including that a partial report never becomes the account of what the machine holds — the fault that destroyed a substrate once.
115 lines
3.7 KiB
Go
115 lines
3.7 KiB
Go
package link_test
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-control/internal/inventory"
|
|
"github.com/novox/mesh-control/internal/link"
|
|
)
|
|
|
|
// Turning what a node said into what the mesh keeps.
|
|
//
|
|
// This is where "refused" and "failed" become different things. They are different situations
|
|
// with different remedies — one is fixed in what was sent and the other on the machine — and the
|
|
// mapping deciding which is which had no test at all.
|
|
|
|
func heardFrom(t *testing.T, report link.Report) (*inventory.Inventory, inventory.Doing, bool) {
|
|
t.Helper()
|
|
inv := inventory.ForTest(t)
|
|
ctx := context.Background()
|
|
if _, err := inv.AddNode(ctx, report.Node); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := (link.Enrolment{Inventory: inv}).Heard(ctx, report); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
doing, said, err := inv.DoingOf(ctx, report.Node)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return inv, doing, said
|
|
}
|
|
|
|
func TestARefusalIsKeptAsARefusalWithTheNodesOwnWords(t *testing.T) {
|
|
_, doing, said := heardFrom(t, link.Report{
|
|
Node: "workstation", Refused: `resource "conf": a file needs a path`,
|
|
})
|
|
if !said {
|
|
t.Fatal("a refusal was not recorded")
|
|
}
|
|
if doing.Outcome != inventory.OutcomeRefused {
|
|
t.Fatalf("a refusal was recorded as %q", doing.Outcome)
|
|
}
|
|
if !strings.Contains(doing.Refused, "needs a path") {
|
|
// The host says exactly what it could not accept. Anything this end wrote instead would
|
|
// be a second, worse explanation of the same thing.
|
|
t.Fatalf("the node's own words were not kept: %q", doing.Refused)
|
|
}
|
|
}
|
|
|
|
func TestSomeOfItFailingIsNotARefusal(t *testing.T) {
|
|
// Refused means the machine is exactly as it was. Failed means it is in a state nobody
|
|
// declared. Reporting one as the other sends somebody to the wrong place.
|
|
_, doing, _ := heardFrom(t, link.Report{
|
|
Node: "workstation",
|
|
Applied: []string{"a", "b", "c"},
|
|
Failed: map[string]string{"svc": "unit not found", "pkg": "no such package"},
|
|
})
|
|
if doing.Outcome != inventory.OutcomeFailed {
|
|
t.Fatalf("a partial failure was recorded as %q", doing.Outcome)
|
|
}
|
|
if doing.Applied != 3 {
|
|
t.Fatalf("what did apply was not kept: %d", doing.Applied)
|
|
}
|
|
if len(doing.Failed) != 2 {
|
|
t.Fatalf("got %+v", doing.Failed)
|
|
}
|
|
// Ordered, so two readings of one failure are the same reading.
|
|
if doing.Failed[0].ID != "pkg" || doing.Failed[1].ID != "svc" {
|
|
t.Fatalf("failures came back unordered: %+v", doing.Failed)
|
|
}
|
|
}
|
|
|
|
func TestACleanApplyIsRecordedAsOne(t *testing.T) {
|
|
inv, doing, _ := heardFrom(t, link.Report{Node: "workstation", Applied: []string{"a", "b"}})
|
|
if doing.Outcome != inventory.OutcomeApplied || doing.Applied != 2 {
|
|
t.Fatalf("got %+v", doing)
|
|
}
|
|
wrong, err := inv.NotDoingWhatTheyWereTold(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(wrong) != 0 {
|
|
t.Fatalf("a clean apply is listed as wrong: %+v", wrong)
|
|
}
|
|
}
|
|
|
|
func TestAFailureDoesNotBecomeTheAccountOfWhatTheMachineHolds(t *testing.T) {
|
|
// A partial list is not an account of what the machine holds. Recording one as though it
|
|
// were would tell a rebuilding node to remove what it still has — which is the fault that
|
|
// destroyed a substrate once (novox/hq 04-ISSUES/010).
|
|
inv := inventory.ForTest(t)
|
|
ctx := context.Background()
|
|
node, err := inv.AddNode(ctx, "workstation")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.RecordOwned(ctx, node.ID, []string{"one", "two", "three"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{
|
|
Node: "workstation", Applied: []string{"one"}, Failed: map[string]string{"two": "no"},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
owned, _, err := inv.Owned(ctx, node.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(owned) != 3 {
|
|
t.Fatalf("a partial report replaced the account of what the machine holds: %v", owned)
|
|
}
|
|
}
|