265 lines
9.8 KiB
Go
265 lines
9.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-host/internal/apply"
|
|
"github.com/novox/mesh-host/internal/link"
|
|
"github.com/novox/mesh-host/internal/store"
|
|
"github.com/novox/mesh-host/internal/system"
|
|
)
|
|
|
|
// Argument handling gets tests because it already failed silently once: `mesh-host inventory
|
|
// --json` printed text. The standard library stops parsing at the first non-flag argument, so
|
|
// the flag sat unread in the positional arguments and the command exited 0 having ignored what
|
|
// the user asked for.
|
|
//
|
|
// Silently doing something other than what was asked, and reporting success, is the fault this
|
|
// project exists to name — so it gets defended here rather than remembered.
|
|
|
|
func TestAFlagAfterTheCommandIsRead(t *testing.T) {
|
|
command, opts, err := parseArgs([]string{"inventory", "--json"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if command != "inventory" {
|
|
t.Errorf("command = %q, want inventory", command)
|
|
}
|
|
if !opts.json {
|
|
t.Error("--json after the subcommand was ignored")
|
|
}
|
|
}
|
|
|
|
func TestFlagsAreReadInEitherPosition(t *testing.T) {
|
|
for _, args := range [][]string{
|
|
{"profile", "--json", "--timeout", "3s"},
|
|
{"profile", "--timeout=3s", "--json"},
|
|
} {
|
|
_, opts, err := parseArgs(args)
|
|
if err != nil {
|
|
t.Fatalf("%v: unexpected error: %v", args, err)
|
|
}
|
|
if !opts.json || opts.timeout != 3*time.Second {
|
|
t.Errorf("%v parsed as json=%v timeout=%s", args, opts.json, opts.timeout)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAMistypedFlagIsRefusedNotIgnored(t *testing.T) {
|
|
// The cost of getting this wrong is asymmetric: an error is a moment's annoyance, and a
|
|
// silently dropped flag is a report that answers a question nobody asked.
|
|
if _, _, err := parseArgs([]string{"profile", "--jsom"}); err == nil {
|
|
t.Fatal("a mistyped flag was accepted")
|
|
}
|
|
}
|
|
|
|
func TestAnUnexpectedArgumentIsRefused(t *testing.T) {
|
|
if _, _, err := parseArgs([]string{"profile", "extra"}); err == nil {
|
|
t.Fatal("a stray argument was ignored rather than refused")
|
|
}
|
|
}
|
|
|
|
func TestTheDefaultsAreTheDocumentedOnes(t *testing.T) {
|
|
// The usage text promises 10s. A default that drifts from what is printed is a small lie
|
|
// that costs someone an afternoon.
|
|
_, opts, err := parseArgs([]string{"profile"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if opts.timeout != 10*time.Second {
|
|
t.Errorf("default timeout is %s; the usage text says 10s", opts.timeout)
|
|
}
|
|
if opts.json {
|
|
t.Error("json output is on by default; the usage text says it is a flag")
|
|
}
|
|
}
|
|
|
|
func TestNoCommandIsNotAnError(t *testing.T) {
|
|
// Running the binary with no arguments prints usage and exits 0. A host that returns
|
|
// failure for "tell me what you do" is noise in every script that probes it.
|
|
command, _, err := parseArgs(nil)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if command != "" {
|
|
t.Errorf("command = %q, want empty", command)
|
|
}
|
|
}
|
|
|
|
func TestApplyNeedsExactlyOneDeclaration(t *testing.T) {
|
|
// `apply` takes a file where every other command takes nothing, so the leftover-argument
|
|
// rule has an exception — and an exception is where a parser stops refusing things it
|
|
// should. Both directions are checked.
|
|
if _, _, err := parseArgs([]string{"apply"}); err == nil {
|
|
t.Error("apply with no file was accepted")
|
|
}
|
|
if _, _, err := parseArgs([]string{"apply", "a.json", "b.json"}); err == nil {
|
|
t.Error("apply with two files was accepted")
|
|
}
|
|
|
|
command, opts, err := parseArgs([]string{"apply", "decl.json", "--dry-run"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if command != "apply" || opts.file != "decl.json" || !opts.dryRun {
|
|
t.Errorf("parsed as command=%q file=%q dry-run=%v", command, opts.file, opts.dryRun)
|
|
}
|
|
}
|
|
|
|
func TestTheStateHasADocumentedDefault(t *testing.T) {
|
|
// A host that wrote its state somewhere unexpected would forget what it owns on the next
|
|
// run, and then leave everything it had applied behind forever.
|
|
_, opts, err := parseArgs([]string{"owned"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if opts.state != store.DefaultPath {
|
|
t.Errorf("default state path is %q, not the documented %q", opts.state, store.DefaultPath)
|
|
}
|
|
}
|
|
|
|
func TestAFlagAfterAPositionalIsRead(t *testing.T) {
|
|
// The same fault as TestAFlagAfterTheCommandIsRead, one level down. Taking the subcommand
|
|
// off the front fixed the flag after the COMMAND and not the flag after its ARGUMENT: the
|
|
// standard library stops at the first non-flag argument wherever that argument is.
|
|
for _, args := range [][]string{
|
|
{"apply", "decl.json", "--dry-run", "--json"},
|
|
{"apply", "--dry-run", "decl.json", "--json"},
|
|
{"apply", "--dry-run", "--json", "decl.json"},
|
|
} {
|
|
command, opts, err := parseArgs(args)
|
|
if err != nil {
|
|
t.Errorf("%v: unexpected error: %v", args, err)
|
|
continue
|
|
}
|
|
if command != "apply" || opts.file != "decl.json" || !opts.dryRun || !opts.json {
|
|
t.Errorf("%v parsed as file=%q dry-run=%v json=%v",
|
|
args, opts.file, opts.dryRun, opts.json)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Defends novox/hq ADR 0100: a reconcile on an adopted node speaks unasked only when what it holds
|
|
// or its firewall changed — which is how a predecessor still writing is caught, without a report
|
|
// every five minutes saying nothing new.
|
|
func TestAReconcileSpeaksOnlyWhenWhatIsHeldChanged(t *testing.T) {
|
|
w := &adoptionWatch{}
|
|
held := link.Report{Firewall: "ufw", Held: []link.Held{{ID: "hello-web.page"}, {ID: "hello-web.server"}}}
|
|
if !w.changed(held) {
|
|
t.Fatal("the first report of a hold was not said")
|
|
}
|
|
again := link.Report{Firewall: "ufw", Held: []link.Held{{ID: "hello-web.server"}, {ID: "hello-web.page"}}}
|
|
if w.changed(again) {
|
|
t.Error("the same holds in another order were said again")
|
|
}
|
|
rewritten := link.Report{Firewall: "ufw", Held: []link.Held{{ID: "hello-web.page", Changed: "rewritten"}, {ID: "hello-web.server"}}}
|
|
if !w.changed(rewritten) {
|
|
t.Error("a held file rewritten by something else was not said")
|
|
}
|
|
if !w.changed(link.Report{Firewall: "none", Held: rewritten.Held}) {
|
|
t.Error("a changed firewall was not said")
|
|
}
|
|
}
|
|
|
|
func TestWhatTheLinkPublishedCountsAsSaid(t *testing.T) {
|
|
w := &adoptionWatch{}
|
|
report := link.Report{Firewall: "ufw", Held: []link.Held{{ID: "a"}}}
|
|
applier := w.noting(func(context.Context, []byte, []byte) link.Report { return report })
|
|
applier(context.Background(), nil, nil)
|
|
if w.changed(report) {
|
|
t.Error("a reconcile repeated what the link had just published")
|
|
}
|
|
}
|
|
|
|
func TestAReconcileSpeaksWhenWhatIsReachableChanged(t *testing.T) {
|
|
// The controller previews a flip from what the node last said is reachable; a port that opened
|
|
// since must reach it without waiting for the next delivery (novox/hq ADR 0100).
|
|
w := &adoptionWatch{}
|
|
before := link.Report{Firewall: "ufw", Reachable: []link.Reach{
|
|
{Protocol: "tcp", Address: "0.0.0.0", Port: 22, By: "sshd"}}}
|
|
if !w.changed(before) {
|
|
t.Fatal("the first report was not said")
|
|
}
|
|
reordered := link.Report{Firewall: "ufw", Reachable: []link.Reach{
|
|
{Protocol: "tcp", Address: "0.0.0.0", Port: 22, By: "sshd"}}}
|
|
if w.changed(reordered) {
|
|
t.Error("the same reachable set was said again")
|
|
}
|
|
opened := link.Report{Firewall: "ufw", Reachable: append(before.Reachable,
|
|
link.Reach{Protocol: "tcp", Address: "0.0.0.0", Port: 8080, By: "hello-web", Published: true, ContainerPort: 80})}
|
|
if !w.changed(opened) {
|
|
t.Error("a newly published port was not said")
|
|
}
|
|
}
|
|
|
|
// Defends the node's own record: the link and the reconcile loop both apply, and each reads the
|
|
// state, acts, and writes it back — so they must not run at the same time, or the last save loses
|
|
// what the other recorded.
|
|
func TestOnlyOneApplyRunsAtATime(t *testing.T) {
|
|
// A host is built for one system at link time, and a test binary has no link time: this asks
|
|
// the machine it runs on, and stands aside where the answer is no.
|
|
built, err := system.For("arch")
|
|
if err != nil || built.Confirm(context.Background(), apply.ExecRunner) != nil {
|
|
t.Skip("this machine is not one these tests can apply on")
|
|
}
|
|
was := builtFor
|
|
builtFor = "arch"
|
|
t.Cleanup(func() { builtFor = was })
|
|
dir := t.TempDir()
|
|
opts := options{state: filepath.Join(dir, "state.json")}
|
|
raw := []byte(`{"declaration":1,"resources":[{"id":"a","type":"file","path":"` +
|
|
filepath.Join(dir, "a.conf") + `","content":"x\n"}]}`)
|
|
|
|
// Whatever else is applying — the link, while this is the reconcile — this waits for it.
|
|
applying.Lock()
|
|
done := make(chan link.Report, 1)
|
|
go func() { done <- applyAndKeep(context.Background(), opts, raw, nil, nil) }()
|
|
select {
|
|
case report := <-done:
|
|
applying.Unlock()
|
|
t.Fatalf("an apply ran while another held the node: %+v", report)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "a.conf")); !errors.Is(err, os.ErrNotExist) {
|
|
applying.Unlock()
|
|
t.Fatal("the waiting apply had already touched the machine")
|
|
}
|
|
applying.Unlock()
|
|
|
|
select {
|
|
case report := <-done:
|
|
if report.Refused != "" {
|
|
t.Fatalf("refused: %s", report.Refused)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("the apply never ran once the node was free")
|
|
}
|
|
known, loadErr := store.Load(opts.state)
|
|
if loadErr != nil || len(known.Resources) != 1 {
|
|
t.Errorf("the apply recorded %d resource(s): %v", len(known.Resources), loadErr)
|
|
}
|
|
}
|
|
|
|
// Defends novox/hq ADR 0100: a change is counted as said only once the mesh has been told. Queued
|
|
// and lost — the link down when the reconcile spoke — it must be said again.
|
|
func TestAChangeThatNeverReachedTheMeshIsSaidAgain(t *testing.T) {
|
|
w := &adoptionWatch{}
|
|
held := link.Report{Firewall: "ufw", Held: []link.Held{{ID: "hello-web.page", Changed: "rewritten"}}}
|
|
if !w.differs(held) {
|
|
t.Fatal("the first report of a change was not new")
|
|
}
|
|
// The link was down: nothing published it, so nothing says it was said.
|
|
if !w.differs(held) {
|
|
t.Error("a change that never reached the mesh was counted as said")
|
|
}
|
|
w.said(held)
|
|
if w.differs(held) {
|
|
t.Error("a change the mesh was told was said again")
|
|
}
|
|
}
|