A delivery and the five-minute reconcile were two paths that applied, ordered only by a lock, and each order it allowed was met live (issues 257, 261, 267). Now both only enqueue: one worker takes the newest declaration held when it starts, applies it once and makes one report, and reports leave in the order they are made. A declaration may carry the controller's lease epoch beside its sequence; one older than what this node applied is refused before anything is touched, counted, logged and reported. A report carries the declaration's epoch and sequence and the host's own report sequence, kept on disk so it goes on increasing across restarts and self-updates. Without an epoch, today's behaviour stands.
674 lines
28 KiB
Go
674 lines
28 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ed25519"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-host/internal/apply"
|
|
"github.com/novox/mesh-host/internal/bundle"
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
"github.com/novox/mesh-host/internal/identity"
|
|
"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")
|
|
}
|
|
// What filters the machine is part of it (novox/hq ADR 0168): a predecessor's chain removed by
|
|
// hand, or the found firewall enabled again, is said without being asked.
|
|
filtered := link.Report{Firewall: "none", Held: rewritten.Held,
|
|
Filters: []link.Filter{{Where: "chain HAL-MESH-ONLY (iptables-legacy)", Owner: "other", Refuses: "-j DROP"}}}
|
|
if !w.changed(filtered) {
|
|
t.Error("a filter appearing was not said")
|
|
}
|
|
if !w.changed(link.Report{Firewall: "none", Held: rewritten.Held}) {
|
|
t.Error("a filter removed was not said")
|
|
}
|
|
if !w.changed(link.Report{Firewall: "none", Held: rewritten.Held, FoundFirewall: &link.FoundFirewall{Kind: "ufw", Active: true}}) {
|
|
t.Error("the found firewall coming back was not said")
|
|
}
|
|
if !worthSaying(link.Report{Filters: filtered.Filters}) {
|
|
t.Error("a report carrying only what filters the machine is not worth saying")
|
|
}
|
|
}
|
|
|
|
// What the mesh heard — a delivery's account as much as a reconcile's — counts as said: the queue
|
|
// tells the watch every report the broker took (link.Queue.Heard).
|
|
func TestWhatTheMeshHeardCountsAsSaid(t *testing.T) {
|
|
w := &adoptionWatch{}
|
|
report := link.Report{Firewall: "ufw", Held: []link.Held{{ID: "a"}}}
|
|
heard := w.said
|
|
heard(report)
|
|
if w.changed(report) {
|
|
t.Error("a reconcile repeated what the mesh had just heard")
|
|
}
|
|
}
|
|
|
|
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, 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")
|
|
}
|
|
}
|
|
|
|
// Defends novox/hq issue 104: a declaration for the other mode than this node is in is refused at
|
|
// the point of application, whichever command delivered it, naming both — an adopted control-node
|
|
// once applied its converged genesis bundle and closed itself for forty-five minutes.
|
|
|
|
func stateWithMode(t *testing.T, mode string) options {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
opts := options{state: filepath.Join(dir, "state.json"), out: &bytes.Buffer{}}
|
|
if err := store.Save(opts.state, store.State{Mode: mode}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return opts
|
|
}
|
|
|
|
func TestAConvergedDeclarationIsRefusedOnAnAdoptedNode(t *testing.T) {
|
|
opts := stateWithMode(t, store.ModeAdopted)
|
|
path := filepath.Join(filepath.Dir(opts.state), "filter.conf")
|
|
raw := []byte(`{"declaration":1,"resources":[{"id":"filter","type":"file","path":"` + path +
|
|
`","content":"table inet filter { chain input { policy drop; } }\n"}]}`)
|
|
d, err := declaration.ParseFileTrusted(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, from := range []provenance{fromFile, fromBundle} {
|
|
err := runApply(context.Background(), opts, d, raw, from)
|
|
if err == nil {
|
|
t.Fatalf("a converged declaration was applied to an adopted node (from %d)", from)
|
|
}
|
|
want := "this node is adopted; the declaration says converged"
|
|
if !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "`converge`") {
|
|
t.Errorf("the refusal does not name both modes and the act that changes it: %v", err)
|
|
}
|
|
}
|
|
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
|
t.Error("the refused declaration touched the machine")
|
|
}
|
|
}
|
|
|
|
func TestTheKeptDeclarationRepairsARecordThatDisagrees(t *testing.T) {
|
|
// What a node kept is signed by the mesh and verified on load; the state's note of the mode is
|
|
// the host's own. A genesis re-run after `converge`, or a state saved when the kept declaration
|
|
// could not be, leaves them apart — and a loop that refused every five minutes would hold the
|
|
// node off what the mesh said until a delivery that comes only when something changes. The
|
|
// kept declaration wins, and the repair is said.
|
|
opts := stateWithMode(t, store.ModeConverged)
|
|
raw := []byte(`{"declaration":1,"adoption":{"taken":[]},"resources":[{"id":"a","type":"file","path":"` +
|
|
filepath.Join(filepath.Dir(opts.state), "a.conf") + `","content":"x\n"}]}`)
|
|
var said []string
|
|
report := applyAndKeep(context.Background(), opts, raw, nil, nil, func(line string) { said = append(said, line) })
|
|
if strings.Contains(report.Refused, "the declaration says") {
|
|
t.Fatalf("what the node kept was refused against its own note: %s", report.Refused)
|
|
}
|
|
if len(said) != 1 || !strings.Contains(said[0], "record said converged") ||
|
|
!strings.Contains(said[0], "says adopted") || !strings.Contains(said[0], "repaired") {
|
|
t.Errorf("the repair was not said: %q", said)
|
|
}
|
|
}
|
|
|
|
func TestTheMeshItselfMayChangeTheMode(t *testing.T) {
|
|
// The flip is a declaration: `converge` on the controller records the mode and sends the
|
|
// first converged declaration. Delivered by the link, signed, it is not held to the record —
|
|
// it becomes it. (With no system linked in, the apply is refused later for that; what this
|
|
// checks is that the refusal is not the mode's.)
|
|
opts := stateWithMode(t, store.ModeAdopted)
|
|
raw := []byte(`{"declaration":1,"resources":[{"id":"a","type":"file","path":"` +
|
|
filepath.Join(filepath.Dir(opts.state), "a.conf") + `","content":"x\n"}]}`)
|
|
report := applyAndKeep(context.Background(), opts, raw, &store.Declared{Declaration: raw}, nil, nil)
|
|
if strings.Contains(report.Refused, "the declaration says") {
|
|
t.Errorf("the mesh's own flip was refused for its mode: %s", report.Refused)
|
|
}
|
|
}
|
|
|
|
// Defends novox/hq issue 104: a declaration older than what the mesh last said is refused, naming
|
|
// both — and `apply FILE` is refused altogether once the mesh has spoken, the last declaration
|
|
// itself included: from a file it is applied as the bundle is, which would record the mesh's
|
|
// resources as this machine's own and remove the foundation as undeclared.
|
|
func TestAnOlderDeclarationIsRefused(t *testing.T) {
|
|
opts := stateWithMode(t, "")
|
|
genesis := []byte(`{"declaration":1,"resources":[{"id":"g","type":"file","path":"/tmp/g","content":"genesis\n"}]}`)
|
|
since := []byte(`{"declaration":1,"resources":[{"id":"g","type":"file","path":"/tmp/g","content":"since\n"}]}`)
|
|
other := []byte(`{"declaration":1,"resources":[{"id":"g","type":"file","path":"/tmp/g","content":"other\n"}]}`)
|
|
if err := store.Save(opts.state, store.State{Genesis: &store.Genesis{Digest: apply.DigestOf(genesis),
|
|
At: time.Now(), Rewritten: true}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.SaveDeclared(store.DeclaredPath(opts.state), store.Declared{Declaration: since,
|
|
Signature: []byte("unverified here")}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
parsed := func(raw []byte) *declaration.Declaration {
|
|
d, err := declaration.ParseFileTrusted(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return d
|
|
}
|
|
|
|
err := runApply(context.Background(), opts, parsed(genesis), genesis, fromFile)
|
|
if err == nil {
|
|
t.Fatal("the bundle genesis consumed was applied over what the mesh said since")
|
|
}
|
|
for _, want := range []string{"older", short(apply.DigestOf(genesis)), short(apply.DigestOf(since))} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("the refusal does not say %q: %v", want, err)
|
|
}
|
|
}
|
|
|
|
err = runApply(context.Background(), opts, parsed(other), other, fromFile)
|
|
if err == nil {
|
|
t.Fatal("a declaration that is not what the mesh last said was applied")
|
|
}
|
|
if !strings.Contains(err.Error(), "for a machine the mesh has not spoken to") ||
|
|
!strings.Contains(err.Error(), short(apply.DigestOf(since))) {
|
|
t.Errorf("the refusal does not say what a file is for and what was last said: %v", err)
|
|
}
|
|
|
|
// The very declaration the mesh last sent, from a file: still a file.
|
|
err = runApply(context.Background(), opts, parsed(since), since, fromFile)
|
|
if err == nil || !strings.Contains(err.Error(), "applied as the bundle is") {
|
|
t.Errorf("the last declaration, from a file, was not refused as a file: %v", err)
|
|
}
|
|
if known, _ := store.Load(opts.state); len(known.Resources) != 0 {
|
|
t.Errorf("a refused file recorded %d resource(s)", len(known.Resources))
|
|
}
|
|
|
|
// A bundle other than the one genesis consumed: what genesis applied was rewritten for this
|
|
// machine, so the carried bytes never are.
|
|
err = runApply(context.Background(), opts, parsed(other), other, fromBundle)
|
|
if err == nil || !strings.Contains(err.Error(), "consumed") ||
|
|
!strings.Contains(err.Error(), short(apply.DigestOf(genesis))) {
|
|
t.Errorf("a bundle other than the consumed one was not refused naming it: %v", err)
|
|
}
|
|
}
|
|
|
|
// Defends novox/hq issue 104: what an apply would change is said before anything is, and
|
|
// `--dry-run` is that and nothing else — an action listed as the action it is.
|
|
func TestADryRunChangesNothingAndListsTheActions(t *testing.T) {
|
|
opts := stateWithMode(t, "")
|
|
opts.dryRun = true
|
|
out := &bytes.Buffer{}
|
|
opts.out = out
|
|
path := filepath.Join(filepath.Dir(opts.state), "a.conf")
|
|
raw := []byte(`{"declaration":1,"resources":[
|
|
{"id":"a","type":"file","path":"` + path + `","content":"x\n"},
|
|
{"id":"init","type":"action","command":["createdb","mesh"],"verify":["psql","-c","select 1"]}]}`)
|
|
d, err := declaration.ParseFileTrusted(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := runApply(context.Background(), opts, d, raw, fromFile); err != nil {
|
|
t.Fatalf("a dry run failed: %v", err)
|
|
}
|
|
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
|
t.Error("a dry run wrote the file")
|
|
}
|
|
for _, want := range []string{"would change", "create file a", "run action init",
|
|
"`createdb mesh`", "--dry-run: nothing applied"} {
|
|
if !strings.Contains(out.String(), want) {
|
|
t.Errorf("the preview does not say %q:\n%s", want, out.String())
|
|
}
|
|
}
|
|
if strings.Contains(out.String(), "applying:") {
|
|
t.Errorf("a dry run went on to apply:\n%s", out.String())
|
|
}
|
|
|
|
// Machine-readable, the same shape as an apply's: the plan, and no report.
|
|
out.Reset()
|
|
opts.json = true
|
|
if err := runApply(context.Background(), opts, d, raw, fromFile); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var shape struct {
|
|
Plan []apply.Step `json:"plan"`
|
|
Report *json.RawMessage `json:"report"`
|
|
}
|
|
if err := json.Unmarshal(out.Bytes(), &shape); err != nil || len(shape.Plan) != 2 || shape.Report != nil {
|
|
t.Errorf("a json dry run is not {plan} alone: %v\n%s", err, out.String())
|
|
}
|
|
}
|
|
|
|
// Defends novox/hq issue 104: once the mesh has told this node anything, `reconcile` holds it to
|
|
// that — never to the bundle the host carries, which genesis consumed.
|
|
func TestReconcileAfterAControllerDeclarationDoesNotReapplyTheBundle(t *testing.T) {
|
|
was := builtFor
|
|
builtFor = "arch"
|
|
t.Cleanup(func() { builtFor = was })
|
|
opts := stateWithMode(t, store.ModeAdopted)
|
|
|
|
controllerPublic, controllerPrivate, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
said := []byte(`{"declaration":1,"adoption":{"taken":[]},"resources":[{"id":"a","type":"file","path":"/tmp/a","content":"x\n"}]}`)
|
|
if err := store.SaveDeclared(store.DeclaredPath(opts.state), store.Declared{Declaration: said,
|
|
Signature: ed25519.Sign(controllerPrivate, said)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Told, and unable to prove by whom: refused, and the bundle is not applied in its place.
|
|
_, _, _, err = reconcileSource(opts)
|
|
if err == nil || !strings.Contains(err.Error(), "not applied in its place") {
|
|
t.Errorf("a node that cannot prove what it was told fell back to something: %v", err)
|
|
}
|
|
|
|
mine, err := identity.Generate("workstation")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mine.Membership = identity.Membership{Broker: "198.51.100.10:5671", Fingerprint: "sha256:0",
|
|
Signer: controllerPublic, Password: "issued"}
|
|
if err := identity.Save(identity.Path(opts.state), mine); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d, raw, from, err := reconcileSource(opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if from != fromDeclared || !bytes.Equal(raw, said) || d.Adoption == nil {
|
|
t.Errorf("reconcile chose %d with %d bytes, not what the mesh last said", from, len(raw))
|
|
}
|
|
|
|
// And before the mesh has said anything: the bundle, as it always was. A test binary carries
|
|
// only the placeholder, and asking for it is what proves the path.
|
|
if err := os.Remove(store.DeclaredPath(opts.state)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _, _, err = reconcileSource(opts)
|
|
if !errors.Is(err, bundle.ErrEmpty) {
|
|
t.Errorf("with nothing said, reconcile did not reach for the carried bundle: %v", err)
|
|
}
|
|
}
|
|
|
|
// **A machine says which links face outside without being asked.**
|
|
//
|
|
// The mesh composes no filter for a machine that has not said (novox/hq ADR 0140), and a machine only
|
|
// speaks unasked when this fingerprint changes. Left out of it, a machine that has just learnt to say
|
|
// could speak only when a declaration arrived — and a declaration cannot be composed until it has
|
|
// spoken. A machine waiting for a push that is waiting for the machine.
|
|
func TestANewOutwardLinkIsSaidUnasked(t *testing.T) {
|
|
w := &adoptionWatch{}
|
|
first := link.Report{Firewall: "none"}
|
|
if !w.differs(first) {
|
|
t.Fatal("the first report should differ from nothing")
|
|
}
|
|
w.said(first)
|
|
|
|
// Only the links changed, and nothing about adoption.
|
|
learnt := link.Report{Firewall: "none", Outward: []string{"eth0"}}
|
|
if !w.differs(learnt) {
|
|
t.Fatal("a machine that has just learnt which links face outside would never say so, " +
|
|
"and could then never be sent a filter")
|
|
}
|
|
w.said(learnt)
|
|
if w.differs(link.Report{Firewall: "none", Outward: []string{"eth0"}}) {
|
|
t.Fatal("the same links are reported as a change, so the machine would speak on every reconcile")
|
|
}
|
|
|
|
// And a link that changes — a laptop moving from a cable to a radio — is said too, because the
|
|
// filter is written around the old one until it is.
|
|
if !w.differs(link.Report{Firewall: "none", Outward: []string{"wlan0"}}) {
|
|
t.Fatal("a changed outward link is not said, so the filter stays written around the old one")
|
|
}
|
|
}
|
|
|
|
// **A converged machine can say which links face outside, unasked.**
|
|
//
|
|
// A reconcile is otherwise silent, and the condition deciding when it speaks asked only what an
|
|
// adopted node reports — what it holds, and the firewall it found. A converged node has neither, so
|
|
// it could speak only in reply to a declaration, and the mesh composes no declaration for a node
|
|
// that has not said which links face outside (novox/hq ADR 0140). Measured: three converged machines
|
|
// sat silent while the control plane refused to send them a filter.
|
|
func TestAConvergedMachineSaysItsOutwardLinksUnasked(t *testing.T) {
|
|
// A converged node's reconcile: nothing held, no found firewall, and the links it can see.
|
|
converged := link.Report{Outward: []string{"eth0"}}
|
|
if !worthSaying(converged) {
|
|
t.Fatal("a converged machine cannot say which links face outside, so it can never be " +
|
|
"sent a filter — a machine waiting for a push that is waiting for the machine")
|
|
}
|
|
|
|
// An adopted node's reasons still hold, because that is how a predecessor still writing is caught.
|
|
if !worthSaying(link.Report{Firewall: "ufw"}) {
|
|
t.Fatal("an adopted machine no longer says which firewall it found")
|
|
}
|
|
if !worthSaying(link.Report{Held: []link.Held{{ID: "a-file"}}}) {
|
|
t.Fatal("an adopted machine no longer says what it holds")
|
|
}
|
|
|
|
// And a reconcile with nothing to say stays silent, or every machine speaks every five minutes
|
|
// about nothing.
|
|
if worthSaying(link.Report{}) {
|
|
t.Fatal("a reconcile with nothing to say speaks anyway")
|
|
}
|
|
|
|
// A refused report says nothing about the machine; the refusal is for the console.
|
|
if worthSaying(link.Report{Outward: []string{"eth0"}, Refused: "not for this node"}) {
|
|
t.Fatal("a refused report is offered as news about the machine")
|
|
}
|
|
}
|
|
|
|
// **A host running as a service says what its apply did.**
|
|
//
|
|
// The serving path passed nil where the apply writes its detail, and nil is silence. The one-shot path
|
|
// has always passed a real function, so everything the apply says was visible when a person ran it by
|
|
// hand and discarded in the way the host actually runs. Measured before this was written: a machine was
|
|
// converged, its found firewall was not retired, and what the host decided was unrecoverable because it
|
|
// had been said to nobody (novox/hq 04-ISSUES/143).
|
|
//
|
|
// This asserts only that the apply's log is never nil and that a line reaches what the caller gave.
|
|
// **It cannot catch the fault it was written for** — a call site passing nil directly — because that is
|
|
// wiring, and wiring is only proved by running the thing. That proof is a deployed host whose journal
|
|
// carries the apply's detail, which is how this fix was verified.
|
|
func TestTheApplysLogIsNeverNil(t *testing.T) {
|
|
if announceOr(nil) == nil {
|
|
t.Fatal("a host with nowhere to say things got a nil log, which the apply will call")
|
|
}
|
|
announceOr(nil)("this goes nowhere and must not panic")
|
|
|
|
var said []string
|
|
announceOr(func(line string) { said = append(said, line) })(" disabled ufw")
|
|
if len(said) != 1 || !strings.Contains(said[0], "disabled ufw") {
|
|
t.Fatalf("the apply's detail did not reach the caller's announce: %v", said)
|
|
}
|
|
}
|
|
|
|
// Defends novox/hq issues 257 and 261: a reconcile due while the link applies a newer declaration
|
|
// applies that newer one once its turn comes — never the one kept when its timer fired. Read before
|
|
// waiting, it gave back a module assigned a second earlier, and reported a declaration the mesh no
|
|
// longer recorded as sent.
|
|
func TestAReconcileAppliesWhatWasKeptWhenItsTurnComes(t *testing.T) {
|
|
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")}
|
|
public, private, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target := filepath.Join(dir, "a.conf")
|
|
kept := func(content string) store.Declared {
|
|
body := []byte(`{"declaration":1,"resources":[{"id":"a","type":"file","path":"` + target +
|
|
`","content":"` + content + `\n"}]}`)
|
|
return store.Declared{Declaration: body, Signature: ed25519.Sign(private, body)}
|
|
}
|
|
if err := store.SaveDeclared(store.DeclaredPath(opts.state), kept("older")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The link is applying: the reconcile's timer fires and it waits its turn.
|
|
applying.Lock()
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := reapplyKept(context.Background(), opts, public, nil, nil)
|
|
done <- err
|
|
}()
|
|
time.Sleep(50 * time.Millisecond)
|
|
// The link's apply ends by keeping the newer declaration, and lets go.
|
|
if err := store.SaveDeclared(store.DeclaredPath(opts.state), kept("newer")); err != nil {
|
|
applying.Unlock()
|
|
t.Fatal(err)
|
|
}
|
|
applying.Unlock()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("the reconcile never ran once the node was free")
|
|
}
|
|
got, err := os.ReadFile(target)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(got) != "newer\n" {
|
|
t.Errorf("the reconcile applied %q, what was kept before the newer declaration", got)
|
|
}
|
|
}
|