Review of the fix for hq issue 104 found three faults in it. A file applied
on an enrolled node — the mesh's own last declaration included — is applied
as the bundle is, so its resources are recorded as the machine's own and
what the mesh declared reads as undeclared: the plan removed the foundation.
`apply FILE` is for a machine the mesh has not spoken to, and is now refused
saying so whenever declared.json exists. The plan looked at what is held
before what the declaration says is taken, so the one cutover ADR 0100 says
must be previewed read as a hold; it now decides in holdOnAdopted's order,
models a step run inside a held container, and a test holds the plan's
sequence to the apply's outcomes. Genesis wrote the mode on every run, so a
re-run after `converge` left the state saying adopted while the kept,
signed declaration said converged, and the reconcile loop refused every five
minutes with no delivery coming to end it: genesis now writes the mode only
when none is recorded, and where the state and the verified kept declaration
disagree, the kept declaration wins and the repair is said.
Also: a file lock beside the state, taken by the link service, the host's
own commands and the installer alike, so a `reconcile` run by hand no
longer races the loop's save — chosen over refusing while a named service is
active, which would miss a `mesh-host run` started by hand; `--json
--dry-run` emits {plan} like an apply emits {plan, report}; the README's
duplicate flag line; and the bundle refusal is about the digest, not a claim
the carried bytes can never match what genesis applied.
504 lines
20 KiB
Go
504 lines
20 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")
|
|
}
|
|
}
|
|
|
|
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, 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)
|
|
}
|
|
}
|