Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88037b33b7 | ||
|
|
422ad516d5 | ||
|
|
8431ecfb48 | ||
|
|
f107d68b2d | ||
|
|
1028193c8a | ||
|
|
f576287b51 | ||
|
|
6c6495f6d9 | ||
|
|
e6d48cf537 | ||
|
|
b8a766f234 | ||
|
|
9caea5bc32 | ||
|
|
df27cee7b7 | ||
|
|
d275e64ed3 | ||
|
|
1a628a4d22 | ||
|
|
b5196e974c | ||
|
|
5162c3b05f | ||
|
|
98fe8edf35 | ||
|
|
cbf50185d0 | ||
|
|
a3b810f1f0 | ||
|
|
971a6d6d03 | ||
|
|
04a27caa43 | ||
|
|
6e90c2692d | ||
|
|
ced54d489f | ||
|
|
94a35a39eb | ||
|
|
ec06369101 |
+154
-22
@@ -51,6 +51,43 @@ var builtFor = ""
|
||||
|
||||
var version = "development build"
|
||||
|
||||
// runningVersion is this host's version: the directory it was delivered into, or the link-time stamp
|
||||
// for one placed by hand.
|
||||
//
|
||||
// **From where it sits, not from its linker** (novox/hq ADR 0142): "It is unpacked into a directory
|
||||
// named for its version, so it can read its own version from its path. The stamp goes, and with it the
|
||||
// need for a build to know what it will be called."
|
||||
//
|
||||
// The mesh's toolchain does not stamp a version, on purpose — a build does not know what it will be
|
||||
// called — so a delivered host read as "development build" and the mesh could not tell which host any
|
||||
// machine ran (novox/hq 04-ISSUES/161, and 087 for why that matters). The path knows: a delivered host
|
||||
// lives at `<libexec>/versions/<version>/<binary>`.
|
||||
//
|
||||
// A host placed by hand keeps its stamp, which is the honest answer for one the mesh did not deliver.
|
||||
func runningVersion() string {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return version
|
||||
}
|
||||
return versionAt(self, version)
|
||||
}
|
||||
|
||||
// versionAt is runningVersion's decision, with the executable's path and the link-time stamp given —
|
||||
// so a test can ask it about a path without being that binary.
|
||||
func versionAt(self, stamped string) string {
|
||||
// .../versions/<version>/<binary> — the parent is the version, and its parent is the versions
|
||||
// directory. Checked rather than assumed, so a binary somewhere else does not read a directory
|
||||
// name as a version.
|
||||
dir := filepath.Dir(self)
|
||||
if filepath.Base(filepath.Dir(dir)) != upgrade.VersionsDirName {
|
||||
return stamped
|
||||
}
|
||||
if name := filepath.Base(dir); name != "" && name != "." && name != string(filepath.Separator) {
|
||||
return name
|
||||
}
|
||||
return stamped
|
||||
}
|
||||
|
||||
const usage = `mesh-host — the node host
|
||||
|
||||
profile what this machine can be asked to do
|
||||
@@ -254,7 +291,7 @@ func run(ctx context.Context, command string, opts options) error {
|
||||
return runLink(ctx, opts)
|
||||
|
||||
case "version":
|
||||
fmt.Println(version)
|
||||
fmt.Println(runningVersion())
|
||||
return nil
|
||||
|
||||
case "", "help", "-h", "--help":
|
||||
@@ -420,10 +457,10 @@ func short(digest string) string {
|
||||
// them again — and everything the mesh declared read as no longer declared and removed. Even the
|
||||
// very declaration the mesh last sent, applied from a file, would plan to remove the foundation.
|
||||
// `apply FILE` is for a machine the mesh has not spoken to, and is refused saying so.
|
||||
func refuseStale(known store.State, kept store.Declared, keptErr error, digest string, from provenance) error {
|
||||
func refuseStale(known store.State, kept store.Declared, keptErr error, digest string, from provenance, sequence int64) error {
|
||||
switch from {
|
||||
case fromDeclared:
|
||||
return nil
|
||||
return refuseOlder(kept, keptErr, sequence)
|
||||
case fromBundle:
|
||||
if known.Genesis == nil || known.Genesis.Digest == digest {
|
||||
return nil
|
||||
@@ -520,7 +557,7 @@ func runApply(ctx context.Context, opts options, d *declaration.Declaration, raw
|
||||
if err := apply.CheckMode(known, d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := refuseStale(known, kept, keptErr, digest, from); err != nil {
|
||||
if err := refuseStale(known, kept, keptErr, digest, from, d.Sequence); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -594,8 +631,8 @@ func runApply(ctx context.Context, opts options, d *declaration.Declaration, raw
|
||||
//
|
||||
// A failure to record is reported and does not fail the apply. The apply worked; what is
|
||||
// lost is a rollback's ability to come back here, which is worse to hide than to say.
|
||||
if version != "" {
|
||||
if err := upgrade.RecordKnownGood(upgrade.KnownGoodPath(opts.state), version); err != nil {
|
||||
if v := runningVersion(); v != "" {
|
||||
if err := upgrade.RecordKnownGood(upgrade.KnownGoodPath(opts.state), v); err != nil {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"mesh-host: applied, but could not record %s as known-good: %v\n"+
|
||||
" a rollback would have nothing to return to.\n", version, err)
|
||||
@@ -706,15 +743,18 @@ func enrol(ctx context.Context, opts options) error {
|
||||
fmt.Printf(" signing key %s\n",
|
||||
base64.StdEncoding.EncodeToString(token.Signer)[:16]+"...")
|
||||
|
||||
// The check that has to happen before this machine says anything.
|
||||
conn, err := link.Dial(token.Broker, token.Fingerprint, opts.timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
fmt.Println("\nthe broker presented the certificate this token pins")
|
||||
|
||||
conn.Close()
|
||||
// **The pin is checked by the connection that presents this token, not by a dial of our own**
|
||||
// (novox/hq 04-ISSUES/146). This opened a raw TLS connection to the bus first, which worked
|
||||
// against the broker the mesh used to run and cannot work against the one it runs now: NATS
|
||||
// speaks its own protocol before it upgrades to TLS, so an immediate handshake is answered
|
||||
// with a plaintext line and the enrolment failed with "first record does not look like a TLS
|
||||
// handshake" — on every node that has tried to join since the bus changed, which is why this
|
||||
// went unnoticed: none had.
|
||||
//
|
||||
// What ADR 0004 requires still holds, and holds better: the client that presents the token
|
||||
// carries the same pinned configuration, reads the server's greeting, upgrades, and the
|
||||
// verification runs inside that handshake — so the one-time secret is sent only after the
|
||||
// certificate has been checked, and nothing of this node's reaches an impostor.
|
||||
|
||||
mine, err := identity.Generate(*name)
|
||||
if err != nil {
|
||||
@@ -788,10 +828,16 @@ func enrol(ctx context.Context, opts options) error {
|
||||
proof := mine.Sign(link.EnrolProof(token.Secret, mine.Public, mine.Overlay.Public,
|
||||
sealing.Public, serving.Public))
|
||||
// The token says where to go and which certificate that address must present. It says nothing
|
||||
// about which bus is there, and does not need to: every token names the one the mesh runs on
|
||||
// today until the rollout (novox/hq ADR 0116 step 5), and that is what an empty Transport is.
|
||||
// about which bus is there, and does not need to: there is one, and this host knows which
|
||||
// (novox/hq ADR 0131 — the mesh speaks to one seat and the old transport is gone).
|
||||
//
|
||||
// **It used to leave this empty** and mean "whatever the mesh runs today", which was true
|
||||
// while two buses existed and became a refusal the moment one did: an empty transport is not
|
||||
// the bus's name, so every enrolment ended at "this token is for the \"\" bus"
|
||||
// (novox/hq 04-ISSUES/146). Nothing caught it because nothing had enrolled since the bus
|
||||
// changed.
|
||||
reply, err := link.Enrol(ctx,
|
||||
link.Approach{Address: token.Broker, Fingerprint: token.Fingerprint},
|
||||
link.Approach{Address: token.Broker, Fingerprint: token.Fingerprint, Transport: link.OnNATS},
|
||||
*name, token.Secret,
|
||||
mine.Public, mine.Overlay.Public, sealing.Public, serving.Public, reported, proof, found,
|
||||
opts.timeout)
|
||||
@@ -1008,7 +1054,12 @@ func runLink(ctx context.Context, opts options) error {
|
||||
// standing before this machine leaves the one it is on (novox/hq design 28, task 5.2).
|
||||
adoptDeliveredMembership(identity.Path(opts.state), &mine, say)
|
||||
|
||||
switch next, waiting, err := upgrade.Successor(upgrade.VersionsDir(""), version); {
|
||||
// Asked with the version this host is RUNNING, read from where it sits — not the link-time
|
||||
// stamp, which every delivered host carries as "development build". Asked with the stamp,
|
||||
// a delivered host never matched the newest delivered version, so it stood aside on every
|
||||
// push for ever, and standing aside cancels the report, so the mesh never heard from it
|
||||
// again (novox/hq 04-ISSUES/163).
|
||||
switch next, waiting, err := upgrade.Successor(upgrade.VersionsDir(""), runningVersion()); {
|
||||
case err != nil:
|
||||
// Said, not fatal. A host that cannot read the delivered versions is still running this
|
||||
// machine correctly; what it has lost is the ability to be replaced.
|
||||
@@ -1198,7 +1249,17 @@ func holdTheMachine(ctx context.Context, opts options, mine identity.Identity, s
|
||||
// A reconcile is otherwise silent. On an adopted node it speaks when what it holds or
|
||||
// its firewall changed, because that is how a predecessor still writing is caught
|
||||
// (novox/hq ADR 0100); publish decides whether anything did.
|
||||
if publish != nil && report.Refused == "" && (len(report.Held) > 0 || report.Firewall != "") {
|
||||
//
|
||||
// **And on any node, when it can say which links face outside** (novox/hq ADR 0140). A
|
||||
// converged node holds nothing and found no firewall, so this gate closed on it and the
|
||||
// node could speak only in reply to a declaration — while the mesh composes no declaration
|
||||
// for a node that has not said which links face outside. A machine waiting for a push that
|
||||
// was waiting for the machine, and measured: three converged machines sat silent while the
|
||||
// control plane refused to send them a filter.
|
||||
//
|
||||
// Offered, not published: whether it is news is still the watch's to decide, so an
|
||||
// unchanged answer costs one comparison every reconcile and nothing on the bus.
|
||||
if publish != nil && worthSaying(report) {
|
||||
publish(report)
|
||||
}
|
||||
switch {
|
||||
@@ -1210,6 +1271,23 @@ func holdTheMachine(ctx context.Context, opts options, mine identity.Identity, s
|
||||
}
|
||||
}
|
||||
|
||||
// worthSaying is whether a reconcile's report carries anything the mesh needs to hear unasked.
|
||||
//
|
||||
// **Named rather than written into the loop**, so the rule can be tested. It was a condition inline
|
||||
// and it was wrong in a way nothing could catch: it asked only what an adopted node reports, so a
|
||||
// converged node — which holds nothing and found no firewall — could speak only in reply to a
|
||||
// declaration, while the mesh composes no declaration for a node that has not said which links face
|
||||
// outside (novox/hq ADR 0140). Three machines sat silent waiting for a push that was waiting for them.
|
||||
//
|
||||
// A refused report says nothing about the machine, so it is not news; the refusal is said on the
|
||||
// console where a person reads it.
|
||||
func worthSaying(report link.Report) bool {
|
||||
if report.Refused != "" {
|
||||
return false
|
||||
}
|
||||
return len(report.Held) > 0 || report.Firewall != "" || len(report.Outward) > 0
|
||||
}
|
||||
|
||||
// applyDeclared applies a declaration that has already been proved to come from the mesh.
|
||||
//
|
||||
// Signature checking happens before this is called, in the link. By the time anything here runs,
|
||||
@@ -1219,6 +1297,21 @@ func applyDeclared(ctx context.Context, opts options, raw []byte, sched *apply.S
|
||||
return applyAndKeep(ctx, opts, raw, nil, sched, say)
|
||||
}
|
||||
|
||||
// announceOr is what the apply writes its detail with, given what the caller has to say things with.
|
||||
//
|
||||
// **Never nil.** This argument was nil on the serving path, and nil is silence: everything the apply
|
||||
// says — a file held, a container replaced, the found firewall retired — was visible when a person ran
|
||||
// the one-shot command and discarded in the way the host actually runs (novox/hq 04-ISSUES/143).
|
||||
//
|
||||
// Named rather than written inline at the call site so both paths reach the apply the same way, and so
|
||||
// a reader asking "where does the apply's output go" finds one answer.
|
||||
func announceOr(say link.Announce) func(string) {
|
||||
if say == nil {
|
||||
return func(string) {}
|
||||
}
|
||||
return say
|
||||
}
|
||||
|
||||
// applying serialises applies within this process.
|
||||
//
|
||||
// **Two things apply here: the link and the reconcile loop**, and each reads the node's state,
|
||||
@@ -1281,8 +1374,18 @@ func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.D
|
||||
|
||||
// Declared, not carried. A declaration from the mesh removes only what the mesh previously
|
||||
// declared — never what this machine raised for itself from its bundle (04-ISSUES/010).
|
||||
// **What the apply says goes to the console, which is the journal when this runs as a service.**
|
||||
//
|
||||
// It was nil, and nil is silence. The one-shot path has always passed a real one, so every detail
|
||||
// the apply produces — a file held, a container replaced, the found firewall retired — was visible
|
||||
// when a person ran it by hand and discarded in the way the host actually runs. Measured: after a
|
||||
// machine was converged and its found firewall was not retired, what the host decided was
|
||||
// unrecoverable, because it had said it to nobody (novox/hq 04-ISSUES/143).
|
||||
//
|
||||
// `say` already reaches stdout, and the launcher's unit sends that to the journal, so this needs
|
||||
// no new mechanism — only for the argument to be passed.
|
||||
outcome, updated, applyErr := apply.ApplyKeeping(ctx, built, declared, known, store.OriginDeclared,
|
||||
apply.ExecRunner, nil, sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)))
|
||||
apply.ExecRunner, announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)))
|
||||
|
||||
// The mode the mesh said, recorded whichever way the apply went: the declaration is kept
|
||||
// either way, and the node is held to it from the next reconcile (novox/hq ADR 0100).
|
||||
@@ -1308,7 +1411,7 @@ func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.D
|
||||
sched.Sync(declared, held)
|
||||
}
|
||||
|
||||
report := link.Report{Carried: carriedPorts(updated), Declared: digestOf(raw), Host: version}
|
||||
report := link.Report{Carried: carriedPorts(updated), Declared: digestOf(raw), Host: runningVersion()}
|
||||
// Which of this machine's links face outside, for the filter the mesh writes around them
|
||||
// (novox/hq ADR 0140). Reported whatever the node's mode: a converged node's filter needs it,
|
||||
// and an adopted one becomes converged without a further round trip. A machine that cannot read
|
||||
@@ -1491,3 +1594,32 @@ func adoptDeliveredMembership(identityPath string, mine *identity.Identity, say
|
||||
say(fmt.Sprintf("moving to the %s bus at %s — restarting to dial it", next.Transport, next.Broker))
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// refuseOlder refuses a declaration from the mesh that is older than the one this node holds.
|
||||
//
|
||||
// **By sequence, not by arrival** (novox/hq 04-ISSUES/107). What the host kept is the last thing
|
||||
// the mesh said, signed; a declaration whose sequence is lower was composed before it, whatever
|
||||
// order they arrived in — a backlog drained after the node was away, or a broker that split a burst.
|
||||
// Applying it would make the machine into something the mesh had already moved past, which is the
|
||||
// incident of issue 104 by another door.
|
||||
//
|
||||
// Only when both sides claim an order. A declaration with no sequence is one an older controller
|
||||
// sent, and one kept with no sequence is one this host received before it understood them; in either
|
||||
// case there is no order to compare, and refusing on a guess would strand the node the moment the
|
||||
// controller is older than the host. Equal is the same declaration again, which reconciling is for.
|
||||
func refuseOlder(kept store.Declared, keptErr error, sequence int64) error {
|
||||
if sequence == 0 || keptErr != nil {
|
||||
return nil
|
||||
}
|
||||
last, err := declaration.ParseTrusted(kept.Declaration)
|
||||
if err != nil || last.Sequence == 0 {
|
||||
return nil
|
||||
}
|
||||
if sequence < last.Sequence {
|
||||
return fmt.Errorf("this declaration is older than what the mesh last said to this node: it "+
|
||||
"is sequence %d, and the one kept here is %d. It arrived late — a backlog, or a broker "+
|
||||
"that split a burst — and applying it would make this machine into something the mesh has "+
|
||||
"already moved past. Refused whole; nothing was applied", sequence, last.Sequence)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -533,3 +533,63 @@ func TestANewOutwardLinkIsSaidUnasked(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// A declaration carries no order, so a host cannot tell an older one from a newer (novox/hq
|
||||
// 04-ISSUES/107). Its only identity was the digest of its bytes: "not the last" could be said,
|
||||
// "older" could not.
|
||||
|
||||
func keptWith(t *testing.T, sequence int64) store.Declared {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"declaration": 1, "resources": []any{}, "owns_nothing": true, "sequence": sequence,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return store.Declared{Declaration: body, Signature: []byte("x")}
|
||||
}
|
||||
|
||||
func TestAnOlderDeclarationFromTheMeshIsRefused(t *testing.T) {
|
||||
err := refuseOlder(keptWith(t, 7), nil, 5)
|
||||
if err == nil {
|
||||
t.Fatal("sequence 5 was accepted over a kept 7")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "older") || !strings.Contains(err.Error(), "nothing was applied") {
|
||||
t.Fatalf("the refusal does not say what it is: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewerOrEqualDeclarationIsNot(t *testing.T) {
|
||||
if err := refuseOlder(keptWith(t, 7), nil, 8); err != nil {
|
||||
t.Fatalf("sequence 8 was refused over a kept 7: %v", err)
|
||||
}
|
||||
// Equal is the same declaration again, which reconciling is for.
|
||||
if err := refuseOlder(keptWith(t, 7), nil, 7); err != nil {
|
||||
t.Fatalf("the same sequence was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoOrderClaimedMeansNoOrderCompared(t *testing.T) {
|
||||
// An older controller sends none; a host that received before it understood them kept none.
|
||||
// Refusing on a guess would strand a node the moment the controller is older than the host.
|
||||
if err := refuseOlder(keptWith(t, 7), nil, 0); err != nil {
|
||||
t.Fatalf("a declaration claiming no order was refused: %v", err)
|
||||
}
|
||||
if err := refuseOlder(keptWith(t, 0), nil, 3); err != nil {
|
||||
t.Fatalf("a declaration was refused against a kept one that claimed no order: %v", err)
|
||||
}
|
||||
if err := refuseOlder(store.Declared{}, store.ErrNothingDeclared, 3); err != nil {
|
||||
t.Fatalf("a first declaration was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A component's version comes from where it sits, not from its linker (novox/hq ADR 0142). The mesh's
|
||||
// toolchain stamps no version — a build does not know what it will be called — so a delivered host
|
||||
// read as "development build" and the mesh could not tell which host a machine ran (04-ISSUES/161).
|
||||
|
||||
func TestADeliveredHostReadsItsVersionFromItsPath(t *testing.T) {
|
||||
// A delivered host lives at <libexec>/versions/<version>/<binary>.
|
||||
dir := t.TempDir()
|
||||
versioned := filepath.Join(dir, "versions", "637f65559d16")
|
||||
if err := os.MkdirAll(versioned, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
self := filepath.Join(versioned, "nox-mesh-host")
|
||||
if err := os.WriteFile(self, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versionAt(self, "development build"); got != "637f65559d16" {
|
||||
t.Fatalf("a delivered host read its version as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHostPlacedByHandKeepsItsStamp(t *testing.T) {
|
||||
// The honest answer for one the mesh did not deliver — and every machine is in that state until
|
||||
// a delivery reaches it.
|
||||
if got := versionAt("/usr/bin/nox-mesh-host", "04a27ca"); got != "04a27ca" {
|
||||
t.Fatalf("a hand-placed host read its version as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADirectoryThatIsNotAVersionIsNotReadAsOne(t *testing.T) {
|
||||
// A binary sitting anywhere else must not have its parent directory's name read as a version.
|
||||
for _, path := range []string{
|
||||
"/opt/somewhere/nox-mesh-host",
|
||||
"/usr/lib/nox-mesh-host/launch",
|
||||
"/home/someone/build/nox-mesh-host",
|
||||
} {
|
||||
if got := versionAt(path, "the stamp"); got != "the stamp" {
|
||||
t.Fatalf("%s read its version as %q", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,12 +127,21 @@
|
||||
{
|
||||
"id": "bus-certificate",
|
||||
"type": "action",
|
||||
"command": ["docker", "run", "--rm", "--entrypoint", "sh", "-v", "mesh-broker-tls:/tls",
|
||||
"192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927",
|
||||
"-c", "test -f /tls/tls.crt || (openssl req -x509 -newkey rsa:2048 -nodes -keyout /tls/tls.key -out /tls/tls.crt -days 3650 -subj '/CN=mesh-broker' -addext 'subjectAltName=DNS:mesh-broker,IP:127.0.0.1' >/dev/null 2>&1 && chmod 644 /tls/tls.crt && chmod 600 /tls/tls.key)"],
|
||||
"verify": ["docker", "run", "--rm", "--entrypoint", "sh", "-v", "mesh-broker-tls:/tls",
|
||||
"192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927",
|
||||
"-c", "test -s /tls/tls.crt && openssl x509 -in /tls/tls.crt -noout"]
|
||||
// **The mesh makes its own** (novox/hq 04-ISSUES/146). This ran `openssl` inside the
|
||||
// broker's image while the broker was one that carried it; the bus that replaced it has a
|
||||
// shell and no openssl, and no other image the bundle names has one either. So the program
|
||||
// that needs the certificate writes it — already on this machine, since the schema step ran
|
||||
// it, and asking nothing of the image it writes into. Self-signed on purpose: a host pins
|
||||
// this server's exact certificate (novox/hq ADR 0004), and at this moment there is no mesh
|
||||
// to ask an authority of.
|
||||
// `--user 0:0` because the volume is root's and this image runs as nobody, which is right
|
||||
// for the long-running control plane and wrong for a one-shot writing into a fresh volume.
|
||||
"command": ["docker", "run", "--rm", "--user", "0:0", "-v", "mesh-broker-tls:/tls",
|
||||
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
|
||||
"broker", "certificate", "--into", "/tls"],
|
||||
"verify": ["docker", "run", "--rm", "--user", "0:0", "-v", "mesh-broker-tls:/tls",
|
||||
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
|
||||
"broker", "certificate", "--check", "--into", "/tls"]
|
||||
},
|
||||
{
|
||||
"id": "bus-conf-dir",
|
||||
|
||||
@@ -1501,13 +1501,15 @@ func containerSpecReading(r *declaration.Container, declares, reads map[string]s
|
||||
for _, a := range r.Args {
|
||||
b.WriteString("arg " + a + "\n")
|
||||
}
|
||||
// **The mesh's names are part of what a container is** (novox/hq 04-ISSUES/135). A container
|
||||
// resolves every other machine and every public name through the entries the mesh gives it at
|
||||
// creation, and nothing re-reads them afterwards — so a container left alone when the roster
|
||||
// moved is one that cannot reach anything by name, for ever, while every check reports it
|
||||
// running. That is exactly what happened when this mesh's overlay range changed: one container
|
||||
// whose image and files never changed kept an address five days out of date and restarted
|
||||
// 2286 times against a database it could no longer find.
|
||||
// **A container's declared names are part of what it is** (novox/hq 04-ISSUES/135). What is
|
||||
// here is what the module declared for itself and nothing else: the mesh's own names are no
|
||||
// longer written into a container (ADR 0148) — they were once, every container got the whole
|
||||
// roster at creation and nothing re-read it, so one left alone when the roster moved could not
|
||||
// reach anything by name for as long as it ran while every check reported it running; and once
|
||||
// the roster was in this digest so that could be caught, one name moving anywhere replaced
|
||||
// every container in the mesh (04-ISSUES/151). A container resolves a mesh name through the
|
||||
// machine's resolver at the moment it asks. What a module declares does not move when the
|
||||
// roster does, so hashing it costs nothing and catches a manifest that changed.
|
||||
//
|
||||
// Sorted, so the digest does not move for a reordering nobody made.
|
||||
hosts := append([]string(nil), r.Hosts...)
|
||||
|
||||
@@ -1137,6 +1137,19 @@ type Declaration struct {
|
||||
// converged node — which is every node the mesh raised before adoption existed, and so the
|
||||
// only form an older controller ever sends (novox/hq ADR 0100).
|
||||
Adoption *Adoption
|
||||
|
||||
// Sequence orders this declaration against every other the mesh has sent this node: each
|
||||
// send is one higher than the last, assigned under the control plane's hold on the node
|
||||
// (novox/hq 04-ISSUES/107). Zero is a declaration that carries no order — every one an older
|
||||
// controller sent, and the bundle genesis applies — and a host makes no ordering claim about
|
||||
// one of those.
|
||||
//
|
||||
// **The one property a declaration needs that its signature does not give it.** A signature
|
||||
// says the mesh sent this; it cannot say the mesh sent it AFTER the one the host is holding.
|
||||
// Before this, "older" was inferred from arrival within a batch and a 750ms window, and a
|
||||
// backlog longer than the batch, or a slow broker, applied a declaration the mesh had already
|
||||
// superseded.
|
||||
Sequence int64
|
||||
}
|
||||
|
||||
// Adoption is a node's mode, as the controller records it: the node is adopted, and these are
|
||||
@@ -1274,6 +1287,9 @@ type envelope struct {
|
||||
// a bug quietly strip a machine.
|
||||
OwnsNothing bool `json:"owns_nothing,omitempty"`
|
||||
Resources []json.RawMessage `json:"resources"`
|
||||
// Sequence is optional on the wire, so a controller that does not send one is still
|
||||
// understood: absent reads as zero, which is "no ordering claimed" rather than "first".
|
||||
Sequence int64 `json:"sequence,omitempty"`
|
||||
}
|
||||
|
||||
func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
||||
@@ -1290,7 +1306,7 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
||||
env.Version, Version)}}
|
||||
}
|
||||
|
||||
d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption}
|
||||
d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence}
|
||||
var problems []string
|
||||
|
||||
if len(env.Resources) == 0 && !env.OwnsNothing {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The count that did not add up was the only symptom sixteen held resources had, and reading it meant
|
||||
// opening the node's state file by hand (novox/hq 04-ISSUES/125). The line that says what an apply did
|
||||
// says what it did not, too.
|
||||
|
||||
func TestTheApplyLineSaysWhatItHeldAndForWhichModule(t *testing.T) {
|
||||
got := heldNote([]Held{
|
||||
{ID: "ca", Module: "route-proxy", Kind: "directory"},
|
||||
{ID: "certs", Module: "route-proxy", Kind: "directory"},
|
||||
{ID: "server", Module: "route-proxy", Kind: "container"},
|
||||
{ID: "mail", Module: "mailu", Kind: "container"},
|
||||
})
|
||||
if !strings.Contains(got, "4 held") {
|
||||
t.Fatalf("the count of what was held is not in the line: %q", got)
|
||||
}
|
||||
// The module is the thing an operator can act on: `take` takes a module.
|
||||
if !strings.Contains(got, "route-proxy: 3") || !strings.Contains(got, "mailu: 1") {
|
||||
t.Fatalf("the line does not break the holds down by module: %q", got)
|
||||
}
|
||||
// Ordered, so two machines holding the same things read the same and a diff of two reports is
|
||||
// about what changed.
|
||||
if strings.Index(got, "mailu") > strings.Index(got, "route-proxy") {
|
||||
t.Fatalf("modules are not in a stated order: %q", got)
|
||||
}
|
||||
// It says why, because "held" alone reads as a failure and this is correct behaviour.
|
||||
if !strings.Contains(got, "taken") {
|
||||
t.Fatalf("the line does not say a hold ends when the module is taken: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnApplyThatHeldNothingSaysNothingExtra(t *testing.T) {
|
||||
// A converged machine holds nothing, which is most applies. Reporting "0 held" on every one of
|
||||
// them is how a line stops being read.
|
||||
if got := heldNote(nil); got != "" {
|
||||
t.Fatalf("an apply with no holds added %q to its line", got)
|
||||
}
|
||||
if got := heldNote([]Held{}); got != "" {
|
||||
t.Fatalf("an apply with no holds added %q to its line", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The drain picked the last to arrive. A backlog longer than the batch, or a broker that split a
|
||||
// burst, delivered a superseded declaration last (novox/hq 04-ISSUES/107).
|
||||
|
||||
func sequenced(t *testing.T, n int64) *said {
|
||||
t.Helper()
|
||||
inner, err := json.Marshal(map[string]any{"declaration": 1, "resources": []any{}, "sequence": n})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := json.Marshal(Signed{Declaration: inner, Signature: []byte("s")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &said{body: body}
|
||||
}
|
||||
|
||||
func TestTheDrainKeepsTheHighestSequenceNotTheLastToArrive(t *testing.T) {
|
||||
waiting := make(chan Declaration, 8)
|
||||
waiting <- sequenced(t, 9)
|
||||
waiting <- sequenced(t, 4) // arrived last, composed earlier
|
||||
latest, aside := newest(waiting, sequenced(t, 8), 30*time.Millisecond)
|
||||
if got := sequenceOf(latest.Body()); got != 9 {
|
||||
t.Fatalf("the drain kept sequence %d, and 9 was waiting", got)
|
||||
}
|
||||
if len(aside) != 2 {
|
||||
t.Fatalf("%d set aside, wanted 2 (the 8 and the late 4)", len(aside))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutSequencesTheLastToArriveStillWins(t *testing.T) {
|
||||
// The behaviour this had before, kept for a controller that sends no order.
|
||||
apply, aside := newest(arriving("two", "three"), &said{body: []byte("one")}, 30*time.Millisecond)
|
||||
if string(apply.Body()) != "three" || len(aside) != 2 {
|
||||
t.Fatalf("applied %q with %d set aside", apply.Body(), len(aside))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnreadableBodyClaimsNoOrder(t *testing.T) {
|
||||
if got := sequenceOf([]byte("not json")); got != 0 {
|
||||
t.Fatalf("garbage claimed sequence %d", got)
|
||||
}
|
||||
}
|
||||
+14
-3
@@ -73,8 +73,19 @@ func PinnedConfig(pin string) (*tls.Config, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Dial opens a TLS connection to the broker, refusing anything but the pinned certificate.
|
||||
func Dial(address, pin string, timeout time.Duration) (*tls.Conn, error) {
|
||||
// dialPinned completes a TLS handshake against an address, refusing anything but the pinned
|
||||
// certificate.
|
||||
//
|
||||
// **Not how the bus is reached, and it used to be** (novox/hq 04-ISSUES/146). Enrolment opened one
|
||||
// of these before it said anything, which was right while the broker answered TLS immediately and
|
||||
// wrong the moment the mesh moved to a bus that speaks its own protocol first. The pin itself was
|
||||
// never the problem — PinnedConfig is what the NATS client is given, and the verification runs
|
||||
// inside the handshake that client performs.
|
||||
//
|
||||
// It stays here because this is where the pin is proven: the tests beside it run a real TLS server
|
||||
// and assert that a wrong certificate is refused before a byte of application data is sent. What it
|
||||
// must not become again is something a caller uses to reach the bus.
|
||||
func dialPinned(address, pin string, timeout time.Duration) (*tls.Conn, error) {
|
||||
config, err := PinnedConfig(pin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -86,7 +97,7 @@ func Dial(address, pin string, timeout time.Duration) (*tls.Conn, error) {
|
||||
if errors.Is(err, ErrWrongCertificate) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("cannot reach the broker at %s: %w", address, err)
|
||||
return nil, fmt.Errorf("cannot reach %s: %w", address, err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func server(t *testing.T) (address string, fingerprint string) {
|
||||
|
||||
func TestTheRightBrokerIsAccepted(t *testing.T) {
|
||||
address, pin := server(t)
|
||||
conn, err := Dial(address, pin, 5*time.Second)
|
||||
conn, err := dialPinned(address, pin, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("the broker its token describes was refused: %v", err)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func TestADifferentBrokerIsRefused(t *testing.T) {
|
||||
address, _ := server(t)
|
||||
_, other := server(t)
|
||||
|
||||
_, err := Dial(address, other, 5*time.Second)
|
||||
_, err := dialPinned(address, other, 5*time.Second)
|
||||
if err == nil {
|
||||
t.Fatal("a broker presenting a different certificate was accepted")
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestNothingIsSentToTheWrongBroker(t *testing.T) {
|
||||
|
||||
// A pin for a certificate this server does not have.
|
||||
_, elsewhere := server(t)
|
||||
if _, err := Dial(listener.Addr().String(), elsewhere, 5*time.Second); err == nil {
|
||||
if _, err := dialPinned(listener.Addr().String(), elsewhere, 5*time.Second); err == nil {
|
||||
t.Fatal("the impostor was accepted")
|
||||
}
|
||||
if n := <-received; n > 0 {
|
||||
@@ -160,7 +160,7 @@ func TestAnUnreachableBrokerIsAnOrdinaryFailure(t *testing.T) {
|
||||
address := listener.Addr().String()
|
||||
listener.Close()
|
||||
|
||||
_, err = Dial(address, pin, 2*time.Second)
|
||||
_, err = dialPinned(address, pin, 2*time.Second)
|
||||
if err == nil {
|
||||
t.Fatal("dialling a closed port succeeded")
|
||||
}
|
||||
|
||||
+67
-2
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -250,9 +252,11 @@ func Run(ctx context.Context, m Membership, apply Applier, say Announce, timeout
|
||||
case report.Refused != "":
|
||||
say("refused a declaration: " + report.Refused)
|
||||
case len(report.Failed) > 0:
|
||||
say(fmt.Sprintf("applied %d and failed: %v", len(report.Applied), report.Failed))
|
||||
say(fmt.Sprintf("applied %d and failed: %v%s",
|
||||
len(report.Applied), report.Failed, heldNote(report.Held)))
|
||||
default:
|
||||
say(fmt.Sprintf("applied %d resource(s)", len(report.Applied)))
|
||||
say(fmt.Sprintf("applied %d resource(s)%s",
|
||||
len(report.Applied), heldNote(report.Held)))
|
||||
}
|
||||
publishReport(ctx, link, m, report, say, timeout)
|
||||
// Settled after the report is published. A node that dies between applying and
|
||||
@@ -296,6 +300,16 @@ func newest(arriving <-chan Declaration, first Declaration, window time.Duration
|
||||
if !ok {
|
||||
return latest, superseded
|
||||
}
|
||||
// **By sequence when both carry one, by arrival when either does not** (novox/hq
|
||||
// 04-ISSUES/107). Arrival is what this window had to go on, and it is wrong exactly
|
||||
// when it matters — a backlog drained out of order. A declaration that says where it
|
||||
// stands is believed over when it turned up; one that does not is the older
|
||||
// controller's, and arrival is all there is.
|
||||
if sequenceOf(next.Body()) < sequenceOf(latest.Body()) &&
|
||||
sequenceOf(next.Body()) > 0 && sequenceOf(latest.Body()) > 0 {
|
||||
superseded = append(superseded, next)
|
||||
continue
|
||||
}
|
||||
superseded = append(superseded, latest)
|
||||
latest = next
|
||||
case <-time.After(window):
|
||||
@@ -304,6 +318,24 @@ func newest(arriving <-chan Declaration, first Declaration, window time.Duration
|
||||
}
|
||||
}
|
||||
|
||||
// sequenceOf is the order a signed declaration claims, or zero when it claims none or cannot be
|
||||
// read. Read from the envelope alone; the signature is verified later, when the winner is applied,
|
||||
// and a forged message that lied about its sequence would only set aside real ones — which are
|
||||
// reported as set aside, and the next push sends the current one again.
|
||||
func sequenceOf(body []byte) int64 {
|
||||
var signed Signed
|
||||
if err := json.Unmarshal(body, &signed); err != nil {
|
||||
return 0
|
||||
}
|
||||
var d struct {
|
||||
Sequence int64 `json:"sequence"`
|
||||
}
|
||||
if err := json.Unmarshal(signed.Declaration, &d); err != nil {
|
||||
return 0
|
||||
}
|
||||
return d.Sequence
|
||||
}
|
||||
|
||||
// declaredIn is the id a signed declaration carries, for a report about one that was not applied.
|
||||
// Empty if the message is not one — a forged or garbled message is refused by handleBody when its
|
||||
// turn comes; here it is only named.
|
||||
@@ -392,3 +424,36 @@ func publishAlive(ctx context.Context, bus Bus, m Membership, say Announce,
|
||||
say("could not tell the mesh this node is here: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// heldNote is what this apply did NOT do, for the line that says what it did.
|
||||
//
|
||||
// **A count that does not add up is the only symptom a held resource had** (novox/hq 04-ISSUES/125).
|
||||
// An adopted node keeps what it found until its module is taken (ADR 0100), and that is correct — but
|
||||
// it was recorded only in the node's own state file. On the edge cut-over the mesh sent 346 resources,
|
||||
// the journal said it applied 330, and nothing anywhere said which sixteen or why. Reading it took
|
||||
// opening state.json by hand; not reading it took every public name on the machine down, because the
|
||||
// operator had four green surfaces and a discrepancy nobody could interpret.
|
||||
//
|
||||
// So the line that reports the apply carries it. Grouped by module and ordered by name, because the
|
||||
// sentence an operator needs is "route-proxy is assigned and not taken", and the module is the thing
|
||||
// they can act on — `take` is the verb, and it takes a module.
|
||||
func heldNote(held []Held) string {
|
||||
if len(held) == 0 {
|
||||
return ""
|
||||
}
|
||||
byModule := map[string]int{}
|
||||
for _, h := range held {
|
||||
byModule[h.Module]++
|
||||
}
|
||||
names := make([]string, 0, len(byModule))
|
||||
for name := range byModule {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
parts := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
parts = append(parts, fmt.Sprintf("%s: %d", name, byModule[name]))
|
||||
}
|
||||
return fmt.Sprintf(", %d held until their module is taken (%s)",
|
||||
len(held), strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
+32
File diff suppressed because one or more lines are too long
@@ -0,0 +1,48 @@
|
||||
package packaging_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The mesh delivers the launcher, so the manifest carries a copy of it (novox/hq 04-ISSUES/142).
|
||||
//
|
||||
// **Two copies of one script is a drift waiting to happen**, and the only reason to accept it is that
|
||||
// a file resource is written atomically — temp file, then rename — while an archive writes in place
|
||||
// with truncate. The running launcher keeps the inode it was started from and the next start picks up
|
||||
// the new one; unpacking an archive over it would truncate the file a running shell is reading.
|
||||
//
|
||||
// So: two copies, and this is the check that they are the same one.
|
||||
func TestTheManifestCarriesTheLauncherExactly(t *testing.T) {
|
||||
onDisk, err := os.ReadFile("nox-mesh-host-launch")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile("../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manifest struct {
|
||||
Resources []struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range manifest.Resources {
|
||||
if r.ID != "launcher" {
|
||||
continue
|
||||
}
|
||||
if r.Content != string(onDisk) {
|
||||
t.Fatal("the launcher the mesh would deliver is not the launcher in this repository. " +
|
||||
"Copy packaging/nox-mesh-host-launch into module.json's `launcher` resource — the " +
|
||||
"machines run what the manifest says, and this file is what gets reviewed")
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("module.json declares no `launcher` resource, so nothing delivers the launcher and a " +
|
||||
"delivered host version is never started")
|
||||
}
|
||||
Reference in New Issue
Block a user