Compare commits

..
Author SHA1 Message Date
jschoubben 8ad86bf50f Look at what a container mounts directly, accept a pre-upgrade label, and write the genesis secret without a newline
Review of the first cut found four things.

A directory mounted into a container is no longer looked inside, not even
for the files this host wrote there. The controller records every
provider's received and contributions file as a plain file under a mounted
directory, so folding those in would have recreated the route proxy — which
re-reads its routes live, by design — on every route change, and killed
every provisioner sidecar, which polls what it receives, mid-reconcile on
every grant. Whether a service reads a file under its directory once or
watches it is the service's; restart-on is how a module says "once", and it
stays the opt-in. Env-files and files mounted directly remain by content.

Genesis wrote the superuser secret as `value\n`; `secret accept` strips the
line ending by design, so the postgres module declared `value` — and with
a mounted file's content in the spec, phase three would have recreated the
store it meant to adopt in place, with the temporary control plane
connected to it. Genesis now writes the value alone. readCredentialFile
tolerated both endings already. Pinned with the bytes the genesis code
path writes, then the module's declaration of the same container: it must
reconcile.

A container carrying a label from before the host folded in what it reads
is accepted rather than recreated, when that label matches the spec as it
used to be computed: what it reads is recorded then, a change is caught
from that record from the next apply on, and the label is renewed at the
next genuine recreate. Recreating them all would have been a restart storm
across the mesh in declaration order, the store first. The trade-off is
stated in the code: a container already stale at upgrade time is not
caught, and could not have been either way.

The record of what a container read is looked up by its name when its
declared id has none — the bundle's `store` becomes `postgres.server` for
the same container — so a change on the day it is adopted still names the
file. The by-target lookup takes the most recently applied record, since
the bundle's record for the same target is never removed by the mesh's.

novox/hq 04-ISSUES/103
2026-09-23 23:37:50 +02:00
jschoubben c0d94e04f3 Recreate a container when the content of a file it reads at creation changes
The host decided whether a container was still the one declared by a digest
of its declaration, and the declaration names an env-file's path and a
mount's path — never what is in them. So when the store was given a new
port, the host rewrote the forge's and the analytics service's environment
files, correctly, and left both containers running with the old port in
their environment: a container reads its env-file when it is CREATED, and
`docker restart` hands it the same environment again. Both looked healthy
until they answered 502.

What a running container takes in at creation is now part of its spec, by
content: every env-file, a file bind-mounted into it, and every file this
host wrote at or under a directory bind-mounted into it — the secrets,
bindings and configs under a module's state directories. The digest is the
one the store already records for a file the host wrote (`wrote`), read
from the state as it stands when the container is reached, so a file
rewritten earlier in the same apply is already the new one; a file the host
has no record of — an env-file a predecessor left, the superuser secret
genesis writes before any declaration names it — is read from disk, which
is what keeps adopting a running store in place a reconcile and not a
recreate.

Deliberately not part of it: what else is in a bind-mounted directory,
which is the service's own data and changes while it runs; a named volume;
a seed created once, which digests as the seed the host wrote and not as
what has grown in it; and a step — a run-once or scheduled container reads
its files when it runs and runs fresh each time. On an adopted node a held
container is held before any of this is looked at.

The host records what each container was created reading, per file, so
the recreate can say which file changed — "recreated: <file> changed" in
the report and, now with its detail, in the log. A container made before
this record existed is recreated once and says so.

novox/hq 04-ISSUES/103
2026-09-23 23:12:52 +02:00
127 changed files with 690 additions and 15719 deletions
+4 -13
View File
@@ -55,13 +55,12 @@ connects to nothing and listens on nothing — what it applies comes from a file
mesh-host profile what this machine can be asked to do
mesh-host inventory what this machine is, and what it holds
mesh-host apply FILE make this machine match a declaration from a file
mesh-host reconcile make this machine match what the mesh last told it — or, before
any mesh has, the bundle this host carries
mesh-host reconcile make this machine match the declaration this host carries
mesh-host bundle show what this host carries
mesh-host owned what this host has applied and still owns
--json machine-readable
--state where this node keeps what it knows
--dry-run say what applying would change, and change nothing
--dry-run read and check the declaration, change nothing
```
```
@@ -115,16 +114,8 @@ A host built for a machine carries its declaration **inside the binary**:
make host BUNDLE=path/to/foundation.lock
```
`mesh-host reconcile` then applies it, on a machine the mesh has told nothing yet. That is the
first node's path — no mesh present, nothing fetched, nothing else copied onto the machine. Once
the mesh has spoken, `reconcile` holds the machine to what it last said and never to the bundle,
which genesis consumed; a bundle or a file is refused when it says the other mode than the node
is in; and `apply FILE` is refused altogether once the mesh has spoken — a file is applied as the
bundle is, its resources recorded as the machine's own, so on an enrolled node it would plan to
remove the foundation. `apply FILE` is for a machine the mesh has not spoken to. (hq's to-be
node lifecycle describes `apply repair.json` as a rescue on an enrolled node; that line is being
amended in hq, and no rescue path exists here yet.) Both commands say what they would change
before changing anything, and `--dry-run` is that alone. `copy it and run it` stops being true the moment
`mesh-host reconcile` then applies it. That is the first node's path — no mesh present, nothing
fetched, nothing else copied onto the machine. `copy it and run it` stops being true the moment
a second file has to arrive with it, which is why the bundle is embedded rather than beside it.
**A default build carries nothing and refuses to reconcile**, saying so. A host that applied
-6
View File
@@ -140,10 +140,6 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh
firewall stay as they are, the foundation's filter is not loaded and
the mesh guards its own ports instead, and each module is taken on it
one at a time. Without it, a machine in use is refused
--tunnel adopted: the interface of the tunnel the private network takes over
(its key, port, range and peers); found by itself when one is up, and
needed only when several are. --hub-port and --overlay-range then
follow the tunnel
The installer carries a builder, not a control plane. What raises a mesh is therefore
the same thing that will maintain it, and the control plane a mesh ends up running is
@@ -338,8 +334,6 @@ func newFlagSet(opts *bootstrap.Options, jsonOut *bool) *flag.FlagSet {
"raise this machine adopted: keep what it runs and its firewall until each module is taken")
set.StringVar(&opts.OverlayRange, "overlay-range", opts.OverlayRange,
"the private network's address range; must not overlap a tunnel the machine already runs")
set.StringVar(&opts.Tunnel, "tunnel", "",
"adopted: the found tunnel's interface the private network takes over; found by itself when one is up")
if opts.Answers == nil {
opts.Answers = map[string]string{}
}
+62 -745
View File
File diff suppressed because it is too large Load Diff
+1 -348
View File
@@ -1,21 +1,14 @@
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"
@@ -171,22 +164,6 @@ func TestAReconcileSpeaksOnlyWhenWhatIsHeldChanged(t *testing.T) {
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")
}
}
func TestWhatTheLinkPublishedCountsAsSaid(t *testing.T) {
@@ -241,7 +218,7 @@ func TestOnlyOneApplyRunsAtATime(t *testing.T) {
// 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) }()
go func() { done <- applyAndKeep(context.Background(), opts, raw, nil, nil) }()
select {
case report := <-done:
applying.Unlock()
@@ -285,327 +262,3 @@ func TestAChangeThatNeverReachedTheMeshIsSaidAgain(t *testing.T) {
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)
}
}
-58
View File
@@ -1,58 +0,0 @@
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)
}
}
-94
View File
@@ -1,94 +0,0 @@
package main
import (
"crypto/ed25519"
"strings"
"testing"
"github.com/novox/mesh-host/internal/identity"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/tunnel"
)
// novox/hq ADR 0105: `overlay take` moves this node's overlay key onto the found tunnel's and
// nothing else — identity, sealing and serving keys stay as they were — and tells the mesh with a
// proof signed by the identity key, over the previous key, the new one and the tunnel.
func anEnrolledNode(t *testing.T) identity.Identity {
t.Helper()
mine, err := identity.Generate("anchor")
if err != nil {
t.Fatal(err)
}
mine.Overlay, err = identity.GenerateOverlayKey()
if err != nil {
t.Fatal(err)
}
mine.Membership = identity.Membership{Broker: "198.51.100.1:5671", Fingerprint: "sha256:aa",
Signer: make([]byte, ed25519.PublicKeySize), Password: "p"}
return mine
}
func aFoundTunnel(t *testing.T) tunnel.Found {
t.Helper()
private, err := identity.GenerateOverlayKey()
if err != nil {
t.Fatal(err)
}
found, err := tunnel.Parse([]byte("[Interface]\nPrivateKey = " + private.Private + "\nListenPort = 51900\n" +
"Address = 192.0.2.1/24\n[Peer]\nPublicKey = PEER-A=\nAllowedIPs = 192.0.2.2/32\n"))
if err != nil {
t.Fatal(err)
}
found.Interface, found.Unit, found.Config = "wg0", "wg-quick@wg0", "/etc/wireguard/wg0.conf"
return found
}
func TestTakingATunnelMovesOnlyTheOverlayKeyAndSignsForIt(t *testing.T) {
mine := anEnrolledNode(t)
found := aFoundTunnel(t)
before := mine.Overlay.Public
taken, rekey, err := rekeyOnto(mine, found)
if err != nil {
t.Fatal(err)
}
if taken.Overlay.Public != found.PublicKey || taken.Overlay.Private != found.PrivateKey() {
t.Fatal("the overlay key is not the tunnel's")
}
if string(taken.Public) != string(mine.Public) || string(taken.Private) != string(mine.Private) ||
taken.Node != mine.Node || taken.Membership.Password != mine.Membership.Password ||
taken.Membership.Broker != mine.Membership.Broker {
t.Fatal("something other than the overlay key moved")
}
if taken.OverlayBefore != before {
t.Errorf("the key before the take was not kept: %q", taken.OverlayBefore)
}
if rekey.Previous != before || rekey.OverlayKey != found.PublicKey || rekey.Tunnel == nil ||
rekey.Tunnel.PublicKey != found.PublicKey || len(rekey.Tunnel.Peers) != 1 {
t.Fatalf("the rekey does not say what moved: %+v", rekey)
}
if !ed25519.Verify(ed25519.PublicKey(mine.Public),
link.RekeyProof("anchor", before, found.PublicKey, rekey.Tunnel), rekey.Proof) {
t.Fatal("the rekey is not signed by this node's identity key over what it says")
}
if ed25519.Verify(ed25519.PublicKey(mine.Public),
link.RekeyProof("laptop", before, found.PublicKey, rekey.Tunnel), rekey.Proof) {
t.Fatal("the proof is not bound to the node")
}
for _, said := range []string{rekey.Previous, rekey.OverlayKey, rekey.Tunnel.Interface} {
if strings.Contains(said, found.PrivateKey()) {
t.Fatal("the private key travels")
}
}
// Run again after the take — the mesh not yet told, or told and refused — the previous key it
// names is still the one before the take, so the mesh can tell a repeat from a replay.
again, second, err := rekeyOnto(taken, found)
if err != nil {
t.Fatal(err)
}
if second.Previous != before || again.OverlayBefore != before || again.Overlay.Public != found.PublicKey {
t.Fatalf("a take run again does not name the key before the first: %+v", second)
}
}
-48
View File
@@ -1,48 +0,0 @@
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)
}
}
}
-200
View File
@@ -1,200 +0,0 @@
// foundation-first-node-nats.lock — what a machine must be before a mesh exists, on the bus being
// built (novox/hq ADR 0106, design 25).
//
// The same twelve steps as foundation-first-node.lock, with one difference that matters: **the mesh
// writes its own user list, and at genesis there is no mesh yet to write it.** So this carries the
// first one — the controller's own account, at a well-known bootstrap password, exactly as the store
// is reached at `postgres:bootstrap` and the old bus at `guest:guest`. It is rotated with those, and
// from the controller's first composition onward the file is the controller's to write.
//
// The accounts file is its own file beside the server's configuration, because the server's own
// settings belong to whoever raises it and the users belong to the mesh (design 25 §4). Both live in
// one directory, of necessity: an include path is resolved relative to the including file's own
// directory, so a server given an absolute one looks for it underneath that directory and refuses to
// start.
//
// No `verify` on the TLS block, deliberately — that setting makes the server demand a *client*
// certificate, and nothing in the mesh presents one: a host pins this server's exact certificate and
// authenticates with a password (ADR 0004, design 25 §4).
{
"declaration": 1,
"resources": [
{
"id": "container-runtime",
"type": "package",
"package": "docker"
},
{
"id": "container-runtime-running",
"type": "service",
"unit": "docker.service",
"state": "running",
"boot": "enabled"
},
// **A filter before anything listens** (novox/hq issue 054, ADR 0088). The store and the
// broker are adopted as modules later and so bind to every interface from the moment they
// start; the packet filter that governs who may reach them is a module too, installed a
// dozen steps later. Between the two, a control-node facing the network had its store and
// its bus open to anyone who could reach the machine. So the foundation carries a filter of
// its own — the same table the filter module will replace wholesale once it can derive one:
// drop by default, keep loopback, replies, ssh and the mesh's own ports (the bus a node
// enrols over, the registry a node pulls from), and let the container runtime's own
// networks through the forward chain so containers keep working. A published container port
// is forwarded, never input (issue 047), which is why the forward chain is where the store's
// and broker's ports are refused from outside — and a container on this machine dialling a
// port this machine publishes reaches it through the runtime's proxy, which IS input, which
// is why the bus and the registry are opened in both chains, exactly as the derived ruleset
// does.
{
"id": "base-filter-package",
"type": "package",
"package": "nftables"
},
{
"id": "base-filter",
"type": "file",
"path": "/etc/nftables.conf",
"mode": "0644",
"content": "#!/usr/sbin/nft -f\n# the foundation's own filter, until the mesh derives one (novox/hq issue 054)\ntable inet mesh {}\ndelete table inet mesh\n\ntable inet mesh {\n\tchain input {\n\t\ttype filter hook input priority filter; policy drop;\n\t\tct state established,related accept\n\t\tct state invalid drop\n\t\tiif lo accept\n\t\ticmp type echo-request accept\n\t\ticmpv6 type { echo-request, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept\n\t\t# ssh, from anywhere — never closed\n\t\ttcp dport 22 accept\n\t\t# the mesh's own, from anywhere: the bus a node enrols over and a container on this machine reaches through the proxy, the registry a node pulls from\n\t\ttcp dport 5671 accept\n\t\ttcp dport 5000 accept\n\t}\n\tchain output {\n\t\ttype filter hook output priority filter; policy accept;\n\t}\n\tchain forward {\n\t\ttype filter hook forward priority filter; policy drop;\n\t\tct state established,related accept\n\t\tct state invalid drop\n\t\t# the container runtime's bridge networks, and the networks its compose files are given\n\t\tip saddr 172.16.0.0/12 accept\n\t\tip saddr 192.168.128.0/17 accept\n\t\t# the mesh's own: the bus a node enrols over, the registry a node pulls from\n\t\tct original proto-dst 5671 accept\n\t\tct original proto-dst 5000 accept\n\t}\n}\n"
},
{
"id": "base-filter-loaded",
"type": "service",
"unit": "nftables.service",
"state": "running",
"boot": "enabled",
"restart-on": ["base-filter"]
},
{
"id": "store",
"type": "container",
"name": "mesh-store",
"image": "192.0.2.250:5000/postgres@sha256:7abf537131b66ed5af448d90653abf1679b0c7e9a1f07efdd4c3108a401b259a",
"env": {
"POSTGRES_PASSWORD": "bootstrap",
"PGDATA": "/var/lib/postgresql/data/pgdata"
},
"ports": ["5432:5432"],
"volumes": ["mesh-store-data:/var/lib/postgresql/data"]
},
// Over TCP, not the socket. While the store initialises it runs a temporary server on the
// socket ONLY, then stops it and starts the real one — so a socket check passes, the action
// exits happy, and the verify a moment later lands in the gap and fails. The action and its
// verify must ask the same question, or the action can succeed into a state verify rejects.
{
"id": "store-ready",
"type": "action",
"in": "mesh-store",
"command": ["sh", "-c", "for i in $(seq 1 180); do pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1 && exit 0; sleep 1; done; echo 'the store did not answer within 180s; its own last words follow'; pg_isready -h 127.0.0.1 -U postgres; tail -n 20 /var/lib/postgresql/data/log/*.log 2>/dev/null; exit 1"],
"verify": ["pg_isready", "-h", "127.0.0.1", "-U", "postgres"]
},
{
"id": "inventory-database",
"type": "action",
"in": "mesh-store",
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE inventory'"],
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw inventory"]
},
{
"id": "identity-database",
"type": "action",
"in": "mesh-store",
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE identity'"],
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw identity"]
},
// Each context owns its own database (novox/hq ADR 0008). A third one is a third database,
// created the same way and named the same way — which is the whole of adding a context to the
// bootstrap, and is why the count is not something the foundation has an opinion about.
{
"id": "licences-database",
"type": "action",
"in": "mesh-store",
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE licences'"],
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw licences"]
},
{
"id": "context-schemas",
"type": "action",
"command": ["docker", "run", "--rm", "--network", "container:mesh-store",
"-e", "MESH_STORE_INVENTORY=postgres://postgres:bootstrap@127.0.0.1:5432/inventory?sslmode=disable",
"-e", "MESH_STORE_IDENTITY=postgres://postgres:bootstrap@127.0.0.1:5432/identity?sslmode=disable",
"-e", "MESH_STORE_LICENCES=postgres://postgres:bootstrap@127.0.0.1:5432/licences?sslmode=disable",
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"migrate"],
"verify": ["sh", "-c", "docker exec mesh-store psql -U postgres -d inventory -tAc \"select to_regclass('public.node')\" | grep -qx node && docker exec mesh-store psql -U postgres -d identity -tAc \"select to_regclass('public.signing_key')\" | grep -qx signing_key && docker exec mesh-store psql -U postgres -d licences -tAc \"select to_regclass('public.licence')\" | grep -qx licence"]
},
{
"id": "bus-certificate",
"type": "action",
// **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",
"type": "directory",
"path": "/var/lib/mesh-bus-conf",
"mode": "0700"
},
{
"id": "bus-conf",
"type": "file",
"path": "/var/lib/mesh-bus-conf/nats.conf",
"mode": "0644",
"content": "port: 4222\nhttp: 127.0.0.1:8222\n\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n}\n\njetstream {\n store_dir: \"/data\"\n}\n\ninclude accounts.conf\n"
},
{
"id": "bus-accounts",
"type": "file",
"path": "/var/lib/mesh-bus-conf/accounts.conf",
"mode": "0600",
"content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.API.>\", \"_INBOX.enrol.>\", \"mesh.assignment.>\", \"mesh.control.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.node-build-agent.accept.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\"] }\n subscribe: { allow: [\"$JS.API.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.node-build-agent.event.built\", \"mesh.seat.mesh-controller.tool.>\", \"$SRV.PING\", \"$SRV.INFO\", \"$SRV.PING.mesh-controller\", \"$SRV.PING.mesh-controller.>\", \"$SRV.INFO.mesh-controller\", \"$SRV.INFO.mesh-controller.>\", \"$SRV.STATS\", \"$SRV.STATS.mesh-controller\", \"$SRV.STATS.mesh-controller.>\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n"
},
{
"id": "broker",
"type": "container",
"name": "mesh-broker",
"image": "192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927",
"ports": ["5671:4222", "127.0.0.1:8222:8222"],
"volumes": ["mesh-broker-data:/data", "mesh-broker-tls:/tls:ro", "/var/lib/mesh-bus-conf:/etc/nats:ro"],
"args": ["-c", "/etc/nats/nats.conf", "-js"]
},
{
"id": "broker-ready",
"type": "action",
"command": ["sh", "-c", "for i in $(seq 1 60); do docker run --rm --network host --entrypoint sh 192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927 -c 'nc -z 127.0.0.1 5671' >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1"],
"verify": ["sh", "-c", "docker run --rm --network host --entrypoint sh 192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927 -c 'nc -z 127.0.0.1 5671'"]
},
{
"id": "control-plane",
"type": "container",
"name": "mesh-controller",
"image": "192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"network": "host",
"args": ["serve"],
"volumes": ["mesh-broker-tls:/broker-tls:ro"],
"env": {
"MESH_STORE_INVENTORY": "postgres://postgres:bootstrap@127.0.0.1:5432/inventory?sslmode=disable",
"MESH_STORE_IDENTITY": "postgres://postgres:bootstrap@127.0.0.1:5432/identity?sslmode=disable",
"MESH_STORE_LICENCES": "postgres://postgres:bootstrap@127.0.0.1:5432/licences?sslmode=disable",
"MESH_BUS_NATS": "nats://controller:bootstrap@127.0.0.1:5671",
"MESH_BROKER_ADDRESS": "192.0.2.10:5671",
"MESH_BROKER_CERTIFICATE": "/broker-tls/tls.crt"
}
}
]
}
+4 -10
View File
@@ -1,15 +1,9 @@
module github.com/novox/mesh-host
go 1.26.0
go 1.25.0
require (
github.com/nats-io/nats.go v1.54.0
golang.org/x/crypto v0.57.0
)
require (
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/sys v0.48.0 // indirect
github.com/rabbitmq/amqp091-go v1.14.0 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/sys v0.47.0 // indirect
)
+6 -12
View File
@@ -1,12 +1,6 @@
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
github.com/rabbitmq/amqp091-go v1.14.0 h1:RSaT7aOKt/OrkVUyswPDW29lnRz9psuGmfZFBmLqLek=
github.com/rabbitmq/amqp091-go v1.14.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+33 -819
View File
File diff suppressed because it is too large Load Diff
+18 -96
View File
@@ -348,27 +348,9 @@ func TestAnUnknownServiceStateIsRefusedNotGuessed(t *testing.T) {
}
}
func TestADroppedServiceIsGivenBackTheStateItWasFoundIn(t *testing.T) {
// novox/hq ADR 0118: undeclaring removes what the mesh made, gives back what it changed, and
// leaves what was the machine's. A service resource never installs a unit — so undeclaring it
// undoes what the mesh did to the unit, and nothing more. Stopping every undeclared unit is
// how unassigning the private network stopped the container runtime (novox/hq issue 130).
cases := []struct {
name string
found *store.FoundUnit
action string
stop bool
disable bool
}{
{"recorded before the host kept what it found", nil, "forgotten", false, false},
{"running before the mesh", &store.FoundUnit{State: "running"}, "forgotten", false, false},
{"running and enabled before the mesh", &store.FoundUnit{State: "running", Boot: "enabled"}, "forgotten", false, false},
{"started by the mesh", &store.FoundUnit{State: "stopped"}, "restored", true, false},
{"started and enabled by the mesh", &store.FoundUnit{State: "stopped", Boot: "disabled"}, "restored", true, true},
{"enabled by the mesh, running before it", &store.FoundUnit{State: "running", Boot: "disabled"}, "restored", false, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
func TestADroppedServiceIsStoppedNotDeleted(t *testing.T) {
// The host did not install the unit and does not own the unit file — only the state it put
// the unit into.
var commands []string
run := func(ctx context.Context, name string, args ...string) (string, error) {
commands = append(commands, strings.Join(args, " "))
@@ -378,81 +360,21 @@ func TestADroppedServiceIsGivenBackTheStateItWasFoundIn(t *testing.T) {
return "", nil
}
state := store.State{Resources: []store.Applied{
{ID: "s", Type: "service", Target: "unit.service", Found: c.found},
{ID: "s", Type: "service", Target: "gone.service"},
}}
d := parse(t, `{"declaration":1,"resources":[
{"id":"other","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
]}`)
report, after, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, run, nil, nil)
if err != nil {
if _, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, run, nil, nil); err != nil {
t.Fatal(err)
}
joined := strings.Join(commands, "; ")
if stopped := strings.Contains(joined, "stop unit.service"); stopped != c.stop {
t.Errorf("stopped %v, want %v: %s", stopped, c.stop, joined)
if !strings.Contains(joined, "stop gone.service") {
t.Errorf("the dropped service was not stopped: %s", joined)
}
if disabled := strings.Contains(joined, "disable unit.service"); disabled != c.disable {
t.Errorf("disabled %v, want %v: %s", disabled, c.disable, joined)
}
if strings.Contains(joined, "start unit.service") || strings.Contains(joined, "mask") {
t.Errorf("the host started or masked a unit on its way out: %s", joined)
}
if o := outcomeOf(report, "s"); o.Action != c.action {
t.Errorf("outcome %+v, want %s", o, c.action)
}
if _, still := after.Find("s"); still {
t.Error("the host still believes it owns the undeclared service")
}
})
}
}
func TestAServiceRecordsTheStateItWasFoundInOnceAndCarriesIt(t *testing.T) {
// Read the first time the host applies the unit — before it starts or enables anything —
// and never again: by the next apply, the unit's state is the mesh's doing.
active := "inactive"
enabled := "disabled"
run := func(ctx context.Context, name string, args ...string) (string, error) {
switch args[0] {
case "show":
return "LoadState=loaded\nActiveState=" + active + "\n", nil
case "is-enabled":
return enabled, nil
case "start":
active = "active"
case "enable":
enabled = "enabled"
}
return "", nil
}
d := parse(t, `{"declaration":1,"resources":[
{"id":"s","type":"service","unit":"filter.service","state":"running","boot":"enabled"}
]}`)
_, first, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
if err != nil {
t.Fatal(err)
}
rec, _ := first.Find("s")
if rec.Found == nil || rec.Found.State != "stopped" || rec.Found.Boot != "disabled" {
t.Fatalf("first apply recorded %+v, want stopped and disabled", rec.Found)
}
_, second, err := Apply(context.Background(), archHost(t), d, first, store.OriginCarried, run, nil, nil)
if err != nil {
t.Fatal(err)
}
rec, _ = second.Find("s")
if rec.Found == nil || rec.Found.State != "stopped" {
t.Errorf("a later apply replaced what was found with what the mesh made: %+v", rec.Found)
}
// A record from before the host kept what it found is not given one later.
old := store.State{Resources: []store.Applied{{ID: "s", Type: "service", Target: "filter.service"}}}
_, third, err := Apply(context.Background(), archHost(t), d, old, store.OriginCarried, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if rec, _ = third.Find("s"); rec.Found != nil {
t.Errorf("a record that predates the field was given one after the mesh had acted: %+v", rec.Found)
if strings.Contains(joined, "disable") || strings.Contains(joined, "mask") {
t.Errorf("the host did more than stop a unit it does not own: %s", joined)
}
}
@@ -713,7 +635,7 @@ func TestAContainerThatExitsImmediatelyFailsTheApply(t *testing.T) {
switch {
case args[0] == "info":
return "27.0\n", nil
case args[0] == "container":
case args[0] == "inspect":
return "false\t" + "", nil // exists, not running
case args[0] == "run":
return "deadbeef\n", nil
@@ -751,7 +673,7 @@ func TestAContainerWhoseDeclarationChangedIsReplaced(t *testing.T) {
switch args[0] {
case "info":
return "27.0\n", nil
case "container":
case "inspect":
if created {
return "true\t" + want, nil
}
@@ -789,7 +711,7 @@ func TestAContainerThatMatchesIsLeftAlone(t *testing.T) {
switch args[0] {
case "info":
return "27.0\n", nil
case "container":
case "inspect":
return "true\t" + spec, nil
}
touched = true
@@ -834,7 +756,7 @@ func TestAContainerIsRecreatedWhenARestartOnResourceChanged(t *testing.T) {
switch args[0] {
case "info":
return "27.0\n", nil
case "container":
case "inspect":
// Already there and running, created against the file as it was. After the host
// recreates it, the runtime holds the one it just made — as a real one would.
if created {
@@ -1078,7 +1000,7 @@ func TestAContainerUsesTheRuntimeTheMachineHas(t *testing.T) {
switch args[0] {
case "info":
return "6.1.0\n", nil
case "container":
case "inspect":
return "false\t\n", errors.New("no such container")
case "run":
return "deadbeef\n", nil
@@ -1404,7 +1326,7 @@ func TestAContainerIsGivenTheMeshsNames(t *testing.T) {
switch args[0] {
case "info":
return "29.0.0\n", nil
case "container":
case "inspect":
return "false\t\n", errors.New("no such container")
case "run":
ran = args
@@ -1439,7 +1361,7 @@ func TestAContainerGivenNoNamesIsRunAsBefore(t *testing.T) {
switch args[0] {
case "info":
return "29.0.0\n", nil
case "container":
case "inspect":
return "false\t\n", errors.New("no such container")
case "run":
ran = args
@@ -1624,7 +1546,7 @@ func TestAContainerStaleFromAnEarlierApplyIsReplaced(t *testing.T) {
switch args[0] {
case "info":
return "27.0\n", nil
case "container":
case "inspect":
if created {
return "true\t" + fresh, nil
}
+7 -58
View File
@@ -66,10 +66,16 @@ func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Ap
}
}
written, err := replaceWith(body, r.Path, r.Owner)
if err := os.MkdirAll(r.Path, 0o755); err != nil {
return out, err
}
written, err := unpack(body, r.Path)
if err != nil {
return out, err
}
if err := ownAll(r.Path, r.Owner); err != nil {
return out, err
}
out.Action = "updated"
if previous.Wrote == "" {
out.Action = "created"
@@ -78,63 +84,6 @@ func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Ap
return out, nil
}
// replaceWith makes the directory exactly the archive (novox/hq issue 220).
//
// **The tree on disk is the archive and nothing else.** The digest is the whole identity of what
// is unpacked here, so a file the previous archive had and this one does not must go. Unpacked over
// the old tree, it stayed: a bundle rebuilt as one file per entrypoint kept the package directory
// of the version before, which code could still import, and a fix that removed a file worked on a
// fresh machine only. So the archive is unpacked into a fresh directory beside the old one, owned,
// and swapped in by rename. A running process keeps the files it has open, and the old tree is
// removed only once the new one is in place. A failed unpack leaves the old tree untouched.
func replaceWith(body []byte, path, owner string) (int, error) {
parent := filepath.Dir(path)
if err := os.MkdirAll(parent, 0o755); err != nil {
return 0, err
}
fresh := path + ".unpacking"
replaced := path + ".replaced"
// What an interrupted earlier attempt left beside the directory.
for _, leftover := range []string{fresh, replaced} {
if err := os.RemoveAll(leftover); err != nil {
return 0, err
}
}
if err := os.Mkdir(fresh, 0o755); err != nil {
return 0, err
}
written, err := unpack(body, fresh)
if err == nil {
err = ownAll(fresh, owner)
}
if err != nil {
os.RemoveAll(fresh)
return written, err
}
hadOne := true
if err := os.Rename(path, replaced); err != nil {
if !os.IsNotExist(err) {
os.RemoveAll(fresh)
return written, err
}
hadOne = false
}
if err := os.Rename(fresh, path); err != nil {
if hadOne {
// Put the old tree back rather than leave nothing at the path.
os.Rename(replaced, path)
}
os.RemoveAll(fresh)
return written, err
}
if hadOne {
if err := os.RemoveAll(replaced); err != nil {
return written, fmt.Errorf("%s is in place, and the tree it replaced could not be removed: %w", path, err)
}
}
return written, nil
}
func fetch(ctx context.Context, source string) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil)
if err != nil {
-68
View File
@@ -1,68 +0,0 @@
package apply
import (
"context"
"os"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// novox/hq issue 220: the tree on disk is exactly the archive. A file the previous archive had and
// this one does not is gone, and nothing is left beside the directory.
func TestAnArchiveReplacesTheTreeItWasUnpackedOver(t *testing.T) {
dir := t.TempDir()
first, firstDigest := anArchive(t, map[string]string{"index.js": "old", "node_modules/dep/index.js": "dep"})
d := declare(t, `{"id":"bundle-tools","type":"archive","source":"`+serving(t, first)+
`","digest":"`+firstDigest+`","path":"`+dir+`/tools"}`)
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
second, secondDigest := anArchive(t, map[string]string{"index.js": "one file"})
d = declare(t, `{"id":"bundle-tools","type":"archive","source":"`+serving(t, second)+
`","digest":"`+secondDigest+`","path":"`+dir+`/tools"}`)
if _, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, noServices, nil, nil); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(dir + "/tools/index.js"); string(got) != "one file" {
t.Fatalf("index.js is %q", got)
}
if _, err := os.Stat(dir + "/tools/node_modules"); err == nil {
t.Fatal("the previous archive's directory is still there")
}
entries, _ := os.ReadDir(dir)
if len(entries) != 1 {
names := []string{}
for _, e := range entries {
names = append(names, e.Name())
}
t.Fatalf("beside the tree: %v", names)
}
}
// A tree is replaced only by one that unpacked whole: an archive refused halfway leaves the old
// tree as it was.
func TestARefusedArchiveLeavesTheTreeItWouldHaveReplaced(t *testing.T) {
dir := t.TempDir()
first, firstDigest := anArchive(t, map[string]string{"index.js": "old"})
d := declare(t, `{"id":"bundle-tools","type":"archive","source":"`+serving(t, first)+
`","digest":"`+firstDigest+`","path":"`+dir+`/tools"}`)
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
bad, badDigest := anArchive(t, map[string]string{"index.js": "new", "../../escaped": "no"})
d = declare(t, `{"id":"bundle-tools","type":"archive","source":"`+serving(t, bad)+
`","digest":"`+badDigest+`","path":"`+dir+`/tools"}`)
if _, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, noServices, nil, nil); err == nil {
t.Fatal("an escaping archive was accepted")
}
if got, _ := os.ReadFile(dir + "/tools/index.js"); string(got) != "old" {
t.Fatalf("index.js is %q after a refused archive", got)
}
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
t.Fatalf("%d entries beside the tree after a refused archive", len(entries))
}
}
-433
View File
@@ -1,433 +0,0 @@
package apply
import (
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// A file written into a marked block, never over (novox/hq issue 128, ADR 0102).
//
// **The file is the machine's; the mesh owns lines in it.** The machine's hosts file is the case
// that needed it. The mesh wrote it whole — its own header, localhost, the machine's name and every
// name in the mesh — and on a workstation that file is shared: the distribution's lines, a local
// development tool's own marked blocks rewritten whenever its projects change, the operator's
// hand-added names. Written whole, all of those went at the next change to the mesh's names, with
// no failure anywhere: the tool believed it had written its block, and the mesh believed it owned
// the file. It is ADR 0102's failure exactly, in a file ADR 0102's JSON verb cannot speak.
//
// So the host finds the lines between `# BEGIN mesh <id>` and `# END mesh <id>`, rewrites those and
// nothing else, and records what they held before. Every line outside the markers is kept byte for
// byte — including another tool's `# BEGIN …` blocks, which are that tool's. Undeclared, the region
// is given back what it held, or taken out with its markers when it held nothing, and a file the
// mesh created goes only if nothing but whitespace is left.
// applyBlock writes a file's declared lines into its region of the file already at its path.
//
// **A link stays a link.** Where the path is a symbolic link — a hosts file some distributions keep
// elsewhere and link into /etc — the file read, written and renamed over is the one it points to,
// so the link and whatever manages it are left as they were. A file written whole, or into JSON,
// still replaces a link with a file; that is unchanged here.
func applyBlock(r *declaration.File, previous store.Applied) (Outcome, error) {
out := begin(r)
opening, closing := declaration.BlockMarkers(r.ID)
want := blockBody(r.Content)
real, err := realPath(r.Path)
if err != nil {
return out, err
}
raw, err := os.ReadFile(real)
existed := err == nil
if err != nil && !errors.Is(err, os.ErrNotExist) {
return out, err
}
// What the file is, taken once with what it holds: its mode and owner are the machine's and
// go back onto what is written. A file read and then not there to stat is a failure, never a
// file with no owner.
var info os.FileInfo
if existed {
if info, err = os.Stat(real); err != nil {
return out, fmt.Errorf("read %s and cannot see it: %w", r.Path, err)
}
}
existing := string(raw)
rec := store.Into{Format: declaration.IntoBlock}
var note string
rebuilt := false
// **A file the mesh once wrote whole** (novox/hq issue 128). The resource keeps its id when its
// module moves from writing the file whole to writing into it, and the file on the machine is
// then the mesh's own old write — its header, its loopback lines, its names. Adding the region
// after that would leave the old names above the new ones, and a resolver takes the first
// line that answers: the region would be shadowed by what it replaced. So the file is rebuilt:
// the original the mesh kept before its first write, with the region in it; or, where the mesh
// made the file itself, the loopback lines every machine needs, kept as the machine's, with the
// region beside them. Changed since the mesh wrote it, the file is somebody's again and is
// written into as it stands, and the outcome says so.
if existed && previous.Into == nil && previous.Wrote != "" {
if digestOf(existing) == previous.Wrote {
if previous.Kept != "" {
original, err := os.ReadFile(previous.Kept)
if err != nil {
return out, fmt.Errorf("%s was written whole by the mesh over an original kept at %s, "+
"which cannot be read to give it back: %w; it was left as it is", r.Path, previous.Kept, err)
}
existing = string(original)
note = "the mesh's old whole file replaced by the original kept at " + previous.Kept + ", with the region in it"
} else {
existing = loopbackOf(existing)
rec.Created = true
note = "the mesh's old whole file replaced by its loopback lines and the region"
}
// Not what was read: the whole of it was the mesh's, and the file is written afresh.
rebuilt = true
} else {
note = "a file the mesh once wrote whole, changed since; its old lines were kept"
}
}
lines := linesOf(existing)
at, found, err := regionIn(lines, opening, closing)
if err != nil {
// Refused, never guessed at: markers the host cannot pair are markers it cannot write
// between without risking lines that are not the mesh's.
return out, fmt.Errorf("%s: %w; it was left as it is", r.Path, err)
}
// A record of a block is carried; anything else — no record, a file once written whole, one
// once written into as JSON — is a file the host is seeing for the first time as a block.
recorded := previous.Into != nil && previous.Into.Format == declaration.IntoBlock
if recorded && existed {
rec.Created = previous.Into.Created
rec.Region = previous.Into.Region
rec.Separated = previous.Into.Separated
rec.At = previous.Into.At
rec.Ended = previous.Into.Ended
} else if !rebuilt {
// A file gone since the last apply is made again, and made by the mesh: what it held
// before went with it, so there is nothing to give back but the file's absence.
rec.Created = !existed
if found {
// **What the host may have written itself is not the machine's** — the same reasoning
// as a key in a JSON file (novox/hq ADR 0102). With no record, a region already holding
// exactly the declared lines cannot be told from one this host wrote a moment ago and
// died before saving; remembered as the machine's, it would be put back on undeclare
// for ever. So it is the mesh's, and undeclaring takes it out.
if held := at.body(lines); held != want {
rec.Region = &held
}
}
}
// Drift: the machine no longer holds, between the mesh's markers, what this host last put
// there. Judged only against a record of a block: a digest of a whole file says nothing about
// a region of it.
drifted := recorded && previous.Wrote != "" && existed &&
(!found || digestOf(at.body(lines)) != previous.Wrote)
var next string
switch {
case !existed:
next = regionOf(opening, closing, want)
case found:
// Where it is, whatever At says: the region is never moved, because moving it moves the
// machine's lines around it.
next = strings.Join(lines[:at.begin+1], "") + want + strings.Join(lines[at.end:], "")
case r.At == declaration.AtStart:
// Above everything, and one blank line between the region and the machine's first line
// unless there is one already — a line in some files means what the lines above it say.
rec.At, rec.Separated, rec.Ended = declaration.AtStart, false, false
next = regionOf(opening, closing, want)
if existing != "" && !strings.HasPrefix(existing, "\n") {
next += "\n"
rec.Separated = true
}
next += existing
default:
// At the end, apart from whatever is there: the file's last line is ended if it was not,
// and one blank line separates the region from the machine's lines unless there is one.
rec.At, rec.Separated, rec.Ended = "", false, false
next = existing
if next != "" && !strings.HasSuffix(next, "\n") {
next += "\n"
rec.Ended = true
}
if next != "" && next != "\n" && !strings.HasSuffix(next, "\n\n") {
next += "\n"
rec.Separated = true
}
next += regionOf(opening, closing, want)
}
// What was not the mesh's is what it was. By construction — and checked, because a slip in
// splicing lines is exactly the fault this mode exists to prevent, and it must never be written.
if found {
after := linesOf(next)
if where, ok, err := regionIn(after, opening, closing); err != nil || !ok || outside(after, where) != outside(lines, at) {
return out, fmt.Errorf("%s: writing the region would change lines outside it; it was left as it is", r.Path)
}
}
same := existed && next == string(raw)
if !same {
mode := os.FileMode(0o644)
if info != nil {
mode = info.Mode().Perm() // the machine's file keeps the machine's mode
} else if mode, err = modeOf(r.Mode, mode); err != nil {
return out, err
}
if err := os.MkdirAll(filepath.Dir(real), 0o755); err != nil {
return out, err
}
if err := writeAtomically(real, []byte(next), mode); err != nil {
return out, err
}
if info != nil {
// The write is a new file renamed over the old, so it belongs to whoever wrote it. The
// machine's file keeps the machine's owner, as it keeps its mode.
if err := keepOwner(real, info); err != nil {
return out, err
}
} else if err := own(real, r.Owner); err != nil {
return out, err
}
}
// Read back: the region holds what was declared. Only the region — another tool writing its
// own lines in the moment after the rename is not a failed write. What remains is the moment
// between reading the file and renaming over it: a line another tool writes there is lost, and
// found again at its next write. Nothing short of a lock every writer honours closes that, and
// the other writers of a hosts file honour none.
written, err := os.ReadFile(real)
if err != nil {
return out, fmt.Errorf("wrote into %s and cannot read it back: %w", r.Path, err)
}
back := linesOf(string(written))
if where, ok, err := regionIn(back, opening, closing); err != nil || !ok || where.body(back) != want {
return out, fmt.Errorf("%s does not hold the mesh's region after writing into it", r.Path)
}
out.into = &rec
out.wrote = digestOf(want)
switch {
case note != "" && !same:
out.Action = "updated"
out.Detail = note
case !existed:
out.Action = "created"
out.Detail = "written into; the file was not there"
case same:
out.Action = "unchanged"
case drifted:
out.Action = "corrected"
out.Detail = "the mesh's region had been changed on the machine; every line outside it was kept"
case !found:
out.Action = "updated"
where := "end"
if rec.At == declaration.AtStart {
where = "start"
}
out.Detail = "the mesh's region added at the " + where + "; every other line kept as it was"
default:
out.Action = "updated"
out.Detail = "the mesh's region rewritten; every line outside it kept as it was"
}
return out, nil
}
// removeBlock gives back what a file written into a block held before the mesh's region.
func removeBlock(a store.Applied) (string, string, error) {
real, err := realPath(a.Target)
if err != nil {
return "", "", err
}
raw, err := os.ReadFile(real)
if errors.Is(err, os.ErrNotExist) {
return "forgotten", "no longer there", nil
}
if err != nil {
return "", "", err
}
info, err := os.Stat(real)
if err != nil {
return "", "", fmt.Errorf("read %s and cannot see it: %w", a.Target, err)
}
opening, closing := declaration.BlockMarkers(a.ID)
lines := linesOf(string(raw))
at, found, err := regionIn(lines, opening, closing)
if err != nil {
return "kept", err.Error() + ", so nothing was taken out of it; remove the mesh's region by hand", nil
}
next, action, detail := string(raw), "forgotten", "the mesh's region was no longer in it"
switch {
case found && a.Into.Region != nil:
next = strings.Join(lines[:at.begin+1], "") + *a.Into.Region + strings.Join(lines[at.end:], "")
action, detail = "restored", "no longer declared; the region was given back what it held"
case found:
from, to := at.begin, at.end+1
// The blank line the host added beside the region, when a blank line still stands there.
// Whether it is the same one the host added cannot be known from the file; a blank line
// is the one line whose going changes nothing any program reads, so it is taken. A line
// that is not blank is never taken, whoever put it there.
if a.Into.Separated {
if a.Into.At == declaration.AtStart {
if to < len(lines) && lines[to] == "\n" {
to++
}
} else if from > 0 && lines[from-1] == "\n" {
from--
}
}
next = strings.Join(lines[:from], "") + strings.Join(lines[to:], "")
// And the line end the host gave the machine's last line, if that line is still last.
if a.Into.Ended && strings.Join(lines[to:], "") == "" {
next = strings.TrimSuffix(next, "\n")
}
action, detail = "restored", "no longer declared; the mesh's region was taken out and every other line kept"
}
if a.Into.Created && strings.TrimSpace(next) == "" && real == a.Target {
if err := os.Remove(real); err != nil {
return "", "", err
}
return "removed", "no longer declared; the mesh had created it and nothing else was in it", nil
}
if next == string(raw) {
return action, detail, nil
}
if err := writeAtomically(real, []byte(next), info.Mode().Perm()); err != nil {
return "", "", err
}
if err := keepOwner(real, info); err != nil {
return "", "", err
}
return action, detail, nil
}
// realPath is the file a path names, through any links; a path that is not there yet is itself.
// A link to nothing is refused: writing through it would replace the link with a file.
func realPath(path string) (string, error) {
real, err := filepath.EvalSymlinks(path)
if err == nil {
return real, nil
}
if _, lerr := os.Lstat(path); errors.Is(lerr, os.ErrNotExist) {
return path, nil
}
return "", fmt.Errorf("%s is a link the host cannot follow to a file: %w; it was left as it is", path, err)
}
// loopbackOf is the lines of a file that answer for the machine itself — localhost, its own name on
// 127.0.1.1, ::1 — and nothing else: what the mesh's old whole hosts file carried that the machine
// needs, without the mesh's header or its names.
func loopbackOf(text string) string {
var b strings.Builder
for _, line := range linesOf(text) {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
if ip := net.ParseIP(fields[0]); ip != nil && ip.IsLoopback() {
b.WriteString(strings.TrimSuffix(line, "\n") + "\n")
}
}
return b.String()
}
// outside is every line of a file but the mesh's region, markers included, as one string.
func outside(lines []string, at region) string {
end := at.end + 1
if end > len(lines) {
end = len(lines)
}
return strings.Join(lines[:at.begin], "") + "\x00" + strings.Join(lines[end:], "")
}
// blockBody is the declared lines as they stand in the region: ending in exactly one line end, or
// nothing at all when there are no lines.
func blockBody(content string) string {
trimmed := strings.TrimRight(content, "\n")
if trimmed == "" {
return ""
}
return trimmed + "\n"
}
func regionOf(begin, end, body string) string {
return begin + "\n" + body + end + "\n"
}
// linesOf splits text into lines that keep their line ends, so joining them again gives back
// exactly the bytes that were read — a last line without one included.
func linesOf(text string) []string {
return strings.SplitAfter(text, "\n")
}
// region is where the mesh's markers stand, as indices into the lines of a file.
type region struct{ begin, end int }
// body is what stands between the markers.
func (r region) body(lines []string) string {
return strings.Join(lines[r.begin+1:r.end], "")
}
// regionIn finds the mesh's markers for one resource. A line is a marker only if it is exactly the
// marker, so another tool's block and another resource's region are never it. Markers that do not
// form one pair — a begin with no end, an end before its begin, either twice — are an error rather
// than a best guess, because a guess is how the host would rewrite lines that are not its own.
func regionIn(lines []string, begin, end string) (region, bool, error) {
at := region{begin: -1, end: -1}
for i, line := range lines {
switch strings.TrimSuffix(line, "\n") {
case begin:
if at.begin >= 0 {
return at, false, fmt.Errorf("%q is in it more than once", begin)
}
at.begin = i
case end:
if at.end >= 0 {
return at, false, fmt.Errorf("%q is in it more than once", end)
}
at.end = i
}
}
switch {
case at.begin < 0 && at.end < 0:
return at, false, nil
case at.begin < 0:
return at, false, fmt.Errorf("%q is in it with no %q before it", end, begin)
case at.end < 0:
return at, false, fmt.Errorf("%q is in it with no %q after it", begin, end)
case at.end < at.begin:
return at, false, fmt.Errorf("%q stands before %q", end, begin)
}
return at, true, nil
}
// keepOwner gives a file rewritten through a new one back to whoever owned what it replaced.
// Changed only where it differs, so a host that is not root can still write a file it owns.
func keepOwner(path string, was os.FileInfo) error {
uid, gid, ok := ownerOf(was)
if !ok {
return nil
}
now, err := os.Stat(path)
if err != nil {
return err
}
if u, g, ok := ownerOf(now); ok && u == uid && g == gid {
return nil
}
if err := os.Chown(path, uid, gid); err != nil {
return fmt.Errorf("cannot give %s back to its owner %d:%d: %w", path, uid, gid, err)
}
return nil
}
-618
View File
@@ -1,618 +0,0 @@
package apply
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq issue 128 and ADR 0102: a text file the mesh shares with software it did not
// install is written into a marked block, never over — every line outside the mesh's markers is
// the machine's and is kept byte for byte, and undeclaring gives the file back.
const namesID = "mesh-wireguard.fact-node-names"
func blockDecl(t *testing.T, path, content string, extra ...string) string {
t.Helper()
more := ""
for _, e := range extra {
more += "," + e
}
return fmt.Sprintf(`{"declaration":1,"resources":[
{"id":%q,"type":"file","path":%q,"into":"block","content":%q%s}
]}`, namesID, path, content, more)
}
func applyBlockDecl(t *testing.T, raw string, known store.State) (Report, store.State) {
t.Helper()
report, state, err := Apply(context.Background(), archHost(t), parse(t, raw), known, store.OriginDeclared, nil, nil, nil)
if err != nil {
t.Fatal(err)
}
return report, state
}
func undeclare(t *testing.T, known store.State) (Report, store.State) {
t.Helper()
report, state, err := Apply(context.Background(), archHost(t), somethingElse(t), known, store.OriginDeclared, nil, nil, nil)
if err != nil {
t.Fatal(err)
}
return report, state
}
func readText(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func marked(id, body string) string {
return "# BEGIN mesh " + id + "\n" + body + "# END mesh " + id + "\n"
}
// A workstation's hosts file, the way issue 128 found it: the distribution's lines, a development
// tool's own marked blocks, and the operator's hand-added names.
const workstationHosts = "127.0.0.1\tlocalhost\n" +
"127.0.1.1\tg14.localdomain g14\n" +
"\n" +
"# BEGIN devtool project-a\n" +
"127.0.0.1 a.test api.a.test\n" +
"# END devtool project-a\n" +
"# BEGIN devtool project-b\n" +
"127.0.0.1 b.test\n" +
"# END devtool project-b\n" +
"192.168.1.20 printer # the operator's\n"
const meshNames = "10.42.0.1 ace\n10.42.0.2 novox\n"
func TestABlockKeepsEveryLineOutsideItsMarkers(t *testing.T) {
path := filepath.Join(t.TempDir(), "hosts")
if err := os.WriteFile(path, []byte(workstationHosts), 0o640); err != nil {
t.Fatal(err)
}
report, state := applyBlockDecl(t, blockDecl(t, path, meshNames), store.State{})
want := workstationHosts + "\n" + marked(namesID, meshNames)
if got := readText(t, path); got != want {
t.Fatalf("the file after writing into it:\n%q\nwant\n%q", got, want)
}
if got := report.Outcomes[0].Action; got != "updated" {
t.Errorf("adding the region was %q", got)
}
if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 {
t.Errorf("the machine's file mode was changed to %o", info.Mode().Perm())
}
rec, _ := state.Find(namesID)
if rec.Into == nil || rec.Into.Format != "block" || rec.Into.Region != nil || rec.Into.Created {
t.Fatalf("recorded as %+v", rec.Into)
}
// Again, with nothing changed: nothing written.
report, state = applyBlockDecl(t, blockDecl(t, path, meshNames), state)
if got := report.Outcomes[0].Action; got != "unchanged" {
t.Errorf("a second apply was %q", got)
}
// The development tool rewrites its block, and the operator adds a line after the mesh's
// region; the mesh's names change. Only the region moves.
edited := strings.Replace(readText(t, path), "127.0.0.1 b.test\n", "127.0.0.1 b.test c.test\n", 1) +
"10.0.0.5 nas # added after\n"
_ = os.WriteFile(path, []byte(edited), 0o640)
changed := meshNames + "10.42.0.3 shanks\n"
report, state = applyBlockDecl(t, blockDecl(t, path, changed), state)
want = strings.Replace(edited, marked(namesID, meshNames), marked(namesID, changed), 1)
if got := readText(t, path); got != want {
t.Fatalf("rewriting the region moved something else:\n%q\nwant\n%q", got, want)
}
if got := report.Outcomes[0].Action; got != "updated" {
t.Errorf("rewriting the region was %q", got)
}
// Undeclared: the region, its markers and the blank line the host put before it go; every
// other line is where it was.
report, _ = undeclare(t, state)
want = strings.Replace(edited, "\n"+marked(namesID, meshNames), "", 1)
if got := readText(t, path); got != want {
t.Fatalf("undeclaring left:\n%q\nwant\n%q", got, want)
}
if got := report.Outcomes[0].Action; got != "restored" {
t.Errorf("undeclaring was %q", got)
}
}
func TestUndeclaringABlockAddedAtTheEndGivesTheFileBackExactly(t *testing.T) {
for name, original := range map[string]string{
"ending in a line": "127.0.0.1 localhost\n",
"ending in a blank line": "127.0.0.1 localhost\n\n",
"empty": "",
} {
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "hosts")
_ = os.WriteFile(path, []byte(original), 0o644)
_, state := applyBlockDecl(t, blockDecl(t, path, meshNames), store.State{})
undeclare(t, state)
if got := readText(t, path); got != original {
t.Errorf("undeclaring left %q, the machine had %q", got, original)
}
})
}
}
func TestABlockAddedAtTheEndIsSetApartFromTheMachinesLines(t *testing.T) {
for name, c := range map[string]struct{ before, after string }{
"no line end": {"127.0.0.1 localhost", "127.0.0.1 localhost\n\n" + marked(namesID, meshNames)},
"a line end": {"127.0.0.1 localhost\n", "127.0.0.1 localhost\n\n" + marked(namesID, meshNames)},
"a blank line already": {"127.0.0.1 localhost\n\n", "127.0.0.1 localhost\n\n" + marked(namesID, meshNames)},
"empty": {"", marked(namesID, meshNames)},
"only a blank line": {"\n", "\n" + marked(namesID, meshNames)},
"content without an end": {"x\n\n", "x\n\n" + marked(namesID, meshNames)},
} {
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "hosts")
_ = os.WriteFile(path, []byte(c.before), 0o644)
applyBlockDecl(t, blockDecl(t, path, meshNames), store.State{})
if got := readText(t, path); got != c.after {
t.Errorf("got %q, want %q", got, c.after)
}
})
}
}
func TestTheRegionEndsInExactlyOneLineEnd(t *testing.T) {
for content, body := range map[string]string{
"10.42.0.1 ace": "10.42.0.1 ace\n",
"10.42.0.1 ace\n": "10.42.0.1 ace\n",
"10.42.0.1 ace\n\n\n": "10.42.0.1 ace\n",
"": "",
"\n\n": "",
} {
path := filepath.Join(t.TempDir(), "hosts")
_, state := applyBlockDecl(t, blockDecl(t, path, content), store.State{})
if got := readText(t, path); got != marked(namesID, body) {
t.Errorf("content %q was written as %q", content, got)
}
// And the same content again is not a change.
report, _ := applyBlockDecl(t, blockDecl(t, path, content), state)
if got := report.Outcomes[0].Action; got != "unchanged" {
t.Errorf("content %q applied twice was %q", content, got)
}
}
}
func TestARegionAlreadyThereIsRewrittenInPlaceAndGivenBack(t *testing.T) {
path := filepath.Join(t.TempDir(), "hosts")
before := "127.0.0.1 localhost\n"
after := "# BEGIN devtool x\n127.0.0.1 x.test\n# END devtool x\n192.168.1.20 printer\n"
found := "10.42.0.9 old-name\n"
original := before + marked(namesID, found) + after
_ = os.WriteFile(path, []byte(original), 0o644)
report, state := applyBlockDecl(t, blockDecl(t, path, meshNames), store.State{})
if got := readText(t, path); got != before+marked(namesID, meshNames)+after {
t.Fatalf("the region was not rewritten in place: %q", got)
}
if got := report.Outcomes[0].Action; got != "updated" {
t.Errorf("rewriting a found region was %q", got)
}
rec, _ := state.Find(namesID)
if rec.Into.Region == nil || *rec.Into.Region != found {
t.Fatalf("what the region held before was recorded as %v", rec.Into.Region)
}
// Undeclared: what the region held goes back, where it was.
report, _ = undeclare(t, state)
if got := readText(t, path); got != original {
t.Errorf("undeclaring left %q, the machine had %q", got, original)
}
if got := report.Outcomes[0].Action; got != "restored" {
t.Errorf("undeclaring was %q", got)
}
}
func TestARegionWithNoRecordHoldingExactlyTheDeclaredLinesIsTheMeshs(t *testing.T) {
// A host that wrote the region and died before saving its state: what is between the markers
// is exactly what the mesh declares, and remembered as the machine's it would never go.
path := filepath.Join(t.TempDir(), "hosts")
original := "127.0.0.1 localhost\n"
_ = os.WriteFile(path, []byte(original+"\n"+marked(namesID, meshNames)), 0o644)
_, state := applyBlockDecl(t, blockDecl(t, path, meshNames), store.State{})
if rec, _ := state.Find(namesID); rec.Into.Region != nil {
t.Fatalf("the mesh's own lines were recorded as the machine's: %q", *rec.Into.Region)
}
undeclare(t, state)
if got := readText(t, path); !strings.HasPrefix(got, original) || strings.Contains(got, "BEGIN mesh") {
t.Errorf("undeclaring left the mesh's region behind: %q", got)
}
}
func TestADriftedRegionIsCorrected(t *testing.T) {
path := filepath.Join(t.TempDir(), "hosts")
_ = os.WriteFile(path, []byte(workstationHosts), 0o644)
_, state := applyBlockDecl(t, blockDecl(t, path, meshNames), store.State{})
written := readText(t, path)
_ = os.WriteFile(path, []byte(strings.Replace(written, "10.42.0.2 novox\n", "10.42.0.2 novox\n6.6.6.6 evil\n", 1)), 0o644)
report, _ := applyBlockDecl(t, blockDecl(t, path, meshNames), state)
if got := report.Outcomes[0].Action; got != "corrected" {
t.Errorf("a region edited on the machine was %q", got)
}
if got := readText(t, path); got != written {
t.Errorf("the region was not put back: %q", got)
}
// The region taken out by hand is drift too, and it is put back.
_ = os.WriteFile(path, []byte(workstationHosts), 0o644)
report, _ = applyBlockDecl(t, blockDecl(t, path, meshNames), state)
if got := report.Outcomes[0].Action; got != "corrected" {
t.Errorf("a region removed on the machine was %q", got)
}
if got := readText(t, path); got != written {
t.Errorf("the region was not put back: %q", got)
}
}
func TestTwoRegionsInOneFileAreEachTheirOwn(t *testing.T) {
path := filepath.Join(t.TempDir(), "hosts")
_ = os.WriteFile(path, []byte(workstationHosts), 0o644)
raw := fmt.Sprintf(`{"declaration":1,"resources":[
{"id":"a.names","type":"file","path":%q,"into":"block","content":"10.42.0.1 ace\n"},
{"id":"b.names","type":"file","path":%q,"into":"block","content":"10.43.0.1 lab\n"}
]}`, path, path)
_, state := applyBlockDecl(t, raw, store.State{})
want := workstationHosts + "\n" + marked("a.names", "10.42.0.1 ace\n") + "\n" + marked("b.names", "10.43.0.1 lab\n")
if got := readText(t, path); got != want {
t.Fatalf("two regions:\n%q\nwant\n%q", got, want)
}
report, state := applyBlockDecl(t, raw, state)
for _, o := range report.Outcomes {
if o.Action != "unchanged" {
t.Errorf("%s applied twice was %q", o.ID, o.Action)
}
}
// One undeclared: only its region goes.
only := fmt.Sprintf(`{"declaration":1,"resources":[
{"id":"b.names","type":"file","path":%q,"into":"block","content":"10.43.0.1 lab\n"}
]}`, path)
applyBlockDecl(t, only, state)
want = workstationHosts + "\n" + marked("b.names", "10.43.0.1 lab\n")
if got := readText(t, path); got != want {
t.Errorf("undeclaring one region:\n%q\nwant\n%q", got, want)
}
}
func TestABlockInAFileThatWasNotThereIsCreatedAndRemovedWithIt(t *testing.T) {
path := filepath.Join(t.TempDir(), "conf.d", "mesh.conf")
report, state := applyBlockDecl(t, blockDecl(t, path, meshNames, `"mode":"0600"`), store.State{})
if got := readText(t, path); got != marked(namesID, meshNames) {
t.Fatalf("a created file holds %q", got)
}
if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {
t.Errorf("a created file is mode %o, declared 0600", info.Mode().Perm())
}
if got := report.Outcomes[0].Action; got != "created" {
t.Errorf("creating was %q", got)
}
if rec, _ := state.Find(namesID); !rec.Into.Created {
t.Error("the mesh creating the file was not recorded")
}
report, _ = undeclare(t, state)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("a file the mesh created, holding only its region, was left behind")
}
if got := report.Outcomes[0].Action; got != "removed" {
t.Errorf("undeclaring was %q", got)
}
// Somebody else wrote into it meanwhile: it is no longer only the mesh's, and it stays.
_, state = applyBlockDecl(t, blockDecl(t, path, meshNames), store.State{})
_ = os.WriteFile(path, []byte(readText(t, path)+"their = line\n"), 0o600)
undeclare(t, state)
if got := readText(t, path); got != "their = line\n" {
t.Errorf("undeclaring a created file somebody wrote into left %q", got)
}
}
func TestMarkersThatDoNotPairAreRefusedAndLeftAlone(t *testing.T) {
for name, text := range map[string]string{
"a begin with no end": "a\n# BEGIN mesh " + namesID + "\nb\n",
"an end with no begin": "a\n# END mesh " + namesID + "\n",
"an end before a begin": "# END mesh " + namesID + "\n# BEGIN mesh " + namesID + "\n",
"a begin twice": marked(namesID, "x\n") + "# BEGIN mesh " + namesID + "\n",
} {
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "hosts")
_ = os.WriteFile(path, []byte(text), 0o644)
if _, _, err := Apply(context.Background(), archHost(t), parse(t, blockDecl(t, path, meshNames)),
store.State{}, store.OriginDeclared, nil, nil, nil); err == nil {
t.Fatal("markers that do not pair were written between")
}
if got := readText(t, path); got != text {
t.Errorf("the file was changed: %q", got)
}
})
}
}
func TestAMarkerOfAnotherIDIsNotThisRegion(t *testing.T) {
// The id is part of the marker: a region whose id merely starts with this one is not it.
path := filepath.Join(t.TempDir(), "hosts")
other := marked(namesID+"-extra", "10.9.9.9 other\n")
_ = os.WriteFile(path, []byte(other), 0o644)
_, state := applyBlockDecl(t, blockDecl(t, path, meshNames), store.State{})
if got := readText(t, path); got != other+"\n"+marked(namesID, meshNames) {
t.Fatalf("got %q", got)
}
undeclare(t, state)
if got := readText(t, path); got != other {
t.Errorf("undeclaring touched the other region: %q", got)
}
}
func TestABlockAtTheStartStandsAboveEverything(t *testing.T) {
// dhcpcd scopes every line after `interface X` to that interface, so the mesh's global options
// go above all of it.
dhcpcd := "hostname\nduid\n\ninterface enp6s0\nstatic ip_address=192.168.1.5/24\n"
opts := "nohook resolv.conf\ndenyinterfaces mesh0\n"
for name, c := range map[string]struct{ before, after string }{
"a file with content": {dhcpcd, marked(namesID, opts) + "\n" + dhcpcd},
"an empty file": {"", marked(namesID, opts)},
"a file opening blank": {"\n" + dhcpcd, marked(namesID, opts) + "\n" + dhcpcd},
"a last line with no end": {"interface enp6s0", marked(namesID, opts) + "\ninterface enp6s0"},
} {
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "dhcpcd.conf")
_ = os.WriteFile(path, []byte(c.before), 0o644)
report, state := applyBlockDecl(t, blockDecl(t, path, opts, `"at":"start"`), store.State{})
if got := readText(t, path); got != c.after {
t.Fatalf("got %q, want %q", got, c.after)
}
if got := report.Outcomes[0].Action; got != "updated" {
t.Errorf("adding the region was %q", got)
}
report, state = applyBlockDecl(t, blockDecl(t, path, opts, `"at":"start"`), state)
if got := report.Outcomes[0].Action; got != "unchanged" {
t.Errorf("a second apply was %q", got)
}
undeclare(t, state)
if got := readText(t, path); got != c.before {
t.Errorf("undeclaring left %q, the machine had %q", got, c.before)
}
})
}
t.Run("a file that was not there", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "dhcpcd.conf")
applyBlockDecl(t, blockDecl(t, path, opts, `"at":"start"`), store.State{})
if got := readText(t, path); got != marked(namesID, opts) {
t.Errorf("got %q", got)
}
})
}
func TestARegionAlreadyThereIsNotMovedWhereverAtSaysItGoes(t *testing.T) {
for _, at := range []string{`"at":"start"`, `"at":"end"`} {
path := filepath.Join(t.TempDir(), "dhcpcd.conf")
original := "hostname\n" + marked(namesID, "old\n") + "interface enp6s0\n"
_ = os.WriteFile(path, []byte(original), 0o644)
applyBlockDecl(t, blockDecl(t, path, "nohook resolv.conf\n", at), store.State{})
want := "hostname\n" + marked(namesID, "nohook resolv.conf\n") + "interface enp6s0\n"
if got := readText(t, path); got != want {
t.Errorf("%s: a found region was moved: %q", at, got)
}
}
}
func TestAFileWrittenIntoABlockIsNeverHeldOnAnAdoptedNode(t *testing.T) {
path := filepath.Join(t.TempDir(), "hosts")
_ = os.WriteFile(path, []byte(workstationHosts), 0o644)
resource := fmt.Sprintf(`{"id":%q,"type":"file","path":%q,"into":"block","content":%q}`, namesID, path, meshNames)
d := adopted(t, `{"taken":[],"untaken":{"mesh-wireguard":["`+namesID+`"]}}`, resource)
report, state := applyAdopted(t, d, store.State{}, &machine{}, t.TempDir())
if got := outcomeOf(report, namesID).Action; got == "held" {
t.Fatal("a file written into a block was held, though it replaces nothing that was found")
}
if len(state.Held) != 0 {
t.Errorf("something was held: %+v", state.Held)
}
if got := readText(t, path); got != workstationHosts+"\n"+marked(namesID, meshNames) {
t.Errorf("the adopted node's file was not written into: %q", got)
}
// Held from when it was declared whole, the hold does not keep the region out.
path2 := filepath.Join(t.TempDir(), "hosts")
_ = os.WriteFile(path2, []byte(workstationHosts), 0o644)
known := store.State{Held: []store.Held{{ID: namesID, Module: "mesh-wireguard", Kind: "file", Target: path2}}}
resource2 := fmt.Sprintf(`{"id":%q,"type":"file","path":%q,"into":"block","content":%q}`, namesID, path2, meshNames)
d2 := adopted(t, `{"taken":[],"untaken":{"mesh-wireguard":["`+namesID+`"]}}`, resource2)
report, state = applyAdopted(t, d2, known, &machine{}, t.TempDir())
if got := outcomeOf(report, namesID).Action; got != "updated" {
t.Errorf("the file was %q, not written into", got)
}
if len(state.Held) != 0 {
t.Errorf("the old hold outlived the block declaration: %+v", state.Held)
}
// And the preview says the same: written into, not held.
for _, s := range Plan(d2, known, store.OriginDeclared) {
if s.ID == namesID && s.Verb == "hold" {
t.Errorf("the preview holds a file written into a block: %+v", s)
}
}
}
func TestTheRecordOfABlockSurvivesTheStateFile(t *testing.T) {
// What undeclaring needs is in the state a host saves, not only in memory.
path := filepath.Join(t.TempDir(), "hosts")
original := "a\n" + marked(namesID, "old\n")
_ = os.WriteFile(path, []byte(original), 0o644)
_, state := applyBlockDecl(t, blockDecl(t, path, meshNames, `"at":"start"`), store.State{})
raw, err := json.Marshal(state)
if err != nil {
t.Fatal(err)
}
var back store.State
if err := json.Unmarshal(raw, &back); err != nil {
t.Fatal(err)
}
undeclare(t, back)
if got := readText(t, path); got != original {
t.Errorf("undeclaring from a saved state left %q", got)
}
}
// The mesh's old whole hosts file, as the controller composed it before issue 128.
const oldWholeHosts = "# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n" +
"# joins or leaves, and an edit would survive until then and vanish.\n\n" +
"127.0.0.1\tlocalhost\n" +
"::1\t\tlocalhost ip6-localhost ip6-loopback\n" +
"127.0.1.1\tg14\n" +
"\n" +
"10.42.0.1\tace.internal\tace\n" +
"10.42.0.9\tg14.internal\tg14\t# this machine\n"
func wholeDecl(path, content string) string {
return fmt.Sprintf(`{"declaration":1,"resources":[
{"id":%q,"type":"file","path":%q,"content":%q}
]}`, namesID, path, content)
}
func applyKeepingIn(t *testing.T, raw string, known store.State, keepDir string) (Report, store.State) {
t.Helper()
report, state, err := ApplyKeeping(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, (&machine{}).run, nil, nil, KeepIn(keepDir))
if err != nil {
t.Fatalf("apply failed: %v", err)
}
return report, state
}
func TestAFileTheMeshWroteWholeAndMadeItselfKeepsOnlyItsLoopbackLines(t *testing.T) {
// Written whole into a file that was not there, then declared as a block under the same id:
// the old names must not stay above the region, where a resolver would answer from them first.
path := filepath.Join(t.TempDir(), "hosts")
_, state := applyKeepingIn(t, wholeDecl(path, oldWholeHosts), store.State{}, t.TempDir())
report, state := applyKeepingIn(t, blockDecl(t, path, meshNames), state, t.TempDir())
floor := "127.0.0.1\tlocalhost\n::1\t\tlocalhost ip6-localhost ip6-loopback\n127.0.1.1\tg14\n"
if got := readText(t, path); got != floor+"\n"+marked(namesID, meshNames) {
t.Fatalf("the old whole file became:\n%q", got)
}
o := outcomeOf(report, namesID)
if o.Action != "updated" || !strings.Contains(o.Detail, "loopback lines") {
t.Errorf("the rebuild was reported as %q: %s", o.Action, o.Detail)
}
if rec, _ := state.Find(namesID); !rec.Into.Created {
t.Error("a file the mesh made itself was not recorded as the mesh's")
}
report, _ = applyKeepingIn(t, blockDecl(t, path, meshNames), state, t.TempDir())
if got := outcomeOf(report, namesID).Action; got != "unchanged" {
t.Errorf("applied again, the rebuilt file was %q", got)
}
undeclare(t, state)
if got := readText(t, path); got != floor {
t.Errorf("undeclared, the file holds %q", got)
}
}
func TestAFileTheMeshWroteWholeOverAnOriginalGetsTheOriginalBack(t *testing.T) {
path := filepath.Join(t.TempDir(), "hosts")
_ = os.WriteFile(path, []byte(workstationHosts), 0o644)
keep := t.TempDir()
_, state := applyKeepingIn(t, wholeDecl(path, oldWholeHosts), store.State{}, keep)
if rec, _ := state.Find(namesID); rec.Kept == "" {
t.Fatal("where the original was kept was not recorded")
}
report, state := applyKeepingIn(t, blockDecl(t, path, meshNames), state, keep)
if got := readText(t, path); got != workstationHosts+"\n"+marked(namesID, meshNames) {
t.Fatalf("the old whole file became:\n%q", got)
}
if d := outcomeOf(report, namesID).Detail; !strings.Contains(d, "original kept at") {
t.Errorf("the rebuild was reported as: %s", d)
}
undeclare(t, state)
if got := readText(t, path); got != workstationHosts {
t.Errorf("undeclared, the machine did not get its original back: %q", got)
}
}
func TestAFileTheMeshWroteWholeAndSomebodyChangedIsWrittenIntoAsItStands(t *testing.T) {
path := filepath.Join(t.TempDir(), "hosts")
_, state := applyKeepingIn(t, wholeDecl(path, oldWholeHosts), store.State{}, t.TempDir())
edited := oldWholeHosts + "192.168.1.20 printer\n"
_ = os.WriteFile(path, []byte(edited), 0o644)
report, _ := applyKeepingIn(t, blockDecl(t, path, meshNames), state, t.TempDir())
if got := readText(t, path); got != edited+"\n"+marked(namesID, meshNames) {
t.Fatalf("an edited whole file became:\n%q", got)
}
if d := outcomeOf(report, namesID).Detail; !strings.Contains(d, "changed since; its old lines were kept") {
t.Errorf("the outcome does not say so: %s", d)
}
}
func TestALinkedFileStaysALink(t *testing.T) {
dir := t.TempDir()
real := filepath.Join(dir, "static", "hosts")
_ = os.MkdirAll(filepath.Dir(real), 0o755)
_ = os.WriteFile(real, []byte(workstationHosts), 0o644)
link := filepath.Join(dir, "hosts")
if err := os.Symlink(real, link); err != nil {
t.Fatal(err)
}
_, state := applyBlockDecl(t, blockDecl(t, link, meshNames), store.State{})
if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("the link was replaced by a file")
}
if got := readText(t, real); got != workstationHosts+"\n"+marked(namesID, meshNames) {
t.Errorf("the file the link names holds %q", got)
}
undeclare(t, state)
if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("undeclaring replaced the link with a file")
}
if got := readText(t, real); got != workstationHosts {
t.Errorf("undeclared, the file the link names holds %q", got)
}
}
func TestALastLineWithNoEndIsGivenBackWithNone(t *testing.T) {
path := filepath.Join(t.TempDir(), "hosts")
_ = os.WriteFile(path, []byte("x"), 0o644)
_, state := applyBlockDecl(t, blockDecl(t, path, meshNames), store.State{})
if got := readText(t, path); got != "x\n\n"+marked(namesID, meshNames) {
t.Fatalf("got %q", got)
}
undeclare(t, state)
if got := readText(t, path); got != "x" {
t.Errorf("undeclaring left %q, the machine had %q", got, "x")
}
}
func TestAFailedBlockWriteKeepsItsHold(t *testing.T) {
// Held from when it was declared whole, then declared as a block into a file whose markers do
// not pair: the write is refused, and the hold — with where its original is — stays.
path := filepath.Join(t.TempDir(), "hosts")
broken := "a\n# BEGIN mesh " + namesID + "\n"
_ = os.WriteFile(path, []byte(broken), 0o644)
known := store.State{Held: []store.Held{{ID: namesID, Module: "mesh-wireguard", Kind: "file",
Target: path, Kept: "/var/lib/mesh/kept/hosts"}}}
resource := fmt.Sprintf(`{"id":%q,"type":"file","path":%q,"into":"block","content":%q}`, namesID, path, meshNames)
d := adopted(t, `{"taken":[],"untaken":{"mesh-wireguard":["`+namesID+`"]}}`, resource)
_, state, err := ApplyKeeping(context.Background(), archHost(t), d, known,
store.OriginDeclared, (&machine{}).run, nil, nil, KeepIn(t.TempDir()))
if err == nil {
t.Fatal("a write into unpaired markers was not refused")
}
h, held := state.HeldAt(namesID)
if !held || h.Kept != "/var/lib/mesh/kept/hosts" {
t.Errorf("a failed write released the hold: %+v", state.Held)
}
}
-97
View File
@@ -1,97 +0,0 @@
package apply
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// A take is a comparison (novox/hq ADR 0163): while a module's container is held, the host reports
// the found image and its age beside the declared one, the networks and who else is on them, the
// mounts and the ports — and says when the declared image is the older.
func TestAHeldContainerCarriesTheFactsATakeCompares(t *testing.T) {
dir, page, m := predecessor(t)
m.containers["hello-web"].image = "web:1.27"
m.containers["hello-web"].imageID = "sha256:found"
m.containers["hello-web"].networks = []string{"predecessor_default"}
m.containers["hello-web"].mounts = []string{"/srv/web:/data"}
m.containers["hello-web"].ports = []string{"80/tcp>0.0.0.0:8080"}
m.images = map[string]string{"sha256:found": "2026-09-17T10:00:00Z", pinned: "2026-08-20T10:00:00Z"}
m.members = map[string][]string{"predecessor_default": {"hello-web", "office", "db"}}
_, state := applyAdopted(t, adopted(t, untakenWeb, webResources(page)), store.State{}, m, dir)
h, ok := state.HeldAt("hello-web.server")
if !ok || h.Facts == nil {
t.Fatalf("a held container carries no facts: %+v", h)
}
f := h.Facts
if f.Image != "web:1.27" || f.ImageCreated != "2026-09-17T10:00:00Z" {
t.Errorf("the found image and its age: %+v", f)
}
if f.DeclaredImage != pinned || f.DeclaredImageCreated != "2026-08-20T10:00:00Z" || !f.Downgrade {
t.Errorf("the declared image, its age, and that it is a downgrade: %+v", f)
}
if got := f.Networks["predecessor_default"]; len(got) != 2 || got[0] != "db" || got[1] != "office" {
t.Errorf("the neighbours on the found network, without the container itself: %v", f.Networks)
}
if len(f.Mounts) != 1 || f.Mounts[0] != "/srv/web:/data" || len(f.Ports) != 1 || f.Ports[0] != "80/tcp>0.0.0.0:8080" {
t.Errorf("mounts and ports as found: %+v", f)
}
// And the held file carries how the declared content differs from what was found.
p, ok := state.HeldAt("hello-web.page")
if !ok || p.Facts == nil || !p.Facts.Differs {
t.Fatalf("a held file that differs from the declared content does not say so: %+v", p)
}
joined := strings.Join(p.Facts.Difference, "\n")
if !strings.Contains(joined, "- the predecessor's page") || !strings.Contains(joined, "+ the mesh's page") {
t.Errorf("the difference does not show what is lost and what is new: %q", joined)
}
_ = os.Remove(filepath.Join(dir, "unused"))
}
// A declared image not yet on the machine leaves its age unknown and the comparison undecided.
func TestAnImageNotYetPulledLeavesTheDowngradeUndecided(t *testing.T) {
dir, page, m := predecessor(t)
m.containers["hello-web"].image = "web:1.27"
m.containers["hello-web"].imageID = "sha256:found"
m.images = map[string]string{"sha256:found": "2026-09-17T10:00:00Z"}
_, state := applyAdopted(t, adopted(t, untakenWeb, webResources(page)), store.State{}, m, dir)
h, _ := state.HeldAt("hello-web.server")
if h.Facts == nil || h.Facts.DeclaredImageCreated != "" || h.Facts.Downgrade {
t.Fatalf("an unknown declared age decided a downgrade: %+v", h.Facts)
}
}
func TestTheDifferenceIsWhatIsLostAndWhatIsNew(t *testing.T) {
differs, lines := differenceOf("a\nprivate scope: local\nb\n", "a\nb\nupstream: public\n")
if !differs || len(lines) != 2 || lines[0] != "- private scope: local" || lines[1] != "+ upstream: public" {
t.Fatalf("got %v %v", differs, lines)
}
if differs, lines := differenceOf("same\n", "same\n"); differs || lines != nil {
t.Fatalf("identical content differs: %v %v", differs, lines)
}
}
// What runs on the machine that the mesh neither wrote nor holds is reported (ADR 0163).
func TestStraysAreWhatRunsHereThatNobodyAsked(t *testing.T) {
m := &machine{containers: map[string]*fakeContainer{
"hello-web": {id: "ours", running: true, image: "web:1"},
"gitea-old": {id: "left-behind", running: true, image: "gitea:1.22"},
"held-thing": {id: "found", running: true, image: "x:1"},
}}
known := store.State{
Resources: []store.Applied{{ID: "hello-web.server", Type: "container", Target: "hello-web"}},
Held: []store.Held{{ID: "other.server", Kind: "container", Target: "held-thing"}},
}
strays, err := Strays(context.Background(), m.run, known)
if err != nil {
t.Fatal(err)
}
if len(strays) != 1 || strays[0].Name != "gitea-old" || !strings.Contains(strays[0].Detail, "gitea:1.22") {
t.Fatalf("strays: %+v", strays)
}
}
-75
View File
@@ -1,75 +0,0 @@
package apply
import (
"context"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// The host's own former archive stops nothing (novox/hq issue 194). A new host's first apply finds
// the version before it as a former target of the archive that delivered it; an archive has no
// removal (issue 162), and the refusal stopped every machine applying anything. A former target of
// such a kind is left in place, said, and forgotten. An archive the declaration dropped still fails,
// as 162 has it.
func TestTheHostsOwnFormerArchiveIsLeftInPlaceNotFatal(t *testing.T) {
run := func(_ context.Context, name string, args ...string) (string, error) {
if name == "docker" && args[0] == "info" {
return "29.0.0\n", nil
}
return "", nil
}
dir := t.TempDir()
former := store.FormerID("mesh-host.next", dir+"/versions/old")
known := store.State{Resources: []store.Applied{
{ID: "mesh-host.next", Type: "archive", Target: dir + "/versions/new", Origin: store.OriginDeclared},
{ID: former, Type: "archive", Target: dir + "/versions/old", Origin: store.OriginDeclared},
}}
body, digest := anArchive(t, map[string]string{"nox-mesh-host": "#!/bin/sh\n"})
d := parse(t, `{"declaration":1,"resources":[
{"id":"mesh-host.next","type":"archive","path":"`+dir+`/versions/new","source":"`+serving(t, body)+`","digest":"`+digest+`"},
{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"x"}
]}`)
report, state, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatalf("the apply failed: %v", err)
}
if _, still := state.HeldAt(former); still {
t.Fatal("held?")
}
for _, r := range state.Resources {
if r.ID == former {
t.Fatal("the former archive is still on record")
}
}
said := false
for _, o := range report.Outcomes {
if o.ID == former && o.Action == "forgotten" && strings.Contains(o.Detail, "left in place") {
said = true
}
}
if !said {
t.Fatalf("leaving the former archive was not said: %+v", report.Outcomes)
}
applied := false
for _, o := range report.Outcomes {
if o.ID == "notes.conf" && o.Action == "created" {
applied = true
}
}
if !applied {
t.Fatalf("the rest of the declaration was not applied: %+v", report.Outcomes)
}
// An archive the declaration dropped is a different matter: nothing can undo it, and saying
// it was would report an effect the host declined to have (issue 162).
dropped := store.State{Resources: []store.Applied{
{ID: "tool.next", Type: "archive", Target: "/usr/lib/tool/versions/old", Origin: store.OriginDeclared},
}}
only := parse(t, `{"declaration":1,"resources":[{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"x"}]}`)
if _, _, err := Apply(context.Background(), archHost(t), only, dropped, store.OriginDeclared, run, nil, nil); err == nil ||
!strings.Contains(err.Error(), "no way to remove") {
t.Fatalf("a dropped archive was passed over: %v", err)
}
}
-387
View File
@@ -1,387 +0,0 @@
package apply
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq ADR 0118: undeclaring removes what the mesh made, gives back what it changed,
// and leaves what was the machine's — which needs what was found kept exactly, and a unit the mesh
// made known for the mesh's whatever its record says.
func unitsMachine(units map[string]*fakeUnit) *machine {
return &machine{containers: map[string]*fakeContainer{}, units: units}
}
// nothingButA is a declaration of one unrelated file, so everything recorded is undeclared.
func nothingButA(t *testing.T) string {
return `{"declaration":1,"resources":[
{"id":"other","type":"file","path":"` + filepath.Join(t.TempDir(), "a") + `","content":"a\n"}]}`
}
// copyOf is a state as a later load of it would be: sharing nothing with the one it came from.
func copyOf(t *testing.T, s store.State) store.State {
t.Helper()
raw, err := json.Marshal(s)
if err != nil {
t.Fatal(err)
}
var out store.State
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatal(err)
}
return out
}
func applyOn(t *testing.T, raw string, known store.State, m *machine) (Report, store.State, error) {
t.Helper()
return Apply(context.Background(), archHost(t), parse(t, raw), known, store.OriginDeclared, m.run, nil, nil)
}
func TestAUnitWhoseFileTheMeshWroteIsStoppedWhateverItsRecordSays(t *testing.T) {
// The adoption guard's unit file is the mesh's own file resource, written where there was none.
// Its service recorded before the host kept what it found has no Found, and forgetting it left
// the guard's table loaded on a converged node and its unit file deleted from under it.
units := t.TempDir()
was := unitDir
unitDir = units
t.Cleanup(func() { unitDir = was })
unitFile := filepath.Join(units, "mesh-guard.service")
if err := os.WriteFile(unitFile, []byte("[Unit]\n"), 0o644); err != nil {
t.Fatal(err)
}
m := unitsMachine(map[string]*fakeUnit{"mesh-guard.service": {active: "active", enabled: "enabled"}})
fileThereAtStop := false
run := func(ctx context.Context, name string, args ...string) (string, error) {
if name == "systemctl" && args[0] == "stop" {
_, err := os.Stat(unitFile)
fileThereAtStop = err == nil
}
return m.run(ctx, name, args...)
}
// As a host before this change recorded them: the unit file first, then the service it
// starts, and no Found on the service.
known := store.State{Resources: []store.Applied{
{ID: "adoption.guard-unit", Type: "file", Target: unitFile, Origin: store.OriginDeclared},
{ID: "adoption.guard-running", Type: "service", Target: "mesh-guard.service", Origin: store.OriginDeclared},
}}
report, after, err := applyWith(t, parse(t, nothingButA(t)), known, run)
if err != nil {
t.Fatal(err)
}
if u := m.units["mesh-guard.service"]; u.active != "inactive" || u.enabled != "disabled" {
t.Errorf("the mesh's own unit was left %s and %s: %v", u.active, u.enabled, m.asked)
}
if !fileThereAtStop {
t.Error("the unit was stopped after its file was deleted, or not at all")
}
if o := outcomeOf(report, "adoption.guard-running"); o.Action != "removed" || !strings.Contains(o.Detail, "unit file") {
t.Errorf("outcome %+v", o)
}
if _, err := os.Stat(unitFile); !os.IsNotExist(err) {
t.Error("the unit file outlived its record")
}
if len(after.Resources) != 1 {
t.Errorf("still recorded: %v", after.IDs())
}
// A unit file the host wrote OVER — its original kept — is the machine's unit, and a record
// with no Found leaves it as it is.
if err := os.WriteFile(unitFile, []byte("[Unit]\n"), 0o644); err != nil {
t.Fatal(err)
}
m = unitsMachine(map[string]*fakeUnit{"mesh-guard.service": {active: "active", enabled: "enabled"}})
known.Resources[0].Kept = filepath.Join(t.TempDir(), "original")
if err := os.WriteFile(known.Resources[0].Kept, []byte("[Unit]\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := applyWith(t, parse(t, nothingButA(t)), known, m.run); err != nil {
t.Fatal(err)
}
if m.did("systemctl stop") || m.did("systemctl disable") {
t.Errorf("a unit whose file the mesh only wrote over was stopped: %v", m.asked)
}
}
func TestOnlyAUnitsOwnFileTheMeshCreatedMakesItTheMeshs(t *testing.T) {
known := store.State{Resources: []store.Applied{
{ID: "a", Type: "file", Target: "/etc/systemd/system/made.service"},
{ID: "b", Type: "file", Target: "/run/systemd/system/runtime.service"},
{ID: "c", Type: "file", Target: "/etc/systemd/system/kept.service", Kept: "/var/lib/mesh-host/kept/x"},
{ID: "d", Type: "file", Target: "/etc/systemd/system/into.service", Into: &store.Into{Format: "block"}},
{ID: "e", Type: "file", Target: "/etc/systemd/system/docker.service.d/mesh.conf"},
{ID: "f", Type: "file", Target: "/etc/mesh/elsewhere.service"},
{ID: "g", Type: "directory", Target: "/etc/systemd/system/dir.service"},
}}
made := meshMadeUnits(known)
for unit, want := range map[string]bool{"made.service": true, "runtime.service": true, "kept.service": false,
"into.service": false, "mesh.conf": false, "docker.service.d": false, "elsewhere.service": false, "dir.service": false} {
if made[unit] != want {
t.Errorf("%s: made %v, want %v", unit, made[unit], want)
}
}
}
func TestWhatWasFoundOutlivesAFirstApplyThatFailed(t *testing.T) {
// Enabled, then it would not start: no record. The next apply must not read the enable as
// the machine's — or undeclaring leaves enabled a unit the mesh enabled.
m := unitsMachine(map[string]*fakeUnit{"filter.service": {active: "inactive", enabled: "disabled", wontStart: true}})
declared := `{"declaration":1,"resources":[
{"id":"s","type":"service","unit":"filter.service","state":"running","boot":"enabled"}]}`
_, first, err := applyOn(t, declared, store.State{}, m)
if err == nil || !m.did("systemctl enable filter.service") {
t.Fatalf("the fixture did not enable and then fail: %v, %v", err, m.asked)
}
if _, ok := first.Find("s"); ok {
t.Fatal("a failed apply was recorded")
}
if p := first.FoundFirst["s"]; p.State != "stopped" || p.Boot != "disabled" || p.Unit != "filter.service" {
t.Fatalf("what was found was not kept through the failure: %+v", first.FoundFirst)
}
failed := copyOf(t, first)
m.units["filter.service"].wontStart = false
_, second, err := applyOn(t, declared, first, m)
if err != nil {
t.Fatal(err)
}
if rec, _ := second.Find("s"); rec.Found == nil || rec.Found.State != "stopped" || rec.Found.Boot != "disabled" {
t.Errorf("the record carries the mesh's own effect as found: %+v", rec.Found)
}
if second.FoundFirst != nil {
t.Errorf("kept apart after the record carried it: %+v", second.FoundFirst)
}
if _, _, err := applyOn(t, nothingButA(t), second, m); err != nil {
t.Fatal(err)
}
if u := m.units["filter.service"]; u.active != "inactive" || u.enabled != "disabled" {
t.Errorf("undeclared, the unit was left %s and %s", u.active, u.enabled)
}
// Undeclared before it was ever recorded, what was found goes with it — only for its origin.
if _, kept, _ := Apply(context.Background(), archHost(t), parse(t, nothingButA(t)), copyOf(t, failed),
store.OriginCarried, m.run, nil, nil); kept.FoundFirst["s"].State == "" {
t.Error("a carried apply dropped what the mesh's declaration found")
}
if _, dropped, err := applyOn(t, nothingButA(t), copyOf(t, failed), m); err != nil || dropped.FoundFirst != nil {
t.Errorf("kept for a service no longer declared: %+v, %v", dropped.FoundFirst, err)
}
}
func TestBootIsFoundTheFirstTimeTheMeshSetsIt(t *testing.T) {
// The declaration said nothing about boot at first, so the mesh never touched it: what is
// there when a declaration first does is still the machine's.
m := unitsMachine(map[string]*fakeUnit{"web.service": {active: "active", enabled: "disabled"}})
known := store.State{Resources: []store.Applied{{ID: "s", Type: "service", Target: "web.service",
Origin: store.OriginDeclared, Found: &store.FoundUnit{Unit: "web.service", State: "running"}}}}
_, after, err := applyOn(t, `{"declaration":1,"resources":[
{"id":"s","type":"service","unit":"web.service","state":"running","boot":"enabled"}]}`, known, m)
if err != nil {
t.Fatal(err)
}
rec, _ := after.Find("s")
if rec.Found == nil || rec.Found.State != "running" || rec.Found.Boot != "disabled" {
t.Fatalf("found %+v, want running and disabled", rec.Found)
}
report, _, err := applyOn(t, nothingButA(t), after, m)
if err != nil {
t.Fatal(err)
}
if u := m.units["web.service"]; u.active != "active" || u.enabled != "disabled" {
t.Errorf("undeclared, the unit is %s and %s; want running, and disabled again", u.active, u.enabled)
}
if o := outcomeOf(report, "s"); o.Action != "restored" || o.Detail != "disabled at boot, as the host found it" {
t.Errorf("outcome %+v", o)
}
}
func TestAServiceOnceDeclaredWithNoStateIsFoundWhenFirstGivenOne(t *testing.T) {
// Declared with no state (novox/hq ADR 0117), the mesh never started or stopped it — so what
// is there when a declaration first gives it one is what the machine had.
m := unitsMachine(map[string]*fakeUnit{"net.service": {active: "inactive", enabled: "disabled"}})
known := store.State{Resources: []store.Applied{{ID: "s", Type: "service", Target: "net.service",
Origin: store.OriginDeclared, Stateless: true}}}
_, after, err := applyOn(t, `{"declaration":1,"resources":[
{"id":"s","type":"service","unit":"net.service","state":"running"}]}`, known, m)
if err != nil {
t.Fatal(err)
}
if rec, _ := after.Find("s"); rec.Found == nil || rec.Found.State != "stopped" {
t.Fatalf("found %+v, want stopped", rec.Found)
}
if _, _, err := applyOn(t, nothingButA(t), after, m); err != nil {
t.Fatal(err)
}
if m.units["net.service"].active != "inactive" {
t.Error("the unit the mesh started outlived its declaration")
}
}
func TestAUnitWrittenInTheSameApplyIsLoadedBeforeItIsRead(t *testing.T) {
// Read before the service manager is told about its new file, the unit is not there to find.
dir := t.TempDir()
m := unitsMachine(map[string]*fakeUnit{"fresh.service": {active: "inactive", enabled: "disabled"}})
_, _, err := applyOn(t, `{"declaration":1,"resources":[
{"id":"unit","type":"file","path":"`+filepath.Join(dir, "fresh.service")+`","content":"[Unit]\n"},
{"id":"s","type":"service","unit":"fresh.service","state":"running","restart-on":["unit"]}]}`,
store.State{}, m)
if err != nil {
t.Fatal(err)
}
reload, show := -1, -1
for i, a := range m.asked {
if a == "systemctl daemon-reload" && reload < 0 {
reload = i
}
if strings.HasPrefix(a, "systemctl show fresh.service") && show < 0 {
show = i
}
}
if reload < 0 || show < 0 || reload > show {
t.Errorf("the unit was read before its file was loaded: %v", m.asked)
}
}
func TestAServiceMovedToAnotherUnitGivesTheOldOneBackAndFindsTheNewOne(t *testing.T) {
m := unitsMachine(map[string]*fakeUnit{
"old.service": {active: "active", enabled: "enabled"},
"new.service": {active: "inactive", enabled: "disabled"},
})
known := store.State{Resources: []store.Applied{{ID: "s", Type: "service", Target: "old.service",
Origin: store.OriginDeclared, Found: &store.FoundUnit{Unit: "old.service", State: "stopped"}}}}
report, after, err := applyOn(t, `{"declaration":1,"resources":[
{"id":"s","type":"service","unit":"new.service","state":"running"}]}`, known, m)
if err != nil {
t.Fatal(err)
}
if m.units["old.service"].active != "inactive" {
t.Error("the unit the mesh started is still running though nothing declares it")
}
if m.units["new.service"].active != "active" {
t.Error("the unit now declared was not started")
}
rec, _ := after.Find("s")
if rec.Found == nil || rec.Found.Unit != "new.service" || rec.Found.State != "stopped" {
t.Errorf("what was found about the old unit was carried to the new one: %+v", rec.Found)
}
if o := outcomeOf(report, "s"); !strings.Contains(o.Detail, "old.service stopped, as the host found it") {
t.Errorf("giving the old unit back went unsaid: %+v", o)
}
}
func TestARemovalSaysWhatItDid(t *testing.T) {
cases := []struct {
name string
found *store.FoundUnit
unit *fakeUnit
action, detail string
}{
{"found stopped and still stopped", &store.FoundUnit{State: "stopped"},
&fakeUnit{active: "inactive", enabled: "disabled"}, "forgotten", "already as the host found it (stopped)"},
{"started by the mesh", &store.FoundUnit{State: "stopped"},
&fakeUnit{active: "active", enabled: "disabled"}, "restored", "stopped, as the host found it"},
{"started and enabled by the mesh", &store.FoundUnit{State: "stopped", Boot: "disabled"},
&fakeUnit{active: "active", enabled: "enabled"}, "restored", "stopped, disabled at boot, as the host found it"},
{"found running, stopped by the mesh", &store.FoundUnit{State: "running"},
&fakeUnit{active: "inactive", enabled: "disabled"}, "forgotten",
"left as it is; the mesh stopped it and does not start anything on the way out"},
{"found running and enabled, disabled by the mesh", &store.FoundUnit{State: "running", Boot: "enabled"},
&fakeUnit{active: "active", enabled: "disabled"}, "forgotten",
"left as it is; the mesh disabled it at boot and does not start anything on the way out"},
{"found running, still running", &store.FoundUnit{State: "running"},
&fakeUnit{active: "active", enabled: "enabled"}, "forgotten", "it was running before the mesh; left as it is"},
// Found says to stop it, so the machine is asked about it — and it is gone.
{"started by the mesh, since uninstalled", &store.FoundUnit{State: "stopped"},
nil, "forgotten", "the unit no longer exists"},
{"recorded before the host kept what it found", nil,
&fakeUnit{active: "active", enabled: "enabled"}, "forgotten", "recorded before the host kept what it found; left as it is"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
units := map[string]*fakeUnit{}
if c.unit != nil {
units["unit.service"] = c.unit
}
m := unitsMachine(units)
known := store.State{Resources: []store.Applied{{ID: "s", Type: "service", Target: "unit.service",
Origin: store.OriginDeclared, Found: c.found}}}
report, after, err := applyOn(t, nothingButA(t), known, m)
if err != nil {
t.Fatal(err)
}
if o := outcomeOf(report, "s"); o.Action != c.action || o.Detail != c.detail {
t.Errorf("said %s: %s; want %s: %s", o.Action, o.Detail, c.action, c.detail)
}
if c.unit == nil && !m.did("systemctl show unit.service") {
t.Errorf("the machine was never asked whether the unit is there: %v", m.asked)
}
if m.did("systemctl start") || m.did("systemctl enable") {
t.Errorf("something was started on the way out: %v", m.asked)
}
if _, still := after.Find("s"); still {
t.Error("still recorded")
}
})
}
}
func TestAUnitTheMeshStartedIsStoppedWhenUndeclaredAndOneFoundRunningIsNot(t *testing.T) {
// End to end: found by the first apply, carried, given back.
m := unitsMachine(map[string]*fakeUnit{
"filter.service": {active: "inactive", enabled: "disabled"},
"runtime.service": {active: "active", enabled: "enabled"},
})
_, state, err := applyOn(t, `{"declaration":1,"resources":[
{"id":"filter","type":"service","unit":"filter.service","state":"running","boot":"enabled"},
{"id":"runtime","type":"service","unit":"runtime.service","state":"running","boot":"enabled"}]}`,
store.State{}, m)
if err != nil {
t.Fatal(err)
}
if m.units["filter.service"].active != "active" {
t.Fatal("the fixture did not start the filter")
}
if _, _, err := applyOn(t, nothingButA(t), state, m); err != nil {
t.Fatal(err)
}
if u := m.units["filter.service"]; u.active != "inactive" || u.enabled != "disabled" {
t.Errorf("the filter the mesh started and enabled is %s and %s", u.active, u.enabled)
}
if u := m.units["runtime.service"]; u.active != "active" || u.enabled != "enabled" {
t.Errorf("the runtime that was running before the mesh is %s and %s", u.active, u.enabled)
}
}
func TestAUnitHeldAndThenTakenIsFoundAsThePredecessorLeftIt(t *testing.T) {
// Held while its module was untaken, nothing was applied and nothing found; taken, the first
// apply finds the predecessor's unit — stopped, but started at boot — before starting it.
dir := t.TempDir()
m := unitsMachine(map[string]*fakeUnit{"hello.service": {active: "inactive", enabled: "enabled"}})
service := `{"id":"hello-web.unit","type":"service","unit":"hello.service","state":"running","boot":"enabled"}`
_, held := applyAdopted(t, adopted(t, untaken("hello-web.unit"), service), store.State{}, m, dir)
if _, ok := held.HeldAt("hello-web.unit"); !ok {
t.Fatal("the fixture's unit was not held")
}
_, taken := applyAdopted(t, adopted(t, `{"taken":["hello-web"]}`, service), held, m, dir)
rec, _ := taken.Find("hello-web.unit")
if rec.Found == nil || rec.Found.State != "stopped" || rec.Found.Boot != "enabled" {
t.Fatalf("found %+v, want the predecessor's stopped and enabled", rec.Found)
}
if m.units["hello.service"].active != "active" {
t.Fatal("the taken unit was not started")
}
if _, _, err := applyOn(t, nothingButA(t), taken, m); err != nil {
t.Fatal(err)
}
if u := m.units["hello.service"]; u.active != "inactive" || u.enabled != "enabled" {
t.Errorf("given back as %s and %s; the predecessor left it stopped and enabled", u.active, u.enabled)
}
}
-318
View File
@@ -1,318 +0,0 @@
package apply
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// novox/hq issue 213: the controller moves from a container to a process on the one machine that
// runs it. Every orphan is removed before anything is applied, so without a handover the container
// went first and nothing answered the mesh's verbs while the process was fetched, unpacked and
// started — and for ever, if it did not start. A process that `replaces` the container is applied
// first; the container goes only once the process is running a moment later.
// aMachine fakes the service manager and the container runtime: it records every command, answers
// `systemctl show` with whether the process is running, and has a container until it is removed.
type aMachine struct {
commands []string
running bool // what `systemctl show` says of the process once it was started
started bool
container bool
timer bool // whether a timer, once started, is up
crashing bool // up at the first look, waiting to restart at the next
looks int
}
func (m *aMachine) run(ctx context.Context, name string, args ...string) (string, error) {
line := name + " " + strings.Join(args, " ")
m.commands = append(m.commands, line)
switch {
case name == "systemctl" && len(args) > 0 && (args[0] == "restart" || args[0] == "start"):
m.started = true
case name == "systemctl" && len(args) > 0 && args[0] == "is-active" && strings.HasSuffix(args[len(args)-1], ".timer"):
if m.timer {
return "active", nil
}
return "inactive", errors.New("inactive")
case name == "systemctl" && len(args) > 0 && args[0] == "is-active":
if m.started && m.running {
return "active", nil
}
return "inactive", errors.New("inactive")
case name == "systemctl" && len(args) > 0 && args[0] == "show":
m.looks++
if m.crashing && m.started {
// Up at the first look; waiting to be started again, a new process, at the second.
if m.looks == 1 {
return "ActiveState=active\nSubState=running\nMainPID=42\nNRestarts=0\n", nil
}
return "ActiveState=activating\nSubState=auto-restart\nMainPID=0\nNRestarts=1\n", nil
}
if m.started && m.running {
return "ActiveState=active\nSubState=running\nMainPID=42\nNRestarts=0\n", nil
}
return "ActiveState=inactive\nSubState=dead\nMainPID=0\nNRestarts=0\n", nil
case name == "docker" && len(args) > 1 && args[0] == "rm":
m.container = false
case name == "docker" && len(args) > 1 && args[0] == "container" && args[1] == "inspect":
if !m.container {
return "", errors.New("no such container")
}
return "true\t", nil
}
return "", nil
}
func (m *aMachine) index(prefix string) int {
for i, c := range m.commands {
if strings.HasPrefix(c, prefix) {
return i
}
}
return -1
}
// theController is a machine whose controller ran as a container, recorded, and a declaration that
// runs it as a process from a bundle served here instead.
func theController(t *testing.T, digest, source, extra string) (store.State, string) {
t.Helper()
known := store.State{Resources: []store.Applied{{
Origin: store.OriginDeclared, ID: "mesh-controller.server", Type: "container", Target: "mesh-controller",
}}}
return known, `{"declaration":1,"resources":[
{"id":"mesh-controller.controller","type":"process","name":"mesh-controller","source":"` + source +
`","digest":"` + digest + `","run":["./mesh-controller","serve"]` + extra + `}]}`
}
func onAMachine(t *testing.T) {
t.Helper()
serviceSettle, handoverSettle = 0, 0
wasUnits, wasBundles := unitDir, daemonRoot
unitDir, daemonRoot = t.TempDir(), t.TempDir()
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
}
func TestAContainerAProcessReplacesGoesOnlyOnceTheProcessRuns(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
known, raw := theController(t, digest, serving(t, body), `,"replaces":["mesh-controller.server"]`)
m := &aMachine{running: true, container: true}
report, after, err := Apply(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, m.run, nil, nil)
if err != nil {
t.Fatalf("the handover failed: %v", err)
}
started, removed := m.index("systemctl restart mesh-controller.service"), m.index("docker rm -f mesh-controller")
if started < 0 || removed < 0 {
t.Fatalf("the process was not started or the container not removed: %v", m.commands)
}
if removed < started {
t.Fatalf("the container was removed before its replacement was started — a window with "+
"nothing answering: %v", m.commands)
}
if looked := m.index("systemctl show mesh-controller.service"); looked < 0 || looked > removed {
t.Errorf("the container was removed without looking whether the process runs: %v", m.commands)
}
if o := outcomeOf(report, "mesh-controller.server"); o.Action != "removed" {
t.Errorf("the container's outcome is %+v, want removed", o)
}
if _, still := after.Find("mesh-controller.server"); still {
t.Error("the host still records the container it removed")
}
if _, has := after.Find("mesh-controller.controller"); !has {
t.Error("the process was not recorded")
}
}
// The case the handover exists for: the replacement does not stay up. The container keeps
// answering, stays recorded so a later apply hands it over, and the apply says why it failed.
func TestAContainerIsKeptWhenItsReplacementDoesNotRun(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
known, raw := theController(t, digest, serving(t, body), `,"replaces":["mesh-controller.server"]`)
m := &aMachine{running: false, container: true}
report, after, err := Apply(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, m.run, nil, nil)
if err == nil {
t.Fatal("a replacement that is not running was reported as a clean apply")
}
if !strings.Contains(err.Error(), "mesh-controller.server") {
t.Errorf("the failure does not name what was kept: %v", err)
}
if m.index("docker rm") >= 0 {
t.Fatalf("the container was removed though its replacement is not running: %v", m.commands)
}
if o := outcomeOf(report, "mesh-controller.server"); o.Action != "kept" {
t.Errorf("the container's outcome is %+v, want kept", o)
}
if _, still := after.Find("mesh-controller.server"); !still {
t.Fatal("the container was forgotten, so no later apply would ever remove it")
}
// The next apply finds the process up and finishes the handover.
m.running, m.commands = true, nil
report, after, err = Apply(context.Background(), archHost(t), parse(t, raw), after,
store.OriginDeclared, m.run, nil, nil)
if err != nil {
t.Fatalf("the second apply failed: %v", err)
}
if o := outcomeOf(report, "mesh-controller.server"); o.Action != "removed" {
t.Errorf("the second apply did not hand over: %+v (%v)", o, m.commands)
}
if _, still := after.Find("mesh-controller.server"); still {
t.Error("the container is still recorded after the handover")
}
}
// A replacement that never applied — its bundle is not what was declared — touches nothing, and
// what it replaces keeps running.
func TestAContainerIsKeptWhenItsReplacementFailsToApply(t *testing.T) {
onAMachine(t)
body, _ := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
known, raw := theController(t, "sha256:"+strings.Repeat("b", 64), serving(t, body),
`,"replaces":["mesh-controller.server"]`)
m := &aMachine{running: true, container: true}
report, after, err := Apply(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, m.run, nil, nil)
if err == nil {
t.Fatal("a replacement whose bundle did not match was reported applied")
}
if m.index("docker rm") >= 0 {
t.Fatalf("the container was removed though nothing replaced it: %v", m.commands)
}
if o := outcomeOf(report, "mesh-controller.server"); o.Action != "kept" {
t.Errorf("the container's outcome is %+v, want kept", o)
}
if _, still := after.Find("mesh-controller.server"); !still {
t.Fatal("the container was forgotten")
}
}
// Without `replaces` nothing changes: an orphan goes before anything is applied, as it always has.
// Kept as a test because it is the window the field exists to close.
func TestWithoutReplacesAnOrphanStillGoesFirst(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
known, raw := theController(t, digest, serving(t, body), ``)
m := &aMachine{running: true, container: true}
if _, _, err := Apply(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, m.run, nil, nil); err != nil {
t.Fatal(err)
}
if removed, started := m.index("docker rm -f mesh-controller"), m.index("systemctl restart mesh-controller.service"); removed < 0 || removed > started {
t.Fatalf("an orphan nothing replaces was not removed first: %v", m.commands)
}
}
// novox/hq issue 213, beyond what the oneshot unit (process_step_test.go) already holds: a step
// written `./name` runs its own bundle's binary — the controller's preparation is its own binary —
// and a step is started, never enabled.
func TestAStepRunsItsOwnBundlesBinaryAndIsNotEnabled(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
m := &aMachine{running: true}
d := declare(t, `{"id":"mesh-controller.controller-prepare","type":"process","name":"mesh-controller-prepare",
"source":"`+serving(t, body)+`","digest":"`+digest+`","run":["./mesh-controller","prepare"],"run-once":true}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil); err != nil {
t.Fatalf("the step failed: %v", err)
}
for _, c := range m.commands {
if strings.HasPrefix(c, "./") || strings.HasPrefix(c, "systemctl enable") {
t.Errorf("the step was run directly or enabled: %v", m.commands)
}
}
unit, err := os.ReadFile(filepath.Join(unitDir, "mesh-controller-prepare.service"))
if err != nil {
t.Fatal(err)
}
want := "ExecStart=" + filepath.Join(daemonRoot, "mesh-controller-prepare", "mesh-controller") + " prepare"
if !strings.Contains(string(unit), want) {
t.Errorf("the step does not run its own bundle's binary (%q):\n%s", want, unit)
}
}
func TestAStepThatFailsGatesItsModule(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
src := serving(t, body)
run := func(ctx context.Context, name string, args ...string) (string, error) {
if name == "systemctl" && len(args) > 1 && args[0] == "start" {
return "", errors.New("Job for mesh-controller-prepare.service failed")
}
if name == "systemctl" && len(args) > 0 && args[0] == "restart" {
t.Errorf("the module's process was started after its step failed")
}
return "", nil
}
d := declare(t, `{"id":"mesh-controller.controller-prepare","type":"process","name":"mesh-controller-prepare",
"source":"`+src+`","digest":"`+digest+`","run":["./mesh-controller","prepare"],"run-once":true},
{"id":"mesh-controller.controller","type":"process","name":"mesh-controller",
"source":"`+src+`","digest":"`+digest+`","run":["./mesh-controller","serve"]}`)
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil)
if err == nil {
t.Fatal("a failed step was reported as a clean apply")
}
if o := outcomeOf(report, "mesh-controller.controller"); o.Action != "skipped" {
t.Errorf("the process after a failed step was %+v, want skipped", o)
}
}
// A completed step is done: applied again unchanged, it is not run again — its service is never up
// between runs, and reading that as "a daemon that stopped" re-ran the controller's preparation on
// every apply. Nor is a scheduled run started off its cadence; its timer is what is kept up.
func TestACompletedStepIsNotRunAgainAndAScheduleIsItsTimer(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"job": "#!/bin/sh\n"})
src := serving(t, body)
for _, mode := range []string{`"run-once":true`, `"schedule":"0 3 * * *"`} {
// The service of either is never up between runs; a scheduled one's timer is.
m := &aMachine{running: false, timer: true}
d := declare(t, `{"id":"m.job","type":"process","name":"m-job","source":"`+src+`","digest":"`+digest+
`","run":["./job"],`+mode+`}`)
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil)
if err != nil {
t.Fatalf("%s: first apply: %v", mode, err)
}
m.commands = nil
report, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, m.run, nil, nil)
if err != nil {
t.Fatalf("%s: second apply: %v", mode, err)
}
if m.index("systemctl start m-job.service") >= 0 || m.index("systemctl restart m-job.service") >= 0 {
t.Errorf("%s: an unchanged apply ran the job again: %v", mode, m.commands)
}
if o := outcomeOf(report, "m.job"); o.Action != "unchanged" {
t.Errorf("%s: an unchanged apply reported %+v", mode, o)
}
}
}
// A replacement crash-looping between its restarts is not up, though the service manager calls it
// "activating" — the state the host's ordinary second look accepts as running. Removing the
// container on that reading would leave nothing answering.
func TestAContainerIsKeptWhenItsReplacementIsCrashLooping(t *testing.T) {
onAMachine(t)
body, digest := anArchive(t, map[string]string{"mesh-controller": "#!/bin/sh\n"})
known, raw := theController(t, digest, serving(t, body), `,"replaces":["mesh-controller.server"]`)
m := &aMachine{crashing: true, container: true}
_, after, err := Apply(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, m.run, nil, nil)
if err == nil || !strings.Contains(err.Error(), "auto-restart") {
t.Fatalf("a crash-looping replacement was accepted: %v", err)
}
if m.index("docker rm") >= 0 {
t.Fatalf("the container was removed for a replacement that keeps dying: %v", m.commands)
}
if _, still := after.Find("mesh-controller.server"); !still {
t.Fatal("the container was forgotten")
}
}
+10 -153
View File
@@ -8,7 +8,6 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
@@ -109,7 +108,7 @@ func lookBefore(ctx context.Context, sys system.System, d *declaration.Declarati
}
}
case *declaration.Service:
if res.Stateless() || known.Recorded(string(declaration.TypeService), res.Unit) {
if known.Recorded(string(declaration.TypeService), res.Unit) {
continue
}
// **Found is a unit somebody put on this machine, or one the machine uses.**
@@ -262,19 +261,6 @@ func heldContainer(known store.State, name string) (store.Held, bool) {
return store.Held{}, false
}
// replacesNothing is a resource that takes nothing found on the machine from it, so on an adopted
// node it is never held and never previewed as replacing what was found: a file written into
// (novox/hq ADR 0102), and a service whose unit's lifecycle is the machine's (novox/hq ADR 0117).
func replacesNothing(r declaration.Resource) bool {
switch res := r.(type) {
case *declaration.File:
return res.Into != ""
case *declaration.Service:
return res.Stateless()
}
return false
}
// holdOnAdopted decides whether a resource of an adopted node is held rather than applied, and
// holds it (novox/hq ADR 0100, ADR 0103). For a module not yet taken, what is present with no
// record is kept as it is: a file or a container under its name, a directory, a service's unit,
@@ -309,11 +295,11 @@ func holdOnAdopted(ctx context.Context, sys system.System, r declaration.Resourc
}
// A file written into replaces nothing that was found, so it is never held (novox/hq ADR
// 0102) — and a hold from when it was declared whole must not keep the mesh's keys out. That
// hold is released by the apply once the write has worked, not here: a write that fails keeps
// it, and with it where the original was kept. A service whose lifecycle is the machine's
// replaces nothing either (novox/hq ADR 0117).
if replacesNothing(r) {
// 0102) — and a hold from when it was declared whole must not keep the mesh's keys out.
if f, ok := r.(*declaration.File); ok && f.Into != "" {
if already {
known.Release(r.Identity())
}
return false, false, out, nil
}
@@ -434,51 +420,17 @@ type foundContainer struct {
id string
running bool
spec string
// What a take compares (novox/hq ADR 0163): the image and its id, the networks the container
// is on, its mounts and its published ports — empty from a runtime (or a test's fake) that
// answers the short form.
image string
imageID string
networks []string
mounts []string
ports []string
}
// foundFormat is what inspectFound asks the runtime for, tab-separated: the three a hold has
// always needed, then the facts a take compares.
const foundFormat = "{{.Id}}\t{{.State.Running}}\t{{index .Config.Labels \"" + specLabel + "\"}}" +
"\t{{.Config.Image}}\t{{.Image}}" +
"\t{{range $k, $v := .NetworkSettings.Networks}}{{$k}},{{end}}" +
"\t{{range .Mounts}}{{.Source}}:{{.Destination}},{{end}}" +
"\t{{range $p, $b := .NetworkSettings.Ports}}{{$p}}{{range $b}}>{{.HostIp}}:{{.HostPort}}{{end}},{{end}}"
func splitList(s string) []string {
var out []string
for _, part := range strings.Split(s, ",") {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
}
}
return out
}
// inspectFound reads a container by name the way a hold needs it: its id, whether it runs, and
// whether a host made it.
//
// **`container inspect`, not the bare form.** A name is not unique across object kinds — a
// module regularly names a network the same as the container that joins it (`keycloak` names
// both, and it is ordinary). The bare form resolves across every kind and returns whichever it
// finds, so a container that does not exist yet but a same-named network does answers with the
// network's JSON — no `.State` field at all — and the template below fails to execute rather
// than failing to find anything. That reads as "the runtime could not say", which this function's
// caller correctly refuses to build on (novox/hq ADR 0100) — but there was something to say, a
// question of kind, not of ambiguity that should have stopped anything.
func inspectFound(ctx context.Context, name string, run Runner) (foundContainer, bool, error) {
cri, err := containerRuntime(ctx, run)
if err != nil {
return foundContainer{}, false, fmt.Errorf("%w, so nothing can be said about %q", err, name)
}
out, err := run(ctx, cri, "container", "inspect", "--format", foundFormat, name)
out, err := run(ctx, cri, "inspect", "--format",
"{{.Id}}\t{{.State.Running}}\t{{index .Config.Labels \""+specLabel+"\"}}", name)
if err != nil {
if absent(err) {
return foundContainer{}, false, nil
@@ -492,100 +444,14 @@ func inspectFound(ctx context.Context, name string, run Runner) (foundContainer,
name, err)
}
parts := strings.Split(strings.TrimSpace(out), "\t")
for len(parts) < 8 {
for len(parts) < 3 {
parts = append(parts, "")
}
spec := strings.TrimSpace(parts[2])
if spec == "<no value>" {
spec = ""
}
return foundContainer{id: strings.TrimSpace(parts[0]), running: parts[1] == "true", spec: spec,
image: strings.TrimSpace(parts[3]), imageID: strings.TrimSpace(parts[4]),
networks: splitList(parts[5]), mounts: splitList(parts[6]), ports: splitList(parts[7])}, true, nil
}
// factsOf is what a take would compare for a found container (novox/hq ADR 0163): the found
// image and when it was made, the networks and who else is on them, mounts and ports — beside
// what the module declares, and the declared image's date when that image is on the machine.
// Every question the runtime cannot answer leaves its fact empty; a preview says so rather than
// guesses.
func factsOf(ctx context.Context, seen foundContainer, res *declaration.Container, run Runner) *Facts {
cri, err := containerRuntime(ctx, run)
if err != nil {
return nil
}
f := &store.Facts{Image: seen.image, Mounts: seen.mounts, Ports: seen.ports,
DeclaredImage: res.Image, DeclaredPorts: res.Ports, DeclaredVolumes: res.Volumes}
if seen.imageID != "" {
if out, err := run(ctx, cri, "image", "inspect", "--format", "{{.Created}}", seen.imageID); err == nil {
f.ImageCreated = strings.TrimSpace(out)
}
}
if res.Image != "" {
if out, err := run(ctx, cri, "image", "inspect", "--format", "{{.Created}}", res.Image); err == nil {
f.DeclaredImageCreated = strings.TrimSpace(out)
}
}
if found, err := time.Parse(time.RFC3339Nano, f.ImageCreated); err == nil {
if declared, err := time.Parse(time.RFC3339Nano, f.DeclaredImageCreated); err == nil {
f.Downgrade = declared.Before(found)
}
}
for _, network := range seen.networks {
if f.Networks == nil {
f.Networks = map[string][]string{}
}
var members []string
if out, err := run(ctx, cri, "network", "inspect", "--format",
"{{range .Containers}}{{.Name}},{{end}}", network); err == nil {
for _, m := range splitList(out) {
if m != res.Name {
members = append(members, m)
}
}
}
sort.Strings(members)
f.Networks[network] = members
}
return (*Facts)(f)
}
// Facts is store.Facts, named here so hold's callers read as one vocabulary.
type Facts = store.Facts
// differenceOf is how a found file differs from the declared content: the lines only the found
// file has, marked -, then the lines only the declared content has, marked +, in their own order,
// bounded so a report stays a report. Not a diff tool's output: the question a take answers is
// "what would be lost and what would be new", and that is these two lists.
func differenceOf(found, declared string) (bool, []string) {
if found == declared {
return false, nil
}
const bound = 40
count := func(s string) map[string]int {
out := map[string]int{}
for _, line := range strings.Split(s, "\n") {
out[line]++
}
return out
}
inFound, inDeclared := count(found), count(declared)
var out []string
add := func(mark, s string, other map[string]int) {
seen := map[string]int{}
for _, line := range strings.Split(s, "\n") {
seen[line]++
if seen[line] > other[line] && len(out) < bound {
out = append(out, mark+" "+line)
}
}
}
add("-", found, inDeclared)
add("+", declared, inFound)
if len(out) >= bound {
out = append(out, "… and more")
}
return true, out
return foundContainer{id: strings.TrimSpace(parts[0]), running: parts[1] == "true", spec: spec}, true, nil
}
// absent is whether a runtime said the thing is not there, rather than failing to answer. Its own
@@ -652,12 +518,6 @@ func hold(ctx context.Context, sys system.System, r declaration.Resource, module
} else if digestOf(string(content)) != h.Digest {
changed = "rewritten"
}
// What a take would replace it with, and how that differs (novox/hq ADR 0163): a
// file declared whole is compared whole; one written into is not replaced at all.
if res.Into == "" {
differs, lines := differenceOf(string(content), res.Content)
h.Facts = &Facts{Differs: differs, Difference: lines}
}
}
case *declaration.Directory:
info, err := os.Lstat(res.Path)
@@ -746,9 +606,6 @@ func hold(ctx context.Context, sys system.System, r declaration.Resource, module
case h.Running && !seen.running:
changed = "stopped"
}
if exists {
h.Facts = factsOf(ctx, seen, res, run)
}
default:
return out, h, fmt.Errorf("a %s cannot be held", r.Kind())
}
+4 -60
View File
@@ -5,7 +5,6 @@ import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
"testing"
@@ -19,17 +18,7 @@ import (
// label when a host made it. Every command it is asked is written down.
type machine struct {
containers map[string]*fakeContainer
// images is what `image inspect --format {{.Created}}` answers per image or id; members is
// what `network inspect` lists per network (ADR 0163).
images map[string]string
members map[string][]string
asked []string
// wgUp is what `wg show interfaces` answers: the tunnels up on the machine.
wgUp string
// handshakes is what `wg show <interface> latest-handshakes` answers, and handshakesFail the
// error it fails with instead — a machine with no `wg`, say (novox/hq ADR 0119).
handshakes string
handshakesFail error
// units are service units by name, as systemd would report them; volumes are the runtime's
// named volumes.
@@ -40,8 +29,6 @@ type machine struct {
type fakeUnit struct {
active, enabled string
// wontStart is a unit that accepts `start` and stays inactive — one that starts and dies.
wontStart bool
// fragment is where systemd loads the unit from; empty means /etc/systemd/system, where an
// administrator installs one.
fragment string
@@ -76,9 +63,7 @@ func (m *machine) systemctl(args []string) (string, error) {
}
return u.enabled + "\n", nil
case "start":
if !u.wontStart {
u.active = "active"
}
case "stop":
u.active = "inactive"
case "enable":
@@ -102,9 +87,6 @@ type fakeContainer struct {
id string
running bool
spec string
// What a take compares (ADR 0163), answered in the long inspect form when set.
image, imageID string
networks, mounts, ports []string
}
func (m *machine) run(_ context.Context, name string, args ...string) (string, error) {
@@ -112,12 +94,6 @@ func (m *machine) run(_ context.Context, name string, args ...string) (string, e
if name == "systemctl" {
return m.systemctl(args)
}
if name == "wg" {
if len(args) > 0 && args[len(args)-1] == "latest-handshakes" {
return m.handshakes, m.handshakesFail
}
return m.wgUp, nil
}
if name == "getent" {
if m.users[args[len(args)-1]] {
return args[len(args)-1] + ":x:1500:1500::/home/" + args[len(args)-1] + ":/bin/bash\n", nil
@@ -135,10 +111,7 @@ func (m *machine) run(_ context.Context, name string, args ...string) (string, e
return "", errors.New("no such volume")
case "info":
return "27.0\n", nil
case "container":
if args[1] != "inspect" {
return "", errors.New("unexpected docker container command")
}
case "inspect":
c, ok := m.containers[args[len(args)-1]]
if !ok {
return "", errors.New("no such container")
@@ -147,39 +120,10 @@ func (m *machine) run(_ context.Context, name string, args ...string) (string, e
if c.running {
running = "true"
}
if strings.HasPrefix(args[3], "{{.Id}}") {
line := c.id + "\t" + running + "\t" + c.spec
if c.image != "" {
line += "\t" + c.image + "\t" + c.imageID + "\t" + strings.Join(c.networks, ",") + "," +
"\t" + strings.Join(c.mounts, ",") + "," + "\t" + strings.Join(c.ports, ",") + ","
}
return line + "\n", nil
if strings.HasPrefix(args[2], "{{.Id}}") {
return c.id + "\t" + running + "\t" + c.spec + "\n", nil
}
return running + "\t" + c.spec + "\n", nil
case "image":
if len(args) > 1 && args[1] == "inspect" {
if created, ok := m.images[args[len(args)-1]]; ok {
return created + "\n", nil
}
return "", errors.New("no such image")
}
return "", nil
case "network":
if len(args) > 1 && args[1] == "inspect" {
return strings.Join(m.members[args[len(args)-1]], ",") + ",\n", nil
}
return "", nil
case "ps":
var lines []string
for name, c := range m.containers {
state := "exited"
if c.running {
state = "running"
}
lines = append(lines, name+"\t"+c.image+"\t"+state)
}
sort.Strings(lines)
return strings.Join(lines, "\n") + "\n", nil
case "rm":
delete(m.containers, args[len(args)-1])
return "", nil
@@ -963,7 +907,7 @@ func TestAVolumeTheRuntimeCannotBeAskedAboutStopsTheContainer(t *testing.T) {
return "27.0\n", nil
case name == "docker" && args[0] == "volume":
return "", errors.New("docker exited 1: Cannot connect to the Docker daemon")
case name == "docker" && args[0] == "container":
case name == "docker" && args[0] == "inspect":
return "", errors.New("Error: No such object: hello-web")
case name == "docker":
return "", errors.New("docker run must not happen")
-66
View File
@@ -1,66 +0,0 @@
package apply
import (
"context"
"errors"
"testing"
)
// A name is not unique across object kinds: a module regularly names a network the same as the
// container that joins it (keycloak does this today, ordinarily). `docker inspect <name>`, unlike
// `docker container inspect <name>`, resolves across every kind — so when the container does not
// exist yet but a same-named network does, the bare form answers with the network's JSON instead
// of reporting the container absent. containerState and inspectFound must ask by kind, or a
// same-named network makes them unable to tell "not here yet" from "the runtime is broken"
// (novox/hq ADR 0100's refusal, tripped by nothing wrong).
//
// dockerLikeByKind is Docker's real behaviour, not the bug: `container inspect` only ever
// answers from the container namespace. A fake that also answered the bare, unscoped form would
// not catch a regression back to it — this one refuses to, on purpose.
func dockerLikeByKind(containers map[string]bool) func(context.Context, string, ...string) (string, error) {
return func(_ context.Context, name string, args ...string) (string, error) {
if name != "docker" {
return "", errors.New("unexpected program: " + name)
}
if len(args) > 0 && args[0] == "info" {
// containerRuntime's probe, answered so inspectFound gets past it to the check under
// test.
return "27.0\n", nil
}
if len(args) < 2 || args[0] != "container" || args[1] != "inspect" {
return "", errors.New("unexpected command: only `docker container inspect` is modelled here")
}
target := args[len(args)-1]
if !containers[target] {
return "", errors.New("Error: No such container: " + target)
}
return "false\t\n", nil
}
}
func TestContainerStateAsksTheContainerNamespaceNotTheBareForm(t *testing.T) {
// "minio" exists only as a network in this scenario — never in `containers` — matching the
// live failure this guards: a module's network and its container share a name, and the
// container does not exist yet.
run := dockerLikeByKind(map[string]bool{"keycloak": true})
if _, err := containerState(context.Background(), "minio", run); err == nil {
t.Fatal("a container that does not exist should report absent, not be mistaken for found")
}
if state, err := containerState(context.Background(), "keycloak", run); err != nil {
t.Fatalf("a container that does exist should be found: %v", err)
} else if state.Running {
t.Errorf("the fake said not running; containerState disagreed: %+v", state)
}
}
func TestInspectFoundAsksTheContainerNamespaceNotTheBareForm(t *testing.T) {
run := dockerLikeByKind(map[string]bool{"keycloak": true})
if _, exists, err := inspectFound(context.Background(), "minio", run); err != nil || exists {
t.Fatalf("a container that does not exist should be reported absent cleanly, not refused: exists=%v err=%v", exists, err)
}
if _, exists, err := inspectFound(context.Background(), "keycloak", run); err != nil || !exists {
t.Fatalf("a container that does exist should be found: exists=%v err=%v", exists, err)
}
}
-214
View File
@@ -1,214 +0,0 @@
package apply
import (
"context"
"errors"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// A taken container keeps a found network by a per-machine setting (novox/hq ADR 0163, rule 4):
// joined once it runs, part of its spec, and refused when it cannot be joined.
func TestAContainerJoinsTheNetworksItKeeps(t *testing.T) {
var ran []string
connectFails := false
run := func(_ context.Context, name string, args ...string) (string, error) {
if name != "docker" {
return "", errors.New("not installed")
}
ran = append(ran, strings.Join(args, " "))
switch args[0] {
case "info":
return "29.0.0\n", nil
case "container":
if len(ran) > 2 {
return "true\t" + specOfLast, nil
}
return "false\t\n", errors.New("no such container")
case "run":
return "deadbeef\n", nil
case "network":
if connectFails {
return "", errors.New("network predecessor_default not found")
}
}
return "", nil
}
d := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"app","type":"container","name":"app","image":"`+pinned+`",
"networks":["predecessor_default"]}
]}`)
specOfLast = containerSpec(d.Resources[0].(*declaration.Container), inputs{})
alone := *d.Resources[0].(*declaration.Container)
alone.Networks = nil
if specOfLast == containerSpec(&alone, inputs{}) {
t.Fatal("the kept network is not part of the container's spec: kept or let go, the container would be left alone")
}
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
if err != nil {
t.Fatal(err)
}
joined := false
for i, line := range ran {
if line == "network connect predecessor_default app" {
joined = true
if ran[i-1] != "container inspect --format {{.State.Running}}\t{{index .Config.Labels \""+specLabel+"\"}} app" &&
!strings.HasPrefix(ran[i-1], "container inspect") {
t.Errorf("joined before the container was read back as running: %v", ran)
}
}
}
if !joined || report.Outcomes[0].Action != "created" {
t.Fatalf("the container did not join the kept network: %v\n%+v", ran, report.Outcomes)
}
connectFails, ran = true, nil
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil); err == nil ||
!strings.Contains(err.Error(), "could not join the kept network predecessor_default") {
t.Fatalf("a network that cannot be joined was passed over: %v", err)
}
}
var specOfLast string
// A module the mesh left out of a declaration is not a module the mesh removed (novox/hq ADR 0163,
// rule 6): what the host wrote for it stays, recorded and said; what it holds for it stays held.
// A module simply absent is removed as it always was.
func TestALeftOutModuleIsNeitherRemovedNorForgotten(t *testing.T) {
var removed []string
gone := map[string]bool{}
run := func(_ context.Context, name string, args ...string) (string, error) {
if name != "docker" {
return "", nil
}
switch args[0] {
case "info":
return "29.0.0\n", nil
case "rm":
removed = append(removed, args[len(args)-1])
gone[args[len(args)-1]] = true
case "container":
if gone[args[len(args)-1]] {
return "", errors.New("no such container")
}
return "true\tspec", nil
}
return "", nil
}
known := store.State{
Resources: []store.Applied{
{ID: "web.server", Type: "container", Target: "web", Origin: store.OriginDeclared},
{ID: "old.server", Type: "container", Target: "old", Origin: store.OriginDeclared},
},
Held: []store.Held{{ID: "web.page", Module: "web", Kind: "file", Target: "/srv/web/index.html"}},
}
d := parse(t, `{"declaration":1,"left_out":["web"],"resources":[
{"id":"notes.conf","type":"file","path":"`+t.TempDir()+`/notes.conf","content":"x"}
]}`)
report, state, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if len(removed) != 1 || removed[0] != "old" {
t.Fatalf("removed %v; only the module that is absent goes", removed)
}
if _, kept := state.At("container", "web"); !kept {
t.Fatal("the left-out module's record was forgotten")
}
if _, held := state.HeldAt("web.page"); !held {
t.Fatal("the left-out module's hold was released")
}
said := false
for _, o := range report.Outcomes {
if o.ID == "web.server" && o.Action == "unchanged" && strings.Contains(o.Detail, "web was left out of this declaration by the mesh") {
said = true
}
if o.ID == "web.server" && o.Action != "unchanged" {
t.Errorf("the left-out module's container was %s", o.Action)
}
}
if !said {
t.Fatalf("keeping the left-out module's container was not said: %+v", report.Outcomes)
}
}
// A container's capabilities reach the runtime and are part of its spec (novox/hq ADR 0170).
func TestACapabilityReachesTheRuntimeAndTheSpec(t *testing.T) {
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
if name != "docker" {
return "", errors.New("not installed")
}
switch args[0] {
case "info":
return "29.0.0\n", nil
case "container":
return "false\t\n", errors.New("no such container")
case "run":
ran = args
return "deadbeef\n", nil
}
return "", nil
}
d := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"fw","type":"container","name":"fw","image":"`+pinned+`","network":"host","capabilities":["NET_ADMIN"]}
]}`)
_, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
granted := false
for i, a := range ran {
if a == "--cap-add" && i+1 < len(ran) && ran[i+1] == "NET_ADMIN" {
granted = true
}
}
if !granted {
t.Fatalf("the capability was not granted: %v", ran)
}
with := d.Resources[0].(*declaration.Container)
without := *with
without.Capabilities = nil
if containerSpec(with, inputs{}) == containerSpec(&without, inputs{}) {
t.Fatal("a capability is not part of the container's spec")
}
}
// A package may be declared absent (novox/hq ADR 0180): removed when it is installed, read back,
// left alone when it is not.
func TestAPackageDeclaredAbsentIsRemovedWhenPresentAndLeftWhenNot(t *testing.T) {
installed := true
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
ran = append(ran, name+" "+strings.Join(args, " "))
if name != "pacman" {
return "", nil
}
switch args[0] {
case "-Q":
if args[1] == "pacman" || installed {
return args[1] + " 1.0\n", nil
}
return "", errors.New("package not found")
case "-R":
installed = false
}
return "", nil
}
d := parseTrusted(t, `{"declaration":1,"resources":[{"id":"front-end","type":"package","package":"ufw","absent":true}]}`)
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if report.Outcomes[0].Action != "removed" || !strings.Contains(strings.Join(ran, "\n"), "pacman -R --noconfirm ufw") {
t.Fatalf("an installed package declared absent was not removed: %+v\n%v", report.Outcomes[0], ran)
}
ran = nil
report, _, err = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if report.Outcomes[0].Action != "unchanged" || strings.Contains(strings.Join(ran, "\n"), "-R") {
t.Fatalf("a package already absent was touched: %+v\n%v", report.Outcomes[0], ran)
}
}
-43
View File
@@ -1,43 +0,0 @@
package apply
import (
"context"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// A container declared to log to the journal is run with the journal as its log driver, and the
// place it logs is part of its spec, so moving it recreates the container (novox/hq ADR 0179).
func TestAContainerLoggingToTheJournalIsRunThatWayAndRecreatedWhenMoved(t *testing.T) {
pinned := "postgres@sha256:" + strings.Repeat("a", 64)
var ran []string
run := func(_ context.Context, cmd string, args ...string) (string, error) {
if cmd == "docker" && len(args) > 0 && args[0] == "run" {
ran = args
return "deadbeef\n", nil
}
return "", nil
}
d := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"front","type":"container","name":"front","image":"`+pinned+`","logging":"journald"}
]}`)
_, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
sent := false
for i, a := range ran {
if a == "--log-driver" && i+1 < len(ran) && ran[i+1] == "journald" {
sent = true
}
}
if !sent {
t.Fatalf("the container's output was not sent to the journal: %v", ran)
}
with := d.Resources[0].(*declaration.Container)
without := *with
without.Logging = ""
if containerSpec(with, inputs{}) == containerSpec(&without, inputs{}) {
t.Fatal("where a container logs is not part of its spec, so moving it would not recreate it")
}
}
-238
View File
@@ -1,238 +0,0 @@
package apply
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/novox/mesh-host/internal/declaration"
)
// A scheduled step may hold its module's own containers still while it runs (novox/hq ADR 0189,
// issue 108).
//
// What it exists for: the artifact store's collector walks the storage and requires every writer
// stopped. A run-once step runs beside containers and a scheduled one is the same container again,
// so the mesh had no way to say it — which is why the store it inherited has never collected
// anything. The risk the field brings is one shape only: a window that opens and never closes.
// Every test here is about that shape.
// windowRun records the order of stop / run / start, which is the whole of what is being asserted.
type windowRun struct {
mu sync.Mutex
order []string
failAt string // the arg[0] that should fail ("run" makes the step fail)
wontGo string // a container name that refuses to start again
}
func (w *windowRun) run(_ context.Context, _ string, args ...string) (string, error) {
w.mu.Lock()
defer w.mu.Unlock()
switch args[0] {
case "info":
return "27.0\n", nil
case "stop", "start":
w.order = append(w.order, args[0]+" "+args[1])
if args[0] == "start" && args[1] == w.wontGo {
return "", errors.New("the runtime refused")
}
case "run":
w.order = append(w.order, "run")
if w.failAt == "run" {
return "", errors.New("the step exited non-zero")
}
}
return "", nil
}
func (w *windowRun) seen() []string {
w.mu.Lock()
defer w.mu.Unlock()
return append([]string{}, w.order...)
}
// aStoreWithACollector is a module in the shape distribution has: a server that must not be
// writing, and a nightly step that walks its storage with the server held still.
func aStoreWithACollector(t *testing.T) *declaration.Declaration {
t.Helper()
return parseTrusted(t, `{"declaration":1,"resources":[
{"id":"store","type":"container","name":"mesh-registry","image":"`+pinned+`"},
{"id":"collect","type":"container","name":"mesh-registry-collect","image":"`+pinned+`",
"schedule":"30 3 * * *","while-stopped":["store"]}
]}`)
}
func fireOnce(t *testing.T, d *declaration.Declaration, w *windowRun) {
t.Helper()
clock := &fixedClock{now: time.Date(2026, 10, 2, 3, 29, 0, 0, time.UTC)}
s := NewScheduler(clock, w.run, func(string) {})
s.Sync(d, nil)
s.Advance(context.Background(), time.Date(2026, 10, 2, 3, 30, 5, 0, time.UTC))
s.Wait()
}
func TestAScheduledStepHoldsItsModulesContainerStillAndStartsItAgain(t *testing.T) {
w := &windowRun{}
fireOnce(t, aStoreWithACollector(t), w)
got := w.seen()
want := []string{"stop mesh-registry", "run", "start mesh-registry"}
var kept []string
for _, line := range got {
if strings.HasPrefix(line, "stop mesh-registry-collect") {
// Clearing the step's own exited container by name; not part of the window.
continue
}
kept = append(kept, line)
}
if len(kept) != len(want) {
t.Fatalf("the window was not stop, run, start: %v", got)
}
for i := range want {
if kept[i] != want[i] {
t.Fatalf("the window was %v, want %v", kept, want)
}
}
}
// The one that matters: a step that fails must leave the service running.
func TestAFailedStepStillClosesTheWindow(t *testing.T) {
w := &windowRun{failAt: "run"}
fireOnce(t, aStoreWithACollector(t), w)
var started bool
for _, line := range w.seen() {
if line == "start mesh-registry" {
started = true
}
}
if !started {
t.Fatalf("the step failed and the container it held still was never started again: %v", w.seen())
}
}
// A container that will not come back is said loudly: it is down, and nothing else notices until
// the next apply compares it.
func TestAContainerThatWillNotStartAgainIsSaidLoudly(t *testing.T) {
w := &windowRun{wontGo: "mesh-registry"}
var said []string
clock := &fixedClock{now: time.Date(2026, 10, 2, 3, 29, 0, 0, time.UTC)}
s := NewScheduler(clock, w.run, func(line string) { said = append(said, line) })
s.Sync(aStoreWithACollector(t), nil)
s.Advance(context.Background(), time.Date(2026, 10, 2, 3, 30, 5, 0, time.UTC))
s.Wait()
var loud bool
for _, line := range said {
if strings.Contains(line, "WILL NOT START AGAIN") && strings.Contains(line, "mesh-registry") {
loud = true
}
}
if !loud {
t.Fatalf("a service left stopped by a maintenance window was not said loudly: %v", said)
}
}
// Several containers come back in the reverse of the order they were stopped: a module names the
// dependant first, and starting it before what it depends on is not bringing it back.
func TestTheWindowClosesInTheReverseOfTheOrderItOpened(t *testing.T) {
d := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"web","type":"container","name":"web","image":"`+pinned+`"},
{"id":"db","type":"container","name":"db","image":"`+pinned+`"},
{"id":"collect","type":"container","name":"collect","image":"`+pinned+`",
"schedule":"30 3 * * *","while-stopped":["web","db"]}
]}`)
w := &windowRun{}
fireOnce(t, d, w)
var stops, starts []string
for _, line := range w.seen() {
switch {
case line == "stop web" || line == "stop db":
stops = append(stops, line)
case strings.HasPrefix(line, "start "):
starts = append(starts, line)
}
}
if len(stops) != 2 || stops[0] != "stop web" || stops[1] != "stop db" {
t.Fatalf("stopped in %v, want the order the step named them", stops)
}
if len(starts) != 2 || starts[0] != "start db" || starts[1] != "start web" {
t.Fatalf("started in %v, want the reverse", starts)
}
}
// And the refusals, each for what it says rather than that it says something.
func TestAMaintenanceWindowIsRefusedWhereItCannotMean(t *testing.T) {
for _, c := range []struct{ name, body, says string }{
{
"a window with no schedule",
`{"id":"collect","type":"container","name":"c","image":"` + pinned + `","while-stopped":["store"]}`,
"needs a schedule",
},
{
"a window naming itself",
`{"id":"collect","type":"container","name":"c","image":"` + pinned + `","schedule":"30 3 * * *","while-stopped":["collect"]}`,
"this step itself",
},
{
"a window naming something that is not a container here",
`{"id":"collect","type":"container","name":"c","image":"` + pinned + `","schedule":"30 3 * * *","while-stopped":["elsewhere"]}`,
"no container by that id",
},
} {
_, err := declaration.ParseTrusted([]byte(`{"declaration":1,"resources":[` + c.body + `]}`))
if err == nil {
t.Errorf("%s was accepted", c.name)
continue
}
if !strings.Contains(err.Error(), c.says) {
t.Errorf("%s: the refusal does not say %q: %v", c.name, c.says, err)
}
}
}
// A changed window is a changed declaration, and the install says so.
//
// The cadence already works this way: "a changed schedule is a changed spec — the marker moves and
// the install is reported updated and re-established" (containerSpec). Which containers are held
// still for the run is the same kind of statement, and a declaration that changed it while the
// machine reported no change would be a machine quietly running the old window.
func TestAChangedWindowMovesTheSpec(t *testing.T) {
one := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"store","type":"container","name":"mesh-registry","image":"`+pinned+`"},
{"id":"other","type":"container","name":"other","image":"`+pinned+`"},
{"id":"collect","type":"container","name":"collect","image":"`+pinned+`",
"schedule":"30 3 * * *","while-stopped":["store"]}
]}`)
two := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"store","type":"container","name":"mesh-registry","image":"`+pinned+`"},
{"id":"other","type":"container","name":"other","image":"`+pinned+`"},
{"id":"collect","type":"container","name":"collect","image":"`+pinned+`",
"schedule":"30 3 * * *","while-stopped":["store","other"]}
]}`)
stepOf := func(d *declaration.Declaration) *declaration.Container {
for _, r := range d.Resources {
if c, ok := r.(*declaration.Container); ok && c.ID == "collect" {
return c
}
}
t.Fatal("no step in the fixture")
return nil
}
if containerSpec(stepOf(one), inputs{}) == containerSpec(stepOf(two), inputs{}) {
t.Fatal("the window changed and the spec did not; the machine would report no change " +
"and keep holding the containers it held yesterday")
}
// And a container with no window is untouched by the field existing at all.
plain := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"store","type":"container","name":"mesh-registry","image":"`+pinned+`"}
]}`)
spec := containerSpec(plain.Resources[0].(*declaration.Container), inputs{})
if strings.Contains(spec, "while-stopped") || strings.Contains(spec, "held") {
t.Errorf("an ordinary container's spec mentions a field it does not set:\n%s", spec)
}
}
-54
View File
@@ -1,54 +0,0 @@
package apply
import (
"testing"
"github.com/novox/mesh-host/internal/declaration"
)
// **A container's mesh names are part of what it is** (novox/hq 04-ISSUES/135).
//
// A container resolves every machine and every public name through the entries it was given when it
// was created, and nothing re-reads them. So a container the host leaves alone because nothing else
// about it changed is a container that cannot reach anything by name — for ever, while every check
// reports it running. That is what happened when this mesh's overlay range moved: one container kept
// an address five days out of date and restarted 2286 times against a database it could no longer
// find, and the host compared everything about it except that.
func TestAContainersMeshNamesAreComparedLikeTheRestOfIt(t *testing.T) {
was := &declaration.Container{
Name: "umami", Image: "ghcr.io/example/umami@sha256:" + zeros(64),
Hosts: []string{"novox.internal:10.42.0.1", "umami.novox.be:10.42.0.1"},
}
moved := &declaration.Container{
Name: was.Name, Image: was.Image,
Hosts: []string{"novox.internal:10.10.0.1", "umami.novox.be:10.10.0.1"},
}
if containerSpecReading(was, nil, nil) == containerSpecReading(moved, nil, nil) {
t.Fatal("a container whose mesh names moved compares equal, so it is never recreated")
}
// And the order they arrive in is not a change: the digest must not move for a reordering
// nobody made.
reordered := &declaration.Container{
Name: moved.Name, Image: moved.Image,
Hosts: []string{moved.Hosts[1], moved.Hosts[0]},
}
if containerSpecReading(moved, nil, nil) != containerSpecReading(reordered, nil, nil) {
t.Fatal("the same names in another order read as a different container")
}
// A container the mesh gives no names is unaffected, so nothing is recreated for a field it
// does not set.
plain := &declaration.Container{Name: "plex", Image: was.Image}
if containerSpecReading(plain, nil, nil) == containerSpecReading(was, nil, nil) {
return // different for other reasons, which is fine
}
}
func zeros(n int) string {
out := make([]byte, n)
for i := range out {
out[i] = '0'
}
return string(out)
}
-55
View File
@@ -1,55 +0,0 @@
package apply
import (
"fmt"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// A node is adopted or converged, and a declaration says which it is for (novox/hq ADR 0100).
//
// **The two are not interchangeable, and the host knows which it is.** A converged declaration
// carries the mesh's drop-by-default filter and retires the firewall the node was found with; on
// an adopted node that closes the machine to everything the predecessor still serves — which is
// what happened when an operator ran `reconcile` on an adopted control-node and it applied the
// converged genesis bundle (novox/hq issue 104). The host reported "adopted" in every report and
// compared nothing. Now it compares, at the point of application, whichever command delivered
// the declaration.
//
// Only the mesh changes a node's mode, and it does so by sending a declaration: the flip arrives
// over the link as the first converged declaration after adopted ones (`converge` on the
// controller), and the way back as the first adopted one (`adopt`). So a declaration the link
// delivers, signed and verified, is the mode's authority and is not checked against the record —
// it becomes the record. Everything else — the carried bundle, a file, the kept declaration a
// disconnected node re-applies — is held to the mode already recorded.
// ModeOf says which mode a declaration is for.
func ModeOf(d *declaration.Declaration) string {
if d.Adoption != nil {
return store.ModeAdopted
}
return store.ModeConverged
}
// CheckMode refuses a declaration whose mode is not the one this node has recorded. A node with
// no recorded mode — a machine nothing has said a mode to yet — takes either.
func CheckMode(known store.State, d *declaration.Declaration) error {
if known.Mode == "" || known.Mode == ModeOf(d) {
return nil
}
act := "`converge`"
if ModeOf(d) == store.ModeAdopted {
act = "`adopt`"
}
return fmt.Errorf("this node is %s; the declaration says %s — %s declaration is not applied "+
"to %s node; %s on the controller is the act that changes it, and it sends the "+
"declaration that does", known.Mode, ModeOf(d), article(ModeOf(d)), article(known.Mode), act)
}
func article(mode string) string {
if mode == store.ModeAdopted {
return "an adopted"
}
return "a converged"
}
-32
View File
@@ -1,32 +0,0 @@
package apply
import (
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq issue 104: a declaration is refused for the other mode than the node is in,
// naming both and the act that changes it; a node no mode has been said to takes either.
func TestADeclarationForTheOtherModeIsRefused(t *testing.T) {
converged := parse(t, `{"declaration":1,"resources":[{"id":"a","type":"file","path":"/tmp/a","content":"x\n"}]}`)
adopted := parse(t, `{"declaration":1,"adoption":{"taken":[]},"resources":[{"id":"a","type":"file","path":"/tmp/a","content":"x\n"}]}`)
err := CheckMode(store.State{Mode: store.ModeAdopted}, converged)
if err == nil || !strings.Contains(err.Error(), "this node is adopted; the declaration says converged") ||
!strings.Contains(err.Error(), "`converge`") {
t.Errorf("a converged declaration on an adopted node: %v", err)
}
err = CheckMode(store.State{Mode: store.ModeConverged}, adopted)
if err == nil || !strings.Contains(err.Error(), "this node is converged; the declaration says adopted") ||
!strings.Contains(err.Error(), "`adopt`") {
t.Errorf("an adopted declaration on a converged node: %v", err)
}
if err := CheckMode(store.State{Mode: store.ModeAdopted}, adopted); err != nil {
t.Errorf("the node's own mode was refused: %v", err)
}
if err := CheckMode(store.State{}, converged); err != nil {
t.Errorf("a node in no mode yet refused a declaration: %v", err)
}
}
+12 -51
View File
@@ -54,53 +54,20 @@ func foundFirewall(ctx context.Context, d *declaration.Declaration, known *store
return kind, nil
}
// retireFirewall keeps the found firewall retired on a converged machine (novox/hq ADR 0100, ADR
// 0168): disabled, never flushed, its configuration left on disk for a return to adopted, and the
// container runtime's rules not its to take.
//
// **Convergence is a state the host keeps, not a step it takes once.** Every converged apply reads
// whether the front end is in force; enabled again by a package, a boot or a hand, it is retired
// again and said. The record says how it came to be inactive — the mesh disabled it, or a reconcile
// found it so — and the two are never confused: a flip that did not take, followed by a hand that
// did, used to be recorded as the mesh's doing (issue 143).
// retireFirewall disables the found firewall once a converged declaration has applied cleanly,
// which is when the mesh's derived filter has taken its place. Disabled, never flushed: its
// configuration stays on disk for a return to adopted, and the container runtime's rules are not
// its to take.
//
// Only a declaration from the mesh converges a node. A carried bundle never says a node is adopted
// — it cannot — so its silence is not the controller's word that the node was converged, and an
// adopted node re-applying its bundle keeps the firewall it was found with.
//
// Returned is what this apply did about the found firewall, for the report; empty when the machine
// has none or is not converged.
func retireFirewall(ctx context.Context, d *declaration.Declaration, origin string, known *store.State,
run Runner, log func(string)) (string, error) {
run Runner, log func(string)) error {
rec := known.Firewall
if origin != store.OriginDeclared || d.Adoption != nil || rec == nil || rec.Kind != string(firewall.UFW) || !rec.WasActive {
return "", nil
}
if !firewall.Installed(ctx, run) {
// Uninstalled (novox/hq ADR 0180): retired for good, by the module that replaced it. Said
// once, and nothing is asked of a command that is not there.
if rec.RetiredBy != firewall.RetiredRemoved {
rec.RetiredBy = firewall.RetiredRemoved
log(" the found firewall (ufw) is no longer installed; the mesh's filter is what filters this machine")
return "removed: ufw is no longer installed; the mesh's filter is what filters this machine", nil
}
return "", nil
}
active := firewall.Active(ctx, run)
if !active && !(rec.Forward != nil && !rec.DisabledByMesh) {
// Inactive, and either the mesh's doing already or nobody's recorded here: said as found,
// never as done (issue 143's second fault). A retirement the mesh began and did not finish —
// the forward policy recorded, ufw down, the restore failed — is the one inactive state that
// is still the mesh's to complete, below.
if rec.RetiredBy == "" {
if rec.DisabledByMesh {
rec.RetiredBy = firewall.RetiredByMesh
} else {
rec.RetiredBy = firewall.RetiredFoundSo
log(" ufw is inactive on this converged node, and not by the mesh; recorded as found so")
}
}
return "", nil
if origin != store.OriginDeclared || d.Adoption != nil || rec == nil || rec.Kind != string(firewall.UFW) || !rec.WasActive ||
rec.DisabledByMesh {
return nil
}
// **Nothing is retired until what replaces it is in force** (novox/hq ADR 0100). The flip
// loads the mesh's derived filter in ufw's place; disabling ufw before that table is actually
@@ -108,10 +75,10 @@ func retireFirewall(ctx context.Context, d *declaration.Declaration, origin stri
// no filter at all.
loaded, err := firewall.MeshTableLoaded(ctx, run)
if err != nil {
return "", err
return err
}
if !loaded {
return "", fmt.Errorf("this node is converged and the mesh's own filter (table %s) is not loaded on "+
return fmt.Errorf("this node is converged and the mesh's own filter (table %s) is not loaded on "+
"this machine, so ufw was left in force: retiring it would leave the machine filtering "+
"nothing. Assign a filter module to this node, or return it to adopted", firewall.MeshTable)
}
@@ -121,17 +88,11 @@ func retireFirewall(ctx context.Context, d *declaration.Declaration, origin stri
rec.Forward = firewall.ForwardPolicies(ctx, run)
}
if err := firewall.Disable(ctx, run, rec.Forward); err != nil {
return "", err
return err
}
again := rec.DisabledByMesh || rec.RetiredBy != ""
rec.DisabledByMesh = true
rec.RetiredBy = firewall.RetiredByMesh
if again {
log(" disabled ufw again: it had been enabled since the mesh retired it; this node is converged and filtered by the mesh")
return "disabled again: ufw had been enabled since the mesh retired it", nil
}
log(" disabled ufw: this node is converged and filtered by the mesh; ufw's configuration is left on disk")
return "disabled: this node is converged and filtered by the mesh; ufw's configuration is left on disk", nil
return nil
}
// applyOpening makes one opening true through the firewall found here.
+2 -65
View File
@@ -8,7 +8,6 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
@@ -190,34 +189,14 @@ func TestConvergingRetiresTheFoundFirewallAndReturningRestoresIt(t *testing.T) {
}
}
// Converged again: nothing more to retire — the node asks ufw whether it is in force, which is
// what keeps convergence a state rather than a step taken once (novox/hq ADR 0168), and touches
// nothing else.
// Converged again: nothing more to retire.
u.asked = nil
if _, state, err = applyWith(t, converged, state, u.run); err != nil {
t.Fatal(err)
}
for _, a := range u.asked {
if strings.HasPrefix(a, "ufw") && a != "ufw status" {
if u.index("ufw") >= 0 {
t.Errorf("a converged node kept talking to a retired ufw: %v", u.asked)
}
}
if state.Firewall.RetiredBy != "mesh" {
t.Errorf("the record does not say the mesh retired it: %+v", state.Firewall)
}
// Enabled again by a hand: retired again, and said.
u.active = true
u.asked = nil
report, state, err := applyWith(t, converged, state, u.run)
if err != nil {
t.Fatal(err)
}
if u.active || u.index("ufw disable") < 0 {
t.Fatalf("ufw enabled again on a converged node was not retired again: %v", u.asked)
}
if !strings.Contains(report.Firewall, "disabled again") {
t.Errorf("retiring it again was not said: %q", report.Firewall)
}
// Returned to adopted: ufw is enabled before the opening is converged through it.
u.asked = nil
@@ -360,18 +339,8 @@ func TestReturningToAdoptedLoadsTheGuardBeforeRemovingTheFilter(t *testing.T) {
dir := t.TempDir()
guard := filepath.Join(dir, "guard.nft")
guardFile := `{"id":"adoption.guard","type":"file","path":"` + guard + `","content":"table inet mesh_guard {}\n"}`
// The filter's unit file is the mesh's own, written where there was none — which is what makes
// its unit the mesh's to stop, with or without a record of what was found (novox/hq ADR 0118).
units := t.TempDir()
was := unitDir
unitDir = units
t.Cleanup(func() { unitDir = was })
filterUnit := filepath.Join(units, "mesh-filter.service")
for _, stopFails := range []bool{false, true} {
_ = os.Remove(guard)
if err := os.WriteFile(filterUnit, []byte("[Unit]\n"), 0o644); err != nil {
t.Fatal(err)
}
guardUpAtStop := false
run := func(_ context.Context, name string, args ...string) (string, error) {
if name != "systemctl" {
@@ -389,9 +358,7 @@ func TestReturningToAdoptedLoadsTheGuardBeforeRemovingTheFilter(t *testing.T) {
}
return "", nil
}
// As a host recorded them before it kept what it found: no Found on the service.
converged := store.State{Resources: []store.Applied{
{ID: "nftables.unit", Type: "file", Target: filterUnit, Origin: store.OriginDeclared},
{ID: "nftables.load", Type: "service", Target: "mesh-filter.service", Origin: store.OriginDeclared}}}
_, state, err := applyWith(t, adopted(t, `{"taken":[]}`, withConf(dir)+","+guardFile), converged, run)
if !guardUpAtStop {
@@ -523,33 +490,3 @@ func TestUfwIsNotRetiredUntilTheMeshsOwnFilterIsLoaded(t *testing.T) {
t.Errorf("ufw was not retired once the mesh's filter was loaded: active %v, %+v", u.active, state.Firewall)
}
}
// A front end that is no longer installed is recorded as removed, said once, and asked nothing of
// (novox/hq ADR 0180).
func TestAnUninstalledFrontEndIsRetiredForGood(t *testing.T) {
dir := t.TempDir()
u := &ufwMachine{installed: false, ruleset: "table inet mesh\n"}
known := store.State{Firewall: &store.FoundFirewall{Kind: "ufw", WasActive: true, DisabledByMesh: true,
RetiredBy: "mesh", FoundAt: time.Now()}}
converged := parse(t, `{"declaration":1,"resources":[`+withConf(dir)+`]}`)
report, state, err := applyWith(t, converged, known, u.run)
if err != nil {
t.Fatal(err)
}
if state.Firewall.RetiredBy != "removed" || !strings.Contains(report.Firewall, "no longer installed") {
t.Fatalf("record %+v, said %q", state.Firewall, report.Firewall)
}
u.asked = nil
report, _, err = applyWith(t, converged, state, u.run)
if err != nil {
t.Fatal(err)
}
if report.Firewall != "" {
t.Errorf("said again: %q", report.Firewall)
}
for _, a := range u.asked {
if strings.HasPrefix(a, "ufw") && a != "ufw status" {
t.Errorf("asked something of a front end that is not there: %v", u.asked)
}
}
}
-337
View File
@@ -1,337 +0,0 @@
package apply
import (
"fmt"
"strings"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/firewall"
"github.com/novox/mesh-host/internal/store"
)
// A declaration is said before it is done.
//
// An apply that prints what it did after it did it is a report; what an operator reaching for
// `reconcile` under pressure needs is a preview — the base filter that closed an adopted
// control-node for forty-five minutes was listed nowhere until it was on disk (novox/hq issue
// 104). Converging already previews on the controller; the host's own commands now do too, and
// `--dry-run` is the preview and nothing else.
// Step is one thing an apply would do to this machine.
type Step struct {
// Verb is create · update · check · hold · run · remove · forget · restore · disable · enable.
Verb string `json:"verb"`
Type string `json:"type,omitempty"`
ID string `json:"id,omitempty"`
Target string `json:"target,omitempty"`
Why string `json:"why,omitempty"`
}
func (s Step) String() string {
line := fmt.Sprintf("%-8s %-10s %s", s.Verb, s.Type, s.ID)
if s.Target != "" && s.Target != s.ID {
line += " (" + s.Target + ")"
}
if s.Why != "" {
line += " — " + s.Why
}
return line
}
// Plan says what applying a declaration would change, in the order ApplyKeeping would do it,
// before anything on the machine is touched.
//
// **Read from the declaration and the node's own record, not from the machine.** What the
// record cannot settle — whether a file recorded here has since drifted, whether a container
// runs the spec it was made from — is said as a check, because that is what the apply does: it
// reads the machine and corrects it. What the record does settle is said as it is: a resource
// with no record is created; a plain file whose declared content differs from what this host
// last wrote is updated; a hold is kept; an action is run — an action is a command, and a
// preview that folded it into "check" would hide the one kind of step that is not read back
// from state.
func Plan(d *declaration.Declaration, known store.State, origin string) []Step {
var steps []Step
declared := map[string]bool{}
for _, r := range d.Resources {
declared[r.Identity()] = true
}
// The found tunnel's configuration is held under an id of its own, declared for as long as the
// service taking it over is — as ApplyKeeping counts it, or a plan would forget a hold the
// apply keeps (novox/hq ADR 0105).
if svc := takesOver(d); svc != nil {
declared[takeOverID(svc)] = true
}
rec := known.Firewall
ufw := rec != nil && rec.Kind == string(firewall.UFW)
if d.Adoption != nil && ufw && rec.DisabledByMesh {
steps = append(steps, Step{Verb: "enable", Type: "firewall", ID: "ufw",
Why: "this node is adopted again, so the firewall found on it is put back in force"})
}
// What is held and no longer declared is let go of on paper only (ApplyKeeping does this
// before the resources); the file or container itself is left as found.
if origin == store.OriginDeclared {
for _, h := range known.Held {
if declared[h.ID] {
continue
}
steps = append(steps, Step{Verb: "forget", Type: h.Kind, ID: h.ID, Target: h.Target,
Why: "held for " + h.Module + " and no longer declared; left as found"})
}
}
var protecting, orphans []Step
made := meshMadeUnits(known)
for _, orphan := range known.Orphans(declared, origin) {
step := Step{Verb: "remove", Type: orphan.Type, ID: orphan.ID, Target: orphan.Target,
Why: "recorded here and no longer declared"}
switch {
case orphan.Stateless:
step.Verb, step.Why = "forget", "no longer declared; its unit's state was never the mesh's and is left as it is"
case orphan.Type == string(declaration.TypeService):
// What removal will do, said before it does it (novox/hq ADR 0118), in removeService's
// words. "restore" only where it may stop or disable something — the record cannot say
// whether the unit is still as the mesh left it, so "may" is as far as a preview goes —
// and a unit whose file the mesh wrote is named as the mesh's, since that one is
// stopped whatever was found.
f := orphan.Found
switch {
case made[orphan.Target]:
step.Verb, step.Why = "remove", "no longer declared; the mesh wrote its unit file, so it is "+
"stopped and disabled at boot before that file goes"
case f == nil:
step.Verb, step.Why = "forget", "no longer declared; recorded before the host kept what it "+
"found, so it is left as it is"
case f.State == "stopped" || f.Boot == "disabled":
var back []string
if f.State == "stopped" {
back = append(back, "stopped")
}
if f.Boot == "disabled" {
back = append(back, "disabled at boot")
}
step.Verb, step.Why = "restore", "no longer declared; the host found it "+
strings.Join(back, " and ")+", and it goes back to that if the mesh changed it"
default:
step.Verb, step.Why = "forget", "no longer declared; it was running before the mesh and is "+
"left as it is — nothing is started or stopped on the way out"
}
}
if d.Adoption == nil && strings.HasPrefix(orphan.ID, declaration.AdoptionPrefix) {
step.Why = "what protected this node while adopted; removed last, once everything else applied"
protecting = append(protecting, step)
continue
}
orphans = append(orphans, step)
}
// The guard goes up before anything is removed on an adopted node; on a converged one the
// removals go first (novox/hq ADR 0103).
var guard, rest []declaration.Resource
for _, r := range d.Resources {
if d.Adoption != nil && strings.HasPrefix(r.Identity(), guardPrefix) {
guard = append(guard, r)
continue
}
rest = append(rest, r)
}
for _, r := range guard {
steps = append(steps, planned(r, d, known))
}
steps = append(steps, orphans...)
for _, r := range rest {
step := planned(r, d, known)
if svc, ok := r.(*declaration.Service); ok && svc.TakesOver != nil && d.Adoption != nil && step.Verb != "hold" {
// The take comes before the service that replaces the tunnel, as it does in the apply.
steps = append(steps, plannedTake(svc, known))
}
steps = append(steps, step)
}
// Only a declaration from the mesh converges a node; a bundle or a file never retires the
// firewall found here, and neither says so in a plan.
if d.Adoption == nil && origin == store.OriginDeclared && ufw && rec.WasActive && !rec.DisabledByMesh {
steps = append(steps, Step{Verb: "disable", Type: "firewall", ID: "ufw",
Why: "this node converges: retired once the mesh's own filter is loaded, never before; " +
"its configuration stays on disk"})
}
steps = append(steps, protecting...)
return steps
}
// planned is what one declared resource would come to.
//
// **In the order holdOnAdopted decides it**, because the one cutover ADR 0100 says must be
// previewed is the one a plan gets backwards if it looks at the record first: a resource this
// node holds for a module the declaration now says is taken is not held any longer — it is
// applied, and what was found is replaced. The declaration's word on which modules are untaken
// comes first; the record of what is held only says what that replacement replaces.
func planned(r declaration.Resource, d *declaration.Declaration, known store.State) Step {
step := Step{Type: string(r.Kind()), ID: r.Identity(), Target: r.Target()}
if d.Adoption != nil {
// Something run inside a held container is held with it, while that container's module
// is untaken; once the module is taken the container is replaced before this runs.
if in := runsIn(r); in != "" {
if container, isHeld := heldContainer(known, in); isHeld {
if _, untaken := d.Adoption.Untaken[container.Module]; untaken {
step.Verb = "hold"
step.Why = "runs in " + in + ", which is held as found; not run until " + container.Module + " is taken"
return step
}
}
}
// A file written into, or a service whose lifecycle is the machine's, replaces nothing that
// was found, so it is never held (ADR 0102, ADR 0117).
into := replacesNothing(r)
h, held := known.HeldAt(r.Identity())
module, untaken := d.Adoption.UntakenModuleOf(r.Identity())
switch {
case into:
case untaken && held:
step.Verb, step.Why = "hold", "found on this machine and kept as it is until "+module+" is taken"
return step
case untaken:
step.Verb = "create"
step.Why = "unless it is found on this machine — then held as it is until " + module + " is taken"
return step
case held:
// The cutover: the module is taken, and what was held for it is replaced.
step.Verb = "create"
if _, recorded := known.Find(r.Identity()); recorded {
step.Verb = "update"
}
step.Why = h.Module + " is taken: replaces what was found and held"
if h.Kept != "" {
step.Why += "; the original stays at " + h.Kept
}
return step
}
}
if a, ok := r.(*declaration.Action); ok {
// Named as what it is. Its verify decides whether it runs, and that is read from the
// machine, not the record.
step.Verb = "run"
step.Target = ""
step.Why = fmt.Sprintf("an action: `%s`, unless its verify `%s` already passes; if it fails, "+
"nothing after it is attempted", strings.Join(a.Command, " "), strings.Join(a.Verify, " "))
if a.In != "" {
step.Why = "in " + a.In + ", " + step.Why
}
return step
}
if svc, ok := r.(*declaration.Service); ok && svc.Stateless() {
// Nothing is created: the unit and whether it runs are the machine's (novox/hq ADR 0117).
step.Verb, step.Why = "check", "its lifecycle is the machine's; reloaded or restarted only if "+
"running when what it reflects changes"
return step
}
was, recorded := known.Find(r.Identity())
if !recorded {
step.Verb, step.Why = "create", "no record of it on this node"
return step
}
if want := wouldWrite(r); want != "" && was.Wrote != "" && want != was.Wrote {
step.Verb, step.Why = "update", "the declaration changed since this host applied it"
return step
}
if c, ok := r.(*declaration.Container); ok {
if changed := readsChanged(c, d, known, was.Reads); len(changed) > 0 {
step.Verb = "update"
step.Why = "recreated: " + strings.Join(changed, ", ") + " changed since it was created"
return step
}
}
step.Verb, step.Why = "check", "recorded here; corrected if this machine drifted from it"
return step
}
// plannedTake is what the take of a found tunnel would do to its configuration (novox/hq ADR 0105,
// ADR 0119): kept as found while the take is not proven, and retired — removed from where its unit
// reads it, its original staying kept — by the first apply that finds the mesh's interface up in
// its place with a peer handshaken. Whether that is this apply is read from the machine, which a
// plan does not do, so it says when rather than whether. One the mesh retired already is said as
// retired: nothing brings it back.
func plannedTake(svc *declaration.Service, known store.State) Step {
t := svc.TakesOver
step := Step{Verb: "hold", Type: string(declaration.TypeFile), ID: takeOverID(svc), Target: t.Config}
if r, ok := known.RetiredAt(t.Config); ok {
step.Verb = "check"
step.Why = "retired once the take of " + t.Interface + " was proven; its original stays at " + r.Kept +
" and the mesh never brings it back"
return step
}
step.Why = "the configuration of the tunnel " + t.Interface + ", kept as found while " + svc.Unit +
" takes it over (" + t.Unit + " stopped and disabled, never flushed); retired — removed from " +
t.Config + ", its original staying kept — once the take is proven by a peer handshaking on " +
strings.TrimPrefix(svc.Unit, "wg-quick@")
if h, ok := known.HeldAt(takeOverID(svc)); ok && h.Kept != "" {
step.Why += "; the original is at " + h.Kept
}
return step
}
// readsChanged is which of the files a container was created reading the apply will hand it
// changed — the same comparison applyContainer makes (novox/hq 04-ISSUES/103), settled from the
// declaration and the record alone.
//
// A file's digest is what this apply will record for it: a plain file declared here, by its
// declared content; otherwise what this host last wrote there, under any id. A file neither
// declares nor records — an env-file a predecessor left — is read by the apply from the machine,
// which a plan does not do, so it stays a check. A container with no record of what it read was
// labelled before the host kept that record and is accepted as it is, so it is a check too.
func readsChanged(c *declaration.Container, d *declaration.Declaration, known store.State,
wasReading map[string]string) []string {
if len(wasReading) == 0 {
return nil
}
willWrite := map[string]string{}
for _, r := range d.Resources {
if f, ok := r.(*declaration.File); ok {
if want := wouldWrite(f); want != "" {
willWrite[f.Path] = want
}
}
}
// Only what it still reads: a file it was created reading and no longer names is a changed
// declaration, not a changed file.
stillReads := map[string]bool{}
for _, path := range c.EnvFile {
stillReads[path] = true
}
for _, v := range c.Volumes {
if src := mountSource(v); strings.HasPrefix(src, "/") {
stillReads[src] = true
}
}
var changed []string
for _, path := range sortedKeys(wasReading) {
if !stillReads[path] {
continue
}
now, settled := willWrite[path]
if !settled {
if f, recorded := known.At(string(declaration.TypeFile), path); recorded {
now, settled = f.Wrote, true
}
}
if settled && now != wasReading[path] {
changed = append(changed, path)
}
}
return changed
}
// wouldWrite is the digest a plain file would be recorded under, or empty where only the apply
// can know: a sealed file, one with secrets in it, one written into, one carrying bytes.
func wouldWrite(r declaration.Resource) string {
f, ok := r.(*declaration.File)
if !ok || f.Into != "" || f.Bytes != "" || f.Secret() || len(f.Secrets) > 0 {
return ""
}
return digestOf(f.Content)
}
-302
View File
@@ -1,302 +0,0 @@
package apply
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq issue 104: what an apply would change is said before anything is, from the
// declaration and the node's record — and an action is named as the action it is.
func trusted(t *testing.T, raw string) *declaration.Declaration {
t.Helper()
d, err := declaration.ParseFileTrusted([]byte(raw))
if err != nil {
t.Fatalf("fixture is not a valid declaration: %v", err)
}
return d
}
func verbs(steps []Step) string {
var out []string
for _, s := range steps {
out = append(out, s.Verb+" "+s.ID)
}
return strings.Join(out, ", ")
}
func TestAPlanNamesAnActionAsAnAction(t *testing.T) {
d := trusted(t, `{"declaration":1,"resources":[
{"id":"init","type":"action","command":["createdb","mesh"],"verify":["psql","-c","select 1"]}]}`)
steps := Plan(d, store.State{}, store.OriginCarried)
if len(steps) != 1 || steps[0].Verb != "run" {
t.Fatalf("an action was planned as %s", verbs(steps))
}
if !strings.Contains(steps[0].Why, "createdb mesh") || !strings.Contains(steps[0].Why, "nothing after it") {
t.Errorf("the plan does not say what the action runs and what failing it means: %q", steps[0].Why)
}
}
func TestAPlanSaysWhatIsRecordedAndWhatIsNot(t *testing.T) {
d := parse(t, `{"declaration":1,"resources":[
{"id":"new","type":"file","path":"/tmp/new","content":"a\n"},
{"id":"same","type":"file","path":"/tmp/same","content":"b\n"},
{"id":"moved","type":"file","path":"/tmp/moved","content":"c\n"},
{"id":"sealed","type":"file","path":"/tmp/sealed","sealed":"AAAA","mode":"0600"}]}`)
known := store.State{}
known.Record(store.Applied{ID: "same", Type: "file", Target: "/tmp/same", Wrote: digestOf("b\n")})
known.Record(store.Applied{ID: "moved", Type: "file", Target: "/tmp/moved", Wrote: digestOf("old\n")})
known.Record(store.Applied{ID: "sealed", Type: "file", Target: "/tmp/sealed", Wrote: digestOf("secret")})
known.Record(store.Applied{ID: "gone", Type: "file", Target: "/tmp/gone"})
got := verbs(Plan(d, known, store.OriginCarried))
want := "remove gone, create new, check same, update moved, check sealed"
if got != want {
t.Errorf("planned %q, want %q", got, want)
}
}
func TestAPlanSaysTheFirewallAConvergingNodeRetires(t *testing.T) {
d := parse(t, `{"declaration":1,"resources":[{"id":"a","type":"file","path":"/tmp/a","content":"x\n"}]}`)
known := store.State{Firewall: &store.FoundFirewall{Kind: "ufw", WasActive: true, FoundAt: time.Now()}}
known.Record(store.Applied{ID: "adoption.guard.table", Type: "file", Target: "/etc/guard", Origin: store.OriginDeclared})
got := verbs(Plan(d, known, store.OriginDeclared))
// The firewall goes last but for what protected the node, which goes after it.
if got != "create a, disable ufw, remove adoption.guard.table" {
t.Errorf("a converging node planned %q", got)
}
// A file or the bundle never retires the firewall found here, and says nothing about it.
if got := verbs(Plan(d, known, store.OriginCarried)); strings.Contains(got, "ufw") {
t.Errorf("a carried declaration planned to touch the firewall: %q", got)
}
}
func TestAPlanHoldsWhatAnAdoptedNodeFound(t *testing.T) {
d := parse(t, `{"declaration":1,"adoption":{"taken":[],"untaken":{"hello-web":["hello-web.page","hello-web.server"]}},
"resources":[
{"id":"hello-web.page","type":"file","path":"/srv/index.html","content":"x\n"},
{"id":"hello-web.server","type":"container","name":"hello-web","image":"example/web@sha256:0000000000000000000000000000000000000000000000000000000000000000"}]}`)
known := store.State{}
known.RecordHeld(store.Held{ID: "hello-web.page", Module: "hello-web", Kind: "file", Target: "/srv/index.html"})
steps := Plan(d, known, store.OriginDeclared)
if len(steps) != 2 || steps[0].Verb != "hold" || steps[1].Verb != "create" {
t.Fatalf("an adopted node planned %s", verbs(steps))
}
if !strings.Contains(steps[1].Why, "held as it is until hello-web is taken") {
t.Errorf("the plan does not say an untaken module's resource is held if found: %q", steps[1].Why)
}
}
// both is a machine with a container runtime and ufw on it at once.
type both struct {
m *machine
u *ufwMachine
}
func (b *both) run(ctx context.Context, name string, args ...string) (string, error) {
switch name {
case "nft", "ufw", "iptables", "ip6tables", "firewall-cmd":
return b.u.run(ctx, name, args...)
}
return b.m.run(ctx, name, args...)
}
func ids(steps []Step) []string {
var out []string
for _, s := range steps {
if s.Type == "firewall" {
continue // said, not an outcome
}
out = append(out, s.ID)
}
return out
}
func outcomeIDs(r Report) []string {
var out []string
for _, o := range r.Outcomes {
out = append(out, o.ID)
}
return out
}
func write(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// Defends novox/hq issue 104: the plan is the apply, said first — the same resources in the same
// order, and the one cutover ADR 0100 says must be previewed said as a cutover, not a hold.
func TestThePlanIsTheApplyInOrder(t *testing.T) {
dir := t.TempDir()
guard, page, keep, old := filepath.Join(dir, "guard.nft"), filepath.Join(dir, "index.html"),
filepath.Join(dir, "keep.html"), filepath.Join(dir, "old.conf")
for _, p := range []string{page, keep, old} {
write(t, p, "the predecessor's\n")
}
// Returning to adopted, with a guard to raise, an orphan, a hold that stays, a hold whose
// module is now taken, and a hold no longer declared.
back := adopted(t, `{"taken":["hello-web"],"untaken":{"keep":["keep.page"]}}`,
`{"id":"adoption.guard.table","type":"file","path":"`+guard+`","content":"table inet mesh-guard {}\n"},
{"id":"hello-web.page","type":"file","path":"`+page+`","content":"the mesh's page\n"},
{"id":"keep.page","type":"file","path":"`+keep+`","content":"the mesh's keep\n"}`)
known := store.State{Firewall: &store.FoundFirewall{Kind: "ufw", WasActive: true, DisabledByMesh: true, FoundAt: time.Now()}}
known.Record(store.Applied{ID: "old.conf", Type: "file", Target: old, Origin: store.OriginDeclared})
for id, module := range map[string]string{"hello-web.page": "hello-web", "keep.page": "keep", "gone.page": "gone"} {
known.RecordHeld(store.Held{ID: id, Module: module, Kind: "file", Target: filepath.Join(dir, id), Since: time.Now()})
}
fake := &both{m: &machine{containers: map[string]*fakeContainer{}}, u: &ufwMachine{installed: true}}
plan := Plan(back, known, store.OriginDeclared)
report, state, err := ApplyKeeping(context.Background(), archHost(t), back, known, store.OriginDeclared,
fake.run, nil, nil, KeepIn(dir))
if err != nil {
t.Fatal(err)
}
if got, want := verbs(plan), "enable ufw, forget gone.page, create adoption.guard.table, remove old.conf, "+
"create hello-web.page, hold keep.page"; got != want {
t.Errorf("planned %q, want %q", got, want)
}
if got, want := strings.Join(ids(plan), " "), strings.Join(outcomeIDs(report), " "); got != want {
t.Errorf("the plan said %q and the apply did %q", got, want)
}
taken := outcomeOf(report, "hello-web.page")
if !strings.Contains(taken.Detail, "taken") || !strings.Contains(plan[4].Why, "hello-web is taken: replaces what was found") {
t.Errorf("the cutover was applied as %q and planned as %q", taken.Detail, plan[4].Why)
}
if !fake.u.active || state.Firewall.DisabledByMesh {
t.Error("returning to adopted did not enable ufw again")
}
// Converged, from the mesh: an orphan, a new file, ufw retired, and what protected the node
// removed last.
flip := parse(t, `{"declaration":1,"resources":[{"id":"a","type":"file","path":"`+filepath.Join(dir, "a.conf")+`","content":"a\n"}]}`)
write(t, old, "again\n")
known = store.State{Firewall: &store.FoundFirewall{Kind: "ufw", WasActive: true, FoundAt: time.Now()}}
known.Record(store.Applied{ID: "adoption.guard.table", Type: "file", Target: guard, Origin: store.OriginDeclared})
known.Record(store.Applied{ID: "old.conf", Type: "file", Target: old, Origin: store.OriginDeclared})
fake = &both{m: &machine{containers: map[string]*fakeContainer{}}, u: &ufwMachine{installed: true, active: true,
ruleset: "table inet mesh {\n}\n"}}
plan = Plan(flip, known, store.OriginDeclared)
report, state, err = ApplyKeeping(context.Background(), archHost(t), flip, known, store.OriginDeclared,
fake.run, nil, nil, KeepIn(dir))
if err != nil {
t.Fatal(err)
}
if got, want := verbs(plan), "remove old.conf, create a, disable ufw, remove adoption.guard.table"; got != want {
t.Errorf("planned %q, want %q", got, want)
}
if got, want := strings.Join(ids(plan), " "), strings.Join(outcomeIDs(report), " "); got != want {
t.Errorf("the plan said %q and the apply did %q", got, want)
}
if fake.u.active || !state.Firewall.DisabledByMesh {
t.Error("converging did not retire ufw")
}
}
func TestAResourceRunInAHeldContainerIsPlannedAsItIsApplied(t *testing.T) {
// A run-once step sharing a container's namespace runs in it, as an action's `in` does.
img := "example/x@sha256:0000000000000000000000000000000000000000000000000000000000000000"
d := parse(t, `{"declaration":1,"adoption":{"taken":["web"],"untaken":{"db":["db.server"]}},"resources":[
{"id":"web.server","type":"container","name":"web","image":"`+img+`"},
{"id":"db.server","type":"container","name":"db","image":"`+img+`"},
{"id":"db.init","type":"container","name":"db-init","image":"`+img+`","run-once":true,"network":"container:db"},
{"id":"web.warm","type":"container","name":"web-warm","image":"`+img+`","run-once":true,"network":"container:web"}]}`)
known := store.State{}
known.RecordHeld(store.Held{ID: "db.server", Module: "db", Kind: "container", Target: "db", Container: "predecessor"})
known.RecordHeld(store.Held{ID: "web.server", Module: "web", Kind: "container", Target: "web", Container: "predecessor"})
got := verbs(Plan(d, known, store.OriginDeclared))
// db is untaken, so what runs in it waits; web is taken, so its held container is replaced (the
// cutover) and what runs in it runs.
if got != "create web.server, hold db.server, hold db.init, create web.warm" {
t.Errorf("planned %q", got)
}
}
func TestAPlanSaysAContainerIsRecreatedWhenAFileItReadsChanged(t *testing.T) {
// The apply recreates a container when the content of a file it reads at creation changed
// (novox/hq 04-ISSUES/103); the plan says so from the record alone — what the container was
// created reading, against what this apply will write. And a container recorded before the
// host kept that record is accepted, so it is a check, not an update.
dir := t.TempDir()
env := filepath.Join(dir, "forge.env")
declare := func(port string) *declaration.Declaration {
return trusted(t, `{"declaration":1,"resources":[
{"id":"forge.env","type":"file","path":"`+env+`","content":"DATABASE_PORT=`+port+`\n"},
{"id":"forge.server","type":"container","name":"forge","image":"`+pinned+`","env-file":["`+env+`"]}]}`)
}
created := digestOf("DATABASE_PORT=5432\n")
known := store.State{}
known.Record(store.Applied{ID: "forge.env", Type: "file", Target: env, Wrote: created})
known.Record(store.Applied{ID: "forge.server", Type: "container", Target: "forge",
Reads: map[string]string{env: created}})
if got := verbs(Plan(declare("5432"), known, store.OriginCarried)); got != "check forge.env, check forge.server" {
t.Errorf("nothing changed and the plan says %q", got)
}
steps := Plan(declare("5433"), known, store.OriginCarried)
if got := verbs(steps); got != "update forge.env, update forge.server" {
t.Fatalf("the env-file changes and the plan says %q", got)
}
if !strings.Contains(steps[1].Why, env+" changed") {
t.Errorf("the plan does not say which file: %+v", steps[1])
}
// No record of what it read: labelled by an earlier host, accepted as it is.
known.Record(store.Applied{ID: "forge.server", Type: "container", Target: "forge"})
if got := verbs(Plan(declare("5433"), known, store.OriginCarried)); got != "update forge.env, check forge.server" {
t.Errorf("a container with no record of what it read is planned as %q", got)
}
}
func TestAPlanSaysWhichUnitsAnUndeclareGivesBackAndWhichItLeaves(t *testing.T) {
// novox/hq ADR 0118, said before it is done: "restore" only where removal may stop or disable
// something, and a unit whose file the mesh wrote named as the mesh's.
known := store.State{}
known.Record(store.Applied{ID: "guard-unit", Type: "file", Target: "/etc/systemd/system/mesh-guard.service"})
known.Record(store.Applied{ID: "guard", Type: "service", Target: "mesh-guard.service"})
known.Record(store.Applied{ID: "filter", Type: "service", Target: "filter.service",
Found: &store.FoundUnit{State: "stopped"}})
known.Record(store.Applied{ID: "boot", Type: "service", Target: "boot.service",
Found: &store.FoundUnit{State: "running", Boot: "disabled"}})
known.Record(store.Applied{ID: "runtime", Type: "service", Target: "docker.service",
Found: &store.FoundUnit{State: "running", Boot: "enabled"}})
known.Record(store.Applied{ID: "old", Type: "service", Target: "sshd.service"})
known.Record(store.Applied{ID: "nm", Type: "service", Target: "NetworkManager.service", Stateless: true})
steps := Plan(parse(t, nothingButA(t)), known, store.OriginCarried)
got := verbs(steps)
want := "forget nm, forget old, forget runtime, restore boot, restore filter, remove guard, remove guard-unit, create other"
if got != want {
t.Fatalf("planned %s\nwant %s", got, want)
}
for _, s := range steps {
switch s.ID {
case "guard":
if !strings.Contains(s.Why, "unit file") {
t.Errorf("the mesh's own unit was not named as the mesh's: %q", s.Why)
}
case "filter":
if !strings.Contains(s.Why, "found it stopped") {
t.Errorf("what the unit goes back to went unsaid: %q", s.Why)
}
case "old":
if !strings.Contains(s.Why, "left as it is") {
t.Errorf("a unit with nothing found was not said to be left: %q", s.Why)
}
}
}
}
+8 -99
View File
@@ -30,7 +30,7 @@ import (
// Under the mesh's own directory rather than somewhere a distribution owns: these are files the
// mesh puts there and replaces, and putting them where a package manager also writes is how two
// owners end up disagreeing about one path.
var daemonRoot = "/var/lib/mesh/daemons"
const daemonRoot = "/var/lib/mesh/daemons"
// unitDir is where the mesh writes the units it owns. A variable only so a test can point it at a
// directory of its own.
@@ -77,26 +77,11 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner,
// Everything about it is as declared. Still asked whether it is RUNNING, because a
// declaration that is satisfied by a record rather than by the machine is how a stopped
// service reports success.
// **The record is carried forward, not re-derived.** An unchanged outcome is recorded
// like any other, so one that said nothing about what was written erased the digest; the
// next apply then found no record, re-created the daemon, and the one after that found a
// record again — the node's runtime restarted every other cycle (novox/hq 04-ISSUES/210).
out.wrote = want
// **A step that ran is done, and a schedule is its timer** (novox/hq issue 213). Neither's
// service is meant to be up between runs, so asking whether it is — and starting it when it
// was not — ran a completed step again on every apply, and a scheduled run off its cadence.
if r.RunOnce {
return out, nil
}
unit := r.Name + ".service"
if r.Schedule != "" {
unit = r.Name + ".timer"
}
if active, err := run(ctx, "systemctl", "is-active", "--quiet", unit); err == nil {
if active, err := run(ctx, "systemctl", "is-active", "--quiet", r.Name+".service"); err == nil {
_ = active
return out, nil
}
if _, err := run(ctx, "systemctl", "start", unit); err != nil {
if _, err := run(ctx, "systemctl", "start", r.Name+".service"); err != nil {
return out, fmt.Errorf("%s is installed and would not start: %w", r.Name, err)
}
out.Action = "updated"
@@ -125,22 +110,9 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner,
// so the machine is not asked to start something that needed a migration that did not happen.
// Nothing is left behind to ask afterwards: the record that it ran is the digest, which is why
// the identity above includes the command.
//
// **Run as the unit a daemon would be, once** (novox/hq design 38 WP4c). Run directly, the
// step started in the host's own working directory, without its environment, its environment
// files or its user — `node bootstrap/index.js` resolved from wherever the host ran and was told
// none of the words it was declared with. A oneshot unit carries all four exactly as a daemon's
// does, and starting one waits for it to finish and fails when it fails.
if r.RunOnce {
unit := filepath.Join(unitDir, r.Name+".service")
if err := os.WriteFile(unit, []byte(unitFor(r)), 0o644); err != nil {
return out, err
}
if _, err := run(ctx, "systemctl", "daemon-reload"); err != nil {
return out, err
}
if _, err := run(ctx, "systemctl", "start", r.Name+".service"); err != nil {
return out, fmt.Errorf("the %s step did not complete (journalctl -u %s.service says why): %w", r.Name, r.Name, err)
if _, err := run(ctx, r.Run[0], r.Run[1:]...); err != nil {
return out, fmt.Errorf("the %s step did not complete: %w", r.Name, err)
}
out.Action = "created"
if previous.Wrote != "" {
@@ -237,9 +209,9 @@ func unitFor(r *declaration.Process) string {
if r.User != "" {
fmt.Fprintf(&b, "User=%s\n", r.User)
}
fmt.Fprintf(&b, "ExecStart=%s\n", strings.Join(runFrom(r), " "))
if r.Schedule != "" || r.RunOnce {
// Started by its timer, or once by the host, and expected to finish. Restarting it would have it run
fmt.Fprintf(&b, "ExecStart=%s\n", strings.Join(r.Run, " "))
if r.Schedule != "" {
// Started by its timer and expected to finish. Restarting it would have it run
// continuously between fires, which is the opposite of a schedule.
b.WriteString("Type=oneshot\n")
b.WriteString("\n")
@@ -318,66 +290,3 @@ func unitValue(key, value string) string {
).Replace(value)
return `"` + key + "=" + escaped + `"`
}
// removeProcess takes away a process the mesh no longer declares: its timer and unit stopped and
// disabled, their files removed, the supervisor told, and the unpacked bundle deleted.
//
// **All of it is the host's**, which is why all of it goes (novox/hq ADR 0118). Before this there
// was no way to remove a process at all, and one left undeclared failed every apply on its node
// until someone edited the host's state by hand — unassigning any module that ran its own code
// stranded the machine.
//
// Idempotent, like every removal: a unit already gone is not an error, and a record whose files
// have all vanished is forgotten rather than reported as removed.
func removeProcess(ctx context.Context, a store.Applied, run Runner) (string, string, error) {
name := a.Target
if problem := declaration.ProcessNameProblem(name); problem != "" {
// The name is a unit name and a directory under the mesh's own, and what goes is that
// directory, whole. One that could climb out of either — ".." is the mesh's own directory's
// parent — is refused rather than acted on, whatever wrote it into the record.
return "", "", fmt.Errorf("a process recorded under %q cannot be removed by name: %s", name, problem)
}
found := false
for _, unit := range []string{name + ".timer", name + ".service"} {
path := filepath.Join(unitDir, unit)
if _, err := os.Stat(path); err != nil {
continue
}
found = true
// The timer first, so a scheduled run cannot start the service between the two.
if _, err := run(ctx, "systemctl", "disable", "--now", unit); err != nil {
return "", "", fmt.Errorf("stopping %s: %w", unit, err)
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return "", "", err
}
}
if found {
if _, err := run(ctx, "systemctl", "daemon-reload"); err != nil {
return "", "", err
}
}
bundle := filepath.Join(daemonRoot, name)
if _, err := os.Stat(bundle); err == nil {
found = true
if err := os.RemoveAll(bundle); err != nil {
return "", "", err
}
}
if !found {
return "forgotten", "no longer there", nil
}
return "removed", "stopped; its unit and its bundle removed — the mesh's own code", nil
}
// runFrom is the command as the unit runs it. **A command written `./name` is that file in the
// process's own unpacked bundle** (novox/hq ADR 0193): a bundle compiled to a binary runs itself,
// and only the host knows where it unpacked it, while the service manager takes an absolute path or
// a name it finds on its own search path — never one relative to the working directory.
func runFrom(r *declaration.Process) []string {
run := append([]string(nil), r.Run...)
if len(run) > 0 && strings.HasPrefix(run[0], "./") {
run[0] = filepath.Join(daemonRoot, r.Name, strings.TrimPrefix(run[0], "./"))
}
return run
}
-81
View File
@@ -1,81 +0,0 @@
package apply
import (
"context"
"errors"
"os"
"os/user"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// A run-once process is a step run where, how and as whom it was declared (novox/hq design 38
// WP4c): its bundle's directory, its environment and environment files, its user. Run directly,
// it started in the host's own directory with none of them.
func TestARunOnceProcessRunsAsItsOneshotUnit(t *testing.T) {
units, bundles := t.TempDir(), t.TempDir()
wasUnits, wasBundles := unitDir, daemonRoot
unitDir, daemonRoot = units, bundles
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
me, err := user.Current()
if err != nil {
t.Fatal(err)
}
body, digest := anArchive(t, map[string]string{"bootstrap/index.js": "console.log(1)\n"})
var commands []string
fail := false
run := func(ctx context.Context, name string, args ...string) (string, error) {
commands = append(commands, name+" "+strings.Join(args, " "))
if fail && name == "systemctl" && len(args) > 0 && args[0] == "start" {
return "", errors.New("exit status 1")
}
return "", nil
}
d := declare(t, `{"id":"mosquitto.bootstrap","type":"process","name":"mosquitto-bootstrap","source":"`+serving(t, body)+
`","digest":"`+digest+`","run":["node","bootstrap/index.js"],"run-once":true,"user":"`+me.Username+`",`+
`"env":{"MESH_ADMIN":"mesh-admin"},"env-file":["/var/lib/mesh/mosquitto/bootstrap.env"]}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil); err != nil {
t.Fatal(err)
}
unit, err := os.ReadFile(filepath.Join(units, "mosquitto-bootstrap.service"))
if err != nil {
t.Fatalf("no unit was written for the step: %v", err)
}
for _, want := range []string{
"WorkingDirectory=" + filepath.Join(bundles, "mosquitto-bootstrap"),
"EnvironmentFile=/var/lib/mesh/mosquitto/bootstrap.env",
`Environment="MESH_ADMIN=mesh-admin"`,
"User=" + me.Username,
"Type=oneshot",
"ExecStart=node bootstrap/index.js",
} {
if !strings.Contains(string(unit), want) {
t.Errorf("the step's unit lacks %q:\n%s", want, unit)
}
}
for _, never := range []string{"Restart=always", "[Install]", "Type=simple"} {
if strings.Contains(string(unit), never) {
t.Errorf("a step's unit says %q:\n%s", never, unit)
}
}
joined := strings.Join(commands, "; ")
if !strings.Contains(joined, "systemctl start mosquitto-bootstrap.service") {
t.Errorf("the step was not started as its unit: %s", joined)
}
if strings.Contains(joined, "node bootstrap/index.js") || strings.Contains(joined, "enable mosquitto-bootstrap") {
t.Errorf("the step was run directly or enabled: %s", joined)
}
// A step that fails fails the apply, and is not recorded as done.
fail = true
d2 := declare(t, `{"id":"mosquitto.bootstrap","type":"process","name":"mosquitto-bootstrap","source":"`+serving(t, body)+
`","digest":"`+digest+`","run":["node","bootstrap/index.js"],"run-once":true,"env":{"MESH_ADMIN":"changed"}}`)
if _, _, err := Apply(context.Background(), archHost(t), d2, store.State{}, store.OriginDeclared, run, nil, nil); err == nil {
t.Error("a step that failed did not fail the apply")
}
}
-163
View File
@@ -1,14 +1,10 @@
package apply
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
func aProcess() *declaration.Process {
@@ -167,162 +163,3 @@ func TestAnEnvironmentValueMeansWhatTheDeclarationSaid(t *testing.T) {
t.Fatalf("a quote was not escaped, so the value ends early: %s", line)
}
}
func TestAnUndeclaredProcessIsRemovedWithItsUnitAndBundle(t *testing.T) {
// novox/hq ADR 0118: a process's unit and bundle are the host's own, so they go with the
// declaration. Before, there was no way to remove a process at all, and one left undeclared
// failed every apply on its node.
units, bundles := t.TempDir(), t.TempDir()
wasUnits, wasBundles := unitDir, daemonRoot
unitDir, daemonRoot = units, bundles
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
for _, f := range []string{"mesh-job.service", "mesh-job.timer"} {
if err := os.WriteFile(filepath.Join(units, f), []byte("[Unit]\n"), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.MkdirAll(filepath.Join(bundles, "mesh-job", "bin"), 0o755); err != nil {
t.Fatal(err)
}
var commands []string
run := func(ctx context.Context, name string, args ...string) (string, error) {
commands = append(commands, strings.Join(args, " "))
return "", nil
}
known := store.State{Resources: []store.Applied{{ID: "p", Type: "process", Target: "mesh-job"}}}
d := parse(t, `{"declaration":1,"resources":[
{"id":"f","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
]}`)
report, after, err := Apply(context.Background(), archHost(t), d, known, store.OriginCarried, run, nil, nil)
if err != nil {
t.Fatalf("an undeclared process failed the apply: %v", err)
}
joined := strings.Join(commands, "; ")
timer, service := strings.Index(joined, "disable --now mesh-job.timer"), strings.Index(joined, "disable --now mesh-job.service")
if timer < 0 || service < 0 || timer > service {
t.Errorf("the timer and the unit were not stopped, timer first: %s", joined)
}
if !strings.Contains(joined, "daemon-reload") {
t.Errorf("the service manager was not told its units changed: %s", joined)
}
for _, gone := range []string{filepath.Join(units, "mesh-job.service"), filepath.Join(units, "mesh-job.timer"), filepath.Join(bundles, "mesh-job")} {
if _, err := os.Stat(gone); !os.IsNotExist(err) {
t.Errorf("%s is still there", gone)
}
}
if o := outcomeOf(report, "p"); o.Action != "removed" {
t.Errorf("outcome %+v, want removed", o)
}
if _, still := after.Find("p"); still {
t.Error("the host still believes it owns the process")
}
// Again, with everything already gone: forgotten, not an error.
_, _, err = Apply(context.Background(), archHost(t), d, known, store.OriginCarried, run, nil, nil)
if err != nil {
t.Errorf("removing a process that is already gone failed: %v", err)
}
}
func TestAProcessRecordedUnderAPathlikeNameIsRefusedNotRemoved(t *testing.T) {
// filepath.Join(daemonRoot, "..") is the mesh's own directory, and removal deletes what that
// names, whole. A record is what some host wrote, perhaps under looser rules than today's, so
// the removal holds the name to the declaration's rule again (novox/hq ADR 0118).
root := t.TempDir()
bundles := filepath.Join(root, "daemons")
wasUnits, wasBundles := unitDir, daemonRoot
unitDir, daemonRoot = t.TempDir(), bundles
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
precious := filepath.Join(root, "state.json")
if err := os.MkdirAll(filepath.Join(bundles, "other"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(precious, []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
for _, name := range []string{"..", ".", "", "-x"} {
known := store.State{Resources: []store.Applied{{ID: "p", Type: "process", Target: name}}}
d := parse(t, `{"declaration":1,"resources":[
{"id":"f","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
]}`)
if _, _, err := Apply(context.Background(), archHost(t), d, known, store.OriginCarried, noServices, nil, nil); err == nil {
t.Errorf("a process recorded as %q was removed by name", name)
}
for _, still := range []string{precious, filepath.Join(bundles, "other")} {
if _, err := os.Stat(still); err != nil {
t.Fatalf("removing a process recorded as %q took %s with it", name, still)
}
}
}
}
// novox/hq 04-ISSUES/210: the node's runtime was re-created — and restarted — on every reconcile,
// because the host did not find what it wrote for a process the cycle before. Applying the same
// process declaration twice must do no work the second time.
func TestAProcessAppliedAgainIsUnchangedAndNotRestarted(t *testing.T) {
units, bundles := t.TempDir(), t.TempDir()
wasUnits, wasBundles := unitDir, daemonRoot
unitDir, daemonRoot = units, bundles
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
body, digest := anArchive(t, map[string]string{"main.js": "console.log(1)\n"})
var commands []string
run := func(ctx context.Context, name string, args ...string) (string, error) {
commands = append(commands, name+" "+strings.Join(args, " "))
return "", nil
}
d := declare(t, `{"id":"node-tools.runtime","type":"process","name":"node-tools","source":"`+serving(t, body)+
`","digest":"`+digest+`","run":["node","main.js"],"env":{"MESH_TOOL_MODULES":"a=/x/index.js"}}`)
first, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if o := outcomeOf(first, "node-tools.runtime"); o.Action != "created" {
t.Fatalf("first apply: %+v, want created", o)
}
rec, ok := state.Find("node-tools.runtime")
if !ok || rec.Wrote == "" {
t.Fatalf("the host did not record what it wrote for the process: %+v", rec)
}
commands = nil
again, state, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if o := outcomeOf(again, "node-tools.runtime"); o.Action != "unchanged" {
t.Errorf("second apply: %+v, want unchanged", o)
}
for _, c := range commands {
if strings.Contains(c, "restart") {
t.Errorf("the second apply restarted the process: %v", commands)
}
}
// And the record survives an unchanged apply: the third cycle is unchanged too. This is the
// cycle the live mesh showed — created, unchanged, created — before the record was carried.
if rec, _ := state.Find("node-tools.runtime"); rec.Wrote == "" {
t.Fatalf("an unchanged apply dropped the digest from the record: %+v", rec)
}
commands = nil
third, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if o := outcomeOf(third, "node-tools.runtime"); o.Action != "unchanged" {
t.Errorf("third apply: %+v, want unchanged", o)
}
}
// novox/hq ADR 0193: a bundle compiled to a binary runs itself — `./name` is that file in the
// process's own unpacked bundle, made absolute because the service manager takes nothing relative.
func TestAProcessRunsItsOwnBundlesBinary(t *testing.T) {
unit := unitFor(&declaration.Process{Name: "node-tools", Run: []string{"./node-tools", "serve"}})
if !strings.Contains(unit, "ExecStart="+filepath.Join(daemonRoot, "node-tools", "node-tools")+" serve\n") {
t.Errorf("the binary is not run from its bundle:\n%s", unit)
}
other := unitFor(&declaration.Process{Name: "x", Run: []string{"node", "src/main.js"}})
if !strings.Contains(other, "ExecStart=node src/main.js\n") {
t.Errorf("a command found on the path was changed:\n%s", other)
}
}
+2 -84
View File
@@ -113,7 +113,7 @@ func TestAFailedRunOnceStepGatesWhatFollows(t *testing.T) {
switch args[0] {
case "info":
return "27.0\n", nil
case "container":
case "inspect":
return "false\t\n", errors.New("no such container")
case "run":
startedNames = append(startedNames, nameOf(args))
@@ -287,7 +287,7 @@ func TestAContainerNamingARunOnceStepIsRecreatedWhenItRan(t *testing.T) {
switch args[0] {
case "info":
return "27.0\n", nil
case "container":
case "inspect":
// The server is up, made from exactly this spec — nothing but the step's run says
// it must be replaced.
return "true\t" + spec, nil
@@ -316,85 +316,3 @@ func TestAContainerNamingARunOnceStepIsRecreatedWhenItRan(t *testing.T) {
t.Errorf("the recreation did not name the step as its reason: %+v", server)
}
}
func TestAFailedStepGatesItsModuleAndNotTheMachine(t *testing.T) {
// **The blast radius of a step is its module** (novox/hq ADR 0136). A step exists to make
// something true before the next thing in its own module needs it — a store seeded before the
// broker starts, a schema prepared before the version that needs it runs. Stopping the whole
// apply is what this host's own loop calls holding a machine hostage, and it was already
// rejected for every other shape (04-ISSUES/011): a module whose database is briefly
// unreachable must not stop every module declared after it.
var startedNames []string
run := func(ctx context.Context, name string, args ...string) (string, error) {
switch args[0] {
case "info":
return "27.0\n", nil
case "container":
return "false\t\n", errors.New("no such container")
case "run":
startedNames = append(startedNames, nameOf(args))
if nameOf(args) == "catalogue-prepare" {
return "", errors.New("exit status 1") // the schema could not be reached
}
return "deadbeef\n", nil
case "rm":
return "", nil
}
return "", nil
}
d := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"mesh-catalog.runtime-prepare","type":"container","name":"catalogue-prepare","image":"`+pinned+`","run-once":true},
{"id":"mesh-catalog.runtime","type":"container","name":"catalogue","image":"`+pinned+`"},
{"id":"gitea.server","type":"container","name":"forge","image":"`+pinned+`"}
]}`)
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
if err == nil {
t.Fatal("a failed step was not reported as a failure")
}
started := map[string]bool{}
for _, n := range startedNames {
started[n] = true
}
if started["catalogue"] {
t.Error("the module's own workload ran although its step did not complete")
}
if !started["forge"] {
t.Error("another module was not attempted, so one module's step held the machine hostage")
}
// And the machine's own account says which was not attempted, rather than leaving it to be
// inferred from silence.
var skipped string
for _, o := range report.Outcomes {
if o.Action == "skipped" {
skipped = o.ID
}
}
if skipped != "mesh-catalog.runtime" {
t.Errorf("the report does not say what was not attempted: %q", skipped)
}
}
func TestAResourcesOwnerIsReadToTheLastDot(t *testing.T) {
// **A module's name may contain a dot.** `novox.be` is one on this mesh, so reading a resource's
// owner to the first dot would make its resources belong to something called "novox" — and a gate
// would skip whatever else happened to start that way. A resource's own id never contains one,
// which is what makes the last dot the boundary.
for identity, want := range map[string]string{
"novox.be.server": "novox.be",
"mesh-catalog.runtime-prepare": "mesh-catalog",
"gitea.admin-bootstrap": "gitea",
} {
got, ours := moduleOf(identity)
if !ours || got != want {
t.Errorf("%q belongs to %q (%v), want %q", identity, got, ours, want)
}
}
// What the mesh declares in its own right belongs to no module: the foundation's resources carry
// no dot, and the adoption's are the mesh's.
for _, identity := range []string{"container-runtime", "store-ready", "adoption.guard", ".server"} {
if _, ours := moduleOf(identity); ours {
t.Errorf("%q was read as a module's", identity)
}
}
}
+1 -86
View File
@@ -65,29 +65,6 @@ type scheduledJob struct {
container *declaration.Container
next time.Time // the next minute at which it is due
running bool // a run is in flight — the next due run is skipped rather than stacked
// hold is the runtime names of the containers held still for the duration of a run, in the
// order the step named them (novox/hq ADR 0189).
hold []string
}
// heldStillFor is the runtime names of the containers a step holds still, resolved from ids.
func heldStillFor(step *declaration.Container, d *declaration.Declaration) []string {
if len(step.WhileStopped) == 0 {
return nil
}
byID := map[string]string{}
for _, r := range d.Resources {
if c, ok := r.(*declaration.Container); ok {
byID[c.Identity()] = c.Name
}
}
out := make([]string, 0, len(step.WhileStopped))
for _, id := range step.WhileStopped {
if name := byID[id]; name != "" {
out = append(out, name)
}
}
return out
}
// NewScheduler builds a scheduler. A nil clock is the system clock; a nil log says nothing.
@@ -146,20 +123,15 @@ func (s *Scheduler) Sync(d *declaration.Declaration, held map[string]bool) {
// on its cadence rather than staying running to be restarted — so its identity cannot
// depend on another resource's content and there is nothing to pass.
spec := containerSpec(c, inputs{})
// The runtime stops containers by name; the declaration names them by id. Resolved here,
// against the declaration this job was armed from, so a fire never has to look anything up
// (novox/hq ADR 0189). The parser has already refused an id that is not a container here.
hold := heldStillFor(c, d)
if existing := s.jobs[c.Identity()]; existing != nil && existing.spec == spec {
// Unchanged: keep where it is in its cadence, refresh the declaration pointer only.
existing.container = c
existing.hold = hold
continue
}
// New or changed: arm it for the next due minute after now.
next, _ := cron.Next(s.clock.Now())
s.jobs[c.Identity()] = &scheduledJob{
id: c.Identity(), spec: spec, cron: cron, container: c, next: next, hold: hold,
id: c.Identity(), spec: spec, cron: cron, container: c, next: next,
}
}
@@ -230,15 +202,6 @@ func (s *Scheduler) fire(ctx context.Context, j *scheduledJob) {
return
}
// **The window opens here and closes in the defer, whatever happens** (novox/hq ADR 0189).
// Deferred before the first stop so a panic, a failing step or a step that runs long all end
// the same way: the service running. The one real risk of this field is a window that never
// closes, and the only defence against it is that closing is not conditional on anything.
if len(j.hold) > 0 {
defer s.letRun(ctx, cri, j)
s.holdStill(ctx, cri, j)
}
// A container by this name left exited by the previous run would collide with --name. Removing
// one that is not there is the state we want, so its error is ignored — the same as run-once.
_, _ = s.run(ctx, cri, "rm", "-f", j.container.Name)
@@ -297,51 +260,3 @@ func (s *Scheduler) Run(ctx context.Context) {
}
}
}
// holdStill stops the containers this step runs instead of, in the order it named them.
//
// A stop that fails is said and not fatal. The step runs anyway: for the case this exists for —
// a collector walking storage nothing must be writing to — a writer that would not stop is worth
// knowing about, and refusing to run would mean the work never happens and the log says nothing
// new each night. What must not be skipped is the restart, and it is not: it is deferred.
func (s *Scheduler) holdStill(ctx context.Context, cri string, j *scheduledJob) {
for _, name := range j.hold {
if _, err := s.run(ctx, cri, "stop", name); err != nil {
s.log(fmt.Sprintf("scheduled step %s: could not stop %s for the run: %v", j.id, name, err))
continue
}
s.log(fmt.Sprintf("scheduled step %s: %s held still for the run", j.id, name))
}
}
// letRun starts them again, in the reverse of the order they were stopped, and says so loudly if
// one does not come back.
//
// **Reverse order**, because stopping walks a dependency the other way: a module that holds two
// containers still names the one that depends on the other first, and bringing them back the same
// way would start a dependant before what it depends on.
//
// Given its own context, because this runs in a defer and the one the run used may already be
// cancelled — a host shutting down mid-window would otherwise leave the service stopped, which is
// precisely the outcome this field must never have.
func (s *Scheduler) letRun(_ context.Context, cri string, j *scheduledJob) {
ctx, cancel := context.WithTimeout(context.Background(), closingWindow)
defer cancel()
for i := len(j.hold) - 1; i >= 0; i-- {
name := j.hold[i]
if _, err := s.run(ctx, cri, "start", name); err != nil {
// Said as loudly as this host says anything: a service the mesh stopped for a
// maintenance window and could not start again is down, and nothing else will notice
// until the next apply compares it.
s.log(fmt.Sprintf(
"scheduled step %s: %s was held still for the run and WILL NOT START AGAIN: %v",
j.id, name, err))
continue
}
s.log(fmt.Sprintf("scheduled step %s: %s running again", j.id, name))
}
}
// closingWindow is how long the host will spend putting back what it stopped. Generous: this is
// the half that must not be given up on.
const closingWindow = 5 * time.Minute
+1 -1
View File
@@ -208,7 +208,7 @@ func TestAScheduledStepDoesNotGateWhatFollows(t *testing.T) {
switch args[0] {
case "info":
return "27.0\n", nil
case "container":
case "inspect":
// The container name is the last argument to `docker inspect --format ... <name>`.
target := args[len(args)-1]
if spec, up := specs[target]; up {
-191
View File
@@ -1,191 +0,0 @@
package apply
import (
"context"
"fmt"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq ADR 0117: a service that omits its state leaves the unit's lifecycle to the
// machine. The mesh reflects its triggers on a unit already running and does nothing else to it —
// never starts, stops, enables or disables it, and forgets it when undeclared.
// unitIn is a service manager whose one unit is active or not, recording what it is asked.
func unitIn(active bool, commands *[]string) Runner {
return func(_ context.Context, name string, args ...string) (string, error) {
line := name + " " + strings.Join(args, " ")
*commands = append(*commands, line)
switch {
case strings.Contains(line, "is-enabled"):
return "enabled", nil
case strings.Contains(line, "show") && strings.Contains(line, "ActiveState"):
if active {
return "LoadState=loaded\nActiveState=active\nSubState=running", nil
}
return "LoadState=loaded\nActiveState=inactive\nSubState=dead", nil
}
return "", nil
}
}
func statelessDecl(path, content, triggers string) string {
return fmt.Sprintf(`{"declaration":1,"resources":[
{"id":"uplink.conf","type":"file","path":%q,"into":"block","content":%q},
{"id":"uplink.manager","type":"service","unit":"NetworkManager.service",%s}
]}`, path, content, triggers)
}
// touched is whether any command would change the unit's lifecycle.
func touched(commands []string) []string {
var changing []string
for _, c := range commands {
for _, verb := range []string{" start ", " stop ", " restart ", " enable ", " disable ", " reload "} {
if strings.Contains(c+" ", verb) {
changing = append(changing, c)
}
}
}
return changing
}
func TestAStatelessServiceRunningIsReloadedForItsTrigger(t *testing.T) {
path := filepath.Join(t.TempDir(), "mesh.conf")
var commands []string
report, _, err := Apply(context.Background(), archHost(t),
parse(t, statelessDecl(path, "[main]\ndns=none\n", `"reload-on":["uplink.conf"]`)),
store.State{}, store.OriginDeclared, unitIn(true, &commands), nil, nil)
if err != nil {
t.Fatal(err)
}
joined := strings.Join(commands, "\n")
if !strings.Contains(joined, "systemctl reload NetworkManager.service") {
t.Errorf("the running manager was not reloaded; commands were %v", commands)
}
for _, c := range touched(commands) {
if !strings.Contains(c, "reload") {
t.Errorf("the manager's lifecycle was touched: %s", c)
}
}
if o := outcomeOf(report, "uplink.manager"); o.Action != "updated" || !strings.Contains(o.Detail, "reloaded for uplink.conf") {
t.Errorf("reported as %q: %s", o.Action, o.Detail)
}
}
func TestAStatelessServiceNotRunningIsLeftSo(t *testing.T) {
path := filepath.Join(t.TempDir(), "mesh.conf")
for _, triggers := range []string{`"reload-on":["uplink.conf"]`, `"restart-on":["uplink.conf"]`} {
var commands []string
report, _, err := Apply(context.Background(), archHost(t),
parse(t, statelessDecl(path, fmt.Sprintf("# %s\n", triggers), triggers)),
store.State{}, store.OriginDeclared, unitIn(false, &commands), nil, nil)
if err != nil {
t.Fatal(err)
}
if changing := touched(commands); len(changing) > 0 {
t.Errorf("%s: an inactive unit was acted on: %v", triggers, changing)
}
if o := outcomeOf(report, "uplink.manager"); o.Action != "unchanged" ||
o.Detail != "not running; the change applies at its next start" {
t.Errorf("%s: reported as %q: %s", triggers, o.Action, o.Detail)
}
}
}
func TestAStatelessServiceIsRestartedOnlyForItsRestartTrigger(t *testing.T) {
path := filepath.Join(t.TempDir(), "mesh.conf")
var commands []string
d := parse(t, statelessDecl(path, "x\n", `"restart-on":["uplink.conf"]`))
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared,
unitIn(true, &commands), nil, nil)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(commands, "\n"), "stop NetworkManager.service") {
t.Errorf("not restarted for its restart trigger; commands were %v", commands)
}
// Nothing it reflects changed: nothing is asked of the unit at all.
commands = nil
report, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared,
unitIn(true, &commands), nil, nil)
if err != nil {
t.Fatal(err)
}
if changing := touched(commands); len(changing) > 0 {
t.Errorf("with nothing changed the unit was acted on: %v", changing)
}
if got := outcomeOf(report, "uplink.manager").Action; got != "unchanged" {
t.Errorf("with nothing changed it was %q", got)
}
}
func TestAStatelessServiceUndeclaredIsForgottenNotStopped(t *testing.T) {
path := filepath.Join(t.TempDir(), "mesh.conf")
var commands []string
_, state, err := Apply(context.Background(), archHost(t),
parse(t, statelessDecl(path, "x\n", `"reload-on":["uplink.conf"]`)),
store.State{}, store.OriginDeclared, unitIn(true, &commands), nil, nil)
if err != nil {
t.Fatal(err)
}
if rec, _ := state.Find("uplink.manager"); !rec.Stateless {
t.Fatal("the record does not say the service was stateless")
}
if steps := Plan(somethingElse(t), state, store.OriginDeclared); !hasStep(steps, "forget", "uplink.manager") {
t.Errorf("the preview does not forget it: %v", steps)
}
commands = nil
report, _, err := Apply(context.Background(), archHost(t), somethingElse(t), state, store.OriginDeclared,
unitIn(true, &commands), nil, nil)
if err != nil {
t.Fatal(err)
}
if changing := touched(commands); len(changing) > 0 {
t.Errorf("undeclaring acted on the unit: %v", changing)
}
o := outcomeOf(report, "uplink.manager")
if o.Action != "forgotten" || o.Detail != "its state was never the mesh's" {
t.Errorf("undeclaring was %q: %s", o.Action, o.Detail)
}
}
func TestAStatelessServiceIsNeverHeldOnAnAdoptedNode(t *testing.T) {
path := filepath.Join(t.TempDir(), "mesh.conf")
resources := fmt.Sprintf(`{"id":"uplink.conf","type":"file","path":%q,"into":"block","content":"x\n"},
{"id":"uplink.manager","type":"service","unit":"NetworkManager.service","reload-on":["uplink.conf"]}`, path)
d := adopted(t, `{"taken":[],"untaken":{"uplink":["uplink.conf","uplink.manager"]}}`, resources)
for _, s := range Plan(d, store.State{}, store.OriginDeclared) {
if s.ID == "uplink.manager" && (s.Verb == "hold" || s.Verb == "create" || strings.Contains(s.Why, "replaces")) {
t.Errorf("the preview holds or replaces a stateless service: %+v", s)
}
}
var commands []string
report, state, err := ApplyKeeping(context.Background(), archHost(t), d, store.State{},
store.OriginDeclared, unitIn(true, &commands), nil, nil, KeepIn(t.TempDir()))
if err != nil {
t.Fatal(err)
}
if got := outcomeOf(report, "uplink.manager").Action; got == "held" {
t.Fatal("a stateless service was held, though it replaces nothing that was found")
}
if len(state.Held) != 0 {
t.Errorf("something was held: %+v", state.Held)
}
for _, c := range touched(commands) {
if !strings.Contains(c, "reload") {
t.Errorf("the adopted node's manager lifecycle was touched: %s", c)
}
}
}
func hasStep(steps []Step, verb, id string) bool {
for _, s := range steps {
if s.Verb == verb && s.ID == id {
return true
}
}
return false
}
-100
View File
@@ -1,100 +0,0 @@
package apply
import (
"context"
"os"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// A daemon that reads a configuration it refuses dies a fraction of a second after the service
// manager has reported it started — fail2ban took 221 milliseconds on the control node the day this
// was written. One read back catches nothing: the unit is active at that instant. The mesh reported
// the service "restarted" while every ban on two public machines was gone, and every check passed
// (novox/hq ADR 0184). The host looks again, after the moment in which that happens.
func TestAServiceThatDiesJustAfterItsRestartIsNotReportedRestarted(t *testing.T) {
serviceSettle = 0
// Alive at the first look after starting, dead at the second — the shape of a daemon that
// refuses what it was just given.
started, looks := false, 0
run := func(ctx context.Context, name string, args ...string) (string, error) {
if args[0] == "show" {
state := "active"
if started {
if looks++; looks >= 2 {
state = "failed"
}
}
return "LoadState=loaded\nActiveState=" + state + "\n", nil
}
if args[0] == "start" {
started = true
}
return "", nil
}
dir := t.TempDir()
d := parse(t, `{"declaration":1,"resources":[
{"id":"conf","type":"file","path":"`+dir+`/jail.conf","content":"[sshd]\n","mode":"0644"},
{"id":"run","type":"service","unit":"fail2ban.service","state":"running","restart-on":["conf"]}
]}`)
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
if err == nil {
t.Fatal("a service that died just after being restarted was reported as restarted")
}
if !strings.Contains(err.Error(), "fail2ban.service") || !strings.Contains(err.Error(), "is stopped") {
t.Errorf("the failure does not name the unit and what it is now: %v", err)
}
if looks < 2 {
t.Errorf("the host looked at the unit %d time(s) after starting it; it must look again", looks)
}
}
// A unit still coming up reads as running at both looks and is accepted: the second look is for a
// unit that WAS running and is not any more, never a wait for a slow one to finish starting.
func TestAUnitStillStartingIsNotAFailure(t *testing.T) {
serviceSettle = 0
run := func(ctx context.Context, name string, args ...string) (string, error) {
if args[0] == "show" {
return "LoadState=loaded\nActiveState=activating\n", nil
}
return "", nil
}
d := parse(t, `{"declaration":1,"resources":[
{"id":"s","type":"service","unit":"slow.service","state":"running"}
]}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil); err != nil {
t.Fatalf("a unit still starting was reported as a failure: %v", err)
}
}
// And a service the declaration asks to be stopped is not waited on at all.
func TestAServiceAskedToStopIsNotWaitedOn(t *testing.T) {
serviceSettle = 0
shows := 0
run := func(ctx context.Context, name string, args ...string) (string, error) {
if args[0] == "show" {
shows++
if shows == 1 {
return "LoadState=loaded\nActiveState=active\n", nil
}
return "LoadState=loaded\nActiveState=inactive\n", nil
}
return "", nil
}
d := parse(t, `{"declaration":1,"resources":[
{"id":"s","type":"service","unit":"off.service","state":"stopped"}
]}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil); err != nil {
t.Fatalf("stopping a service was reported as a failure: %v", err)
}
}
// The settle between a unit's two read-backs is a real pause on a machine and nothing in a test:
// no test here drives a service manager that takes time, so paying it would only slow the suite
// (novox/hq ADR 0184).
func TestMain(m *testing.M) {
serviceSettle = 0
os.Exit(m.Run())
}
-54
View File
@@ -1,54 +0,0 @@
package apply
import (
"context"
"sort"
"strings"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// Strays is what runs on the machine that the mesh neither wrote nor holds (novox/hq ADR 0163):
// every container the runtime has that no record names and no hold names. The question nothing
// answered on 2026-09-23, when a renamed resource left its old container running for a day; asked
// on every apply now, and reported, so a thing left behind is seen the day it is left.
//
// Containers only, today. A listener nobody declared is harder to attribute to a thing, and the
// machine's own services are not strays; that account is issue 160's.
func Strays(ctx context.Context, run Runner, known store.State) ([]store.Stray, error) {
cri, err := containerRuntime(ctx, run)
if err != nil {
return nil, nil // a machine with no runtime has no containers to stray
}
out, err := run(ctx, cri, "ps", "-a", "--format", "{{.Names}}\t{{.Image}}\t{{.State}}")
if err != nil {
return nil, err
}
ours := map[string]bool{}
for _, r := range known.Resources {
if declaration.Type(r.Type) == declaration.TypeContainer {
ours[r.Target] = true
}
}
for _, h := range known.Held {
if h.Kind == string(declaration.TypeContainer) {
ours[h.Target] = true
}
}
var strays []store.Stray
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
parts := strings.Split(line, "\t")
name := strings.TrimSpace(parts[0])
if name == "" || ours[name] {
continue
}
detail := ""
if len(parts) > 2 {
detail = strings.TrimSpace(parts[1]) + ", " + strings.TrimSpace(parts[2])
}
strays = append(strays, store.Stray{Kind: string(declaration.TypeContainer), Name: name, Detail: detail})
}
sort.Slice(strays, func(i, j int) bool { return strays[i].Name < strays[j].Name })
return strays, nil
}
-579
View File
@@ -1,579 +0,0 @@
package apply
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
"github.com/novox/mesh-host/internal/tunnel"
)
// The private network takes over the tunnel it found (novox/hq ADR 0105).
//
// The controller says so on the interface's service: `takes-over` names the found interface, the
// unit that raised it and its configuration file. Before the mesh's unit is started, the host keeps
// that file like any held file — the original recorded before anything else happens to it — and
// stops and disables the found unit. Never a flush: `wg set … peer … remove` is never run, the
// file is never written, and the found interface goes down the way its own unit takes it down.
// Then the mesh's interface comes up, with the found key the node took at enrolment, on the found
// port, with the found peers in its list — and a peer of the tunnel cannot tell it changed hands.
//
// Every apply, not once: a found unit somebody starts again would take the port back from the
// mesh's interface, so it is stopped again and said so. That is the one place an adopted node
// undoes something done by hand, and it is because the tunnel is the mesh's now.
//
// **Until the take is proven, and then the found configuration is retired** (novox/hq ADR 0119).
// Keeping it on disk was the caution the take needed: if the mesh's interface does not come up,
// the found unit is started again and the peers never notice. That caution is spent once the
// tunnel is taken — the found unit down and disabled, the mesh's interface up — and a peer has
// handshaken with the mesh's interface. From then on a configuration nothing maintains, one
// command away from raising a second way onto the network, is not a rollback path but a door
// nobody watches. So it is removed from where its unit reads it; its original, kept before
// anything happened to it (ADR 0100), stays kept; and the hold on it ends. A take never proven
// keeps it, and says so — a broken take is visible, not silently retired.
// TakenTunnel is what an apply says about a tunnel it took over, for the node's report.
type TakenTunnel struct {
Interface string
Port int
Range string
Peers int
// State is "not-taken" (the found interface still up, the mesh's not), "taken" (the found one
// down and disabled, the mesh's up with its key) or "down" (the found one down and the mesh's
// not up: the peers reach nothing). Note is what this apply did about it — and, for a taken
// tunnel, whether the take is proven and its found configuration retired (novox/hq ADR 0119).
// Kept is where the found configuration's original is, retired or not.
State string
Note string
Kept string
}
// The states, as the link says them.
const (
NotTaken = "not-taken"
Taken = "taken"
TunnelDown = "down"
)
// takeoverRecheck is how often, and takeoverRechecks how many times, a found interface still up
// after its unit stopped is looked at again before the takeover is refused: `wg-quick down` by a
// person takes a moment. Variables so a test need not wait.
var (
takeoverRecheck = 2 * time.Second
takeoverRechecks = 3
)
// takeOverID is the held record's id for the found configuration: the service's own with a suffix,
// so it is declared for as long as the service is and never mistaken for the service itself.
func takeOverID(svc *declaration.Service) string { return svc.ID + ".takes-over" }
// takeOver keeps the found tunnel's configuration and stops its unit, ahead of the service that
// replaces it. Returned is the hold's outcome, and what was found for the report.
//
// **Nothing is stopped until the mesh's interface is known to be able to replace it** (the record's
// option 2 is exactly this going wrong): the declared configuration must listen on the found port
// at the found address, and the key file it points at must hold the found key. Only then is the
// found unit stopped — and `stopped` says whether this apply did, so a mesh interface that then
// fails to start can have the found unit started again.
func takeOver(ctx context.Context, sys system.System, svc *declaration.Service, d *declaration.Declaration,
known *store.State, run Runner, keep Keep, now time.Time) (out Outcome, facts TakenTunnel, stopped bool, err error) {
t := svc.TakesOver
id := takeOverID(svc)
module, _ := d.Adoption.UntakenModuleOf(svc.ID)
if module == "" {
module = "the private network"
}
facts = TakenTunnel{Interface: t.Interface, State: NotTaken}
// 0. What the found configuration says, before anything: the checks below are against it.
found, ferr := readFoundTunnel(t.Config)
// 1. The configuration, kept like any held file. A synthetic file resource stands for it, so
// the same code keeps its original, digests it and notices it changing.
//
// **Unless it was retired** (novox/hq ADR 0119): the take was proven and the mesh removed
// it, so there is nothing to hold and nothing missing — only where its original is, which
// the retirement recorded. A hold still standing is let go: that is what retiring it meant.
//
// **One that comes back is held again on its FIRST original** — the one kept before anything
// happened to it (ADR 0100), never whatever was put back — and retired again by the first
// apply that finds the take still proven, keeping what came back only if it differs from
// what is already kept. Put back by hand while the private network is assigned, it is not a
// rollback: that means unassigning the private network first, and the note says so.
file := &declaration.File{ID: id, Type: declaration.TypeFile, Path: t.Config}
var held store.Held
retired, wasRetired := known.RetiredAt(t.Config)
cameBack := wasRetired && present(t.Config)
switch {
case wasRetired && !cameBack:
known.Release(id)
held = store.Held{Kept: retired.Kept}
out = begin(file)
out.Action = "unchanged"
facts.Note = "the found configuration " + t.Config + " was retired once the take was proven; " +
"its original is kept at " + retired.Kept + " and the mesh never brings it back"
default:
was, already := known.HeldAt(id)
why := "the configuration of the tunnel " + t.Interface + ", taken over by " + svc.Unit
if cameBack && !already {
digest := retired.Digest
if digest == "" {
if raw, err := os.ReadFile(retired.Kept); err == nil {
digest = digestOf(string(raw))
}
}
was = store.Held{ID: id, Module: module, Kind: string(declaration.TypeFile), Target: t.Config,
Since: now, Why: why, Kept: retired.Kept, Digest: digest}
already = true
}
out, held, err = hold(ctx, sys, file, module, was, already, why, run, keep, now)
if err != nil {
return begin(file), facts, false, fmt.Errorf("keeping the found tunnel's configuration: %w", err)
}
known.RecordHeld(held)
if cameBack {
facts.Note = "the found configuration " + t.Config + " came back after it was retired; while the " +
"private network is assigned the mesh retires it again, so rolling back to the found tunnel " +
"means unassigning the private network first"
}
}
facts.Kept = held.Kept
// What the file says, for the report: from the machine, or from the kept original when the
// machine's copy is gone. The private key stays in the file; nothing here keeps it.
unread := ""
if ferr != nil && held.Kept != "" {
found, ferr = readFoundTunnel(held.Kept)
}
if ferr == nil {
facts.Port, facts.Range, facts.Peers = found.Port, found.Range, len(found.Peers)
} else {
unread = ferr.Error()
}
// 2. Where things stand: the found unit, and the mesh's.
foundState, unitErr := sys.ServiceState(ctx, run, t.Unit)
meshState, _ := sys.ServiceState(ctx, run, svc.Unit)
if foundState == "running" && meshState == "running" {
// Both up. On the hub this cannot last — the found unit cannot bind the port the mesh's
// holds — and on a spoke two interfaces with one key flap between them. Not stopped again
// by the mesh: what is found on an adopted node is reported, and the first takeover was
// the one act (the PR note says why). Said, so a person sees it.
facts.Note = t.Unit + " is running again beside the mesh's interface; not stopped by the mesh — " +
"`systemctl stop " + t.Unit + "` on the machine"
}
// 3. Before the found unit is stopped: can the mesh's interface replace it? Its declared
// configuration must listen on the found port at the found address, and the key file it
// points at must hold the found key, or the peers would be dropped the moment it came up.
if foundState == "running" && meshState != "running" {
if ferr != nil {
return out, facts, false, fmt.Errorf("the found tunnel's configuration at %s cannot be read as a "+
"tunnel's (%v), so nothing says what the mesh's interface must match; %s is left running",
t.Config, ferr, t.Unit)
}
if err := replaces(d, svc, found); err != nil {
return out, facts, false, fmt.Errorf("%w; %s is left running", err, t.Unit)
}
}
// 4. The found unit: stopped if it runs and the mesh's does not, disabled if it starts at
// boot. A unit that is not there is not an error — the interface may have been raised
// another way, which the check below catches — and neither is one already down.
var did []string
switch {
case unitErr != nil:
did = append(did, t.Unit+" is not a unit here")
case foundState == "running" && meshState != "running":
if err := sys.SetServiceState(ctx, run, t.Unit, "stopped"); err != nil {
return out, facts, false, fmt.Errorf("stopping the found %s: %w", t.Unit, err)
}
after, err := sys.ServiceState(ctx, run, t.Unit)
if err != nil {
return out, facts, true, err
}
if after != "stopped" {
return out, facts, true, fmt.Errorf("%s was asked to stop and is %s", t.Unit, after)
}
stopped = true
did = append(did, "stopped "+t.Unit)
}
if unitErr == nil {
if boot, err := sys.ServiceBoot(ctx, run, t.Unit); err == nil && boot == "enabled" {
if err := sys.SetServiceBoot(ctx, run, t.Unit, "disabled"); err != nil {
return out, facts, stopped, fmt.Errorf("disabling the found %s at boot: %w", t.Unit, err)
}
did = append(did, "disabled it at boot")
}
}
// 5. The interface is gone. If it is still up, something other than its unit raised it —
// the predecessor brings its up by hand — and the mesh's interface cannot take its port
// and address while it is. Looked at again for a moment, since a person taking it down
// takes a moment; then refused, naming what to do.
if meshState != "running" {
for try := 0; ; try++ {
if !interfaceUp(ctx, run, t.Interface) {
break
}
if try >= takeoverRechecks {
return out, facts, stopped, fmt.Errorf("%s is still up although its unit %s is not running: it was "+
"raised by hand, not by its unit, and the mesh's interface cannot take its port and "+
"address while it is. On the machine: `wg-quick down %s` — the next reconcile takes it "+
"over. Nothing was flushed", t.Interface, t.Unit, t.Interface)
}
select {
case <-ctx.Done():
return out, facts, stopped, ctx.Err()
case <-time.After(takeoverRecheck):
}
}
}
out.Detail = "the tunnel " + t.Interface + "'s configuration, kept as found"
switch {
case cameBack:
out.Detail = "the tunnel " + t.Interface + "'s configuration, back after it was retired; held until " +
"it is retired again"
case wasRetired:
out.Detail = "the tunnel " + t.Interface + "'s configuration, retired once the take was proven"
}
if held.Kept != "" {
out.Detail += " (original at " + held.Kept + ")"
}
if len(did) > 0 {
out.Detail += "; " + strings.Join(did, ", ") + " — never flushed"
}
if held.Changed != "" {
out.Detail += "; " + held.Changed + " by something other than the mesh since it was found"
}
if unread != "" {
// Said, not swallowed: the report would otherwise say a tunnel with no port and no
// peers was carried, which reads as a tunnel that was not one.
out.Detail += "; what it says could not be read as a tunnel's: " + unread
}
return out, facts, stopped, nil
}
// readFoundTunnel is the found configuration as a tunnel.
func readFoundTunnel(path string) (tunnel.Found, error) {
raw, err := os.ReadFile(path)
if err != nil {
return tunnel.Found{}, err
}
return tunnel.Parse(raw)
}
// interfaceUp is whether a WireGuard interface is up on the machine.
func interfaceUp(ctx context.Context, run Runner, iface string) bool {
up, err := run(ctx, "wg", "show", "interfaces")
if err != nil {
return false
}
for _, name := range strings.Fields(up) {
if name == iface {
return true
}
}
return false
}
// replaces holds the mesh's declared interface configuration against the found tunnel it is to
// replace: same port, same address, and a key file holding the found key. The configuration is
// the file the service restarts on; its `PostUp = wg set %i private-key <path>` names the key.
func replaces(d *declaration.Declaration, svc *declaration.Service, found tunnel.Found) error {
var conf *declaration.File
for _, r := range d.Resources {
f, ok := r.(*declaration.File)
if !ok {
continue
}
for _, id := range svc.RestartOn {
if f.ID == id {
conf = f
}
}
}
if conf == nil {
return fmt.Errorf("%s takes over %s and restarts on no declared file, so the interface it would "+
"raise cannot be checked against the found one", svc.Unit, found.Interface)
}
port, address, keyPath := "", "", ""
for _, line := range strings.Split(conf.Content, "\n") {
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok {
continue
}
key, value = strings.ToLower(strings.TrimSpace(key)), strings.TrimSpace(value)
switch key {
case "listenport":
port = value
case "address":
address = strings.TrimSpace(strings.Split(value, ",")[0])
case "postup":
if _, after, ok := strings.Cut(value, "private-key "); ok {
keyPath = strings.Fields(after)[0]
}
}
}
var wrong []string
if port != fmt.Sprint(found.Port) {
wrong = append(wrong, fmt.Sprintf("it listens on port %q and the tunnel on %d", port, found.Port))
}
if host(address) != host(found.Address) {
wrong = append(wrong, fmt.Sprintf("its address is %q and the tunnel's %s", address, found.Address))
}
switch raw, err := os.ReadFile(keyPath); {
case keyPath == "":
wrong = append(wrong, "it names no key file")
case err != nil:
wrong = append(wrong, fmt.Sprintf("its key file %s cannot be read (%v)", keyPath, err))
default:
public, perr := tunnel.PublicKeyOf(strings.TrimSpace(string(raw)))
if perr != nil || public != found.PublicKey {
wrong = append(wrong, fmt.Sprintf("the key at %s is not the tunnel's — `mesh-host overlay take "+
"--tunnel %s` on this machine takes it, then push again", keyPath, found.Interface))
}
}
if len(wrong) > 0 {
return fmt.Errorf("the mesh's interface would not replace the tunnel on %s: %s — the peers would be "+
"dropped the moment it came up. Re-place the hub on the tunnel's address and port and push again",
found.Interface, strings.Join(wrong, "; "))
}
return nil
}
// host is an address without its prefix length.
func host(address string) string {
if i := strings.Index(address, "/"); i >= 0 {
return address[:i]
}
return address
}
// tunnelState is where the tunnel stands, read from the machine: the found unit or interface up
// and the mesh's not is not taken; the mesh's up and the found one down is taken; neither up is
// down — the peers reach nothing.
func tunnelState(ctx context.Context, sys system.System, foundUnit, meshUnit string, run Runner) string {
foundState, _ := sys.ServiceState(ctx, run, foundUnit)
meshState, _ := sys.ServiceState(ctx, run, meshUnit)
foundUp := foundState == "running" || interfaceUp(ctx, run, strings.TrimPrefix(foundUnit, "wg-quick@"))
switch {
case meshState == "running" && !foundUp:
return Taken
case meshState == "running":
// Both up: not a takeover that holds, and said as not taken so nobody reads it as one.
return NotTaken
case foundUp:
return NotTaken
default:
return TunnelDown
}
}
// restoreFound starts the found unit again after the mesh's interface failed to replace it, so the
// machine has the tunnel it had rather than none, and says so in the account.
func restoreFound(ctx context.Context, sys system.System, unit string, run Runner, facts *TakenTunnel) {
if err := sys.SetServiceState(ctx, run, unit, "running"); err != nil {
facts.State = TunnelDown
facts.Note += "; " + unit + " could not be started again (" + err.Error() + ") — on the machine: systemctl start " + unit
return
}
if state, err := sys.ServiceState(ctx, run, unit); err != nil || state != "running" {
facts.State = TunnelDown
facts.Note += "; " + unit + " was started again and is not running — on the machine: systemctl start " + unit
return
}
facts.State = NotTaken
facts.Note += "; " + unit + " was started again, so the machine has the tunnel it had"
}
// wireguardDir is where a found tunnel's configuration may be retired from: wg-quick's own, and
// nowhere else. A variable so a test can hand in a directory.
var wireguardDir = tunnel.ConfigDir
// retireFound removes the found tunnel's configuration from where its unit reads it, once the take
// is proven, and ends the hold on it (novox/hq ADR 0119). Asked after the mesh's service applied
// and the tunnel reads as taken; retired says whether this apply retired it, and out is then what
// replaces the take's outcome for the configuration.
//
// **Proven is taken and a handshake.** Taken alone — the found unit down and disabled, the mesh's
// interface up — says the mesh's interface exists, not that any peer reaches it: an interface up
// with the wrong key is taken and carries nothing. A peer that has completed a handshake with it
// has checked its key, so that is the proof, asked of the kernel through `wg`. Any handshake counts,
// however old: a change to the mesh's configuration restarts its unit, which recreates the
// interface and resets its counters, so a time that is there at all was made by this interface.
// Anything short of one — no peer yet, every time zero, `wg` missing or failing — keeps the file,
// and the account says which: a take that never proves itself is visible rather than silently
// retired.
//
// **Only what the take names, and only wg-quick's own file.** Nothing is removed unless the path
// is exactly `<wireguard dir>/<found interface>.conf`, is not a path the mesh itself writes, and is
// a file rather than a link: removing a link would leave the key-bearing file it points at where it
// is, a retirement in name only, so that one is said and left to a person.
//
// **The original must still be kept.** It is the record of what the predecessor was and a
// person's only way back (ADR 0100); a kept copy that has gone missing is said, and the file is
// not removed, since removing it then would lose the only copy. What is on disk now, if it differs
// from the first original and from what was kept at the last retirement, is kept too before it
// goes — by content, so the first original is never overwritten and a file that keeps coming back
// the same keeps nothing more.
//
// The found unit is left disabled; without its configuration it cannot raise the interface, so
// every later apply's check of it finds nothing to do. Nothing here ever writes the file back.
func retireFound(ctx context.Context, svc *declaration.Service, d *declaration.Declaration, known *store.State,
run Runner, keep Keep, facts *TakenTunnel, now time.Time) (out Outcome, retired bool) {
t := svc.TakesOver
id := takeOverID(svc)
if facts.State != Taken {
return out, false
}
held, isHeld := known.HeldAt(id)
if !isHeld {
// Retired already (takeOver let any hold go and said so), or never held: nothing to do.
return out, false
}
say := func(note string) {
if facts.Note != "" {
facts.Note += "; "
}
facts.Note += note
}
notRetired := func(why string) (Outcome, bool) {
say("the found configuration " + t.Config + " is not retired: " + why)
return Outcome{}, false
}
mesh := strings.TrimPrefix(svc.Unit, "wg-quick@")
peers, err := tunnel.Handshaken(ctx, tunnel.Runner(run), mesh)
if err != nil {
say("taken, not yet proven: " + err.Error() + "; the found configuration " + t.Config + " is kept")
return out, false
}
if peers == 0 {
say("taken, not yet proven: no peer has handshaken on " + mesh + "; the found configuration " +
t.Config + " is kept")
return out, false
}
proven := fmt.Sprintf("proven: %d peer(s) handshaken on %s", peers, mesh)
say(proven)
// What may be removed at all.
if want := filepath.Join(wireguardDir, t.Interface+".conf"); t.Config != want {
return notRetired("only " + want + ", the found interface's own wg-quick configuration, is ever " +
"retired by the mesh, and the take names " + t.Config)
}
if known.Recorded(string(declaration.TypeFile), t.Config) || declaresFile(d, t.Config) {
return notRetired("it is a path the mesh itself writes")
}
if info, err := os.Lstat(t.Config); err == nil && info.Mode()&os.ModeSymlink != 0 {
target, _ := os.Readlink(t.Config)
return notRetired("it is a link to " + target + "; removing the link would leave the key-bearing file " +
"it points at, so it must be retired by hand — both are kept")
}
// The kept original, read back — not just named in a record.
if held.Kept == "" {
return notRetired("no original of it was kept, so removing it would leave no record of what the " +
"predecessor was")
}
original, err := os.ReadFile(held.Kept)
if err != nil || (held.Digest != "" && digestOf(string(original)) != held.Digest) {
why := "is missing"
if err == nil {
why = "no longer holds what was found"
} else if !errors.Is(err, os.ErrNotExist) {
why = "cannot be read (" + err.Error() + ")"
}
return notRetired("its kept original " + held.Kept + " " + why + ", so removing it would lose the only copy")
}
before, cameBack := known.RetiredAt(t.Config)
record := store.Retired{ID: id, Path: t.Config, Kept: held.Kept, Digest: digestOf(string(original)), At: now}
if cameBack {
// The first original stays the record's, and so does what the last retirement kept.
record.Extra, record.ExtraDigest, record.Again = before.Extra, before.ExtraDigest, before.Again+1
}
newCopy := ""
gone := !present(t.Config)
if !gone {
current, err := os.ReadFile(t.Config)
if err != nil {
return notRetired("it cannot be read (" + err.Error() + ")")
}
if sum := digestOf(string(current)); sum != record.Digest && sum != record.ExtraDigest {
if keep == nil {
return notRetired("it holds something other than its kept original and this host has nowhere " +
"to keep it")
}
where, err := keep(t.Config, current, 0o600)
if err != nil {
return notRetired("keeping what it holds now failed (" + err.Error() + ")")
}
record.Extra, record.ExtraDigest, newCopy = where, sum, where
}
if err := os.Remove(t.Config); err != nil && !errors.Is(err, os.ErrNotExist) {
return notRetired("removing it failed (" + err.Error() + ")")
}
if present(t.Config) {
return notRetired("it is still there after it was removed")
}
}
known.RecordRetired(record)
known.Release(id)
facts.Kept = held.Kept
copied := ""
if newCopy != "" {
copied = "; what it held, which differed from the original, is kept at " + newCopy
}
out = Outcome{ID: id, Type: string(declaration.TypeFile), Target: t.Config, Action: "removed"}
switch {
case cameBack:
say("the found configuration came back and was retired again — its original still kept at " +
held.Kept + copied + "; rolling back to the found tunnel means unassigning the private network first")
out.Detail = "the found configuration came back and was retired again; original kept at " + held.Kept + copied
default:
say("the found configuration " + t.Config + " is retired — its original kept at " + held.Kept + copied +
", " + t.Unit + " left disabled, and the mesh never brings it back")
out.Detail = "retired: the take of " + t.Interface + " is " + proven + "; original kept at " + held.Kept + copied
}
if gone {
// Already gone — removed by something other than the mesh, or by an apply whose record was
// never saved. Nothing removed here; the hold ends all the same.
out.Action = "unchanged"
out.Detail = "retired: the take of " + t.Interface + " is " + proven + " and " + t.Config +
" was already gone; original kept at " + held.Kept
}
return out, true
}
// declaresFile is whether a declaration writes a file at a path.
func declaresFile(d *declaration.Declaration, path string) bool {
for _, r := range d.Resources {
if f, ok := r.(*declaration.File); ok && filepath.Clean(f.Path) == filepath.Clean(path) {
return true
}
}
return false
}
// takesOver is the service in a declaration that takes over a tunnel, if any: one per node, since
// a machine has one private network.
func takesOver(d *declaration.Declaration) *declaration.Service {
for _, r := range d.Resources {
if svc, ok := r.(*declaration.Service); ok && svc.TakesOver != nil {
return svc
}
}
return nil
}
// errNotAdopted is a takeover on a declaration that does not say the node is adopted, which the
// parser refuses already; kept as a second line of defence at the point of acting.
var errNotAdopted = errors.New("a tunnel is taken over on an adopted node only")
-715
View File
@@ -1,715 +0,0 @@
package apply
import (
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// novox/hq ADR 0105: the host raises the mesh's interface with the found key and peers, stops the
// found interface without flushing it, and keeps its configuration — and stops nothing until the
// mesh's interface is known to be able to replace it.
// foundKey is the predecessor's private key, a real one made once per run: the key is what the
// takeover must never print or copy, so it had better be one.
var foundKey = func() string {
k, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(k.Bytes())
}()
var foundConf = "[Interface]\nPrivateKey = " + foundKey + "\n" +
"ListenPort = 51900\nAddress = 192.0.2.1/24\n\n[Peer]\nPublicKey = PEER-A=\nAllowedIPs = 192.0.2.2/32\n" +
"\n[Peer]\nPublicKey = PEER-B=\nAllowedIPs = 192.0.2.3/32\n"
// aTakeover is the private network's declaration for an adopted hub whose interface takes over
// the found tunnel: the mesh's configuration — on the found port and address, its key set from the
// node's own key file, the found peers in its list — and the interface's service naming what it
// replaces. Port and address are parameters so a test can declare a wrong one.
func aTakeover(t *testing.T, config, mesh, keyFile, port, address string) *declaration.Declaration {
t.Helper()
return adopted(t,
`{"taken":[],"untaken":{"mesh-wireguard":["mesh-wireguard.overlay-config","mesh-wireguard.overlay-up"]}}`,
`{"id":"mesh-wireguard.overlay-config","type":"file","path":"`+mesh+`","mode":"0600",
"content":"[Interface]\nAddress = `+address+`/32\nListenPort = `+port+`\nPostUp = wg set %i private-key `+keyFile+`\n\n[Peer]\nPublicKey = PEER-A=\nAllowedIPs = 192.0.2.2/32\n"},
{"id":"mesh-wireguard.overlay-up","type":"service","unit":"wg-quick@mesh0","state":"running","boot":"enabled",
"restart-on":["mesh-wireguard.overlay-config"],
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"`+config+`"}}`)
}
// aHubInUse is a machine with the predecessor's tunnel up and the mesh's not yet: the found
// configuration on disk, and the node's key file holding the found key, as enrolment left it.
func aHubInUse(t *testing.T) (dir, config, mesh, keyFile string, m *machine) {
t.Helper()
dir = t.TempDir()
config = filepath.Join(dir, "wg0.conf")
mesh = filepath.Join(dir, "mesh0.conf")
keyFile = filepath.Join(dir, "overlay.key")
if err := os.WriteFile(config, []byte(foundConf), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keyFile, []byte(foundKey+"\n"), 0o600); err != nil {
t.Fatal(err)
}
m = &machine{containers: map[string]*fakeContainer{}, units: map[string]*fakeUnit{
"wg-quick@wg0": {active: "active", enabled: "enabled"},
"wg-quick@mesh0": {active: "inactive", enabled: "disabled", fragment: "/usr/lib/systemd/system/wg-quick@.service"},
}}
takeoverRecheck = 0
// The found configuration lives in this test's own wireguard directory (novox/hq ADR 0119).
was := wireguardDir
wireguardDir = dir
t.Cleanup(func() { wireguardDir = was })
return dir, config, mesh, keyFile, m
}
func TestTheFoundTunnelIsStoppedNeverFlushedAndItsConfigurationKept(t *testing.T) {
dir, config, mesh, keyFile, m := aHubInUse(t)
report, state := applyAdopted(t, aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1"), store.State{}, m, dir)
// The found interface: its unit stopped and disabled, and nothing else done to it.
if u := m.units["wg-quick@wg0"]; u.active != "inactive" || u.enabled != "disabled" {
t.Fatalf("the found unit was not stopped and disabled: %+v", u)
}
for _, asked := range m.asked {
// Only ever asked about: which interfaces are up, and whether a peer has handshaken with
// the mesh's own (novox/hq ADR 0119).
if strings.HasPrefix(asked, "wg ") && !strings.HasPrefix(asked, "wg show interfaces") &&
asked != "wg show mesh0 latest-handshakes" {
t.Errorf("the found interface was touched with %q; it is stopped, never flushed", asked)
}
if strings.HasPrefix(asked, "wg-quick") || strings.Contains(asked, "peer remove") {
t.Errorf("the found interface was flushed: %q", asked)
}
}
// Its configuration: on disk as it was, its original kept, held for the module.
if got, _ := os.ReadFile(config); string(got) != foundConf {
t.Fatalf("the found configuration was changed:\n%s", got)
}
held, ok := state.HeldAt("mesh-wireguard.overlay-up.takes-over")
if !ok || held.Kind != "file" || held.Target != config || held.Kept == "" || held.Module != "mesh-wireguard" {
t.Fatalf("the found configuration is not held: %+v", held)
}
if kept, _ := os.ReadFile(held.Kept); string(kept) != foundConf {
t.Fatalf("the original was not kept as found: %q", kept)
}
// The mesh's interface: up, enabled, with the found peers in the file the mesh wrote.
if u := m.units["wg-quick@mesh0"]; u.active != "active" || u.enabled != "enabled" {
t.Fatalf("the mesh's interface was not raised: %+v", u)
}
if got, _ := os.ReadFile(mesh); !strings.Contains(string(got), "PEER-A=") || strings.Contains(string(got), "PrivateKey") {
t.Fatalf("the mesh's configuration does not carry the found peer, or carries a key:\n%s", got)
}
// And the report says so, with what was found — port, range, peers — and never the key.
if report.Tunnel == nil || report.Tunnel.State != Taken || report.Tunnel.Port != 51900 ||
report.Tunnel.Range != "192.0.2.0/24" || report.Tunnel.Peers != 2 || report.Tunnel.Kept != held.Kept {
t.Fatalf("the report does not say what was carried: %+v", report.Tunnel)
}
for _, o := range report.Outcomes {
if strings.Contains(o.Detail, foundKey) {
t.Errorf("the found key was printed in an outcome: %+v", o)
}
}
if strings.Contains(report.Tunnel.Note, foundKey) {
t.Error("the found key was printed in the account")
}
if o := outcomeOf(report, "mesh-wireguard.overlay-up.takes-over"); o.Action != "held" ||
!strings.Contains(o.Detail, "stopped wg-quick@wg0") || !strings.Contains(o.Detail, "never flushed") {
t.Errorf("the takeover was not reported as a hold that stopped the found unit: %+v", o)
}
if _, recorded := state.Find("mesh-wireguard.overlay-up.takes-over"); recorded {
t.Error("the found configuration was recorded as applied, so it would be removed as an orphan")
}
}
func TestNothingIsStoppedUntilTheMeshsInterfaceCanReplaceTheFoundOne(t *testing.T) {
dir, config, mesh, keyFile, m := aHubInUse(t)
otherKey := filepath.Join(dir, "other.key")
k, _ := ecdh.X25519().GenerateKey(rand.Reader)
if err := os.WriteFile(otherKey, []byte(base64.StdEncoding.EncodeToString(k.Bytes())+"\n"), 0o600); err != nil {
t.Fatal(err)
}
cases := map[string]*declaration.Declaration{
"another port": aTakeover(t, config, mesh, keyFile, "51821", "192.0.2.1"),
"another address": aTakeover(t, config, mesh, keyFile, "51900", "10.42.0.1"),
"another key": aTakeover(t, config, mesh, otherKey, "51900", "192.0.2.1"),
"no key file": aTakeover(t, config, mesh, filepath.Join(dir, "missing.key"), "51900", "192.0.2.1"),
}
for name, d := range cases {
m.asked = nil
report, state, err := ApplyKeeping(t.Context(), archHost(t), d, store.State{},
store.OriginDeclared, m.run, nil, nil, KeepIn(dir))
if err == nil || !strings.Contains(err.Error(), "would not replace the tunnel") {
t.Fatalf("%s: the takeover was not refused: %v", name, err)
}
if name == "another key" && !strings.Contains(err.Error(), "overlay take") {
t.Errorf("%s: the refusal does not name the remedy: %v", name, err)
}
if m.units["wg-quick@wg0"].active != "active" || m.did("systemctl stop wg-quick@wg0") {
t.Fatalf("%s: the found unit was stopped although the mesh's interface could not replace it", name)
}
if m.units["wg-quick@mesh0"].active == "active" {
t.Fatalf("%s: the mesh's interface was started on top of the found one", name)
}
if report.Tunnel == nil || report.Tunnel.State != NotTaken || !strings.Contains(report.Tunnel.Note, "would not replace") {
t.Fatalf("%s: the account does not say the tunnel is not taken and why: %+v", name, report.Tunnel)
}
if _, held := state.HeldAt("mesh-wireguard.overlay-up.takes-over"); !held {
t.Errorf("%s: the found configuration was not kept before the refusal", name)
}
}
}
func TestAMeshInterfaceThatFailsToStartGivesTheFoundOneBack(t *testing.T) {
dir, config, mesh, keyFile, m := aHubInUse(t)
m.units["wg-quick@mesh0"].wontStart = true
report, _, err := ApplyKeeping(t.Context(), archHost(t), aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1"),
store.State{}, store.OriginDeclared, m.run, nil, nil, KeepIn(dir))
if err == nil {
t.Fatal("a mesh interface that did not come up was reported as applied")
}
if !m.did("systemctl stop wg-quick@wg0") || !m.did("systemctl start wg-quick@wg0") {
t.Fatalf("the found unit was not stopped and then started again: %v", m.asked)
}
if m.units["wg-quick@wg0"].active != "active" {
t.Fatal("the machine was left with no tunnel at all")
}
if report.Tunnel == nil || report.Tunnel.State != NotTaken ||
!strings.Contains(report.Tunnel.Note, "did not come up") || !strings.Contains(report.Tunnel.Note, "started again") {
t.Fatalf("the account does not say the mesh's interface failed and the found one was given back: %+v", report.Tunnel)
}
}
func TestATakeoverIsSteadyAndAFoundUnitUpAgainIsSaidNotStopped(t *testing.T) {
dir, config, mesh, keyFile, m := aHubInUse(t)
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
_, state := applyAdopted(t, d, store.State{}, m, dir)
m.asked = nil
report, again := applyAdopted(t, d, state, m, dir)
if report.Changed() {
t.Errorf("a second apply moved the machine: %+v", report.Outcomes)
}
if _, still := again.HeldAt("mesh-wireguard.overlay-up.takes-over"); !still {
t.Error("the hold on the found configuration was forgotten while the service still declares it")
}
if m.did("systemctl stop wg-quick@wg0") {
t.Error("a found unit already down was stopped again")
}
if report.Tunnel == nil || report.Tunnel.State != Taken {
t.Errorf("a steady takeover does not read as taken: %+v", report.Tunnel)
}
// Somebody starts the found unit again beside the mesh's interface. Not stopped by the mesh —
// on the hub it cannot hold the port, on a spoke stopping it would be a fight — but said.
m.units["wg-quick@wg0"].active = "active"
m.asked = nil
report, _ = applyAdopted(t, d, again, m, dir)
if m.did("systemctl stop wg-quick@wg0") {
t.Error("a found unit started again by hand was stopped by the mesh")
}
if report.Tunnel == nil || report.Tunnel.State != NotTaken || !strings.Contains(report.Tunnel.Note, "running again beside") {
t.Errorf("the account does not say the found unit is up again: %+v", report.Tunnel)
}
}
func TestAFoundInterfaceRaisedByHandIsRefusedNamingTheRemedy(t *testing.T) {
dir, config, mesh, keyFile, m := aHubInUse(t)
// The unit is not running, yet the interface is up: the predecessor raised it by hand.
m.units["wg-quick@wg0"].active = "inactive"
m.wgUp = "wg0 mesh0\n"
report, state, err := ApplyKeeping(t.Context(), archHost(t), aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1"),
store.State{}, store.OriginDeclared, m.run, nil, nil, KeepIn(dir))
if err == nil || !strings.Contains(err.Error(), "wg-quick down wg0") || !strings.Contains(err.Error(), "Nothing was flushed") {
t.Fatalf("an interface raised by hand was not refused naming the remedy: %v", err)
}
if m.units["wg-quick@mesh0"].active == "active" {
t.Error("the mesh's interface was started on a port the found one still holds")
}
// Looked at more than once before giving up: a person taking it down takes a moment.
shows := 0
for _, a := range m.asked {
if a == "wg show interfaces" {
shows++
}
}
if shows < takeoverRechecks+1 {
t.Errorf("the interface was looked at %d time(s) before the refusal; a person needs a moment", shows)
}
if report.Tunnel == nil || report.Tunnel.State != NotTaken {
t.Errorf("the account does not say the tunnel is not taken: %+v", report.Tunnel)
}
if _, held := state.HeldAt("mesh-wireguard.overlay-up.takes-over"); !held {
t.Error("the found configuration was not kept before the refusal")
}
}
func TestATakeoverIsRefusedOnAConvergedDeclaration(t *testing.T) {
_, err := declaration.Parse([]byte(`{"declaration":1,"resources":[
{"id":"up","type":"service","unit":"wg-quick@mesh0","state":"running",
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"/etc/wireguard/wg0.conf"}}]}`))
if err == nil || !strings.Contains(err.Error(), "adopted") {
t.Fatalf("a takeover on a converged node was accepted: %v", err)
}
}
// novox/hq ADR 0119: once the take is proven — taken, and a peer handshaken on the mesh's
// interface — the found configuration is removed from where its unit reads it, its original stays
// kept and the hold on it ends. Never before, and never brought back.
const takesOverID = "mesh-wireguard.overlay-up.takes-over"
// handshaken is `wg show mesh0 latest-handshakes` with one of the two peers through.
const handshaken = "PEER-A=\t1790000000\nPEER-B=\t0\n"
func TestAProvenTakeRetiresTheFoundConfiguration(t *testing.T) {
dir, config, mesh, keyFile, m := aHubInUse(t)
m.handshakes = handshaken
report, state := applyAdopted(t, aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1"), store.State{}, m, dir)
if _, err := os.Lstat(config); !os.IsNotExist(err) {
t.Fatalf("a proven take left the found configuration where its unit reads it: %v", err)
}
retired, ok := state.RetiredAt(config)
if !ok || retired.Kept == "" || retired.ID != takesOverID {
t.Fatalf("the retirement was not recorded: %+v", state.Retired)
}
if kept, _ := os.ReadFile(retired.Kept); string(kept) != foundConf {
t.Fatalf("the kept original did not survive the retirement: %q", kept)
}
if _, held := state.HeldAt(takesOverID); held {
t.Error("the hold on the found configuration did not end with its retirement")
}
if u := m.units["wg-quick@wg0"]; u.active != "inactive" || u.enabled != "disabled" {
t.Errorf("the found unit is not left down and disabled: %+v", u)
}
if report.Tunnel == nil || report.Tunnel.State != Taken || report.Tunnel.Kept != retired.Kept ||
!strings.Contains(report.Tunnel.Note, "proven: 1 peer(s) handshaken on mesh0") ||
!strings.Contains(report.Tunnel.Note, "is retired") {
t.Fatalf("the account does not say the take is proven and the configuration retired: %+v", report.Tunnel)
}
if o := outcomeOf(report, takesOverID); o.Action != "removed" || !strings.Contains(o.Detail, "retired") {
t.Errorf("the retirement is not what the apply says it did to the file: %+v", o)
}
// And the account still carries what was found, read from the kept original.
if report.Tunnel.Port != 51900 || report.Tunnel.Peers != 2 {
t.Errorf("the account lost what the tunnel was: %+v", report.Tunnel)
}
}
func TestATakeNotProvenKeepsTheFoundConfigurationAndSaysSo(t *testing.T) {
cases := map[string]struct {
handshakes string
fail error
says string
}{
"no peer at all": {"", nil, "no peer has handshaken on mesh0"},
"every handshake at zero": {"PEER-A=\t0\nPEER-B=\t0\n", nil, "no peer has handshaken on mesh0"},
"wg is not there": {"", errors.New(`exec: "wg": executable file not found in $PATH`), "executable file not found"},
"the answer is nonsense": {"unable to access interface\n", nil, "not a peer and a time"},
}
for name, c := range cases {
dir, config, mesh, keyFile, m := aHubInUse(t)
m.handshakes, m.handshakesFail = c.handshakes, c.fail
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
report, state := applyAdopted(t, d, store.State{}, m, dir)
if got, _ := os.ReadFile(config); string(got) != foundConf {
t.Fatalf("%s: a take not proven lost the found configuration", name)
}
if _, held := state.HeldAt(takesOverID); !held {
t.Errorf("%s: the hold ended although the take is not proven", name)
}
if _, retired := state.RetiredAt(config); retired {
t.Errorf("%s: recorded as retired", name)
}
if report.Tunnel == nil || report.Tunnel.State != Taken ||
!strings.Contains(report.Tunnel.Note, "taken, not yet proven") ||
!strings.Contains(report.Tunnel.Note, c.says) || !strings.Contains(report.Tunnel.Note, "is kept") {
t.Errorf("%s: the account does not say the take is not proven and why: %+v", name, report.Tunnel)
}
// A later apply that finds a peer through retires it: the take itself need not be the one.
m.handshakes, m.handshakesFail = handshaken, nil
_, state = applyAdopted(t, d, state, m, dir)
if _, err := os.Lstat(config); !os.IsNotExist(err) {
t.Errorf("%s: the apply after the take was proven kept the found configuration", name)
}
if _, retired := state.RetiredAt(config); !retired {
t.Errorf("%s: the later retirement was not recorded", name)
}
}
}
func TestAFoundConfigurationWhoseKeptOriginalIsMissingIsNotRetired(t *testing.T) {
dir, config, mesh, keyFile, m := aHubInUse(t)
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
_, state := applyAdopted(t, d, store.State{}, m, dir)
held, _ := state.HeldAt(takesOverID)
if err := os.Remove(held.Kept); err != nil {
t.Fatal(err)
}
m.handshakes = handshaken
report, state := applyAdopted(t, d, state, m, dir)
if got, _ := os.ReadFile(config); string(got) != foundConf {
t.Fatal("the found configuration was removed with no kept original left of it")
}
if _, still := state.HeldAt(takesOverID); !still {
t.Error("the hold ended although nothing was retired")
}
if _, retired := state.RetiredAt(config); retired {
t.Error("recorded as retired")
}
if report.Tunnel == nil || !strings.Contains(report.Tunnel.Note, "is not retired") ||
!strings.Contains(report.Tunnel.Note, held.Kept+" is missing") {
t.Errorf("the account does not say the kept original is missing: %+v", report.Tunnel)
}
}
func TestAFoundConfigurationRewrittenSinceItWasFoundIsKeptAgainBeforeItGoes(t *testing.T) {
dir, config, mesh, keyFile, m := aHubInUse(t)
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
_, state := applyAdopted(t, d, store.State{}, m, dir)
rewritten := foundConf + "\n[Peer]\nPublicKey = PEER-C=\nAllowedIPs = 192.0.2.4/32\n"
if err := os.WriteFile(config, []byte(rewritten), 0o600); err != nil {
t.Fatal(err)
}
m.handshakes = handshaken
_, state = applyAdopted(t, d, state, m, dir)
if _, err := os.Lstat(config); !os.IsNotExist(err) {
t.Fatal("a proven take kept a rewritten configuration")
}
retired, _ := state.RetiredAt(config)
if first, _ := os.ReadFile(retired.Kept); string(first) != foundConf {
t.Errorf("the first original was overwritten: %q", first)
}
kept, _ := filepath.Glob(filepath.Join(dir, "kept", "*-wg0.conf"))
var found bool
for _, k := range kept {
if got, _ := os.ReadFile(k); string(got) == rewritten {
found = true
}
}
if !found {
t.Errorf("what the file held when it was retired was not kept: %v", kept)
}
}
func TestARetiredTakeIsSteadyAndItsFoundUnitFindsNothingToDo(t *testing.T) {
dir, config, mesh, keyFile, m := aHubInUse(t)
m.handshakes = handshaken
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
_, state := applyAdopted(t, d, store.State{}, m, dir)
retired, _ := state.RetiredAt(config)
// wg-quick@wg0 with no configuration: inactive, and disabled — the check of it every apply
// makes must find nothing to do and fail on nothing.
m.asked = nil
report, again := applyAdopted(t, d, state, m, dir)
if report.Changed() {
t.Errorf("an apply after the retirement moved the machine: %+v", report.Outcomes)
}
if m.did("systemctl stop wg-quick@wg0") || m.did("systemctl start wg-quick@wg0") {
t.Errorf("the retired tunnel's unit was acted on: %v", m.asked)
}
if _, err := os.Lstat(config); !os.IsNotExist(err) {
t.Error("the found configuration came back")
}
if _, held := again.HeldAt(takesOverID); held {
t.Error("a retired configuration is held again")
}
if r, ok := again.RetiredAt(config); !ok || r != retired {
t.Errorf("the retirement was not kept as it was: %+v", again.Retired)
}
if o := outcomeOf(report, takesOverID); o.Action != "unchanged" || !strings.Contains(o.Detail, "retired") {
t.Errorf("the retired configuration is not said as retired: %+v", o)
}
if report.Tunnel == nil || report.Tunnel.State != Taken || report.Tunnel.Kept != retired.Kept ||
!strings.Contains(report.Tunnel.Note, "retired") || report.Tunnel.Port != 51900 {
t.Errorf("the account of a retired take does not say so: %+v", report.Tunnel)
}
// Enabled at boot again by a person: disabled again, as any take does, and still no error.
m.units["wg-quick@wg0"].enabled = "enabled"
_, _ = applyAdopted(t, d, again, m, dir)
if m.units["wg-quick@wg0"].enabled != "disabled" {
t.Error("the found unit enabled again by hand was left to start at boot")
}
}
func TestUndeclaringThePrivateNetworkAfterRetirementBringsNothingBack(t *testing.T) {
dir, config, mesh, keyFile, m := aHubInUse(t)
m.handshakes = handshaken
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
_, state := applyAdopted(t, d, store.State{}, m, dir)
// The private network unassigned: only something else is declared.
other := adopted(t, `{"taken":[],"untaken":{}}`,
`{"id":"other.file","type":"file","path":"`+filepath.Join(dir, "other.conf")+`","content":"x\n"}`)
m.asked = nil
_, after := applyAdopted(t, other, state, m, dir)
if _, err := os.Lstat(config); !os.IsNotExist(err) {
t.Fatal("undeclaring the private network brought the found configuration back")
}
if m.did("systemctl start wg-quick@wg0") || m.did("systemctl enable wg-quick@wg0") {
t.Errorf("undeclaring the private network started the found tunnel: %v", m.asked)
}
if _, ok := after.RetiredAt(config); !ok {
t.Error("the retirement was forgotten with the private network")
}
// Assigned again, it finds the configuration retired rather than missing, and raises the
// mesh's interface.
report, _ := applyAdopted(t, d, after, m, dir)
if report.Tunnel == nil || report.Tunnel.State != Taken {
t.Errorf("the private network assigned again did not take the tunnel: %+v", report.Tunnel)
}
if _, err := os.Lstat(config); !os.IsNotExist(err) {
t.Error("assigning the private network again brought the found configuration back")
}
}
func TestAPlanSaysTheFoundConfigurationIsRetiredWhenTheTakeIsProven(t *testing.T) {
dir, config, mesh, keyFile, m := aHubInUse(t)
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
plan := Plan(d, store.State{}, store.OriginDeclared)
report, state := applyAdopted(t, d, store.State{}, m, dir)
if got, want := strings.Join(ids(plan), " "), strings.Join(outcomeIDs(report), " "); got != want {
t.Errorf("the plan said %q and the apply did %q", got, want)
}
var take Step
for _, s := range plan {
if s.ID == takesOverID {
take = s
}
}
if take.Verb != "hold" || take.Target != config || !strings.Contains(take.Why, "retired — removed from "+config) ||
!strings.Contains(take.Why, "handshaking on mesh0") {
t.Errorf("the plan does not say the found configuration is retired once proven: %+v", take)
}
// Held and still declared: never planned as forgotten.
if strings.Contains(verbs(Plan(d, state, store.OriginDeclared)), "forget "+takesOverID) {
t.Error("the plan forgets a hold the apply keeps")
}
m.handshakes = handshaken
_, state = applyAdopted(t, d, state, m, dir)
for _, s := range Plan(d, state, store.OriginDeclared) {
if s.ID == takesOverID && (s.Verb != "check" || !strings.Contains(s.Why, "retired once the take")) {
t.Errorf("a retired configuration is planned as %+v", s)
}
}
}
// keptCopies is every copy kept of the found configuration.
func keptCopies(t *testing.T, dir string) []string {
t.Helper()
kept, err := filepath.Glob(filepath.Join(dir, "kept", "*-wg0.conf"))
if err != nil {
t.Fatal(err)
}
return kept
}
func TestAConfigurationPutBackIsRetiredAgainOnItsFirstOriginalAndSettles(t *testing.T) {
dir, config, mesh, keyFile, m := aHubInUse(t)
m.handshakes = handshaken
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
_, state := applyAdopted(t, d, store.State{}, m, dir)
first, _ := state.RetiredAt(config)
// Put back by hand with the original, while no peer is through yet: held on the first
// original, and the account says what a rollback takes.
write(t, config, foundConf)
m.handshakes = ""
report, state := applyAdopted(t, d, state, m, dir)
if held, ok := state.HeldAt(takesOverID); !ok || held.Kept != first.Kept {
t.Fatalf("what came back is not held on the first original: %+v", held)
}
if report.Tunnel == nil || !strings.Contains(report.Tunnel.Note, "came back after it was retired") ||
!strings.Contains(report.Tunnel.Note, "unassigning the private network first") {
t.Errorf("the account does not say a rollback means unassigning the private network: %+v", report.Tunnel)
}
// Proven: retired again, nothing more kept, said once.
m.handshakes = handshaken
report, state = applyAdopted(t, d, state, m, dir)
again, _ := state.RetiredAt(config)
if _, err := os.Lstat(config); !os.IsNotExist(err) || again.Kept != first.Kept || again.Extra != "" {
t.Fatalf("put back as it was, it was not retired again on the first original: %+v", again)
}
if o := outcomeOf(report, takesOverID); o.Action != "removed" ||
!strings.HasPrefix(o.Detail, "the found configuration came back and was retired again") ||
strings.Contains(o.Detail, "differed") {
t.Errorf("the second retirement is not said as one: %+v", o)
}
if !strings.Contains(report.Tunnel.Note, "unassigning the private network first") {
t.Errorf("the account does not say what a rollback takes: %q", report.Tunnel.Note)
}
if n := len(keptCopies(t, dir)); n != 1 {
t.Errorf("%d copies kept of one content", n)
}
if report, _ := applyAdopted(t, d, state, m, dir); report.Changed() {
t.Errorf("a steady machine moved after the second retirement: %+v", report.Outcomes)
}
// Put back with something else: that is kept beside the first original, which stays the record's.
other := strings.Replace(foundConf, "PEER-B=", "PEER-Z=", 1)
write(t, config, other)
report, state = applyAdopted(t, d, state, m, dir)
third, _ := state.RetiredAt(config)
if third.Kept != first.Kept || third.Extra == "" || third.Extra == first.Kept {
t.Fatalf("other content was not kept apart from the first original: %+v", third)
}
if got, _ := os.ReadFile(third.Extra); string(got) != other {
t.Errorf("the extra copy does not hold what was put back: %q", got)
}
if o := outcomeOf(report, takesOverID); !strings.Contains(o.Detail, third.Extra) ||
!strings.Contains(report.Tunnel.Note, third.Extra) {
t.Errorf("where the extra copy is was not said: %+v / %q", o, report.Tunnel.Note)
}
// And the same other content again: nothing more kept, the record as it was.
write(t, config, other)
report, state = applyAdopted(t, d, state, m, dir)
fourth, _ := state.RetiredAt(config)
if fourth.Kept != first.Kept || fourth.Extra != third.Extra || len(keptCopies(t, dir)) != 2 {
t.Errorf("the same content put back again grew the copies: %+v, %v", fourth, keptCopies(t, dir))
}
if o := outcomeOf(report, takesOverID); strings.Contains(o.Detail, "differed") {
t.Errorf("a copy already kept was said as new: %+v", o)
}
if report, _ := applyAdopted(t, d, state, m, dir); report.Changed() {
t.Errorf("a steady machine moved: %+v", report.Outcomes)
}
}
func TestOnlyWgQuicksOwnConfigurationIsRetired(t *testing.T) {
// Not under the wireguard directory.
dir, config, mesh, keyFile, m := aHubInUse(t)
wireguardDir = filepath.Join(dir, "elsewhere")
m.handshakes = handshaken
report, state := applyAdopted(t, aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1"), store.State{}, m, dir)
if got, _ := os.ReadFile(config); string(got) != foundConf {
t.Fatal("a configuration outside wg-quick's directory was removed")
}
if _, held := state.HeldAt(takesOverID); !held || !strings.Contains(report.Tunnel.Note, "is not retired: only ") {
t.Errorf("the refusal is not said, or the hold ended: %+v", report.Tunnel)
}
// A path the mesh itself writes.
dir, config, mesh, keyFile, m = aHubInUse(t)
m.handshakes = handshaken
known := store.State{}
known.Record(store.Applied{ID: "bundle.wg0", Type: "file", Target: config, Origin: store.OriginCarried})
report, _ = applyAdopted(t, aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1"), known, m, dir)
if got, _ := os.ReadFile(config); string(got) != foundConf {
t.Fatal("a path the mesh writes was retired")
}
if !strings.Contains(report.Tunnel.Note, "a path the mesh itself writes") {
t.Errorf("the refusal is not said: %+v", report.Tunnel)
}
}
func TestAFoundConfigurationThatIsALinkIsKeptAndLeftToAPerson(t *testing.T) {
dir, config, mesh, keyFile, m := aHubInUse(t)
target := filepath.Join(dir, "predecessor", "hub.conf")
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
t.Fatal(err)
}
write(t, target, foundConf)
if err := os.Remove(config); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, config); err != nil {
t.Fatal(err)
}
m.handshakes = handshaken
report, state := applyAdopted(t, aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1"), store.State{}, m, dir)
if _, err := os.Lstat(config); err != nil {
t.Fatal("the link was removed, leaving the key-bearing file it points at")
}
if got, _ := os.ReadFile(target); string(got) != foundConf {
t.Fatal("the file the link points at was touched")
}
held, ok := state.HeldAt(takesOverID)
if !ok {
t.Fatal("the hold ended")
}
if kept, _ := os.ReadFile(held.Kept); string(kept) != foundConf {
t.Errorf("what was kept is not what the link points at: %q", kept)
}
if !strings.Contains(report.Tunnel.Note, "is a link to "+target) || !strings.Contains(report.Tunnel.Note, "by hand") {
t.Errorf("the account does not say the link must be retired by hand: %q", report.Tunnel.Note)
}
}
func TestARetirementWhoseRecordWasNeverSavedIsRecordedByTheNextApply(t *testing.T) {
dir, config, mesh, keyFile, m := aHubInUse(t)
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
_, state := applyAdopted(t, d, store.State{}, m, dir)
held, _ := state.HeldAt(takesOverID)
// An apply removed the file and stopped before its state was saved.
if err := os.Remove(config); err != nil {
t.Fatal(err)
}
m.handshakes = handshaken
report, state := applyAdopted(t, d, state, m, dir)
if r, ok := state.RetiredAt(config); !ok || r.Kept != held.Kept {
t.Fatalf("the retirement was not recorded: %+v", state.Retired)
}
if _, still := state.HeldAt(takesOverID); still {
t.Error("the hold did not end")
}
if o := outcomeOf(report, takesOverID); o.Action != "unchanged" || !strings.Contains(o.Detail, "already gone") {
t.Errorf("a file already gone is not said as such: %+v", o)
}
}
func TestARemovalThatFailsKeepsTheFileAndTheHold(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root removes from a directory it may not write to")
}
dir, _, mesh, keyFile, m := aHubInUse(t)
wg := filepath.Join(dir, "wireguard")
if err := os.MkdirAll(wg, 0o700); err != nil {
t.Fatal(err)
}
config := filepath.Join(wg, "wg0.conf")
write(t, config, foundConf)
wireguardDir = wg
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
_, state := applyAdopted(t, d, store.State{}, m, dir)
if err := os.Chmod(wg, 0o500); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(wg, 0o700) })
m.handshakes = handshaken
report, state := applyAdopted(t, d, state, m, dir)
if got, _ := os.ReadFile(config); string(got) != foundConf {
t.Fatal("the found configuration is gone although it could not be removed")
}
if _, held := state.HeldAt(takesOverID); !held {
t.Error("the hold ended although nothing was retired")
}
if _, retired := state.RetiredAt(config); retired {
t.Error("recorded as retired")
}
if !strings.Contains(report.Tunnel.Note, "removing it failed") || !strings.Contains(report.Tunnel.Note, "permission denied") {
t.Errorf("the failed removal is not said: %q", report.Tunnel.Note)
}
}
-13
View File
@@ -1,13 +0,0 @@
package apply
// ForTests points where the host writes units and unpacks daemons at directories a test owns, and
// waits nothing between its looks at a unit, until the returned function puts them back. For tests
// in other packages that apply a process — the bootstrap's, which checks that what genesis raises is
// what the controller's process takes over (novox/hq issue 223). Nothing outside a test calls it.
func ForTests(units, daemons string) (restore func()) {
wasUnits, wasDaemons, wasSettle, wasHandover := unitDir, daemonRoot, serviceSettle, handoverSettle
unitDir, daemonRoot, serviceSettle, handoverSettle = units, daemons, 0, 0
return func() {
unitDir, daemonRoot, serviceSettle, handoverSettle = wasUnits, wasDaemons, wasSettle, wasHandover
}
}
+1 -49
View File
@@ -376,7 +376,7 @@ func TestAContainerIsGivenItsEnvironmentFiles(t *testing.T) {
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
ran = append(ran, name+" "+strings.Join(args, " "))
if len(args) > 0 && args[0] == "container" {
if len(args) > 0 && args[0] == "inspect" {
return "", fmt.Errorf("no such container")
}
return "", nil
@@ -403,51 +403,3 @@ func TestAContainerIsGivenItsEnvironmentFiles(t *testing.T) {
}
}
}
// A container may name its resolvers and its own address — the shape a module shipping its own
// validating DNS needs: the resolver pinned where its siblings can find it, the siblings pointed
// at it. Both flags take addresses, so both reach the runtime verbatim.
func TestAContainerIsGivenItsResolverAndItsAddress(t *testing.T) {
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
ran = append(ran, name+" "+strings.Join(args, " "))
if len(args) > 0 && args[0] == "container" {
return "", fmt.Errorf("no such container")
}
return "", nil
}
d := declare(t, `{"id":"imap","type":"container","name":"mailu-imap",`+
`"image":"dovecot@sha256:0000000000000000000000000000000000000000000000000000000000000000",`+
`"network":"mailu","dns":["192.168.203.254"],"ip":"192.168.203.7"}`)
_, _, _ = Apply(context.Background(), archHost(t), d, store.State{},
store.OriginDeclared, run, nil, nil)
var started string
for _, line := range ran {
if strings.Contains(line, "run ") {
started = line
}
}
for _, want := range []string{"--dns 192.168.203.254", "--ip 192.168.203.7"} {
if !strings.Contains(started, want) {
t.Errorf("the container was started without %q:\n%s", want, started)
}
}
}
// The resolver and address are part of the spec — a container whose dns or ip moved is a
// different container, or the fields can never reach one that already runs. That is not
// hypothetical: their first deployment compared equal and changed nothing.
func TestAChangedResolverOrAddressIsAChangedContainer(t *testing.T) {
base := &declaration.Container{ID: "c", Name: "x", Image: "a@sha256:00"}
withDns := &declaration.Container{ID: "c", Name: "x", Image: "a@sha256:00", Dns: []string{"192.168.203.254"}}
withIP := &declaration.Container{ID: "c", Name: "x", Image: "a@sha256:00", IP: "192.168.203.254"}
plain := containerSpecReading(base, nil, nil)
if containerSpecReading(withDns, nil, nil) == plain {
t.Error("adding a resolver did not change the spec, so it can never reach a running container")
}
if containerSpecReading(withIP, nil, nil) == plain {
t.Error("adding an address did not change the spec, so it can never reach a running container")
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ func (l *labelled) run(_ context.Context, _ string, args ...string) (string, err
switch args[0] {
case "info":
return "27.0\n", nil
case "container":
case "inspect":
spec, ok := l.spec[args[len(args)-1]]
if !ok {
return "", errors.New("no such container")
+1 -30
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"path/filepath"
"strings"
"time"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/declaration"
@@ -34,16 +33,8 @@ type Runner = apply.Runner
// What it does not do is the host's own lifecycle bookkeeping — recording a known-good version,
// clearing the launcher's start counter. Those are facts about a running `mesh-host`, and this is
// not one.
//
// What it does record is that the bundle was consumed, and in which mode the operator raised
// the machine. `raw` is the exact bytes of what is applied — the bundle as rewritten for this
// machine — and its digest is what `mesh-host reconcile` later holds the carried bundle against,
// by digest: a host built from the carried template does not match it, since genesis rewrote the
// ports, root credentials and adoption; a host built from the lock genesis wrote out does.
// Applying the unrewritten template on a raised node recreated the store and loaded the converged
// filter on an adopted one (novox/hq issue 104).
func ApplyBundle(ctx context.Context, o Options, sys system.System, d *declaration.Declaration,
raw []byte, run Runner, say func(string)) (apply.Report, error) {
run Runner, say func(string)) (apply.Report, error) {
// Refuse a shape this host cannot apply before anything is applied, exactly as `mesh-host`
// does: finding out half way through is the half-configured machine tier 0 exists to prevent.
@@ -51,12 +42,6 @@ func ApplyBundle(ctx context.Context, o Options, sys system.System, d *declarati
return apply.Report{}, err
}
// One apply at a time on this machine: a host already running here applies too.
unlock, err := store.Lock(o.State, func() { say(" waiting another apply holds this node's state") })
if err != nil {
return apply.Report{}, err
}
defer unlock()
known, err := store.Load(o.State)
if err != nil {
return apply.Report{}, err
@@ -70,20 +55,6 @@ func ApplyBundle(ctx context.Context, o Options, sys system.System, d *declarati
func(line string) { say(" " + strings.TrimPrefix(line, " ")) }, refuseSealed,
apply.KeepIn(filepath.Dir(o.State)))
// The bundle is consumed, whichever way the apply went: what is on the machine came from these
// bytes, and the carried ones must not be applied over it. The mode is the operator's word at
// genesis, and only at genesis: the controller records the same and says it in every
// declaration from then on (novox/hq ADR 0100), so a node the mesh has spoken to — this
// installer re-run on it, or finishing a pivot — keeps the mode it has, which may since have
// been flipped.
updated.Genesis = &store.Genesis{Digest: apply.DigestOf(raw), At: time.Now().UTC(), Rewritten: true}
if updated.Mode == "" {
updated.Mode = store.ModeConverged
if o.Adopted {
updated.Mode = store.ModeAdopted
}
}
// Saved whichever way it went, for the reason `mesh-host` gives: what was applied before a
// failure is on the machine either way, and a host that did not record it would believe it
// owns less than it does and leave that behind for ever.
+1 -54
View File
@@ -3,15 +3,12 @@ package bootstrap
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
@@ -116,7 +113,7 @@ func TestTheBundleKeepsTheOriginalOfWhatItWritesOver(t *testing.T) {
t.Fatal(err)
}
o := Options{State: filepath.Join(dir, "state.json")}
report, err := ApplyBundle(context.Background(), o, sys, d, nil, nil, quietly)
report, err := ApplyBundle(context.Background(), o, sys, d, nil, quietly)
if err != nil {
t.Fatal(err)
}
@@ -130,53 +127,3 @@ func TestTheBundleKeepsTheOriginalOfWhatItWritesOver(t *testing.T) {
t.Errorf("the kept original is %q (%v)", got, err)
}
}
// Defends novox/hq issue 104: genesis consumes the bundle, recording the digest of what it applied
// and the mode the operator raised the machine in, so the host's own `reconcile` never applies the
// carried bytes over it.
func TestGenesisConsumesTheBundleAndRecordsTheMode(t *testing.T) {
dir := t.TempDir()
raw := []byte(`{"declaration":1,"resources":[
{"id":"a","type":"file","path":"` + filepath.Join(dir, "a.conf") + `","content":"x\n"}]}`)
d, err := declaration.ParseFileTrusted(raw)
if err != nil {
t.Fatal(err)
}
sys, err := system.For("arch")
if err != nil {
t.Fatal(err)
}
for _, adopted := range []bool{false, true} {
o := Options{State: filepath.Join(dir, fmt.Sprintf("state-%v.json", adopted)), Adopted: adopted}
if _, err := ApplyBundle(context.Background(), o, sys, d, raw, nil, quietly); err != nil {
t.Fatal(err)
}
known, err := store.Load(o.State)
if err != nil {
t.Fatal(err)
}
if known.Genesis == nil || known.Genesis.Digest != apply.DigestOf(raw) || !known.Genesis.Rewritten {
t.Errorf("adopted=%v: genesis did not record the bundle it consumed: %+v", adopted, known.Genesis)
}
want := store.ModeConverged
if adopted {
want = store.ModeAdopted
}
if known.Mode != want {
t.Errorf("adopted=%v: genesis recorded the mode as %q, want %q", adopted, known.Mode, want)
}
}
// Re-run on a node the mesh has spoken to since — and converged — genesis leaves the mode
// alone: it is the operator's word at genesis, and the controller's from then on.
o := Options{State: filepath.Join(dir, "state-flipped.json"), Adopted: true}
if err := store.Save(o.State, store.State{Mode: store.ModeConverged}); err != nil {
t.Fatal(err)
}
if _, err := ApplyBundle(context.Background(), o, sys, d, raw, nil, quietly); err != nil {
t.Fatal(err)
}
if known, _ := store.Load(o.State); known.Mode != store.ModeConverged {
t.Errorf("a genesis re-run set the mode back to %q over the mesh's converged", known.Mode)
}
}
+1 -27
View File
@@ -36,7 +36,6 @@ import (
"time"
"github.com/novox/mesh-host/internal/firewall"
"github.com/novox/mesh-host/internal/tunnel"
)
// Step names one stage. A failure says which one, because "the bootstrap failed" is a sentence
@@ -202,11 +201,6 @@ type Options struct {
// until each module is taken, its firewall stays in force, and the mesh guards its own ports
// in a table that only refuses. Without it, a machine in use is refused.
Adopted bool
// Tunnel names the found tunnel's interface an adopted hub takes over (novox/hq ADR 0105), when
// more than one is up and the machine cannot say which. Empty finds the one that is up. Once
// found, the tunnel's port is the hub's and its range the private network's; --hub-port and
// --overlay-range may agree with it or be left unsaid.
Tunnel string
}
// pivots reports whether this run goes past the foundation.
@@ -317,9 +311,6 @@ type Result struct {
// Filter is the packet filter chosen for when the node converges; an adopted genesis loads
// none, and the flip assigns this one.
Filter string `json:"filter-on-converge,omitempty"`
// Tunnel is the found tunnel an adopted genesis takes over (novox/hq ADR 0105): what was read
// from it, never its key.
Tunnel *tunnel.Found `json:"tunnel,omitempty"`
}
// Run performs the bootstrap, saying what it is doing as it goes.
@@ -403,23 +394,6 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
}
result.Firewall = string(kind)
say(" adopted what is on this machine is kept; its firewall (" + string(kind) + ") stays in force")
// The tunnel the predecessor left, which the private network takes over (novox/hq ADR
// 0105): its port is the hub's and its range is the mesh's from here on, so both are
// settled before the ports are checked free and the bundle rewritten.
found, err := TakeTheTunnel(&o, d.Run)
if err != nil {
return result, failed(StepPreflight, err)
}
if found != nil {
result.Tunnel = found
result.Ports = o.Ports
say(fmt.Sprintf(" tunnel %s — the private network takes it over: its port %d is "+
"the hub's, its range %s the mesh's, and its %d peer(s) are carried until they enrol",
found.Interface, found.Port, found.Range, len(found.Peers)))
} else {
say(" tunnel none up on this machine; the private network is raised on its own port and range")
}
}
sys, err := WorkOutSystem(ctx, d.Run, o.System)
@@ -592,7 +566,7 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
// ---- 4. apply -----------------------------------------------------------------------
say("apply — raising the foundation")
report, err := ApplyBundle(ctx, o, sys, rewritten.Declaration, rewritten.Bundle, d.Run, say)
report, err := ApplyBundle(ctx, o, sys, rewritten.Declaration, d.Run, say)
result.Applied, result.Changed = len(report.Outcomes), report.Changed()
if err != nil {
return result, failed(StepApply, err)
-39
View File
@@ -6,8 +6,6 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
@@ -114,43 +112,6 @@ func BuildControlPlane(ctx context.Context, run Runner, builderTag string, sourc
return Built{}, nil
}
// **Which form the controller is in at that commit** (novox/hq issue 223). An image the module
// builds is the form genesis always raised, and the builder builds it as before. A process the
// module runs from a Go bundle cannot be built here — its toolchain is one the mesh makes later —
// so genesis builds the controller's own Dockerfile and raises it as the container that process
// replaces (genesis_form.go).
workspace, err := os.MkdirTemp("", "mesh-genesis-*")
if err != nil {
return Built{}, err
}
defer os.RemoveAll(workspace)
dir, commit, err := cloneAt(ctx, run, builderTag, source, workspace)
if err != nil {
return Built{}, fmt.Errorf("the control plane could not be fetched from %s at %s: %w",
source.Repository, shortRef(source.Ref), err)
}
raw, err := os.ReadFile(filepath.Join(dir, "module.json"))
if err != nil {
return Built{}, fmt.Errorf("%s at %s has no module manifest: %w", source.Repository, shortRef(source.Ref), err)
}
form, err := processFormOf(raw)
if err != nil {
return Built{}, err
}
if form.Found {
image, err := buildGenesisImage(ctx, run, dir)
if err != nil {
return Built{}, err
}
manifest, err := genesisForm(raw, form, image)
if err != nil {
return Built{}, err
}
say(fmt.Sprintf(" built %s from %s, as the container its process %s replaces (%s)",
ControlPlaneModule, shortRef(commit), form.Process, form.Replaces))
return Built{Module: ControlPlaneModule, Commit: commit, Image: image, Manifest: manifest}, nil
}
out, err := run(ctx, "docker", args...)
if err != nil {
return Built{}, fmt.Errorf("the control plane could not be built from %s at %s: %w",
+6 -6
View File
@@ -49,10 +49,10 @@ func TestARepositoryAndACommitIsEnough(t *testing.T) {
func TestTheBuildHandsOverTheManifestTheMeshWillHold(t *testing.T) {
manifest := `{"module":"mesh-controller","version":"1","resources":[` +
`{"id":"server","type":"container","name":"mesh-controller","image":"` + builtImage + `"}]}`
runtime := &asked{answer: aRepository(t, imageFormManifest, func(string, []string) (string, error) {
runtime := &asked{answer: func(string, []string) (string, error) {
return `{"module":"mesh-controller","commit":"a1b2c3d4","manifest":` + manifest +
`,"made":[{"name":"server","kind":"image","reference":"` + builtImage + `"}]}` + "\n", nil
})}
}}
built, err := BuildControlPlane(context.Background(), runtime.run, "mesh-builder:test",
Source{Repository: "https://example.invalid/mesh-controller.git", Ref: "a1b2c3d4"}, false, func(string) {})
if err != nil {
@@ -69,10 +69,10 @@ func TestTheBuildHandsOverTheManifestTheMeshWillHold(t *testing.T) {
// A result without a manifest is a build the installer cannot finish, and it is refused beside the
// builder that said it rather than at step 9 with a message about a missing file.
func TestABuildReportingNoManifestIsRefused(t *testing.T) {
runtime := &asked{answer: aRepository(t, imageFormManifest, func(string, []string) (string, error) {
runtime := &asked{answer: func(string, []string) (string, error) {
return `{"module":"mesh-controller","commit":"a1b2c3d4",` +
`"made":[{"name":"server","kind":"image","reference":"` + builtImage + `"}]}`, nil
})}
}}
_, err := BuildControlPlane(context.Background(), runtime.run, "mesh-builder:test",
Source{Repository: "https://example.invalid/mesh-controller.git", Ref: "a1b2c3d4"}, false, func(string) {})
if err == nil || !strings.Contains(err.Error(), "no manifest") {
@@ -82,11 +82,11 @@ func TestABuildReportingNoManifestIsRefused(t *testing.T) {
// And a manifest that does not name the image the build produced describes some other build.
func TestABuildWhoseManifestNamesAnotherImageIsRefused(t *testing.T) {
runtime := &asked{answer: aRepository(t, imageFormManifest, func(string, []string) (string, error) {
runtime := &asked{answer: func(string, []string) (string, error) {
return `{"module":"mesh-controller","commit":"a1b2c3d4","manifest":{"module":"mesh-controller",` +
`"resources":[{"id":"server","type":"container","image":"sha256:` + strings.Repeat("9", 64) + `"}]},` +
`"made":[{"name":"server","kind":"image","reference":"` + builtImage + `"}]}`, nil
})}
}}
_, err := BuildControlPlane(context.Background(), runtime.run, "mesh-builder:test",
Source{Repository: "https://example.invalid/mesh-controller.git", Ref: "a1b2c3d4"}, false, func(string) {})
if err == nil || !strings.Contains(err.Error(), "does not name that image") {
+1 -8
View File
@@ -112,14 +112,7 @@ func Enrol(ctx context.Context, o Options, sys system.System, control controlPla
return out, err
}
joining, cancel := context.WithTimeout(ctx, o.Wait)
args := []string{"enrol", "--token", token, "--state", o.State}
if o.Tunnel != "" {
// The found tunnel's key becomes this node's overlay key, and the tunnel travels with
// the enrolment (novox/hq ADR 0105). Named, so the host takes the one genesis settled
// its ports and range on and not another that came up since.
args = append(args, "--tunnel", o.Tunnel)
}
joined, err := control.run(joining, o.Host, args...)
joined, err := control.run(joining, o.Host, "enrol", "--token", token, "--state", o.State)
cancel()
if err != nil {
return out, fmt.Errorf(
-79
View File
@@ -1,79 +0,0 @@
package bootstrap
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// What genesis raises, it raises as the module that succeeds it declares — name, data directory
// and image — so the module adopts it by the found rule that already exists (novox/hq ADR 0163,
// rule 7; issue 090). The network is the one difference left: the bootstrap forge runs on the
// machine's network to reach the store on its loopback, and a take says so.
func TestGenesisRaisesTheForgeAsTheModuleDeclaresIt(t *testing.T) {
var ran [][]string
run := func(_ context.Context, name string, args ...string) (string, error) {
if name == "docker" && args[0] == "container" {
return "", nil // not raised yet
}
ran = append(ran, append([]string{name}, args...))
return "", nil
}
if err := raiseGiteaServer(context.Background(), run, time.Second, "pw", DefaultPorts(), quietly); err != nil {
t.Fatal(err)
}
var raised []string
for _, r := range ran {
if r[0] == "docker" && r[1] == "run" {
raised = r
}
}
line := strings.Join(raised, " ")
for _, want := range []string{"--name gitea ", "--volume " + giteaDataDir + ":/data", " " + giteaImage} {
if !strings.Contains(line+" ", want) {
t.Errorf("the forge is not raised with %q: %s", want, line)
}
}
if giteaBootstrap != ForgeModule {
t.Errorf("the bootstrap forge is %q and the module names its container %q", giteaBootstrap, ForgeModule)
}
// Against the module's own manifest, where the catalogue is checked out beside this repository.
var manifest []byte
for _, candidate := range []string{"../../../mesh-catalog/modules/gitea/module.json", "../../../../../mesh-catalog/modules/gitea/module.json"} {
if raw, err := os.ReadFile(filepath.Clean(candidate)); err == nil {
manifest = raw
break
}
}
if manifest == nil {
t.Skip("the catalogue is not beside this checkout; the module's pin is not compared")
}
var m struct {
Resources []struct {
ID, Type, Name, Image string
Volumes []string
} `json:"resources"`
}
if err := json.Unmarshal(manifest, &m); err != nil {
t.Fatal(err)
}
for _, r := range m.Resources {
if r.Type != "container" || r.ID != "server" {
continue
}
if r.Name != giteaBootstrap {
t.Errorf("the module names its container %q; genesis raises %q", r.Name, giteaBootstrap)
}
if r.Image != giteaImage {
t.Errorf("the module pins %s; genesis raises %s — the two must move together", r.Image, giteaImage)
}
if len(r.Volumes) != 1 || !strings.HasSuffix(r.Volumes[0], ":/data") {
t.Errorf("the module mounts %v; genesis mounts %s:/data", r.Volumes, giteaDataDir)
}
}
}
-205
View File
@@ -1,205 +0,0 @@
package bootstrap
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// The controller as a process, raised at genesis as a container (novox/hq issue 213, issue 223).
//
// **The mesh runs the controller as a Go bundle the host starts as a process; genesis cannot.** A
// process's bundle is fetched from the mesh's artifact store, which genesis raises long after the
// controller, and a Go bundle is compiled in a toolchain the mesh builds later still. So genesis
// pivots to the controller as it always has — an image it built from the controller's own
// Dockerfile, run as a container the temporary controller composes — and the first time the mesh
// builds the controller from its repository, the controller's own declaration is the process, which
// names that container under `replaces`, and the host hands over: the process is started, seen up,
// and only then is the container removed (mesh-host `replaces`, issue 213).
//
// **The container is genesis's shape, not the manifest's.** The manifest declares only the process.
// From it genesis takes what the process is given — its environment, which is host paths and words —
// and the id the process replaces; the container around it is written here: the image genesis built,
// the host's network, and every host path the environment names mounted at the same path read-only.
// It runs as the image's own unprivileged user (65534), so the secrets belong to that number until
// the process's account takes them over — the image is FROM scratch and knows no account by name. Its
// id is the one the process replaces, so the first declaration the mesh composes for this machine
// hands this container over rather than leaving two controllers running.
// genesisUser is who the genesis container runs as — the image's own USER — and who its secrets
// belong to until the process takes them over: the image has no passwd to look an account up in.
const genesisUser = "65534:65534"
// ProcessForm is the controller's process, as its manifest declares it, and the container id that
// process replaces. Found is false for a manifest in the image form — an older controller — which
// genesis installs as it always did.
type ProcessForm struct {
Found bool
Process string // the process resource's id
Replaces string // the id of the container genesis raises in its place
}
// processFormOf finds the controller's process in its manifest: a process resource running a bundle
// the module builds, saying which one resource it replaces.
func processFormOf(manifest []byte) (ProcessForm, error) {
var m struct {
Build *struct {
Artifacts []struct {
Name string `json:"name"`
Kind string `json:"kind"`
} `json:"artifacts"`
} `json:"build"`
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(manifest, &m); err != nil {
return ProcessForm{}, fmt.Errorf("the %s module's manifest is not readable: %w", ControlPlaneModule, err)
}
kinds := map[string]string{}
if m.Build != nil {
for _, a := range m.Build.Artifacts {
kinds[a.Name] = a.Kind
}
}
for _, kind := range kinds {
if kind == "image" {
return ProcessForm{}, nil // the image form: the builder builds it, as before
}
}
var found []ProcessForm
for _, r := range m.Resources {
if r["type"] != "process" || kinds[fmt.Sprint(r["artifact"])] != "bundle" {
continue
}
if once, _ := r["run-once"].(bool); once {
continue
}
replaces, _ := r["replaces"].([]any)
if len(replaces) != 1 {
return ProcessForm{}, fmt.Errorf(
"the %s module runs as the process %v and says it replaces %v. Genesis raises the "+
"controller as a container that process takes over, so the process names exactly "+
"one resource it replaces — the id genesis gives the container",
ControlPlaneModule, r["id"], r["replaces"])
}
found = append(found, ProcessForm{Found: true, Process: fmt.Sprint(r["id"]),
Replaces: fmt.Sprint(replaces[0])})
}
if len(found) != 1 {
return ProcessForm{}, fmt.Errorf(
"the %s module builds no image and runs %d process(es) of its own; genesis raises one "+
"controller, from the process its manifest declares", ControlPlaneModule, len(found))
}
return found[0], nil
}
// genesisForm is the manifest genesis registers: the controller's own manifest, its process
// replaced by the container genesis runs in its place, under the id the process replaces, running
// the image genesis built. Resolved as a build would resolve it — no build section, the image named
// — because that is what the temporary controller is handed.
func genesisForm(manifest []byte, form ProcessForm, image string) ([]byte, error) {
var m map[string]any
if err := json.Unmarshal(manifest, &m); err != nil {
return nil, err
}
resources, _ := m["resources"].([]any)
var out []any
for _, raw := range resources {
r, _ := raw.(map[string]any)
if r == nil || r["id"] != form.Process {
out = append(out, raw)
continue
}
env, _ := r["env"].(map[string]any)
var volumes []any
for _, key := range sortedAnyKeys(env) {
value := fmt.Sprint(env[key])
if strings.HasPrefix(value, "/") || strings.HasPrefix(value, "${dir:") {
volumes = append(volumes, value+":"+value+":ro")
}
}
container := map[string]any{
"id": form.Replaces, "type": "container", "name": ControlPlaneModule,
"image": image, "network": "host", "args": []any{"serve"},
}
if len(env) > 0 {
container["env"] = env
}
if len(volumes) > 0 {
container["volumes"] = volumes
}
out = append(out, container)
}
m["resources"] = out
// What the container reads must be readable by who it runs as. The process's account owns them
// once the process takes over, and the host gives them to it in the same apply.
m["secrets-owner"] = genesisUser
// **Nothing to prepare at genesis.** The temporary controller — the same commit — migrated the
// stores when the foundation raised it, and a preparation step is derived from a resource running
// an artifact the module built, which a pinned image is not: the controller refuses `prepares`
// with nothing to run it in. The process prepares the stores itself when it takes over.
delete(m, "prepares")
delete(m, "build")
var b bytes.Buffer
enc := json.NewEncoder(&b)
enc.SetEscapeHTML(false)
if err := enc.Encode(m); err != nil {
return nil, err
}
return bytes.TrimSpace(b.Bytes()), nil
}
func sortedAnyKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// cloneAt fetches the controller's repository at the commit genesis builds, into a directory of
// this machine's, with the carried builder's git — the machine is not assumed to have one. Returns
// the module's directory and the commit that was checked out.
func cloneAt(ctx context.Context, run Runner, builderTag string, source Source, into string) (string, string, error) {
git := func(args ...string) (string, error) {
return run(ctx, "docker", append([]string{"run", "--rm", "-v", into + ":/ws",
"--entrypoint", "git", builderTag}, args...)...)
}
if _, err := git("clone", "--quiet", source.Repository, "/ws/src"); err != nil {
return "", "", fmt.Errorf("cloning %s: %w", source.Repository, err)
}
if _, err := git("-C", "/ws/src", "checkout", "--quiet", "--detach", source.Ref); err != nil {
return "", "", fmt.Errorf("checking out %s: %w", shortRef(source.Ref), err)
}
commit, err := git("-C", "/ws/src", "rev-parse", "HEAD")
if err != nil {
return "", "", err
}
return filepath.Join(into, "src", source.Path), strings.TrimSpace(commit), nil
}
// buildGenesisImage builds the controller's image from its own Dockerfile (the one `make image`
// uses), on this machine, and names it by the digest of its own configuration, as the builder did.
func buildGenesisImage(ctx context.Context, run Runner, dir string) (string, error) {
if _, err := os.Stat(filepath.Join(dir, "Dockerfile")); err != nil {
return "", fmt.Errorf("the %s repository has no Dockerfile, so genesis has no image to raise "+
"the controller from: %w", ControlPlaneModule, err)
}
out, err := run(ctx, "docker", "build", "--quiet", dir)
if err != nil {
return "", fmt.Errorf("building the %s image: %w", ControlPlaneModule, err)
}
image := strings.TrimSpace(out)
if i := strings.LastIndex(image, "\n"); i >= 0 {
image = strings.TrimSpace(image[i+1:])
}
if !strings.HasPrefix(image, "sha256:") {
return "", fmt.Errorf("docker build said %q, which is not an image id", firstLine(out))
}
return image, nil
}
-182
View File
@@ -1,182 +0,0 @@
package bootstrap
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// imageFormManifest is a controller from before issue 213: it builds an image and runs a container.
const imageFormManifest = `{"module":"mesh-controller","version":"1","resources":[
{"id":"server","type":"container","name":"mesh-controller","network":"host","artifact":"server"}],
"build":{"artifacts":[{"name":"server","kind":"image","from":"Dockerfile"}]}}`
// processFormManifest is the controller's manifest as novox/mesh-controller declares it after issue
// 213: a Go bundle the host runs as a process, replacing the container it ran as.
const processFormManifest = `{
"module": "mesh-controller", "version": "1", "slug": "control", "prepares": true,
"claims": [{"name": "mesh-controller", "scope": "mesh"}],
"accesses": [{"path": "/var/lib/mesh-broker-tls", "mode": "read"}],
"own-secrets": {"inventory": "${dir:mesh-state}/inventory", "bus": "${dir:mesh-state}/bus"},
"secrets-owner": "mesh-controller",
"tools": ["status"],
"resources": [
{"id": "account", "type": "user", "name": "mesh-controller", "shell": "/usr/bin/nologin", "home": "/var/lib/mesh-controller"},
{"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh", "owner": "mesh-controller"},
{"id": "controller", "type": "process", "name": "mesh-controller", "artifact": "controller",
"run": ["./mesh-controller", "serve"], "user": "mesh-controller",
"env": {"MESH_BROKER_CERTIFICATE": "/var/lib/mesh-broker-tls/tls.crt",
"MESH_STORE_INVENTORY_FILE": "${dir:mesh-state}/inventory",
"MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}",
"MESH_BUS_NATS_FILE": "${dir:mesh-state}/bus"},
"replaces": ["server"]}
],
"build": {"artifacts": [{"name": "controller", "kind": "bundle", "language": "go", "system": "arch",
"from": "cmd/mesh-controller", "binary": "mesh-controller"}]}
}`
// aRepository answers the carried builder's git as a clone of a repository holding this manifest and
// a Dockerfile, and hands every other command to then.
func aRepository(t *testing.T, manifest string, then func(string, []string) (string, error)) func(string, []string) (string, error) {
t.Helper()
return func(name string, args []string) (string, error) {
if name == "docker" && len(args) > 5 && args[0] == "run" && args[4] == "--entrypoint" && args[5] == "git" {
host := strings.TrimSuffix(args[3], ":/ws")
joined := strings.Join(args, " ")
switch {
case strings.Contains(joined, " clone "):
src := filepath.Join(host, "src")
if err := os.MkdirAll(src, 0o755); err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(src, "module.json"), []byte(manifest), 0o644); err != nil {
return "", err
}
return "", os.WriteFile(filepath.Join(src, "Dockerfile"), []byte("FROM scratch\n"), 0o644)
case strings.Contains(joined, "rev-parse"):
return "a1b2c3d4e5f6\n", nil
}
return "", nil
}
return then(name, args)
}
}
// novox/hq issue 223: a controller declared as a process is raised at genesis as a container built
// from its own Dockerfile, not by the builder — which has no Go toolchain at genesis and would refuse.
func TestAProcessFormControllerIsBuiltFromItsDockerfileAndRaisedAsAContainer(t *testing.T) {
runtime := &asked{answer: aRepository(t, processFormManifest, func(name string, args []string) (string, error) {
if name == "docker" && args[0] == "build" {
return builtImage + "\n", nil
}
t.Fatalf("genesis ran %s %v; a process-form controller is built from its Dockerfile alone", name, args)
return "", nil
})}
built, err := BuildControlPlane(context.Background(), runtime.run, "mesh-builder:test",
Source{Repository: "https://example.invalid/mesh-controller.git", Ref: "a1b2c3d4"}, false, func(string) {})
if err != nil {
t.Fatal(err)
}
if built.Image != builtImage || built.Commit != "a1b2c3d4e5f6" {
t.Errorf("built %q from %q", built.Image, built.Commit)
}
if runtime.ran("mesh-builder:test build") {
t.Error("the builder was asked to build a controller it cannot build at genesis")
}
var m map[string]any
if err := json.Unmarshal(built.Manifest, &m); err != nil {
t.Fatalf("the genesis manifest is not JSON: %v", err)
}
if _, has := m["prepares"]; has {
t.Error("the genesis manifest prepares its state; the temporary controller already did, and a pinned image is nothing the controller can derive a step from")
}
if _, has := m["build"]; has {
t.Error("the genesis manifest still says how it is built; it is handed over resolved")
}
var container map[string]any
for _, raw := range m["resources"].([]any) {
r := raw.(map[string]any)
if r["type"] == "process" {
t.Errorf("the genesis manifest still runs the process: %v", r)
}
if r["type"] == "container" {
container = r
}
}
if container == nil {
t.Fatal("the genesis manifest runs no container")
}
for key, want := range map[string]any{"id": "server", "name": "mesh-controller", "image": builtImage,
"network": "host"} {
if container[key] != want {
t.Errorf("the genesis container's %s is %v, not %v", key, container[key], want)
}
}
if _, has := container["user"]; has {
t.Error("the genesis container says a user; the host's container has no such field, the image's USER is who it runs as")
}
if m["secrets-owner"] != "65534:65534" {
t.Errorf("the secrets belong to %v, which the container cannot read as", m["secrets-owner"])
}
volumes, _ := json.Marshal(container["volumes"])
for _, want := range []string{"${dir:mesh-state}/inventory:${dir:mesh-state}/inventory:ro",
"/var/lib/mesh-broker-tls/tls.crt:/var/lib/mesh-broker-tls/tls.crt:ro"} {
if !strings.Contains(string(volumes), want) {
t.Errorf("the container does not mount %s: %s", want, volumes)
}
}
if strings.Contains(string(volumes), "seat:") {
t.Errorf("a word that is not a path was mounted: %s", volumes)
}
// And it is what step 9 installs: pinned, its container found, its stores delivered from its
// environment — the same path an image-form controller takes.
pinned, _, err := pinImage(built.Manifest, built.Image, "registry.internal:5000/mesh-controller@sha256:"+strings.Repeat("e", 64), ControlPlaneModule)
if err != nil {
t.Fatal(err)
}
if id := controlPlaneResourceIn(pinned); id != "server" {
t.Errorf("step 9 finds the controller's container as %q", id)
}
wanted, err := secretsByVariableIn(pinned)
if err != nil {
t.Fatalf("step 9 cannot deliver the stores into the genesis container: %v", err)
}
if wanted["MESH_STORE_INVENTORY"] != "inventory" {
t.Errorf("step 9 delivers %v", wanted)
}
}
// An older controller — an image and a container — is still built by the builder, as before.
func TestAnImageFormControllerIsStillBuiltByTheBuilder(t *testing.T) {
manifest := `{"module":"mesh-controller","version":"1","resources":[` +
`{"id":"server","type":"container","name":"mesh-controller","image":"` + builtImage + `"}]}`
runtime := &asked{answer: aRepository(t, imageFormManifest, func(name string, args []string) (string, error) {
if name == "docker" && args[0] == "build" {
t.Fatal("an image-form controller was built from its Dockerfile rather than by the builder")
}
return `{"module":"mesh-controller","commit":"a1b2c3d4","manifest":` + manifest +
`,"made":[{"name":"server","kind":"image","reference":"` + builtImage + `"}]}`, nil
})}
built, err := BuildControlPlane(context.Background(), runtime.run, "mesh-builder:test",
Source{Repository: "https://example.invalid/mesh-controller.git", Ref: "a1b2c3d4"}, false, func(string) {})
if err != nil {
t.Fatal(err)
}
if string(built.Manifest) != manifest || !runtime.ran("mesh-builder:test build") {
t.Errorf("the image form did not go through the builder: %s", built.Manifest)
}
}
func TestAProcessFormNamingNoOneReplacementIsRefused(t *testing.T) {
for _, bad := range []string{`"replaces": []`, `"replaces": ["a", "b"]`} {
raw := strings.Replace(processFormManifest, `"replaces": ["server"]`, bad, 1)
if _, err := processFormOf([]byte(raw)); err == nil {
t.Errorf("a process saying %s was accepted; genesis would not know what to name its container", bad)
}
}
}
-121
View File
@@ -1,121 +0,0 @@
package bootstrap
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
// novox/hq issue 223: what genesis raises is what the controller's process takes over. The temporary
// controller composes the genesis container, and the host records it as `<module>.<its id>`; the
// first declaration the mesh composes from the controller's real manifest names that same id under
// the process's `replaces` (the composer prefixes both alike — mesh-controller's
// TestTheControllerIsAProcessAndNoContainer). So the first apply hands over: the process is started,
// seen up, and only then is the genesis container removed — one controller before, one after, never
// none and never two left.
func TestTheFirstApplyHandsTheGenesisContainerOverToTheProcess(t *testing.T) {
restore := apply.ForTests(t.TempDir(), t.TempDir())
defer restore()
form, err := processFormOf([]byte(processFormManifest))
if err != nil {
t.Fatal(err)
}
genesis, err := genesisForm([]byte(processFormManifest), form, builtImage)
if err != nil {
t.Fatal(err)
}
// What the host records for the genesis container, as the composer names it.
recorded := ""
var m struct {
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(genesis, &m); err != nil {
t.Fatal(err)
}
for _, r := range m.Resources {
if r["type"] == "container" {
recorded = ControlPlaneModule + "." + r["id"].(string)
}
}
known := store.State{Resources: []store.Applied{{Origin: store.OriginDeclared, ID: recorded,
Type: "container", Target: ControlPlaneModule}}}
// The controller's first composed declaration: its process, replacing what the manifest names.
body, digest := aBundle(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(body) }))
defer server.Close()
replaces, _ := json.Marshal([]string{ControlPlaneModule + "." + form.Replaces})
d, err := declaration.Parse([]byte(`{"declaration":1,"resources":[{"id":"` + ControlPlaneModule + "." + form.Process +
`","type":"process","name":"mesh-controller","source":"` + server.URL + `/c.tgz","digest":"` + digest +
`","run":["./mesh-controller","serve"],"replaces":` + string(replaces) + `}]}`))
if err != nil {
t.Fatal(err)
}
var commands []string
started, container := false, true
run := func(_ context.Context, name string, args ...string) (string, error) {
line := name + " " + strings.Join(args, " ")
commands = append(commands, line)
switch {
case strings.HasPrefix(line, "systemctl restart mesh-controller.service"):
started = true
case strings.HasPrefix(line, "systemctl show mesh-controller.service") && started:
return "ActiveState=active\nSubState=running\nMainPID=7\nNRestarts=0\n", nil
case strings.HasPrefix(line, "docker rm -f mesh-controller"):
if !started {
t.Error("the genesis container was removed before the process was started")
}
container = false
case strings.HasPrefix(line, "docker container inspect") && !container:
return "", errors.New("no such container")
}
return "", nil
}
sys, err := system.For("arch")
if err != nil {
t.Fatal(err)
}
_, after, err := apply.Apply(context.Background(), sys, d, known, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatalf("the first apply did not hand over: %v\n%s", err, strings.Join(commands, "\n"))
}
if container {
t.Fatalf("the genesis container is still running beside the process — two controllers:\n%s",
strings.Join(commands, "\n"))
}
if _, still := after.Find(recorded); still {
t.Error("the host still records the genesis container")
}
}
func aBundle(t *testing.T) ([]byte, string) {
t.Helper()
var raw bytes.Buffer
zipped := gzip.NewWriter(&raw)
w := tar.NewWriter(zipped)
content := "#!/bin/sh\n"
if err := w.WriteHeader(&tar.Header{Name: "mesh-controller", Mode: 0o755, Size: int64(len(content)), Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(content))
_ = w.Close()
_ = zipped.Close()
sum := sha256.Sum256(raw.Bytes())
return raw.Bytes(), "sha256:" + hex.EncodeToString(sum[:])
}
+1 -1
View File
@@ -91,7 +91,7 @@ func TestARerunOfGenesisIsNotAMachineInUse(t *testing.T) {
if err := store.Save(o.State, store.State{Resources: []store.Applied{{ID: "store", Type: "container", Target: "mesh-store"}}}); err != nil {
t.Fatal(err)
}
m := inUseRunner{ps: giteaBootstrap + "\t\n", ss: servingSockets}
m := inUseRunner{ps: "mesh-gitea-server\t\n", ss: servingSockets}
if err := RefuseAMachineInUse(context.Background(), o, m.run, quietly); err != nil {
t.Errorf("what an earlier genesis raised was counted as a machine in use: %v", err)
}
+8 -26
View File
@@ -25,24 +25,11 @@ const (
// foundationStore is the foundation's postgres container — the mesh's own memory, raised from the
// bundle. gitea's bootstrap database lives here too, so a mesh runs one postgres (issue 051).
foundationStore = "mesh-store"
// giteaBootstrap is the gitea server raised directly at genesis, before gitea is a module —
// under the name the gitea MODULE declares for its container, so the module finds it and holds
// it rather than raising a second forge beside it (novox/hq ADR 0163, rule 7; issue 090).
giteaBootstrap = "gitea"
// giteaImage is the image the gitea module declares for that container, pinned to the same
// digest, so taking the module over is not a downgrade and not an upgrade. **Moves with the
// module's pin**: the two are compared by a take, and a difference is said there — but a
// genesis that raised an older image than the module declares would be taken over as an
// upgrade on first push, which a forge holding the mesh's packages must not have done to it
// unannounced. Checked in TestGenesisRaisesTheForgeAsTheModuleDeclaresIt against the module's
// manifest where the catalogue is beside this checkout.
giteaImage = "gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef"
// giteaDataDir is where the module's `data` directory resolves on a machine with the default
// layout (<data root>/<module>/<id>, novox/hq ADR 0112): mounted at /data as the module mounts
// it, so the repositories, attachments and indexes the bootstrap forge accumulates are the
// module's the day it is taken — before this, the forge had no volume and its data was the
// container's, lost with it.
giteaDataDir = "/var/lib/gitea/data"
// giteaBootstrap is the gitea server raised directly at genesis, before gitea is a module.
giteaBootstrap = "mesh-gitea-server"
// giteaImage is the same upstream image the gitea module runs, pinned identically so the module
// adopts the running server rather than replacing it.
giteaImage = "gitea/gitea@sha256:dfc61e347c8b582df918f4556401bf2cecdfbdb56c5282ae9488dd76fca3e41c"
// packagesOrg is the npm owner: every module consumes `@novox/*` from this gitea org.
packagesOrg = "novox"
// packagesTeam is the org team whose members may read and write the org's packages.
@@ -243,9 +230,8 @@ func raiseGiteaServer(ctx context.Context, run Runner, timeout time.Duration, db
defer cancel()
// Already there: a re-run does not raise a second one. `docker start` is a no-op on a running
// container and revives a stopped one. `container inspect`, not the bare form: a name is not
// unique across object kinds, and `.Id` resolves on a network or volume too.
if out, _ := run(asking, "docker", "container", "inspect", "--format", "{{.Id}}", giteaBootstrap); strings.TrimSpace(out) != "" {
// container and revives a stopped one.
if out, _ := run(asking, "docker", "inspect", "--format", "{{.Id}}", giteaBootstrap); strings.TrimSpace(out) != "" {
_, _ = run(asking, "docker", "start", giteaBootstrap)
return nil
}
@@ -275,13 +261,9 @@ func raiseGiteaServer(ctx context.Context, run Runner, timeout time.Duration, db
"run", "-d", "--name", giteaBootstrap,
// Host network, like the control plane: it reaches the foundation store on the machine's
// loopback (where the store publishes 5432) and answers on the machine's own 3000, which is
// where mesh-bootstrap and the builder's build containers look for it. The module runs
// bridged and publishes its ports; that is the one difference a take still has to say
// (ADR 0163, rule 7) — the data, the name and the image are the module's already.
// where mesh-bootstrap and the builder's build containers look for it.
"--network", "host",
"--restart", "unless-stopped",
// The module's data directory, so what the forge accumulates is the module's when taken.
"--volume", giteaDataDir + ":/data",
}, env...)
args = append(args, giteaImage)
+2 -50
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"sort"
@@ -14,7 +13,6 @@ import (
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/reachable"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/tunnel"
)
// FoundationPorts are the machine's ports the foundation binds (novox/hq ADR 0100).
@@ -323,36 +321,6 @@ func PortsFree(ctx context.Context, run Runner, p FoundationPorts, ours func(rea
return nil
}
// TakeTheTunnel finds the tunnel an adopted machine's private network takes over (novox/hq ADR
// 0105) and settles the options on it: the hub's port is the tunnel's, the mesh's range is the
// tunnel's, and the interface is named for the enrolment that takes its key. Nil when no tunnel is
// up, which is an ordinary machine. A --hub-port or --overlay-range that disagrees with the
// tunnel is refused: the peers dial the tunnel's port and live in its range, and a mesh raised
// beside them on other numbers is the two-tunnel shape the record rejects.
func TakeTheTunnel(o *Options, run Runner) (*tunnel.Found, error) {
found, err := tunnel.Find(context.Background(), tunnel.Runner(run), o.Tunnel)
if errors.Is(err, tunnel.ErrNone) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("%w. An adopted hub takes over the tunnel it finds; nothing was changed", err)
}
if o.Ports.Hub != 0 && o.Ports.Hub != DefaultPorts().Hub && o.Ports.Hub != found.Port {
return nil, fmt.Errorf("--hub-port %d disagrees with the tunnel %s, which listens on %d: the "+
"private network takes over that tunnel on its own port, so leave --hub-port unsaid or "+
"say %d", o.Ports.Hub, found.Interface, found.Port, found.Port)
}
if o.OverlayRange != "" && o.OverlayRange != DefaultOverlayRange && o.OverlayRange != found.Range {
return nil, fmt.Errorf("--overlay-range %s disagrees with the tunnel %s, whose range is %s: the "+
"private network takes over that tunnel with its range, so leave --overlay-range unsaid "+
"or say %s", o.OverlayRange, found.Interface, found.Range, found.Range)
}
o.Tunnel = found.Interface
o.Ports.Hub = found.Port
o.OverlayRange = found.Range
return &found, nil
}
// OverlayClear refuses a private-network range that overlaps an address or a route the machine
// already has — a predecessor's tunnel still running — naming the interface. The mesh's own
// interface is not counted.
@@ -431,9 +399,7 @@ func NamesFree(ctx context.Context, run Runner, names []string, known store.Stat
sorted := append([]string{}, names...)
sort.Strings(sorted)
for _, name := range sorted {
// `container inspect`: a name is not unique across object kinds, and the bare form can
// resolve to a same-named network or volume instead of reporting the container absent.
out, err := run(ctx, "docker", "container", "inspect", "--format",
out, err := run(ctx, "docker", "inspect", "--format",
"{{index .Config.Labels \"mesh-host.spec\"}}", name)
if err != nil {
continue // no such container
@@ -493,26 +459,12 @@ func CheckTheMachine(ctx context.Context, o Options, run Runner, bundle *declara
}
return false
}
if o.Tunnel != "" {
// The hub's port is the found tunnel's, held by that tunnel until the mesh's interface
// takes it over (novox/hq ADR 0105): held by design, not by something else.
inner := ours
ours = func(r reachable.Reach) bool {
return inner(r) || (r.Protocol == "udp" && r.Port == p.Hub)
}
}
if err := PortsFree(ctx, run, p, ours); err != nil {
return err
}
say(fmt.Sprintf(" ports free store %d, bus %d, amqp %d, management %d, registry %d, packages %d, hub %d/udp",
p.Store, p.Bus, p.AMQP, p.Management, p.Registry, p.Packages, p.Hub))
if o.Tunnel != "" {
// One tunnel and one range: the mesh's range IS the found tunnel's, so the rule that the
// two must not overlap applies only where a found tunnel is left running beside the mesh's
// (ADR 0100, narrowed by ADR 0105).
say(fmt.Sprintf(" range %s is the tunnel %s's, taken over; not checked against it",
o.OverlayRange, o.Tunnel))
} else if err := OverlayClear(ctx, run, o.OverlayRange); err != nil {
if err := OverlayClear(ctx, run, o.OverlayRange); err != nil {
return err
}
if err := NamesFree(ctx, run, names, known); err != nil {
+2 -81
View File
@@ -2,9 +2,6 @@ package bootstrap
import (
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"errors"
"os"
"path/filepath"
@@ -15,7 +12,6 @@ import (
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/reachable"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/tunnel"
)
// Defends novox/hq ADR 0100: the foundation's ports are the node's — inputs to genesis, checked free,
@@ -134,7 +130,7 @@ func TestTwoThingsOnOnePortAreRefused(t *testing.T) {
// machineRunner answers ss, docker ps, docker inspect and ip from fixtures.
type machineRunner struct {
ss, ps, addrs, routes, wg string
ss, ps, addrs, routes string
unlabelled map[string]bool
labelled map[string]bool
}
@@ -145,7 +141,7 @@ func (m machineRunner) run(_ context.Context, name string, args ...string) (stri
return m.ss, nil
case name == "docker" && args[0] == "ps":
return m.ps, nil
case name == "docker" && args[0] == "container":
case name == "docker" && args[0] == "inspect":
n := args[len(args)-1]
if m.labelled[n] {
return "abc\n", nil
@@ -158,8 +154,6 @@ func (m machineRunner) run(_ context.Context, name string, args ...string) (stri
return m.addrs, nil
case name == "ip" && args[1] == "route":
return m.routes, nil
case name == "wg":
return m.wg, nil
}
return "", nil
}
@@ -301,76 +295,3 @@ func TestARerunOfGenesisFindsItsOwnPackageRegistry(t *testing.T) {
t.Error("a container under the package registry's name on a fresh machine was not refused")
}
}
// novox/hq ADR 0105: an adopted genesis takes over the tunnel it finds — its port is the hub's,
// its range the mesh's, and neither is refused for being held by it.
func TestAnAdoptedGenesisSettlesOnTheTunnelItFinds(t *testing.T) {
private, err := aFoundKey()
if err != nil {
t.Fatal(err)
}
conf := "[Interface]\nPrivateKey = " + private + "\nListenPort = 51900\nAddress = 192.0.2.1/24\n" +
"[Peer]\nPublicKey = PEER=\nAllowedIPs = 192.0.2.2/32\n"
tunnel.ReadFile = func(path string) ([]byte, error) {
if path == tunnel.ConfigDir+"/wg0.conf" {
return []byte(conf), nil
}
return nil, errors.New("no such file")
}
t.Cleanup(func() { tunnel.ReadFile = os.ReadFile })
m := machineRunner{
wg: "wg0\n",
ss: "udp UNCONN 0 0 0.0.0.0:51900 0.0.0.0:*\n",
addrs: "5: wg0 inet 192.0.2.1/24 scope global wg0\n",
routes: "192.0.2.0/24 dev wg0 proto kernel scope link src 192.0.2.1\n",
}
o := Options{Adopted: true, Ports: DefaultPorts(), OverlayRange: DefaultOverlayRange, State: filepath.Join(t.TempDir(), "state.json")}
found, err := TakeTheTunnel(&o, m.run)
if err != nil || found == nil {
t.Fatalf("the tunnel was not found and taken: %+v %v", found, err)
}
if o.Tunnel != "wg0" || o.Ports.Hub != 51900 || o.OverlayRange != "192.0.2.0/24" {
t.Fatalf("genesis did not settle on the tunnel's port and range: %+v", o)
}
// Its port is held by the tunnel and its range overlaps the tunnel's — by design, not refused.
if err := CheckTheMachine(context.Background(), o, m.run, producedBundle(t).Declaration, func(string) {}); err != nil {
t.Fatalf("the machine was refused for the tunnel it takes over: %v", err)
}
// Whereas the same machine not taking it over is refused on both counts (ADR 0100).
plain := o
plain.Tunnel = ""
if err := CheckTheMachine(context.Background(), plain, m.run, producedBundle(t).Declaration, func(string) {}); err == nil ||
!strings.Contains(err.Error(), "51900") {
t.Fatalf("a tunnel not taken over stopped being refused for holding the hub's port: %v", err)
}
plain.Ports.Hub = 51821
if err := CheckTheMachine(context.Background(), plain, m.run, producedBundle(t).Declaration, func(string) {}); err == nil ||
!strings.Contains(err.Error(), "wg0") {
t.Fatalf("a tunnel not taken over stopped being refused for overlapping the range: %v", err)
}
// Numbers that disagree with the tunnel are refused, naming the tunnel's.
for name, given := range map[string]Options{
"--hub-port": {Adopted: true, Ports: FoundationPorts{Hub: 51821}, OverlayRange: DefaultOverlayRange},
"--overlay-range": {Adopted: true, Ports: DefaultPorts(), OverlayRange: "10.77.0.0/16"},
} {
if _, err := TakeTheTunnel(&given, m.run); err == nil || !strings.Contains(err.Error(), name) {
t.Errorf("a %s disagreeing with the tunnel was accepted: %v", name, err)
}
}
// And no tunnel up is an ordinary machine.
m.wg = "mesh0\n"
none := Options{Adopted: true, Ports: DefaultPorts(), OverlayRange: DefaultOverlayRange}
if found, err := TakeTheTunnel(&none, m.run); err != nil || found != nil || none.Ports.Hub != DefaultPorts().Hub {
t.Errorf("a machine with no tunnel was not left as it was: %+v %v", found, err)
}
}
func aFoundKey() (string, error) {
k, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(k.Bytes()), nil
}
+1 -1
View File
@@ -150,7 +150,7 @@ func digestOf(ctx context.Context, o Options, d Deps, remote string) (string, er
// The registry has it. What digest, according to the runtime that pushed it.
reading, cancel := context.WithTimeout(ctx, o.Timeout)
out, err := d.Run(reading, "docker", "image", "inspect", "--format", "{{json .RepoDigests}}",
out, err := d.Run(reading, "docker", "inspect", "--format", "{{json .RepoDigests}}",
remote+":"+genesisTag)
cancel()
if err != nil {
+4 -4
View File
@@ -50,7 +50,7 @@ func TestAnImageNoRegistryHasEverHeldIsPushed(t *testing.T) {
case "push":
pushed = true
return "", nil
case "image":
case "inspect":
return `["127.0.0.1:5000/mesh-controller@sha256:` + strings.Repeat("a", 64) + `"]`, nil
}
return "", fmt.Errorf("unexpected: %v", args)
@@ -80,7 +80,7 @@ func TestAnImageTheRegistryAlreadyServesIsNotPushedAgain(t *testing.T) {
return http.StatusOK, `{"name":"mesh-controller","tags":["genesis"]}`, nil
},
func(_ string, args []string) (string, error) {
if args[0] == "image" {
if args[0] == "inspect" {
return `["127.0.0.1:5000/mesh-controller@sha256:` + strings.Repeat("b", 64) + `"]`, nil
}
return "", fmt.Errorf("unexpected: %v", args)
@@ -111,7 +111,7 @@ func TestTheDigestComesFromThisMeshsOwnRegistry(t *testing.T) {
return http.StatusOK, `{"tags":["genesis"]}`, nil
},
func(_ string, args []string) (string, error) {
if args[0] == "image" {
if args[0] == "inspect" {
return `["` + elsewhere + `","` + ours + `"]`, nil
}
return "", fmt.Errorf("unexpected: %v", args)
@@ -166,7 +166,7 @@ func TestATagIsNotAPin(t *testing.T) {
return http.StatusOK, `{"tags":["genesis"]}`, nil
},
func(_ string, args []string) (string, error) {
if args[0] == "image" {
if args[0] == "inspect" {
return `["127.0.0.1:5000/mesh-controller:genesis"]`, nil
}
return "", nil
+1 -1
View File
@@ -62,7 +62,7 @@ func aMeshThatAgrees(answers map[string]string) func(string, []string) (string,
return "", fmt.Errorf("unexpected program %q", name)
case args[0] == "cp":
return "", nil
case args[0] == "container":
case args[0] == "inspect":
return "true running\n", nil
case args[0] == "exec":
return "", nil
+1 -1
View File
@@ -89,7 +89,7 @@ func RetireTheTemporaryControlPlane(ctx context.Context, o Options, sys system.S
// same state file. What makes this a removal rather than a no-op is that the state file
// records the container as something this installer applied, and the declaration no longer
// asks for it.
report, err := ApplyBundle(ctx, o, sys, without, bundle, run, say)
report, err := ApplyBundle(ctx, o, sys, without, run, say)
if err != nil {
return out, fmt.Errorf(
"%w\n\nThe permanent control plane is running and the temporary one is still here. "+
+2 -3
View File
@@ -129,9 +129,8 @@ func containerRunning(ctx context.Context, run Runner, probe time.Duration, name
defer cancel()
// Both facts in one answer, so a container that is not running is reported with what it IS
// rather than with the absence of what it should be. `container inspect`, not the bare form:
// a same-named network or volume would otherwise answer in the container's place.
out, err := run(asking, "docker", "container", "inspect", "--format", "{{.State.Running}} {{.State.Status}}", name)
// rather than with the absence of what it should be.
out, err := run(asking, "docker", "inspect", "--format", "{{.State.Running}} {{.State.Status}}", name)
if err != nil {
return containerState{}, fmt.Errorf(
"the container %q is not there at all, and the apply reported it applied: %w", name, err)
+7 -7
View File
@@ -30,7 +30,7 @@ func TestAContainerThatIsUpIsNotAControlPlaneThatReplies(t *testing.T) {
runtime := &asked{answer: func(_ string, args []string) (string, error) {
switch args[0] {
case "container":
case "inspect":
return "true running\n", nil
case "exec":
// Up, and saying nothing. The program inside is not answering.
@@ -59,7 +59,7 @@ func TestAControlPlaneThatSaysNothingHasNotAnswered(t *testing.T) {
defer func() { answerEvery = previous }()
runtime := &asked{answer: func(_ string, args []string) (string, error) {
if args[0] == "container" {
if args[0] == "inspect" {
return "true running\n", nil
}
return " \n", nil
@@ -74,7 +74,7 @@ func TestAControlPlaneThatSaysNothingHasNotAnswered(t *testing.T) {
// The foundation answering is the whole point, and what it said is reported rather than asserted.
func TestAFoundationThatIsUpAndAnsweringIsAccepted(t *testing.T) {
runtime := &asked{answer: func(_ string, args []string) (string, error) {
if args[0] == "container" {
if args[0] == "inspect" {
return "true running\n", nil
}
return "1 node, 0 waiting\n", nil
@@ -112,7 +112,7 @@ func TestAControlPlaneThatIsStillStartingIsWaitedFor(t *testing.T) {
attempts := 0
runtime := &asked{answer: func(_ string, args []string) (string, error) {
if args[0] == "container" {
if args[0] == "inspect" {
return "true running\n", nil
}
attempts++
@@ -132,10 +132,10 @@ func TestAControlPlaneThatIsStillStartingIsWaitedFor(t *testing.T) {
// than being told only that something is not what it should be.
func TestAContainerThatExitedIsNamedWithItsState(t *testing.T) {
runtime := &asked{answer: func(_ string, args []string) (string, error) {
if args[0] == "container" && args[len(args)-1] == "mesh-broker" {
if args[0] == "inspect" && args[len(args)-1] == "mesh-broker" {
return "false exited\n", nil
}
if args[0] == "container" {
if args[0] == "inspect" {
return "true running\n", nil
}
return "", fmt.Errorf("unexpected command: %v", args)
@@ -155,7 +155,7 @@ func TestAContainerThatExitedIsNamedWithItsState(t *testing.T) {
// `FROM scratch` and has no shell for a command line to be interpreted by.
func TestTheControlPlaneIsAskedByRunningItsOwnBinary(t *testing.T) {
runtime := &asked{answer: func(_ string, args []string) (string, error) {
if args[0] == "container" {
if args[0] == "inspect" {
return "true running\n", nil
}
return "1 node\n", nil
-25
View File
@@ -105,28 +105,3 @@ func TestACarriedBundleCannotSayTheNodeIsAdopted(t *testing.T) {
t.Fatalf("a bundle claiming adoption was not refused: %v", err)
}
}
// novox/hq ADR 0105: a service may take over a found tunnel, said whole and on an adopted node.
func TestTakingOverATunnelIsSaidWholeAndForARunningService(t *testing.T) {
adoptedWith := func(service string) error {
_, err := Parse([]byte(`{"declaration":1,"adoption":{"taken":[]},"resources":[` + service + `]}`))
return err
}
good := `{"id":"up","type":"service","unit":"wg-quick@mesh0","state":"running",
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"/etc/wireguard/wg0.conf"}}`
if err := adoptedWith(good); err != nil {
t.Fatalf("a whole takeover on an adopted node was refused: %v", err)
}
for name, bad := range map[string]string{
"its own unit": `{"id":"up","type":"service","unit":"wg-quick@wg0","state":"running",
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"/etc/wireguard/wg0.conf"}}`,
"no config": `{"id":"up","type":"service","unit":"wg-quick@mesh0","state":"running",
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0"}}`,
"a stopped service": `{"id":"up","type":"service","unit":"wg-quick@mesh0","state":"stopped",
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"/etc/wireguard/wg0.conf"}}`,
} {
if err := adoptedWith(bad); err == nil {
t.Errorf("a takeover naming %s was accepted", name)
}
}
}
-52
View File
@@ -1,52 +0,0 @@
package declaration
import (
"strings"
"testing"
)
// Defends novox/hq issue 128: a file written into a block carries only its lines, in content,
// never a line the host keeps as its own marker, and says where a new region goes only as a block.
func TestAFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines(t *testing.T) {
for name, c := range map[string]struct{ resource, refusal string }{
"a begin marker in content": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a\n# BEGIN mesh f\nb\n"}`, "# BEGIN mesh f"},
"an end marker in content": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a\n# END mesh other\n"}`, "# END mesh other"},
"a marker with a CR": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"# BEGIN mesh x\r\n"}`, "marker"},
"sealed": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","sealed":"abc"}`, "not sealed"},
"bytes": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","bytes":"YQ=="}`, "not sealed, bytes"},
"secrets": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"${secret:s}","secrets":{"s":"abc"}}`, "secrets"},
"create-once": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a","create-once":true}`, "create-once"},
"at on a whole file": {`{"id":"f","type":"file","path":"/etc/hosts","content":"a","at":"start"}`, `at "start"`},
"at on a JSON file": {`{"id":"f","type":"file","path":"/etc/x.json","into":"json","content":"{}","at":"end"}`, `at "end"`},
"at somewhere else": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a","at":"middle"}`, `"start" or "end"`},
"an id ending in a space": {`{"id":"f ","type":"file","path":"/etc/hosts","into":"block","content":"a"}`, "whitespace"},
"an unknown format": {`{"id":"f","type":"file","path":"/etc/hosts","into":"lines","content":"a"}`, `"json" or "block"`},
} {
_, err := Parse([]byte(`{"declaration":1,"resources":[` + c.resource + `]}`))
if err == nil || !strings.Contains(err.Error(), c.refusal) {
t.Errorf("%s: want a refusal naming %q, got %v", name, c.refusal, err)
}
}
}
func TestAFileWrittenIntoABlockIsRead(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"mesh-wireguard.fact-node-names","type":"file","path":"/etc/hosts","into":"block","content":"10.42.0.1 ace\n# BEGIN devtool x\n"},
{"id":"dhcpcd.options","type":"file","path":"/etc/dhcpcd.conf","into":"block","content":"nohook resolv.conf\n","at":"start"},
{"id":"empty","type":"file","path":"/etc/x","into":"block","content":"","at":"end"}
]}`))
if err != nil {
t.Fatal(err)
}
if f := d.Resources[0].(*File); f.Into != IntoBlock || f.At != "" {
t.Errorf("read as into %q at %q", f.Into, f.At)
}
if f := d.Resources[1].(*File); f.At != AtStart {
t.Errorf("at was read as %q", f.At)
}
if begin, end := BlockMarkers("mesh-wireguard.fact-node-names"); begin != "# BEGIN mesh mesh-wireguard.fact-node-names" ||
end != "# END mesh mesh-wireguard.fact-node-names" {
t.Errorf("the markers are %q and %q", begin, end)
}
}
+19 -450
View File
@@ -11,12 +11,10 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"reflect"
"regexp"
"slices"
"sort"
"strconv"
"strings"
)
@@ -161,30 +159,8 @@ type File struct {
// "json" is spoken — the content is a JSON object whose keys the host sets in the file's
// object, keeping every other key as it found it and recording what each of its keys held
// before, so undeclaring the file gives those back.
//
// "block" is the same idea for a file that is not structured (novox/hq issue 128): the
// content is the mesh's lines, and the host owns only the region between `# BEGIN mesh <id>`
// and `# END mesh <id>`, keeping every line outside it byte for byte. The machine's hosts file
// is the case that needed it — on a workstation the distribution, a local development tool and
// the operator all write into it, and the mesh writing it whole took their lines away at the
// next change to the mesh's names, silently. Marked blocks are the shape the other tools in
// that file already use, and `#` is the comment character of every file this serves.
//
// Written into, in either format, the file's mode and owner are the machine's: a declared mode
// and owner apply only to a file the host creates, and a file that was there keeps its own.
Into string `json:"into,omitempty"`
// At is where a file written into a block has its region added when the file does not hold
// one yet: "end", the default, or "start". A region already there stays where it is, whatever
// this says — moving it would move the lines around it, and those are the machine's.
//
// **Some files give a line its meaning by what stands above it.** dhcpcd's configuration scopes
// every line after `interface X` to that interface, and a real one ends with exactly that — an
// interface and its static address. A region added at the end would make the mesh's global
// options (`nohook resolv.conf`, `denyinterfaces mesh0`) options of one interface, and dhcpcd
// would read them without complaint. At the start, nothing stands above them.
At string `json:"at,omitempty"`
// Sealed is content encrypted to this node's sealing key, for a file the mesh must deliver
// without being able to read.
//
@@ -263,50 +239,16 @@ func (f *File) validate(where string, _ bool) []string {
problems = append(problems, where+
": a file written into JSON carries a JSON object of the keys it sets")
}
case IntoBlock:
// The markers are how the host finds its region again. A marker in the content would be
// a second region, or the end of this one, the next time the file is read — and the host
// would then rewrite, or on undeclare take out, lines that were never the mesh's.
for _, line := range strings.Split(f.Content, "\n") {
if strings.HasPrefix(line, BlockBegin) || strings.HasPrefix(line, BlockEnd) {
problems = append(problems, fmt.Sprintf(
"%s: a file written into a block carries the mesh's lines, and %q is a marker the "+
"host keeps for itself", where, strings.TrimRight(line, "\r")))
break
}
}
// The id is written into the markers, so it has to stay on one line — and whitespace at
// either end of it is whitespace the host would have to match exactly in a line some
// editor may trim.
if strings.ContainsAny(f.ID, "\r\n") {
if f.Sealed != "" || f.Bytes != "" || len(f.Secrets) > 0 || f.CreateOnce {
problems = append(problems, where+
": a file written into a block names its region by its id, and this id spans lines")
} else if strings.TrimSpace(f.ID) != f.ID {
problems = append(problems, where+
": a file written into a block names its region by its id, and this id begins or ends in whitespace")
": a file written into says only its keys, in content — not sealed, bytes, "+
"secrets or create-once")
}
default:
problems = append(problems, fmt.Sprintf(
"%s: into %q; a file is written into \"json\" or \"block\", or omits it to be written whole",
"%s: into %q; a file is written into \"json\", or omits it to be written whole",
where, f.Into))
}
switch {
case f.At == "":
case f.Into != IntoBlock:
problems = append(problems, fmt.Sprintf(
"%s: at %q; only a file written into a block has a place its region is added", where, f.At))
case f.At != AtStart && f.At != AtEnd:
problems = append(problems, fmt.Sprintf(
"%s: at %q; a block is added at \"start\" or \"end\", or omits it to be added at the end",
where, f.At))
}
if f.Into == IntoJSON || f.Into == IntoBlock {
if f.Sealed != "" || f.Bytes != "" || len(f.Secrets) > 0 || f.CreateOnce {
problems = append(problems, where+
": a file written into says only its part, in content — not sealed, bytes, "+
"secrets or create-once")
}
}
var said []string
for name, value := range map[string]string{
"content": f.Content, "sealed": f.Sealed, "bytes": f.Bytes,
@@ -569,52 +511,22 @@ type Process struct {
// that runs once does not run on a schedule, and something that is not running cannot be
// restarted when a file changes.
Schedule string `json:"schedule,omitempty"`
// Replaces names resources this declaration no longer declares that this process takes the
// place of (novox/hq issue 213). Such a resource is not removed with the other orphans, before
// anything is applied: it is removed only once this process is applied and still running a
// moment later, and kept when it is not. So the thing being replaced answers until the thing
// replacing it does — the controller moving from its container to a process is the case: the
// container removed first left nothing answering the mesh's verbs for as long as fetching,
// unpacking and starting the process took, and for ever if the process did not start.
//
// For a process that stays up; a step or a scheduled run is not running a moment later by
// design, so there is nothing to hand over to.
Replaces []string `json:"replaces,omitempty"`
}
func (d *Process) Identity() string { return d.ID }
func (d *Process) Kind() Type { return TypeProcess }
func (d *Process) Target() string { return d.Name }
// ProcessNameProblem says what is wrong with a process name, or nothing.
//
// The name becomes a unit name, a file under the unit directory and a directory under the mesh's
// own — the one removing the process deletes, whole (novox/hq ADR 0118). So a name that is not one
// plain path element is refused: with a separator it writes somewhere nobody meant, and "." or
// ".." IS the mesh's directory or its parent — removing a process named ".." would delete every
// bundle the mesh has, and more. One with a leading dash is read by the service manager as an
// option, not a unit. Exported because the removal checks the recorded name again: a record is
// what the host wrote, and a host of an older version wrote it under looser rules.
func ProcessNameProblem(name string) string {
switch {
case name == "":
return "a process needs a name, which is what its unit is called"
case strings.ContainsAny(name, "/ \t"):
return "a process name becomes a unit name, so it cannot contain a path separator or a space"
case name == "." || name == "..":
return fmt.Sprintf("a process name becomes a directory under the mesh's own, and %q would be "+
"that directory or its parent", name)
case strings.HasPrefix(name, "-"):
return "a process name cannot begin with a dash: the service manager would read it as an option"
}
return ""
}
func (d *Process) validate(where string, _ bool) []string {
var problems []string
if problem := ProcessNameProblem(d.Name); problem != "" {
problems = append(problems, where+": "+problem)
if d.Name == "" {
problems = append(problems, where+": a process needs a name, which is what its unit is called")
}
if strings.ContainsAny(d.Name, "/ \t") {
// It becomes a unit name and a file on disk. A name with a separator in it would write
// somewhere nobody meant.
problems = append(problems, where+": a process name becomes a unit name, so it cannot "+
"contain a path separator or a space")
}
if d.Source == "" {
problems = append(problems, where+": a process needs somewhere to fetch its bundle from")
@@ -666,20 +578,6 @@ func (d *Process) validate(where string, _ bool) []string {
problems = append(problems, where+": "+err.Error())
}
}
if len(d.Replaces) > 0 && (d.RunOnce || d.Schedule != "") {
problems = append(problems, where+
": only a process that stays up replaces something — a step or a scheduled run is not "+
"running a moment later, so what it replaced would be removed with nothing in its place, "+
"or never")
}
for _, id := range d.Replaces {
switch {
case strings.TrimSpace(id) == "":
problems = append(problems, where+": replaces names an empty id")
case id == d.ID:
problems = append(problems, where+": a process cannot replace itself")
}
}
return problems
}
@@ -692,17 +590,7 @@ type Service struct {
ID string `json:"id"`
Type Type `json:"type"`
Unit string `json:"unit"`
// State is "running" or "stopped" — or absent, and then **the unit's lifecycle is the
// machine's; the mesh only reflects its triggers** (novox/hq ADR 0117). The uplink modules
// declare the machine's own network manager this way: the mesh writes into its configuration
// and needs it to read that again, and nothing more. Stated, the host would start the manager
// on a machine that uses another one — two managers fighting over the same links — and, when
// the module was unassigned, stop it: the machine's network, the channel the mesh itself
// arrives on, gone at the moment of a routine change. So a service without a state is never
// started, stopped, enabled or disabled, is reloaded or restarted only when a trigger changed
// and it is already running, and undeclared is simply forgotten. It says nothing unless it
// names a trigger, and it may not say boot or takes-over, which are both lifecycle.
State string `json:"state,omitempty"`
State string `json:"state"`
// Boot is "enabled" or "disabled" — whether the unit starts at boot. Optional: absent means
// the host asserts nothing about it and leaves whatever is there.
//
@@ -729,28 +617,8 @@ type Service struct {
// container runtime, whose restart stops every container on the machine (novox/hq ADR 0102).
// A change that is also in RestartOn restarts it, which covers a reload.
ReloadOn []string `json:"reload-on,omitempty"`
// TakesOver names the found tunnel this service replaces (novox/hq ADR 0105): before this unit
// is started, the named unit is stopped and disabled — never flushed — and its configuration
// file is kept like any held file — until the take is proven by a peer's handshake, and then
// retired, its original staying kept (novox/hq ADR 0119). Only on an adopted node, and only
// said by the controller, which knows the found tunnel's key is this node's own: without that,
// starting this unit on the found one's port would drop every peer's packets.
TakesOver *TakeOver `json:"takes-over,omitempty"`
}
// TakeOver is a found tunnel a service replaces: its interface, the unit that raised it, and its
// configuration file.
type TakeOver struct {
Interface string `json:"interface"`
Unit string `json:"unit"`
Config string `json:"config"`
}
// Stateless reports whether the unit's lifecycle is the machine's, and the mesh only reflects the
// service's triggers (novox/hq ADR 0117).
func (s *Service) Stateless() bool { return s.State == "" }
func (s *Service) Identity() string { return s.ID }
func (s *Service) Kind() Type { return TypeService }
func (s *Service) Target() string { return s.Unit }
@@ -760,65 +628,20 @@ func (s *Service) validate(where string, _ bool) []string {
if s.Unit == "" {
problems = append(problems, where+": a service needs a unit")
}
switch {
case s.State == "running" || s.State == "stopped":
case s.State != "":
if s.State != "running" && s.State != "stopped" {
problems = append(problems, fmt.Sprintf(
"%s: state %q; a service is \"running\" or \"stopped\", or omits state to leave the "+
"unit's lifecycle to the machine", where, s.State))
case s.Boot != "" || s.TakesOver != nil:
problems = append(problems, where+": a service that omits state leaves the unit's lifecycle "+
"to the machine, and boot and takes-over are both its lifecycle")
case len(s.RestartOn) == 0 && len(s.ReloadOn) == 0:
problems = append(problems, where+": a service that omits state leaves the unit's lifecycle "+
"to the machine, and names no restart-on or reload-on — it declares nothing")
"%s: state %q; a service is \"running\" or \"stopped\"", where, s.State))
}
if s.Boot != "" && s.Boot != "enabled" && s.Boot != "disabled" {
problems = append(problems, fmt.Sprintf(
"%s: boot %q; a service is \"enabled\" or \"disabled\" at boot, or omits it to "+
"leave the machine's own setting alone", where, s.Boot))
}
if t := s.TakesOver; t != nil {
switch {
case t.Unit == "" || t.Config == "" || t.Interface == "":
problems = append(problems, where+": takes-over names the found tunnel's interface, unit "+
"and config, and this leaves one out")
case t.Unit == s.Unit:
problems = append(problems, fmt.Sprintf("%s: takes-over names %s, which is this service's own unit",
where, t.Unit))
case s.State != "running" && s.State != "":
problems = append(problems, where+": a service that takes over a tunnel is running — stopping "+
"the found one for a service that will not run would leave the peers with nothing")
}
}
return problems
}
// What a file is written into (novox/hq ADR 0102): a JSON object whose keys the mesh sets, or a
// text file in which the mesh owns one marked block of lines (novox/hq issue 128).
const (
IntoJSON = "json"
IntoBlock = "block"
)
// The lines that delimit the mesh's region in a file written into a block, each followed by the
// resource's id. Exact lines, never patterns: another tool's `# BEGIN …` block in the same file is
// that tool's, and a marker that merely resembled the mesh's must not be taken for it.
const (
BlockBegin = "# BEGIN mesh "
BlockEnd = "# END mesh "
)
// Where a file written into a block has its region added, when it has none yet.
const (
AtStart = "start"
AtEnd = "end"
)
// BlockMarkers are the two lines, without their line ends, that delimit a resource's region.
func BlockMarkers(id string) (begin, end string) {
return BlockBegin + id, BlockEnd + id
}
// IntoJSON is the one structured format a file is written into.
const IntoJSON = "json"
// Opening is a port reachable on an adopted node, from where, and on which path.
//
@@ -896,12 +719,6 @@ type Package struct {
ID string `json:"id"`
Type Type `json:"type"`
Package string `json:"package"`
// Absent declares that the package is NOT installed (novox/hq ADR 0180): the host removes it
// when it is, and leaves a machine that never had it alone. For the one case a module replaces
// software the machine was found with and the operator has decided it does not come back — the
// firewall front end a converged machine's filter module retired. Nothing to undo when the
// declaration drops it: the host does not install what a declaration stopped saying is absent.
Absent bool `json:"absent,omitempty"`
}
func (p *Package) Identity() string { return p.ID }
@@ -962,44 +779,6 @@ type Container struct {
// worse failure mode.
Network string `json:"network,omitempty"`
// Dns is the resolvers this container asks, passed to the runtime unchanged.
//
// **Because some software refuses to run behind the runtime's forwarding resolver.** A mail
// server's admin demands a DNSSEC-validating resolver, and the runtime's own (127.0.0.11)
// forwards to whatever the machine has — so a module that ships its own validating resolver
// must be able to point its other containers at it. Addresses, not names: the runtime's flag
// takes only addresses, which is also why IP below exists — the resolver has to be somewhere
// its siblings can name before any of them can resolve anything.
Dns []string `json:"dns,omitempty"`
// Capabilities are the Linux capabilities this container is granted beyond the runtime's
// default set, by name (novox/hq ADR 0170): a holder's runtime that changes the machine's packet
// filter asks for NET_ADMIN. Exactly these, named in the spec so a change recreates the
// container; a privileged container stays undeclarable.
Capabilities []string `json:"capabilities,omitempty"`
// Logging names where the runtime sends this container's output: "journald" sends it to the
// machine's journal, under the container's name, where what reads the machine's logs — its
// intrusion prevention first of all (novox/hq ADR 0179) — can read it the way it reads the
// machine's own services. Empty keeps the runtime's default, which is a file of the runtime's
// own that nothing but the runtime reads. Part of the spec: a container that logs elsewhere
// is a different container, and the runtime cannot change a running one's driver.
Logging string `json:"logging,omitempty"`
// Networks are networks this container also joins once created, by name — a found network a
// per-machine setting keeps for a taken container (novox/hq ADR 0163, rule 4), so a
// neighbour that resolves it there keeps resolving it until the neighbour is taken too.
// Joined after creation, because a runtime starts a container on one network; part of the
// container's spec, so a network kept or let go recreates it.
Networks []string `json:"networks,omitempty"`
// IP is this container's address on its network, passed to the runtime unchanged.
//
// Only meaningful on a user-defined network, and refused by the runtime elsewhere. Exists for
// exactly one shape: a container others must reach *before* name resolution works — a
// module's own DNS resolver being the case that forced it (see Dns).
IP string `json:"ip,omitempty"`
// RestartOn names resources whose change means this container must be recreated — the same
// field a service has, for the same reason (novox/hq 04-ISSUES/009). A container reads a
// mounted file once at start; a changed file leaves the running process holding the old value,
@@ -1037,24 +816,6 @@ type Container struct {
// rather than stacked. It is exclusive with RunOnce and with restart-on: a container runs once
// and gates, runs on a cadence, or stays up — never two of these.
Schedule string `json:"schedule,omitempty"`
// WhileStopped names resources of the same module — containers — that must be held still for
// the duration of this step's run (novox/hq ADR 0189). The host stops each before the run and
// starts each again after it, **whatever the step did**: a step that failed must leave the
// service running, because the one real risk of this field is a window that never closes.
//
// **For the work a service cannot have done underneath it.** The artifact store's collector
// walks the storage and requires every writer stopped; a run-once step runs beside containers
// and a scheduled one is the same container again, so until this there was no way for a module
// to say it. The predecessor said it with a shell script, which is how the mesh inherited a
// store that has never collected anything.
//
// **Its own module's containers, and only on a schedule.** A module that could quiesce a
// neighbour could stop the mesh. And at apply time the host already has a window — the
// declaration is applied in order and a run-once step gates what follows — so a one-time
// offline job says *before*, not *instead of*; a recurring window is the case order cannot
// express, and the only one this serves.
WhileStopped []string `json:"while-stopped,omitempty"`
}
func (c *Container) Identity() string { return c.ID }
@@ -1086,57 +847,6 @@ func (c *Container) validate(where string, _ bool) []string {
problems = append(problems, where+": "+err.Error())
}
}
// A maintenance window belongs to a recurring step (novox/hq ADR 0189). Refused on anything
// else here, where the field is; that it names containers of the same module, and not itself,
// is judged against the whole declaration (see whileStoppedNames).
if len(c.WhileStopped) > 0 && c.Schedule == "" {
problems = append(problems, where+": while-stopped needs a schedule; at apply the host "+
"already has a window — the declaration is applied in order and a run-once step gates "+
"what follows — so a one-time offline job is declared before what it works on")
}
for _, id := range c.WhileStopped {
if id == c.ID {
problems = append(problems, where+": while-stopped names "+strconv.Quote(id)+
", which is this step itself")
}
}
// The runtime's flags take addresses, and a name here would be handed to it verbatim and
// refused at create — after the old container was already removed. Refused on arrival instead.
for _, d := range c.Dns {
if net.ParseIP(d) == nil {
problems = append(problems, where+": dns "+strconv.Quote(d)+" is not an address; "+
"the runtime's resolver flag takes only addresses")
}
}
if c.IP != "" {
if net.ParseIP(c.IP) == nil {
problems = append(problems, where+": ip "+strconv.Quote(c.IP)+" is not an address")
}
if c.Network == "" {
problems = append(problems, where+": an ip needs a network; the runtime refuses a "+
"static address anywhere but a user-defined one")
}
}
for _, cap := range c.Capabilities {
if !capabilityName.MatchString(cap) {
problems = append(problems, where+": capabilities names "+strconv.Quote(cap)+", which is not a "+
"capability's name (CAP_NET_ADMIN or NET_ADMIN)")
}
}
if c.Logging != "" && c.Logging != "journald" {
problems = append(problems, where+": logging is "+strconv.Quote(c.Logging)+", and the only place a "+
"container's output can be sent besides the runtime's own file is \"journald\"")
}
for _, n := range c.Networks {
problems = append(problems, (&Network{Name: n}).validate(where+": networks", false)...)
if n == c.Network {
problems = append(problems, where+": networks names "+n+", which is already the container's network")
}
}
if len(c.Networks) > 0 && (c.RunOnce || c.Schedule != "") {
problems = append(problems, where+": networks is for a container that keeps running; a step "+
"runs and exits, and joins nothing afterwards")
}
return append(problems, checkImage(where, c.Image)...)
}
@@ -1242,41 +952,6 @@ 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
// LeftOut names the modules of this machine's set the mesh left out of this declaration,
// because a setting stored for one cannot compose with its definition (novox/hq ADR 0163,
// rule 6). A machine is told everything or nothing about what it IS told; this is what it is
// not told, said. The host keeps what it holds for a left-out module and touches none of
// what it wrote for it — its resources are absent from the declaration, and absence would
// otherwise read as removal.
LeftOut []string
}
// LeftOutModuleOf says which left-out module a recorded resource belongs to, if any: its id is the
// module's name, a dot, and the module's own id for it. A module's name may contain a dot, so the
// longest left-out name that prefixes the id wins; a false match keeps a thing an apply would
// otherwise remove, which is the conservative mistake.
func (d *Declaration) LeftOutModuleOf(id string) (string, bool) {
best := ""
for _, m := range d.LeftOut {
if strings.HasPrefix(id, m+".") && len(m) > len(best) {
best = m
}
}
return best, best != ""
}
// Adoption is a node's mode, as the controller records it: the node is adopted, and these are
@@ -1297,10 +972,6 @@ type Adoption struct {
Untaken map[string][]string `json:"untaken,omitempty"`
}
// capabilityName is what a Linux capability is called: upper case, underscores, an optional CAP_
// prefix. The runtime accepts either spelling.
var capabilityName = regexp.MustCompile(`^(CAP_)?[A-Z][A-Z0-9_]*$`)
// AdoptionPrefix is the id prefix of what the mesh itself declares because a node is adopted —
// its openings and its guard. Nothing under it belongs to a module, so none of it is ever held.
const AdoptionPrefix = "adoption."
@@ -1410,19 +1081,7 @@ type envelope struct {
Version int `json:"declaration"`
For string `json:"for,omitempty"`
Adoption *Adoption `json:"adoption,omitempty"`
// OwnsNothing is the control plane saying, explicitly, that this node's declaration is empty
// on purpose — it owns nothing the mesh put there (novox/hq issue 127). Without it an empty
// resources list is refused as a likely mistake; with it the node applies the empty
// declaration and drops what it last held. The two are distinguished because a truncated or
// mis-composed body arrives as empty too, and a host that could not tell them apart would let
// 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"`
// LeftOut is optional on the wire too, and absent when nothing was left out (ADR 0163).
LeftOut []string `json:"left_out,omitempty"`
}
func parse(raw []byte, allowActions bool) (*Declaration, error) {
@@ -1439,22 +1098,10 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
env.Version, Version)}}
}
d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence,
LeftOut: env.LeftOut}
d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption}
var problems []string
if len(env.LeftOut) > 0 && allowActions {
// The bundle is carried with the binary and leaves nothing out: which module a setting
// stopped composing for is the mesh's record (ADR 0163).
problems = append(problems, "a carried bundle says modules were left out, and only the "+
"mesh can say that")
}
for _, m := range env.LeftOut {
if strings.TrimSpace(m) == "" {
problems = append(problems, "left_out names a module with no name")
}
}
if len(env.Resources) == 0 && !env.OwnsNothing {
if len(env.Resources) == 0 {
problems = append(problems, "no resources. An empty declaration is a mistake, not a "+
"machine with nothing on it — say so with an explicit empty list if that is meant")
}
@@ -1514,9 +1161,7 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
problems = append(problems, resource.validate(where, allowActions)...)
d.Resources = append(d.Resources, resource)
}
problems = append(problems, checkWhileStopped(d.Resources)...)
problems = append(problems, checkAdoption(env.Adoption, d.Resources, allowActions)...)
problems = append(problems, checkReplaces(d.Resources)...)
if env.Adoption == nil {
for _, r := range d.Resources {
if r.Kind() == TypeOpening {
@@ -1527,14 +1172,6 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
"resource %q: an opening is for an adopted node, and this declaration does not "+
"say the node is adopted", r.Identity()))
}
if svc, ok := r.(*Service); ok && svc.TakesOver != nil {
// A tunnel is taken over on an adopted node, where what is found is kept: on a
// converged one there is nothing found to take over, and stopping a unit the
// mesh did not declare would be the host deciding (novox/hq ADR 0105).
problems = append(problems, fmt.Sprintf(
"resource %q: taking over a tunnel is for an adopted node, and this declaration "+
"does not say the node is adopted", r.Identity()))
}
}
}
@@ -1544,41 +1181,6 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
return d, nil
}
// checkWhileStopped judges a maintenance window against the whole declaration (novox/hq ADR 0189).
//
// A step may hold still only a container that is **here** — in this same declaration, which is to
// say on this machine and placed by the mesh. That is what makes it the module's own: a node's
// declaration carries one module's resources beside another's, so the id must also be a container
// and not a file or a directory, which there would be nothing to stop.
//
// Refused on arrival rather than discovered at the first fire. A window that names something the
// host cannot stop is a window that opens at 03:00 and reports nothing until somebody reads a log.
func checkWhileStopped(resources []Resource) []string {
containers := map[string]bool{}
for _, r := range resources {
if r.Kind() == TypeContainer {
containers[r.Identity()] = true
}
}
var problems []string
for _, r := range resources {
c, ok := r.(*Container)
if !ok {
continue
}
for _, id := range c.WhileStopped {
if containers[id] {
continue
}
problems = append(problems, fmt.Sprintf(
"resource %q: while-stopped names %q, and this declaration has no container by "+
"that id. A step may hold still only a container placed on this machine "+
"beside it", c.ID, id))
}
}
return problems
}
func strictDecode(raw []byte, into any) error {
// DisallowUnknownFields is the whole point rather than strictness for its own sake: a
// field the host does not know is a thing the control plane believes it asked for.
@@ -1735,36 +1337,3 @@ func stripComments(raw []byte) []byte {
}
return []byte(strings.Join(kept, "\n"))
}
// checkReplaces refuses a `replaces` naming something this same declaration still declares, or
// named by two processes (novox/hq issue 213). What is replaced is what the declaration no longer
// says — a resource still declared is applied, not handed over, and one handed to two replacements
// would go when the first of them came up, whatever became of the second.
func checkReplaces(resources []Resource) []string {
declared := map[string]bool{}
for _, r := range resources {
declared[r.Identity()] = true
}
var problems []string
by := map[string]string{}
for _, r := range resources {
p, ok := r.(*Process)
if !ok {
continue
}
for _, id := range p.Replaces {
if declared[id] {
problems = append(problems, fmt.Sprintf(
"resource %q: replaces %q, which this declaration still declares — a process "+
"replaces what the declaration no longer says", p.ID, id))
}
if other, taken := by[id]; taken && other != p.ID {
problems = append(problems, fmt.Sprintf(
"resource %q: replaces %q, which %q replaces too — one thing has one replacement",
p.ID, id, other))
}
by[id] = p.ID
}
}
return problems
}
-118
View File
@@ -427,121 +427,3 @@ func TestASecretTheContentNeverUsesIsRefused(t *testing.T) {
t.Fatal("a secret the content never mentions was accepted")
}
}
// The runtime's resolver and address flags take only addresses; a name would be refused at
// create, after the old container was already gone. Refused on arrival instead — and an address
// without a user-defined network is refused for the same reason.
func TestAContainersResolverAndAddressAreAddressesOrRefused(t *testing.T) {
refused := func(body string) []string {
_, err := Parse([]byte(`{"declaration":1,"resources":[` + body + `]}`))
if err == nil {
return nil
}
return []string{err.Error()}
}
base := `"id":"c","type":"container","name":"x",` +
`"image":"a@sha256:0000000000000000000000000000000000000000000000000000000000000000"`
if p := refused(`{` + base + `,"network":"m","dns":["resolver.local"]}`); len(p) == 0 {
t.Error("a resolver named by name was accepted; the runtime takes only addresses")
}
if p := refused(`{` + base + `,"ip":"192.168.203.7"}`); len(p) == 0 {
t.Error("a static address with no network was accepted; the runtime refuses it")
}
if p := refused(`{` + base + `,"network":"m","dns":["192.168.203.254"],"ip":"192.168.203.7"}`); len(p) != 0 {
t.Errorf("a well-formed resolver and address were refused: %v", p)
}
}
func TestAnExplicitlyEmptyDeclarationIsAccepted(t *testing.T) {
// A deliberately-empty declaration (novox/hq issue 127) says owns_nothing, and is applied so
// the node drops what it last held — distinct from an accidental empty body, which is refused.
if _, err := Parse([]byte(`{"declaration":1,"owns_nothing":true,"resources":[]}`)); err != nil {
t.Fatalf("an explicitly-empty declaration must be accepted: %v", err)
}
// Without the marker, an empty declaration is still refused as a likely mistake.
_, err := Parse([]byte(`{"declaration":1,"resources":[]}`))
if err == nil || !strings.Contains(err.Error(), "no resources") {
t.Fatalf("an unmarked empty declaration must still be refused; got %v", err)
}
}
// A container's kept networks are names, not its own network, and not for a step (novox/hq ADR
// 0163, rule 4); and the mesh may say which modules it left out, which a carried bundle may not.
func TestKeptNetworksAndLeftOutModulesAreReadStrictly(t *testing.T) {
pinnedImage := "postgres@sha256:" + strings.Repeat("a", 64)
d, err := Parse([]byte(`{"declaration":1,"left_out":["web"],"resources":[
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","networks":["predecessor_default"]}
]}`))
if err != nil {
t.Fatal(err)
}
if got := d.Resources[0].(*Container).Networks; len(got) != 1 || got[0] != "predecessor_default" {
t.Fatalf("the kept network was not read: %v", got)
}
if m, left := d.LeftOutModuleOf("web.server"); !left || m != "web" {
t.Fatalf("web.server is not web's: %q %v", m, left)
}
if _, left := d.LeftOutModuleOf("webapp.server"); left {
t.Fatal("webapp.server was taken for web's")
}
for name, raw := range map[string]string{
"a bad network name": `{"declaration":1,"resources":[
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","networks":["a/b"]}]}`,
"its own network": `{"declaration":1,"resources":[
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","network":"own","networks":["own"]}]}`,
"a step": `{"declaration":1,"resources":[
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","run-once":true,"networks":["x"]}]}`,
"a nameless module": `{"declaration":1,"left_out":[""],"resources":[
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `"}]}`,
} {
if _, err := Parse([]byte(raw)); err == nil {
t.Errorf("%s was accepted", name)
}
}
if _, err := ParseTrusted([]byte(`{"declaration":1,"left_out":["web"],"resources":[
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `"}]}`)); err == nil ||
!strings.Contains(err.Error(), "only the mesh can say that") {
t.Fatalf("a carried bundle leaving modules out was accepted: %v", err)
}
}
// A container may ask for a capability by name, and nothing else (novox/hq ADR 0170).
func TestACapabilityIsNamedOrRefused(t *testing.T) {
image := "postgres@sha256:" + strings.Repeat("a", 64)
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"fw","type":"container","name":"fw","image":"` + image + `","network":"host","capabilities":["NET_ADMIN","CAP_NET_RAW"]}
]}`))
if err != nil {
t.Fatal(err)
}
if got := d.Resources[0].(*Container).Capabilities; len(got) != 2 || got[0] != "NET_ADMIN" {
t.Fatalf("capabilities read as %v", got)
}
for _, bad := range []string{`"net_admin"`, `"ALL;rm -rf /"`, `"privileged"`} {
if _, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"fw","type":"container","name":"fw","image":"` + image + `","capabilities":[` + bad + `]}]}`)); err == nil {
t.Errorf("%s was accepted as a capability", bad)
}
}
}
// A container may send its output to the machine's journal, and nowhere else but the runtime's own
// file (novox/hq ADR 0179): what reads the machine's logs then reads the container's too.
func TestAContainerMayLogToTheJournalAndNowhereElse(t *testing.T) {
image := "postgres@sha256:" + strings.Repeat("a", 64)
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"front","type":"container","name":"front","image":"` + image + `","logging":"journald"}
]}`))
if err != nil {
t.Fatal(err)
}
if got := d.Resources[0].(*Container).Logging; got != "journald" {
t.Fatalf("logging read as %q", got)
}
for _, bad := range []string{`"syslog"`, `"none"`, `"json-file"`} {
if _, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"front","type":"container","name":"front","image":"` + image + `","logging":` + bad + `}]}`)); err == nil {
t.Errorf("%s was accepted as a place to log", bad)
}
}
}
+1 -4
View File
@@ -55,10 +55,7 @@ func TestAProcessMustSayWhatToRun(t *testing.T) {
// Its name becomes a unit name and a path, so a separator in it would write somewhere nobody meant.
func TestAProcesssNameCannotEscapeItsUnit(t *testing.T) {
// "." and ".." are one path element each, and the mesh's own bundle directory and its parent:
// removing a process named ".." would delete every bundle the mesh has, and more (novox/hq ADR
// 0118). A leading dash is an option to the service manager, not a unit.
for _, bad := range []string{"", "../escape", "two words", "a/b", ".", "..", "-", "--now"} {
for _, bad := range []string{"", "../escape", "two words", "a/b"} {
d := aProcess()
d.Name = bad
if problems := d.validate("a process", false); len(problems) == 0 {
-63
View File
@@ -1,63 +0,0 @@
package declaration
import (
"strings"
"testing"
)
// novox/hq issue 213: a process says what it takes the place of, so the host can keep the old one
// answering until the new one does. What it may name is narrow, and each refusal is said here.
const aReplacingProcess = `{"id":"m.controller","type":"process","name":"m","source":"https://store.invalid/m",
"digest":"sha256:` + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + `","run":["./m","serve"]%s}`
func replacing(extra string) string {
return `{"declaration":1,"resources":[` + strings.Replace(aReplacingProcess, "%s", extra, 1) + `]}`
}
func TestAProcessMayNameWhatItReplaces(t *testing.T) {
d, err := Parse([]byte(replacing(`,"replaces":["m.server"]`)))
if err != nil {
t.Fatalf("a process replacing an undeclared container was refused: %v", err)
}
if p := d.Resources[0].(*Process); len(p.Replaces) != 1 || p.Replaces[0] != "m.server" {
t.Fatalf("replaces was not read: %+v", p)
}
}
// What is replaced is what the declaration no longer says. A resource still declared is applied,
// and handing it over as well would remove something the same declaration asks to keep.
func TestAProcessMayNotReplaceSomethingStillDeclared(t *testing.T) {
raw := `{"declaration":1,"resources":[
{"id":"m.server","type":"directory","path":"/tmp/x"},
` + strings.Replace(aReplacingProcess, "%s", `,"replaces":["m.server"]`, 1) + `]}`
if _, err := Parse([]byte(raw)); err == nil || !strings.Contains(err.Error(), "still declares") {
t.Fatalf("a process replacing a declared resource was accepted: %v", err)
}
}
func TestOnlyAProcessThatStaysUpReplacesAnything(t *testing.T) {
for _, mode := range []string{`,"run-once":true`, `,"schedule":"0 3 * * *"`} {
if _, err := Parse([]byte(replacing(mode + `,"replaces":["m.server"]`))); err == nil {
t.Errorf("a process with %s was allowed to replace something", mode)
}
}
}
func TestAProcessCannotReplaceItselfOrNothing(t *testing.T) {
for _, bad := range []string{`,"replaces":["m.controller"]`, `,"replaces":[""]`} {
if _, err := Parse([]byte(replacing(bad))); err == nil {
t.Errorf("replaces %s was accepted", bad)
}
}
}
func TestOneThingHasOneReplacement(t *testing.T) {
second := strings.NewReplacer(`"id":"m.controller"`, `"id":"m.other"`, `"name":"m"`, `"name":"other"`).
Replace(strings.Replace(aReplacingProcess, "%s", `,"replaces":["m.server"]`, 1))
raw := `{"declaration":1,"resources":[` +
strings.Replace(aReplacingProcess, "%s", `,"replaces":["m.server"]`, 1) + "," + second + `]}`
if _, err := Parse([]byte(raw)); err == nil {
t.Fatal("two processes replacing one resource were accepted")
}
}
-30
View File
@@ -1,30 +0,0 @@
package declaration
import (
"strings"
"testing"
)
// Defends novox/hq ADR 0117: a service may leave its unit's lifecycle to the machine, and then
// says nothing but its triggers.
func TestAServiceWithoutAStateSaysOnlyItsTriggers(t *testing.T) {
for name, c := range map[string]struct{ resource, refusal string }{
"no trigger": {`{"id":"s","type":"service","unit":"NetworkManager.service"}`, "declares nothing"},
"with boot": {`{"id":"s","type":"service","unit":"NetworkManager.service","boot":"enabled","reload-on":["f"]}`, "boot and takes-over"},
"with takes-over": {`{"id":"s","type":"service","unit":"a.service","reload-on":["f"],"takes-over":{"interface":"wg0","unit":"b.service","config":"/etc/x"}}`, "boot and takes-over"},
"an unknown state": {`{"id":"s","type":"service","unit":"a.service","state":"paused"}`, "omits state to leave the unit's lifecycle to the machine"},
} {
_, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"f","type":"file","path":"/etc/x","content":"x"},` + c.resource + `]}`))
if err == nil || !strings.Contains(err.Error(), c.refusal) {
t.Errorf("%s: want a refusal naming %q, got %v", name, c.refusal, err)
}
}
d, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"f","type":"file","path":"/etc/x","content":"x"},
{"id":"s","type":"service","unit":"NetworkManager.service","restart-on":["f"]}]}`))
if err != nil {
t.Fatal(err)
}
if s := d.Resources[1].(*Service); !s.Stateless() {
t.Error("a service without a state was not read as stateless")
}
}
-326
View File
@@ -1,326 +0,0 @@
package firewall
import (
"context"
"fmt"
"regexp"
"sort"
"strings"
)
// What filters a machine, said with an owner (novox/hq ADR 0168).
//
// "The firewall found" names one front end, and a machine carries rules from several sources: the
// front end's own, the container runtime's plumbing, a ban list, the mesh's own tables, and whatever
// a predecessor installed directly — on both machines of the first mesh, in the user chain the
// runtime leaves for an administrator, where the mesh's reader of rules counted it as the runtime's.
// So the host reports every table and chain that refuses traffic, each with whose it is, and the
// mesh says truthfully what filters a converged machine. It removes none of it.
// Owners of a refusal.
const (
// OwnerMesh is the mesh's own tables: the derived filter and the guard.
OwnerMesh = "mesh"
// OwnerFoundFirewall is the front end found on the machine — ufw's chains.
OwnerFoundFirewall = "found-firewall"
// OwnerRuntime is the container runtime's own plumbing: its chains, the forward policy it sets
// when it turns forwarding on, its guard against reaching a container's address from off its
// bridge. Not the user chain it leaves for an administrator.
OwnerRuntime = "runtime"
// OwnerBan is a refusal that names the sources it refuses, in a chain that accepts nothing — a
// ban list, which is not a firewall.
OwnerBan = "ban"
// OwnerOther is everything else: rules the mesh did not write and cannot attribute. Where a
// predecessor's rules live.
OwnerOther = "other"
)
// A Filter is one place on the machine that refuses traffic: a chain of a table, or a chain of the
// legacy filter, with its owner and what it refuses in one line.
type Filter struct {
// Where names the chain: "table ip filter, chain DOCKER-USER", or "chain HAL-MESH-ONLY
// (iptables-legacy)".
Where string `json:"where"`
// Owner is one of the owners above.
Owner string `json:"owner"`
// Refuses is the first refusing line, counters stripped, and how many more there are.
Refuses string `json:"refuses"`
table, chain string
}
// userChain is the chain the container runtime creates empty and leaves for an administrator's
// rules, consulted before its own forwarding. Nothing in it is the runtime's.
const userChain = "DOCKER-USER"
// Filters classifies every refusing chain of an `nft list ruleset` and of the legacy filter's `-S`
// listings (by tool: iptables-legacy, ip6tables-legacy), in the order they appear.
func Filters(ruleset string, legacy map[string]string, ufwActive bool) []Filter {
var out []Filter
r := parseNft(ruleset)
refusing := map[string][]nftRule{} // by "table\x00chain"
for _, rule := range r.refusals {
k := rule.table + "\x00" + rule.chain
refusing[k] = append(refusing[k], rule)
}
for _, k := range r.chainOrder {
c := r.chains[k]
table, chain, _ := strings.Cut(k, "\x00")
rules := refusing[k]
if !c.dropping && len(rules) == 0 {
continue
}
f := Filter{table: table, chain: chain, Where: "table " + table + ", chain " + chain}
switch {
case table == MeshTable || table == "inet mesh_guard":
f.Owner = OwnerMesh
case strings.HasPrefix(chain, "ufw"):
f.Owner = OwnerFoundFirewall
if !ufwActive {
// Left behind by a retired front end, and still refusing: not ufw's any more in
// any sense that matters, since nothing maintains it.
f.Owner = OwnerOther
}
case chain == userChain:
f.Owner = OwnerOther
case c.dropping && (r.managed[table] || iptablesTable(table)) && runtimes(table, chain, c.policyLine):
f.Owner = OwnerRuntime
case len(rules) > 0 && (r.managed[table] || iptablesTable(table)) && allRuntimes(table, chain, rules):
f.Owner = OwnerRuntime
case len(rules) > 0 && allBans(r, rules):
f.Owner = OwnerBan
case c.dropping && !iptablesTable(table) && !r.managed[table] && len(rules) == 0:
// A table of its own whose base chain drops by policy: a firewall nobody declared.
f.Owner = OwnerOther
default:
f.Owner = OwnerOther
}
if ufwActive && (r.managed[table] || iptablesTable(table)) && f.Owner == OwnerOther && len(rules) == 0 && c.dropping {
// A base chain ufw set to drop while it is in force is ufw's.
f.Owner = OwnerFoundFirewall
}
f.Refuses = refusesLine(c, rules)
out = append(out, f)
}
tools := make([]string, 0, len(legacy))
for tool := range legacy {
tools = append(tools, tool)
}
sort.Strings(tools)
for _, tool := range tools {
out = append(out, legacyFilters(legacy[tool], tool, ufwActive)...)
}
return out
}
// allRuntimes is whether every refusal in a chain is the runtime's own.
func allRuntimes(table, chain string, rules []nftRule) bool {
for _, rule := range rules {
if !runtimes(table, chain, rule.line) {
return false
}
}
return true
}
// allBans is whether every refusal in a chain only bans the sources it names.
func allBans(r *nftRuleset, rules []nftRule) bool {
for _, rule := range rules {
if !r.onlyBans(rule) {
return false
}
}
return true
}
var counters = regexp.MustCompile(`\s*counter packets \d+ bytes \d+`)
// refusesLine is one line a person reads: the policy when the chain drops by policy, else the first
// refusing rule with its counters stripped, and how many more there are.
func refusesLine(c *nftChain, rules []nftRule) string {
var parts []string
if c.dropping {
parts = append(parts, "policy drop")
}
if len(rules) > 0 {
line := strings.TrimSpace(counters.ReplaceAllString(rules[0].line, ""))
if len(rules) > 1 {
line += fmt.Sprintf(" (and %d more)", len(rules)-1)
}
parts = append(parts, line)
}
return strings.Join(parts, "; ")
}
// legacyFilters classifies the chains of an `iptables-legacy -S` listing that refuse.
func legacyFilters(rules, tool string, ufwActive bool) []Filter {
policy := map[string]string{}
accepting := map[string]bool{}
jumpedFrom := map[string][]string{}
for _, line := range strings.Split(rules, "\n") {
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
switch fields[0] {
case "-P":
policy[fields[1]] = fields[2]
case "-A":
for i, f := range fields {
if (f == "-j" || f == "-g") && i+1 < len(fields) {
switch fields[i+1] {
case "ACCEPT":
accepting[fields[1]] = true
case "DROP", "REJECT", "RETURN", "LOG":
default:
jumpedFrom[fields[i+1]] = append(jumpedFrom[fields[i+1]], fields[1])
}
}
}
}
}
// A chain of refusals is a ban list when every refusal names the sources it refuses and the
// chain accepts nothing — the same rule the nftables side applies, and no more.
//
// **The policy of the chains that jump to it says nothing about what it is.** An earlier cut
// required every path into the chain to come from a built-in whose policy accepts, and the
// mesh's own intrusion prevention then read as a foreign rule set on the home server: its ban
// chain hangs off the container runtime's user chain as well as INPUT, and that machine's
// forward policy is DROP because the runtime set it. The machine reported "NOT the mesh alone"
// about a chain the mesh had just written (novox/hq ADR 0186). The policy is already classified
// where it belongs — as the runtime's — so requiring it here counted it twice.
ban := func(chain, line string) bool {
return bansSources(line) && !accepting[chain] && len(jumpedFrom[chain]) > 0
}
type seen struct {
owner string
lines []string
}
chains := map[string]*seen{}
var order []string
note := func(chain, owner, line string) {
s := chains[chain]
if s == nil {
s = &seen{owner: owner}
chains[chain] = s
order = append(order, chain)
}
if owner == OwnerOther || s.owner == "" {
s.owner = owner
}
s.lines = append(s.lines, line)
}
for _, line := range strings.Split(rules, "\n") {
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
chain := fields[1]
switch fields[0] {
case "-P":
if fields[2] != "DROP" {
continue
}
owner := OwnerOther
if chain == "FORWARD" {
owner = OwnerRuntime
}
if ufwActive {
owner = OwnerFoundFirewall
}
note(chain, owner, "policy DROP")
case "-A":
refuses := false
for i, f := range fields {
if f == "-j" && i+1 < len(fields) && (fields[i+1] == "DROP" || fields[i+1] == "REJECT") {
refuses = true
}
}
if !refuses {
continue
}
owner := OwnerOther
switch {
case strings.HasPrefix(chain, "ufw"):
owner = OwnerFoundFirewall
if !ufwActive {
owner = OwnerOther
}
case chain != userChain && strings.HasPrefix(chain, "DOCKER"):
owner = OwnerRuntime
case ban(chain, line):
owner = OwnerBan
}
note(chain, owner, strings.TrimSpace(line))
}
}
var out []Filter
for _, chain := range order {
s := chains[chain]
refuses := s.lines[0]
if len(s.lines) > 1 {
refuses += fmt.Sprintf(" (and %d more)", len(s.lines)-1)
}
out = append(out, Filter{Where: "chain " + chain + " (" + tool + ")", Owner: s.owner, Refuses: refuses})
}
return out
}
// Collect reads what filters this machine now: its nftables ruleset and, where the legacy tools
// exist, their listings. A machine without nft is read through iptables, as Detect reads it.
func Collect(ctx context.Context, run Runner, ufwActive bool) ([]Filter, error) {
ruleset := ""
noNft := false
out, err := run(ctx, "nft", "list", "ruleset")
switch {
case err == nil:
ruleset = out
case missing(err):
noNft = true
default:
return nil, fmt.Errorf("cannot read this machine's packet filter: %w", err)
}
legacy := map[string]string{}
tools := []string{"iptables-legacy", "ip6tables-legacy"}
if noNft {
tools = append(tools, "iptables", "ip6tables")
}
for _, tool := range tools {
if out, err := run(ctx, tool, "-S"); err == nil && strings.TrimSpace(out) != "" {
legacy[tool] = out
}
}
return Filters(ruleset, legacy, ufwActive), nil
}
// Alone is whether a machine is filtered by the mesh alone: nothing in the list but the mesh's
// own tables, the runtime's plumbing and bans (novox/hq ADR 0168).
func Alone(filters []Filter) bool {
for _, f := range filters {
if f.Owner == OwnerOther || f.Owner == OwnerFoundFirewall {
return false
}
}
return true
}
// Active says whether ufw is in force on this machine now. A machine without ufw is not.
func Active(ctx context.Context, run Runner) bool {
out, err := run(ctx, "ufw", "status")
return err == nil && statusActive(out)
}
// Installed says whether ufw is on this machine at all: a command that is not there is a front end
// that was uninstalled (novox/hq ADR 0180), not one that is silent.
func Installed(ctx context.Context, run Runner) bool {
_, err := run(ctx, "ufw", "status")
return !missing(err)
}
// Retirements of a found firewall, as the host records them.
const (
RetiredByMesh = "mesh"
RetiredFoundSo = "found-inactive"
// RetiredRemoved is a front end uninstalled by the module that replaced it (ADR 0180).
RetiredRemoved = "removed"
)
-130
View File
@@ -1,130 +0,0 @@
package firewall
import (
"os"
"strings"
"testing"
)
func fixture(t *testing.T, name string) string {
t.Helper()
raw, err := os.ReadFile("testdata/" + name)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func ownerOf(filters []Filter, where string) string {
for _, f := range filters {
if f.Where == where {
return f.Owner
}
}
return "(not reported)"
}
// Every refusing table and chain is classified with an owner (novox/hq ADR 0168), over rulesets
// captured from three machines of the first mesh. The control node: a ban list reached through the
// runtime's user chain is a ban; a refusal left in that chain, and a chain a retired front end left
// behind, are *other*; the runtime's own and the mesh's own are theirs.
func TestTheControlNodesRefusalsAreClassified(t *testing.T) {
got := Filters(fixture(t, "control-node.nft"), nil, false)
for where, want := range map[string]string{
"table ip filter, chain f2b-recidive": OwnerBan,
"table ip filter, chain DOCKER": OwnerRuntime,
"table ip raw, chain PREROUTING": OwnerRuntime,
"table inet mesh, chain input": OwnerMesh,
"table inet mesh, chain forward": OwnerMesh,
"table ip6 filter, chain DOCKER-USER": OwnerOther,
"table ip6 filter, chain ufw6-docker-logging-deny": OwnerOther,
} {
if o := ownerOf(got, where); o != want {
t.Errorf("%s: %s, want %s", where, o, want)
}
}
if Alone(got) {
t.Error("a machine with a refusal in the runtime's user chain reads as filtered by the mesh alone")
}
// What refuses adoption does not move (rule 4): the user chain's refusals are reported, not
// refused. The chain a retired front end left behind, still dropping, is what it always was
// to Detect — a refusal nobody speaks for, in one table.
if refusing := Refusing(fixture(t, "control-node.nft"), false); len(refusing) != 1 || refusing[0] != "table ip6 filter" {
t.Errorf("adoption's threshold moved: %v", refusing)
}
// The counters are stripped from what a person reads.
for _, f := range got {
if strings.Contains(f.Refuses, "counter packets") {
t.Errorf("counters in the line: %s", f.Refuses)
}
}
}
// The laptop: the runtime's forward policy and bridge guards, a virtualisation host and an endpoint
// agent that refuse nothing, and the mesh — filtered by the mesh alone.
func TestTheLaptopIsFilteredByTheMeshAlone(t *testing.T) {
got := Filters(fixture(t, "laptop.nft"), nil, false)
for where, want := range map[string]string{
"table ip filter, chain FORWARD": OwnerRuntime,
"table ip filter, chain DOCKER": OwnerRuntime,
"table ip raw, chain PREROUTING": OwnerRuntime,
"table inet mesh, chain input": OwnerMesh,
} {
if o := ownerOf(got, where); o != want {
t.Errorf("%s: %s, want %s", where, o, want)
}
}
for _, f := range got {
if strings.Contains(f.Where, "incus") || strings.Contains(f.Where, "fct_") {
t.Errorf("a table that refuses nothing is reported: %+v", f)
}
}
if !Alone(got) {
t.Errorf("the laptop is not read as filtered by the mesh alone: %+v", got)
}
}
// The home server: its rules are in the legacy filter, where a predecessor's chain still drops what
// arrives on the outward link for the forwarded path — invisible to the mesh until now (issue 144).
func TestThePredecessorsChainInTheLegacyFilterIsOther(t *testing.T) {
mesh := "table inet mesh {\n\tchain forward {\n\t\ttype filter hook forward priority filter; policy drop;\n\t}\n}\n"
got := Filters(mesh, map[string]string{"iptables-legacy": fixture(t, "home-server-legacy-S.txt")}, false)
for where, want := range map[string]string{
"table inet mesh, chain forward": OwnerMesh,
"chain FORWARD (iptables-legacy)": OwnerRuntime,
"chain DOCKER (iptables-legacy)": OwnerRuntime,
"chain HAL-MESH-ONLY (iptables-legacy)": OwnerOther,
} {
if o := ownerOf(got, where); o != want {
t.Errorf("%s: %s, want %s", where, o, want)
}
}
var other Filter
for _, f := range got {
if f.Owner == OwnerOther {
other = f
}
}
if !strings.Contains(other.Refuses, "-j DROP") {
t.Errorf("what the predecessor's chain refuses is not said: %+v", other)
}
if Alone(got) {
t.Error("a machine with a predecessor's chain reads as filtered by the mesh alone")
}
}
// With the front end in force, its chains are its own; retired, a chain it left behind that still
// refuses is nobody's and said so.
func TestAFrontEndsChainsAreItsWhileItIsInForce(t *testing.T) {
ruleset := dockerOnly(t) + ufwChains
for _, f := range Filters(ruleset, nil, true) {
if strings.Contains(f.Where, "ufw") && f.Owner != OwnerFoundFirewall {
t.Errorf("active: %+v", f)
}
}
for _, f := range Filters(ruleset, nil, false) {
if strings.Contains(f.Where, "ufw") && f.Owner != OwnerOther {
t.Errorf("retired: %+v", f)
}
}
}
+71 -92
View File
@@ -128,82 +128,42 @@ func statusActive(out string) bool {
// mesh needs, so a refusal that names the sources it refuses, in a table or a chain that accepts
// nothing and is entered only from chains whose policy accepts, is not counted.
func Refusing(ruleset string, ufwActive bool) []string {
var refusing []string
for _, f := range Filters(ruleset, nil, ufwActive) {
if f.Owner != OwnerOther || f.chain == userChain {
// A refusal in the runtime's user chain is reported as *other* and does not refuse
// adoption (novox/hq ADR 0168, rule 4): both predecessors kept their rules there.
continue
}
name := "table " + f.table
if len(refusing) == 0 || refusing[len(refusing)-1] != name {
if !contains(refusing, name) {
refusing = append(refusing, name)
}
}
}
return refusing
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// nftRule is one line of a ruleset that refuses, with where it is.
type nftRule struct{ table, chain, line string }
// nftChain is what a parse knows about one chain.
type nftChain struct {
type rule struct{ table, chain, line string }
type chainOf struct {
base, dropping, accepts bool
policyLine string
jumpedFrom []string
}
// nftRuleset is `nft list ruleset`, read: its tables in order, its chains, every refusing line,
// and which tables iptables-nft manages.
type nftRuleset struct {
tables []string
chains map[string]*nftChain // by "table\x00chain"
chainOrder []string
tableAccepts map[string]bool
refusals []nftRule
managed map[string]bool
}
func (r *nftRuleset) get(t, c string) *nftChain {
k := t + "\x00" + c
if r.chains[k] == nil {
r.chains[k] = &nftChain{}
r.chainOrder = append(r.chainOrder, k)
}
return r.chains[k]
}
func parseNft(ruleset string) *nftRuleset {
r := &nftRuleset{chains: map[string]*nftChain{}, tableAccepts: map[string]bool{}, managed: map[string]bool{}}
chains := map[string]*chainOf{} // by "table\x00chain"
tableAccepts := map[string]bool{}
var tables []string
var refusals []rule
managed := map[string]bool{}
var table, chain string
get := func(t, c string) *chainOf {
k := t + "\x00" + c
if chains[k] == nil {
chains[k] = &chainOf{}
}
return chains[k]
}
for _, raw := range strings.Split(ruleset, "\n") {
line := strings.TrimSpace(raw)
switch {
case strings.HasPrefix(line, "# Warning: table ") && strings.Contains(line, "managed by iptables-nft"):
name := strings.TrimPrefix(line, "# Warning: table ")
name, _, _ = strings.Cut(name, " is managed")
r.managed[name] = true
managed[name] = true
continue
case strings.HasPrefix(line, "table "):
table = strings.TrimSuffix(strings.TrimSpace(strings.TrimPrefix(line, "table ")), "{")
table = strings.TrimSpace(table)
r.tables = append(r.tables, table)
tables = append(tables, table)
chain = ""
continue
case strings.HasPrefix(line, "chain "):
chain = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "chain "), "{"))
r.get(table, chain)
get(table, chain)
continue
case strings.HasPrefix(line, "set ") || strings.HasPrefix(line, "map ") ||
strings.HasPrefix(line, "flowtable "):
@@ -212,7 +172,7 @@ func parseNft(ruleset string) *nftRuleset {
case line == "" || line == "}" || strings.HasPrefix(line, "#") || chain == "":
continue
}
c := r.get(table, chain)
c := get(table, chain)
if strings.HasPrefix(line, "type ") {
c.base = true
c.policyLine = line
@@ -223,66 +183,85 @@ func parseNft(ruleset string) *nftRuleset {
if i := strings.Index(line, verb); i >= 0 {
target := strings.Fields(line[i+len(verb):])
if len(target) > 0 {
r.get(table, target[0]).jumpedFrom = append(r.get(table, target[0]).jumpedFrom, chain)
get(table, target[0]).jumpedFrom = append(get(table, target[0]).jumpedFrom, chain)
}
}
}
if accepts(line) {
c.accepts = true
r.tableAccepts[table] = true
tableAccepts[table] = true
}
if verdictRefuses(line) {
r.refusals = append(r.refusals, nftRule{table, chain, line})
refusals = append(refusals, rule{table, chain, line})
}
}
return r
}
// onlyBans is whether a refusal only refuses the sources it names: in a table that accepts nothing
// and whose base chains all accept by default, or in a chain that accepts nothing and is entered
// only from base chains that accept by default.
func (r *nftRuleset) onlyBans(rule nftRule) bool {
if !bansSources(rule.line) {
skipped := func(table string) bool {
if table == "inet mesh" || table == "inet mesh_guard" {
return true
}
return (managed[table] || iptablesTable(table)) && ufwActive
}
// onlyBans is whether a refusal only refuses the sources it names: in a table that accepts
// nothing and whose base chains all accept by default, or in a chain that accepts nothing and
// is entered only from base chains that accept by default.
onlyBans := func(r rule) bool {
if !bansSources(r.line) {
return false
}
allAccepting := true
for k, c := range r.chains {
if strings.HasPrefix(k, rule.table+"\x00") && c.base && !strings.Contains(c.policyLine, "policy accept") {
for k, c := range chains {
if strings.HasPrefix(k, r.table+"\x00") && c.base && !strings.Contains(c.policyLine, "policy accept") {
allAccepting = false
}
}
if !r.tableAccepts[rule.table] && allAccepting {
if !tableAccepts[r.table] && allAccepting {
return true
}
return r.enteredAccepting(rule.table, rule.chain, map[string]bool{})
}
// enteredAccepting is whether a chain accepts nothing and is entered only through chains that
// accept by default — base chains whose policy accepts, or chains that are themselves entered that
// way and accept nothing. A ban list jumped to from the runtime's user chain, which the forward
// chain enters with an accepting policy, is still a ban list.
func (r *nftRuleset) enteredAccepting(table, chain string, seen map[string]bool) bool {
if seen[chain] {
return false
}
seen[chain] = true
c := r.get(table, chain)
c := get(r.table, r.chain)
if c.base || c.accepts || len(c.jumpedFrom) == 0 {
return false
}
for _, from := range c.jumpedFrom {
caller := r.get(table, from)
if caller.base {
if !strings.Contains(caller.policyLine, "policy accept") {
return false
}
continue
}
if caller.accepts || !r.enteredAccepting(table, from, seen) {
caller := get(r.table, from)
if !caller.base || !strings.Contains(caller.policyLine, "policy accept") {
return false
}
}
return true
}
counted := map[string]bool{}
for k, c := range chains {
t, name, _ := strings.Cut(k, "\x00")
if skipped(t) || !c.dropping {
continue
}
if (managed[t] || iptablesTable(t)) && runtimes(t, name, c.policyLine) {
continue
}
counted[t] = true
}
for _, r := range refusals {
if skipped(r.table) || counted[r.table] {
continue
}
if (managed[r.table] || iptablesTable(r.table)) && runtimes(r.table, r.chain, r.line) {
continue
}
if onlyBans(r) {
continue
}
counted[r.table] = true
}
var refusing []string
for _, t := range tables {
if counted[t] {
counted[t] = false
refusing = append(refusing, "table "+t)
}
}
return refusing
}
// iptablesTable is whether a table is one iptables-nft writes. Named rather than read from the
-60
View File
@@ -1,60 +0,0 @@
package firewall
import (
"os"
"testing"
)
// The mesh's own ban list is a ban wherever it hangs (novox/hq ADR 0186).
//
// Captured from the home server after the intrusion prevention had banned four addresses: its ban
// chain is jumped to from INPUT, whose policy accepts, and from the container runtime's user chain,
// which hangs off a FORWARD the runtime set to DROP. Requiring every path to come from an accepting
// built-in made the machine report "NOT the mesh alone" about a chain the mesh had just written.
func TestTheMeshsOwnBanChainIsABanBehindADroppingForward(t *testing.T) {
legacy, err := os.ReadFile("testdata/home-server-bans-S.txt")
if err != nil {
t.Fatal(err)
}
filters := Filters("", map[string]string{"iptables-legacy": string(legacy)}, false)
var ban, other []string
for _, f := range filters {
switch f.Owner {
case OwnerBan:
ban = append(ban, f.Where)
case OwnerOther:
other = append(other, f.Where)
}
}
if len(other) > 0 {
t.Errorf("the machine reports %v as rule sets the mesh did not write", other)
}
found := false
for _, w := range ban {
if w == "chain f2b-route-proxy (iptables-legacy)" {
found = true
}
}
if !found {
t.Errorf("the intrusion prevention's own chain was not read as a ban; bans were %v", ban)
}
if !Alone(filters) {
t.Error("a machine filtered by the mesh and its own bans does not read as the mesh alone")
}
}
// A chain that accepts anything is doing more than banning, and is still not a ban — which is what
// keeps a predecessor's allow-and-drop chain classified as something the operator must look at.
func TestAChainThatAcceptsIsNotABan(t *testing.T) {
rules := "-P INPUT ACCEPT\n" +
"-A INPUT -j HAL-MESH-ONLY\n" +
"-N HAL-MESH-ONLY\n" +
"-A HAL-MESH-ONLY -s 10.0.0.0/8 -j ACCEPT\n" +
"-A HAL-MESH-ONLY -s 203.0.113.7/32 -j DROP\n"
for _, f := range Filters("", map[string]string{"iptables-legacy": rules}, false) {
if f.Where == "chain HAL-MESH-ONLY (iptables-legacy)" && f.Owner != OwnerOther {
t.Errorf("a chain that accepts was classified as %s", f.Owner)
}
}
}
-588
View File
@@ -1,588 +0,0 @@
# Warning: table ip filter is managed by iptables-nft, do not touch!
table ip filter {
chain INPUT {
type filter hook input priority filter; policy accept;
ip protocol tcp counter packets 945757787 bytes 1737008792038 jump f2b-sshd
ip protocol tcp counter packets 945756610 bytes 1737008898620 jump f2b-recidive
counter packets 2862213204 bytes 3144751431654 jump ufw-before-logging-input
counter packets 2862213204 bytes 3144751431654 jump ufw-before-input
counter packets 989333889 bytes 1776344988272 jump ufw-after-input
counter packets 989303248 bytes 1776343408920 jump ufw-after-logging-input
counter packets 989303248 bytes 1776343408920 jump ufw-reject-input
counter packets 989303248 bytes 1776343408920 jump ufw-track-input
}
chain FORWARD {
type filter hook forward priority filter; policy accept;
oifname "mesh0" counter packets 1613103 bytes 2577614868 accept
iifname "mesh0" counter packets 995195 bytes 84526284 accept
counter packets 20454697 bytes 11504107676 jump DOCKER-USER
counter packets 20442192 bytes 11503368404 jump DOCKER-FORWARD
counter packets 12438285 bytes 10907281833 jump ufw-before-logging-forward
counter packets 12438285 bytes 10907281833 jump ufw-before-forward
counter packets 384 bytes 39643 jump ufw-after-forward
counter packets 384 bytes 39643 jump ufw-after-logging-forward
counter packets 384 bytes 39643 jump ufw-reject-forward
counter packets 384 bytes 39643 jump ufw-track-forward
}
chain OUTPUT {
type filter hook output priority filter; policy accept;
counter packets 3195070897 bytes 3951725261199 jump ufw-before-logging-output
counter packets 3195070897 bytes 3951725261199 jump ufw-before-output
counter packets 945745931 bytes 1778546547406 jump ufw-after-output
counter packets 945745931 bytes 1778546547406 jump ufw-after-logging-output
counter packets 945745931 bytes 1778546547406 jump ufw-reject-output
counter packets 945745931 bytes 1778546547406 jump ufw-track-output
}
chain DOCKER-FORWARD {
counter packets 20442192 bytes 11503368404 jump DOCKER-CT
counter packets 8079126 bytes 1230017985 jump DOCKER-INTERNAL
counter packets 8079126 bytes 1230017985 jump DOCKER-BRIDGE
iifname "br-cadedce55fe9" counter packets 0 bytes 0 accept
iifname "br-dd007c7e67bc" counter packets 0 bytes 0 accept
iifname "br-a5fbc29c2c2a" counter packets 0 bytes 0 accept
iifname "br-6eb1e7f7f847" counter packets 0 bytes 0 accept
iifname "br-8ce143481a5b" counter packets 14700 bytes 2493600 accept
iifname "br-84e7d0cfeada" counter packets 0 bytes 0 accept
iifname "br-f8b083119d99" counter packets 264 bytes 57438 accept
iifname "br-0d1490cc67c9" counter packets 732468 bytes 351624109 accept
iifname "br-3b338a381229" counter packets 137 bytes 11876 accept
iifname "br-3008d408e73a" counter packets 25380 bytes 1564417 accept
iifname "br-ca07a9577a7f" counter packets 0 bytes 0 accept
iifname "docker0" counter packets 6695791 bytes 795364128 accept
iifname "br-a63fa64a9e18" counter packets 0 bytes 0 accept
iifname "br-1ccb887b3344" counter packets 237174 bytes 36804979 accept
iifname "br-9fd22324ec08" counter packets 0 bytes 0 accept
iifname "br-73641cceafc3" counter packets 36 bytes 6614 accept
iifname "br-77eb8a9e2ba1" counter packets 0 bytes 0 accept
iifname "br-e99ce5248c84" counter packets 0 bytes 0 accept
iifname "br-e5d78502832d" counter packets 0 bytes 0 accept
iifname "br-2e4a76a7cd2e" counter packets 20339 bytes 1799711 accept
iifname "br-72fd626a8ff7" counter packets 0 bytes 0 accept
}
chain DOCKER-USER {
ip protocol tcp counter packets 3054221 bytes 3329963005 jump f2b-sshd
ip protocol tcp counter packets 3054221 bytes 3329963005 jump f2b-recidive
counter packets 1421378091 bytes 2045004412819 return
}
chain ufw-before-logging-input {
}
chain ufw-before-logging-output {
}
chain ufw-before-logging-forward {
}
chain ufw-before-input {
}
chain ufw-before-output {
}
chain ufw-before-forward {
}
chain ufw-after-input {
}
chain ufw-after-output {
}
chain ufw-after-forward {
}
chain ufw-after-logging-input {
}
chain ufw-after-logging-output {
}
chain ufw-after-logging-forward {
}
chain ufw-reject-input {
}
chain ufw-reject-output {
}
chain ufw-reject-forward {
}
chain ufw-track-input {
}
chain ufw-track-output {
}
chain ufw-track-forward {
}
chain DOCKER {
ip daddr 172.17.0.7 iifname != "docker0" oifname "docker0" tcp dport 5432 counter packets 8 bytes 480 accept
ip daddr 172.19.0.2 iifname != "br-72fd626a8ff7" oifname "br-72fd626a8ff7" tcp dport 8080 counter packets 0 bytes 0 accept
ip daddr 172.17.0.6 iifname != "docker0" oifname "docker0" tcp dport 9443 counter packets 0 bytes 0 accept
ip daddr 172.17.0.6 iifname != "docker0" oifname "docker0" tcp dport 9000 counter packets 0 bytes 0 accept
ip daddr 192.168.176.2 iifname != "br-f8b083119d99" oifname "br-f8b083119d99" tcp dport 9001 counter packets 0 bytes 0 accept
ip daddr 192.168.176.2 iifname != "br-f8b083119d99" oifname "br-f8b083119d99" tcp dport 9000 counter packets 47769 bytes 2866140 accept
ip daddr 172.20.0.2 iifname != "br-6eb1e7f7f847" oifname "br-6eb1e7f7f847" tcp dport 8080 counter packets 0 bytes 0 accept
ip daddr 172.27.0.2 iifname != "br-3008d408e73a" oifname "br-3008d408e73a" tcp dport 3000 counter packets 0 bytes 0 accept
ip daddr 192.168.48.5 iifname != "br-77eb8a9e2ba1" oifname "br-77eb8a9e2ba1" tcp dport 80 counter packets 0 bytes 0 accept
ip daddr 192.168.48.4 iifname != "br-77eb8a9e2ba1" oifname "br-77eb8a9e2ba1" tcp dport 80 counter packets 0 bytes 0 accept
ip daddr 192.168.48.3 iifname != "br-77eb8a9e2ba1" oifname "br-77eb8a9e2ba1" tcp dport 80 counter packets 0 bytes 0 accept
ip daddr 192.168.48.2 iifname != "br-77eb8a9e2ba1" oifname "br-77eb8a9e2ba1" tcp dport 9000 counter packets 0 bytes 0 accept
ip daddr 172.18.0.2 iifname != "br-2e4a76a7cd2e" oifname "br-2e4a76a7cd2e" tcp dport 80 counter packets 0 bytes 0 accept
ip daddr 172.17.0.5 iifname != "docker0" oifname "docker0" tcp dport 80 counter packets 0 bytes 0 accept
ip daddr 172.17.0.3 iifname != "docker0" oifname "docker0" tcp dport 8222 counter packets 0 bytes 0 accept
ip daddr 172.17.0.3 iifname != "docker0" oifname "docker0" tcp dport 4222 counter packets 97 bytes 5744 accept
ip daddr 172.28.0.2 iifname != "br-8ce143481a5b" oifname "br-8ce143481a5b" tcp dport 1433 counter packets 0 bytes 0 accept
ip daddr 192.168.80.2 iifname != "br-e99ce5248c84" oifname "br-e99ce5248c84" tcp dport 8080 counter packets 0 bytes 0 accept
ip daddr 192.168.112.3 iifname != "br-e5d78502832d" oifname "br-e5d78502832d" tcp dport 9000 counter packets 0 bytes 0 accept
ip daddr 192.168.112.2 iifname != "br-e5d78502832d" oifname "br-e5d78502832d" tcp dport 80 counter packets 0 bytes 0 accept
ip daddr 192.168.128.2 iifname != "br-73641cceafc3" oifname "br-73641cceafc3" tcp dport 27017 counter packets 14 bytes 840 accept
ip daddr 192.168.208.2 iifname != "br-9fd22324ec08" oifname "br-9fd22324ec08" tcp dport 35621 counter packets 0 bytes 0 accept
ip daddr 192.168.203.13 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 4243 counter packets 0 bytes 0 accept
ip daddr 192.168.203.12 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 995 counter packets 194 bytes 11000 accept
ip daddr 192.168.203.12 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 993 counter packets 188 bytes 9394 accept
ip daddr 192.168.203.12 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 587 counter packets 444 bytes 23312 accept
ip daddr 192.168.203.12 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 465 counter packets 104 bytes 5852 accept
ip daddr 192.168.203.12 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 443 counter packets 0 bytes 0 accept
ip daddr 192.168.203.12 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 143 counter packets 443 bytes 25280 accept
ip daddr 192.168.203.12 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 110 counter packets 192 bytes 9561 accept
ip daddr 192.168.203.12 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 80 counter packets 0 bytes 0 accept
ip daddr 192.168.203.12 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 25 counter packets 430 bytes 22919 accept
ip daddr 172.17.0.2 iifname != "docker0" oifname "docker0" tcp dport 3000 counter packets 0 bytes 0 accept
ip daddr 172.17.0.2 iifname != "docker0" oifname "docker0" tcp dport 22 counter packets 1578 bytes 93884 accept
ip daddr 172.17.0.4 iifname != "docker0" oifname "docker0" tcp dport 5000 counter packets 25 bytes 1492 accept
iifname != "br-cadedce55fe9" oifname "br-cadedce55fe9" counter packets 0 bytes 0 drop
iifname != "br-dd007c7e67bc" oifname "br-dd007c7e67bc" counter packets 0 bytes 0 drop
iifname != "br-a5fbc29c2c2a" oifname "br-a5fbc29c2c2a" counter packets 0 bytes 0 drop
iifname != "br-6eb1e7f7f847" oifname "br-6eb1e7f7f847" counter packets 0 bytes 0 drop
iifname != "br-8ce143481a5b" oifname "br-8ce143481a5b" counter packets 0 bytes 0 drop
iifname != "br-84e7d0cfeada" oifname "br-84e7d0cfeada" counter packets 0 bytes 0 drop
iifname != "br-f8b083119d99" oifname "br-f8b083119d99" counter packets 0 bytes 0 drop
iifname != "br-0d1490cc67c9" oifname "br-0d1490cc67c9" counter packets 0 bytes 0 drop
iifname != "br-3b338a381229" oifname "br-3b338a381229" counter packets 0 bytes 0 drop
iifname != "br-3008d408e73a" oifname "br-3008d408e73a" counter packets 0 bytes 0 drop
iifname != "br-ca07a9577a7f" oifname "br-ca07a9577a7f" counter packets 0 bytes 0 drop
iifname != "docker0" oifname "docker0" counter packets 0 bytes 0 drop
iifname != "br-a63fa64a9e18" oifname "br-a63fa64a9e18" counter packets 0 bytes 0 drop
iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" counter packets 0 bytes 0 drop
iifname != "br-9fd22324ec08" oifname "br-9fd22324ec08" counter packets 0 bytes 0 drop
iifname != "br-73641cceafc3" oifname "br-73641cceafc3" counter packets 0 bytes 0 drop
iifname != "br-77eb8a9e2ba1" oifname "br-77eb8a9e2ba1" counter packets 0 bytes 0 drop
iifname != "br-e99ce5248c84" oifname "br-e99ce5248c84" counter packets 0 bytes 0 drop
iifname != "br-e5d78502832d" oifname "br-e5d78502832d" counter packets 0 bytes 0 drop
iifname != "br-2e4a76a7cd2e" oifname "br-2e4a76a7cd2e" counter packets 0 bytes 0 drop
iifname != "br-72fd626a8ff7" oifname "br-72fd626a8ff7" counter packets 0 bytes 0 drop
}
chain DOCKER-BRIDGE {
oifname "br-cadedce55fe9" counter packets 0 bytes 0 jump DOCKER
oifname "br-dd007c7e67bc" counter packets 0 bytes 0 jump DOCKER
oifname "br-a5fbc29c2c2a" counter packets 0 bytes 0 jump DOCKER
oifname "br-6eb1e7f7f847" counter packets 799 bytes 47940 jump DOCKER
oifname "br-8ce143481a5b" counter packets 0 bytes 0 jump DOCKER
oifname "br-84e7d0cfeada" counter packets 0 bytes 0 jump DOCKER
oifname "br-f8b083119d99" counter packets 98911 bytes 5934660 jump DOCKER
oifname "br-0d1490cc67c9" counter packets 69740 bytes 4118476 jump DOCKER
oifname "br-3b338a381229" counter packets 32 bytes 1920 jump DOCKER
oifname "br-3008d408e73a" counter packets 458 bytes 27480 jump DOCKER
oifname "br-ca07a9577a7f" counter packets 0 bytes 0 jump DOCKER
oifname "docker0" counter packets 87073 bytes 5223529 jump DOCKER
oifname "br-a63fa64a9e18" counter packets 1353 bytes 81180 jump DOCKER
oifname "br-1ccb887b3344" counter packets 7662 bytes 419862 jump DOCKER
oifname "br-9fd22324ec08" counter packets 173 bytes 10380 jump DOCKER
oifname "br-73641cceafc3" counter packets 162 bytes 9720 jump DOCKER
oifname "br-77eb8a9e2ba1" counter packets 94 bytes 5640 jump DOCKER
oifname "br-e99ce5248c84" counter packets 8 bytes 480 jump DOCKER
oifname "br-e5d78502832d" counter packets 26 bytes 1560 jump DOCKER
oifname "br-2e4a76a7cd2e" counter packets 7 bytes 420 jump DOCKER
oifname "br-72fd626a8ff7" counter packets 0 bytes 0 jump DOCKER
}
chain DOCKER-CT {
oifname "br-cadedce55fe9" xt match "conntrack" counter packets 0 bytes 0 accept
oifname "br-dd007c7e67bc" xt match "conntrack" counter packets 0 bytes 0 accept
oifname "br-a5fbc29c2c2a" xt match "conntrack" counter packets 0 bytes 0 accept
oifname "br-6eb1e7f7f847" xt match "conntrack" counter packets 38458 bytes 6234236 accept
oifname "br-8ce143481a5b" xt match "conntrack" counter packets 60403 bytes 20478794 accept
oifname "br-84e7d0cfeada" xt match "conntrack" counter packets 0 bytes 0 accept
oifname "br-f8b083119d99" xt match "conntrack" counter packets 1008024 bytes 206364436 accept
oifname "br-0d1490cc67c9" xt match "conntrack" counter packets 871134 bytes 1416174426 accept
oifname "br-3b338a381229" xt match "conntrack" counter packets 4649 bytes 2311375 accept
oifname "br-3008d408e73a" xt match "conntrack" counter packets 13415 bytes 1974731 accept
oifname "br-ca07a9577a7f" xt match "conntrack" counter packets 0 bytes 0 accept
oifname "docker0" xt match "conntrack" counter packets 8909862 bytes 7892163419 accept
oifname "br-a63fa64a9e18" xt match "conntrack" counter packets 16688 bytes 6822829 accept
oifname "br-1ccb887b3344" xt match "conntrack" counter packets 461453 bytes 141913887 accept
oifname "br-9fd22324ec08" xt match "conntrack" counter packets 1677 bytes 427538 accept
oifname "br-73641cceafc3" xt match "conntrack" counter packets 417619 bytes 35343624 accept
oifname "br-77eb8a9e2ba1" xt match "conntrack" counter packets 125437 bytes 94155193 accept
oifname "br-e99ce5248c84" xt match "conntrack" counter packets 91 bytes 19173 accept
oifname "br-e5d78502832d" xt match "conntrack" counter packets 128653 bytes 40539569 accept
oifname "br-2e4a76a7cd2e" xt match "conntrack" counter packets 20257 bytes 158631592 accept
oifname "br-72fd626a8ff7" xt match "conntrack" counter packets 0 bytes 0 accept
}
chain DOCKER-INTERNAL {
}
chain f2b-recidive {
ip saddr 2.57.122.209 counter packets 0 bytes 0 xt target "REJECT"
ip saddr 2.57.122.76 counter packets 127 bytes 7600 xt target "REJECT"
ip saddr 195.178.110.228 counter packets 17 bytes 1000 xt target "REJECT"
ip saddr 2.57.122.74 counter packets 11 bytes 620 xt target "REJECT"
ip saddr 195.178.110.26 counter packets 56 bytes 3360 xt target "REJECT"
ip saddr 92.118.39.77 counter packets 2 bytes 80 xt target "REJECT"
ip saddr 92.118.39.71 counter packets 1 bytes 40 xt target "REJECT"
ip saddr 45.148.10.240 counter packets 0 bytes 0 xt target "REJECT"
ip saddr 195.178.110.30 counter packets 8 bytes 320 xt target "REJECT"
counter packets 948810608 bytes 1740338848565 return
}
chain f2b-sshd {
counter packets 948810709 bytes 1740338655735 return
}
}
# Warning: table ip6 filter is managed by iptables-nft, do not touch!
table ip6 filter {
chain INPUT {
type filter hook input priority filter; policy accept;
counter packets 5426360 bytes 34419159588 jump ufw6-before-logging-input
counter packets 5426360 bytes 34419159588 jump ufw6-before-input
counter packets 367982 bytes 3415126642 jump ufw6-after-input
counter packets 367982 bytes 3415126642 jump ufw6-after-logging-input
counter packets 367982 bytes 3415126642 jump ufw6-reject-input
counter packets 367982 bytes 3415126642 jump ufw6-track-input
}
chain FORWARD {
type filter hook forward priority filter; policy accept;
counter packets 0 bytes 0 jump DOCKER-USER
counter packets 0 bytes 0 jump DOCKER-FORWARD
counter packets 0 bytes 0 jump ufw6-before-logging-forward
counter packets 0 bytes 0 jump ufw6-before-forward
counter packets 0 bytes 0 jump ufw6-after-forward
counter packets 0 bytes 0 jump ufw6-after-logging-forward
counter packets 0 bytes 0 jump ufw6-reject-forward
counter packets 0 bytes 0 jump ufw6-track-forward
}
chain OUTPUT {
type filter hook output priority filter; policy accept;
counter packets 6004354 bytes 1866587952 jump ufw6-before-logging-output
counter packets 6004354 bytes 1866587952 jump ufw6-before-output
counter packets 2241898 bytes 639173314 jump ufw6-after-output
counter packets 2241898 bytes 639173314 jump ufw6-after-logging-output
counter packets 2241898 bytes 639173314 jump ufw6-reject-output
counter packets 2241898 bytes 639173314 jump ufw6-track-output
}
chain DOCKER-FORWARD {
counter packets 0 bytes 0 jump DOCKER-CT
counter packets 0 bytes 0 jump DOCKER-INTERNAL
counter packets 0 bytes 0 jump DOCKER-BRIDGE
}
chain DOCKER-USER {
counter packets 0 bytes 0 jump ufw6-user-forward
xt match "conntrack" counter packets 0 bytes 0 return
xt match "conntrack" counter packets 0 bytes 0 drop
iifname "docker0" oifname "docker0" counter packets 0 bytes 0 accept
ip6 saddr fd00::/8 counter packets 0 bytes 0 return
ip6 daddr fd00::/8 xt match "conntrack" counter packets 0 bytes 0 jump ufw6-docker-logging-deny
counter packets 0 bytes 0 return
}
chain ufw6-before-logging-input {
}
chain ufw6-before-logging-output {
}
chain ufw6-before-logging-forward {
}
chain ufw6-before-input {
}
chain ufw6-before-output {
}
chain ufw6-before-forward {
}
chain ufw6-after-input {
}
chain ufw6-after-output {
}
chain ufw6-after-forward {
}
chain ufw6-after-logging-input {
}
chain ufw6-after-logging-output {
}
chain ufw6-after-logging-forward {
}
chain ufw6-reject-input {
}
chain ufw6-reject-output {
}
chain ufw6-reject-forward {
}
chain ufw6-track-input {
}
chain ufw6-track-output {
}
chain ufw6-track-forward {
}
chain ufw6-user-forward {
}
chain ufw6-docker-logging-deny {
limit rate 3/minute burst 10 packets counter packets 0 bytes 0 xt target "LOG"
counter packets 0 bytes 0 drop
}
chain DOCKER {
}
chain DOCKER-BRIDGE {
}
chain DOCKER-CT {
}
chain DOCKER-INTERNAL {
}
}
# Warning: table ip nat is managed by iptables-nft, do not touch!
table ip nat {
chain PREROUTING {
type nat hook prerouting priority dstnat; policy accept;
xt match "addrtype" counter packets 10757093 bytes 647829087 jump DOCKER
}
chain OUTPUT {
type nat hook output priority dstnat; policy accept;
ip daddr != 127.0.0.0/8 xt match "addrtype" counter packets 128611 bytes 7705178 jump DOCKER
}
chain POSTROUTING {
type nat hook postrouting priority srcnat; policy accept;
ip saddr 172.19.0.0/16 oifname != "br-72fd626a8ff7" counter packets 0 bytes 0 xt target "MASQUERADE"
ip saddr 172.18.0.0/16 oifname != "br-2e4a76a7cd2e" counter packets 825 bytes 49500 xt target "MASQUERADE"
ip saddr 192.168.112.0/20 oifname != "br-e5d78502832d" counter packets 126 bytes 7560 xt target "MASQUERADE"
ip saddr 192.168.80.0/20 oifname != "br-e99ce5248c84" counter packets 0 bytes 0 xt target "MASQUERADE"
ip saddr 192.168.48.0/20 oifname != "br-77eb8a9e2ba1" counter packets 99 bytes 5940 xt target "MASQUERADE"
ip saddr 192.168.128.0/20 oifname != "br-73641cceafc3" counter packets 536 bytes 32160 xt target "MASQUERADE"
ip saddr 192.168.208.0/20 oifname != "br-9fd22324ec08" counter packets 2 bytes 120 xt target "MASQUERADE"
ip saddr 192.168.203.0/24 oifname != "br-1ccb887b3344" counter packets 37248 bytes 2854476 xt target "MASQUERADE"
ip saddr 192.168.64.0/20 oifname != "br-a63fa64a9e18" counter packets 353 bytes 21180 xt target "MASQUERADE"
ip saddr 172.17.0.0/16 oifname != "docker0" counter packets 99933 bytes 6001436 xt target "MASQUERADE"
ip saddr 172.21.0.0/16 oifname != "br-84e7d0cfeada" counter packets 2 bytes 128 xt target "MASQUERADE"
ip saddr 192.168.176.0/20 oifname != "br-f8b083119d99" counter packets 209 bytes 12644 xt target "MASQUERADE"
ip saddr 172.20.0.0/16 oifname != "br-6eb1e7f7f847" counter packets 699 bytes 42516 xt target "MASQUERADE"
ip saddr 172.28.0.0/16 oifname != "br-8ce143481a5b" counter packets 1934 bytes 116040 xt target "MASQUERADE"
ip saddr 172.27.0.0/16 oifname != "br-3008d408e73a" counter packets 2904 bytes 174240 xt target "MASQUERADE"
ip saddr 172.25.0.0/16 oifname != "br-cadedce55fe9" counter packets 0 bytes 0 xt target "MASQUERADE"
ip saddr 172.24.0.0/16 oifname != "br-3b338a381229" counter packets 385 bytes 23164 xt target "MASQUERADE"
ip saddr 192.168.224.0/20 oifname != "br-ca07a9577a7f" counter packets 0 bytes 0 xt target "MASQUERADE"
ip saddr 192.168.0.0/20 oifname != "br-a5fbc29c2c2a" counter packets 10 bytes 600 xt target "MASQUERADE"
ip saddr 172.31.0.0/16 oifname != "br-dd007c7e67bc" counter packets 0 bytes 0 xt target "MASQUERADE"
ip saddr 192.168.240.0/20 oifname != "br-0d1490cc67c9" counter packets 587045 bytes 35224163 xt target "MASQUERADE"
}
chain DOCKER {
iifname != "docker0" tcp dport 5100 counter packets 8247 bytes 494812 xt target "DNAT"
iifname != "docker0" tcp dport 222 counter packets 2304 bytes 137444 xt target "DNAT"
iifname != "docker0" tcp dport 20000 counter packets 1532 bytes 91584 xt target "DNAT"
iifname != "br-1ccb887b3344" tcp dport 25 counter packets 433 bytes 23099 xt target "DNAT"
iifname != "br-1ccb887b3344" tcp dport 7080 counter packets 35 bytes 1864 xt target "DNAT"
iifname != "br-1ccb887b3344" tcp dport 110 counter packets 195 bytes 9741 xt target "DNAT"
iifname != "br-1ccb887b3344" tcp dport 143 counter packets 448 bytes 25580 xt target "DNAT"
iifname != "br-1ccb887b3344" tcp dport 7443 counter packets 58 bytes 2868 xt target "DNAT"
iifname != "br-1ccb887b3344" tcp dport 465 counter packets 107 bytes 6032 xt target "DNAT"
iifname != "br-1ccb887b3344" tcp dport 587 counter packets 447 bytes 23492 xt target "DNAT"
iifname != "br-1ccb887b3344" tcp dport 993 counter packets 201 bytes 10174 xt target "DNAT"
iifname != "br-1ccb887b3344" tcp dport 995 counter packets 197 bytes 11180 xt target "DNAT"
iifname != "br-1ccb887b3344" tcp dport 20004 counter packets 5 bytes 300 xt target "DNAT"
iifname != "br-9fd22324ec08" tcp dport 20005 counter packets 5 bytes 300 xt target "DNAT"
iifname != "br-73641cceafc3" tcp dport 20006 counter packets 19 bytes 1140 xt target "DNAT"
iifname != "br-e5d78502832d" tcp dport 20007 counter packets 5 bytes 284 xt target "DNAT"
iifname != "br-e5d78502832d" tcp dport 20008 counter packets 4 bytes 240 xt target "DNAT"
iifname != "br-e99ce5248c84" tcp dport 1842 counter packets 12 bytes 720 xt target "DNAT"
iifname != "br-8ce143481a5b" tcp dport 4848 counter packets 40 bytes 1960 xt target "DNAT"
iifname != "docker0" tcp dport 4222 counter packets 3846 bytes 231012 xt target "DNAT"
ip daddr 127.0.0.1 iifname != "docker0" tcp dport 8222 counter packets 0 bytes 0 xt target "DNAT"
iifname != "docker0" tcp dport 20003 counter packets 18942 bytes 1136512 xt target "DNAT"
iifname != "br-2e4a76a7cd2e" tcp dport 9070 counter packets 195 bytes 11676 xt target "DNAT"
iifname != "br-77eb8a9e2ba1" tcp dport 9102 counter packets 13 bytes 772 xt target "DNAT"
iifname != "br-77eb8a9e2ba1" tcp dport 8102 counter packets 17 bytes 944 xt target "DNAT"
iifname != "br-77eb8a9e2ba1" tcp dport 8104 counter packets 16 bytes 916 xt target "DNAT"
iifname != "br-77eb8a9e2ba1" tcp dport 8103 counter packets 13 bytes 756 xt target "DNAT"
iifname != "br-3008d408e73a" tcp dport 1212 counter packets 189 bytes 11188 xt target "DNAT"
iifname != "br-6eb1e7f7f847" tcp dport 20009 counter packets 138 bytes 8280 xt target "DNAT"
iifname != "br-f8b083119d99" tcp dport 20001 counter packets 47780 bytes 2866736 xt target "DNAT"
iifname != "br-f8b083119d99" tcp dport 20002 counter packets 7 bytes 404 xt target "DNAT"
iifname != "docker0" tcp dport 20010 counter packets 74 bytes 4424 xt target "DNAT"
iifname != "docker0" tcp dport 20011 counter packets 4 bytes 240 xt target "DNAT"
iifname != "br-72fd626a8ff7" tcp dport 20012 counter packets 237 bytes 14220 xt target "DNAT"
iifname != "docker0" tcp dport 6852 counter packets 16489 bytes 989324 xt target "DNAT"
}
}
# Warning: table ip6 nat is managed by iptables-nft, do not touch!
table ip6 nat {
chain PREROUTING {
type nat hook prerouting priority dstnat; policy accept;
xt match "addrtype" counter packets 399 bytes 22104 jump DOCKER
}
chain OUTPUT {
type nat hook output priority dstnat; policy accept;
ip6 daddr != ::1 xt match "addrtype" counter packets 0 bytes 0 jump DOCKER
}
chain DOCKER {
}
}
table ip raw {
chain PREROUTING {
type filter hook prerouting priority raw; policy accept;
ip daddr 127.0.0.1 iifname != "lo" tcp dport 8222 counter packets 0 bytes 0 drop
}
}
table ip mangle {
chain FORWARD {
type filter hook forward priority mangle; policy accept;
}
}
table inet mesh {
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iif "lo" accept
iifname != { "mesh0", "enp9s0" } accept
icmp type echo-request accept
icmpv6 type { echo-request, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept
iifname != { "mesh0", "enp9s0" } udp dport { 53, 67 } accept
iifname != { "mesh0", "enp9s0" } tcp dport 53 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 22 accept
tcp dport 22 accept
tcp dport 4222 accept
tcp dport 22 accept
tcp dport 25 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 53 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } udp dport 53 accept
tcp dport 80 accept
tcp dport 110 accept
tcp dport 143 accept
tcp dport 222 accept
tcp dport 443 accept
tcp dport 465 accept
tcp dport 587 accept
tcp dport 993 accept
tcp dport 995 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 1212 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 1842 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 4222 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 4848 accept
tcp dport 5100 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 6852 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 7080 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 7443 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 8102 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 8103 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 8104 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 9000 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 9070 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 9102 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20000 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20001 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20002 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20003 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20004 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20005 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20006 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20007 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20008 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20009 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20010 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20011 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20012 accept
udp dport 51820 accept
}
chain output {
type filter hook output priority filter; policy accept;
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iifname != { "mesh0", "enp9s0" } accept
iifname "mesh0" oifname "mesh0" accept
ct original proto-dst 22 accept
ct original proto-dst 25 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 53 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 53 accept
ct original proto-dst 80 accept
ct original proto-dst 110 accept
ct original proto-dst 143 accept
ct original proto-dst 222 accept
ct original proto-dst 443 accept
ct original proto-dst 465 accept
ct original proto-dst 587 accept
ct original proto-dst 993 accept
ct original proto-dst 995 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 1212 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 1842 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 4222 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 4848 accept
ct original proto-dst 5100 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 6852 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 7080 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 7443 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 8102 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 8103 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 8104 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 9000 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 9070 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 9102 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20000 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20001 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20002 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20003 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20004 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20005 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20006 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20007 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20008 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20009 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20010 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20011 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20012 accept
ct original proto-dst 51820 accept
ct original proto-dst 4222 accept
}
}
-147
View File
@@ -1,147 +0,0 @@
-P INPUT ACCEPT
-P FORWARD DROP
-P OUTPUT ACCEPT
-N DOCKER
-N DOCKER-BRIDGE
-N DOCKER-CT
-N DOCKER-FORWARD
-N DOCKER-INTERNAL
-N DOCKER-USER
-N f2b-route-proxy
-N ufw-after-forward
-N ufw-after-input
-N ufw-after-logging-forward
-N ufw-after-logging-input
-N ufw-after-logging-output
-N ufw-after-output
-N ufw-before-forward
-N ufw-before-input
-N ufw-before-logging-forward
-N ufw-before-logging-input
-N ufw-before-logging-output
-N ufw-before-output
-N ufw-reject-forward
-N ufw-reject-input
-N ufw-reject-output
-N ufw-track-forward
-N ufw-track-input
-N ufw-track-output
-A INPUT -p tcp -j f2b-route-proxy
-A INPUT -j ufw-before-logging-input
-A INPUT -j ufw-before-input
-A INPUT -j ufw-after-input
-A INPUT -j ufw-after-logging-input
-A INPUT -j ufw-reject-input
-A INPUT -j ufw-track-input
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-FORWARD
-A FORWARD -j ufw-before-logging-forward
-A FORWARD -j ufw-before-forward
-A FORWARD -j ufw-after-forward
-A FORWARD -j ufw-after-logging-forward
-A FORWARD -j ufw-reject-forward
-A FORWARD -j ufw-track-forward
-A OUTPUT -j ufw-before-logging-output
-A OUTPUT -j ufw-before-output
-A OUTPUT -j ufw-after-output
-A OUTPUT -j ufw-after-logging-output
-A OUTPUT -j ufw-reject-output
-A OUTPUT -j ufw-track-output
-A DOCKER -d 172.17.0.18/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8686 -j ACCEPT
-A DOCKER -d 172.17.0.14/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8989 -j ACCEPT
-A DOCKER -d 172.17.0.15/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 7878 -j ACCEPT
-A DOCKER -d 172.17.0.5/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 9117 -j ACCEPT
-A DOCKER -d 172.17.0.13/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 6789 -j ACCEPT
-A DOCKER -d 172.19.0.2/32 ! -i br-32062158f584 -o br-32062158f584 -p tcp -m tcp --dport 8080 -j ACCEPT
-A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 5432 -j ACCEPT
-A DOCKER -d 172.27.0.2/32 ! -i br-0910a98c6158 -o br-0910a98c6158 -p tcp -m tcp --dport 5678 -j ACCEPT
-A DOCKER -d 172.17.0.21/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3579 -j ACCEPT
-A DOCKER -d 172.17.0.19/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8181 -j ACCEPT
-A DOCKER -d 172.17.0.17/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8787 -j ACCEPT
-A DOCKER -d 172.17.0.16/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 6767 -j ACCEPT
-A DOCKER -d 172.17.0.12/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3000 -j ACCEPT
-A DOCKER -d 172.17.0.11/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 80 -j ACCEPT
-A DOCKER -d 172.17.0.10/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 9443 -j ACCEPT
-A DOCKER -d 172.17.0.10/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 9000 -j ACCEPT
-A DOCKER -d 172.17.0.9/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3000 -j ACCEPT
-A DOCKER -d 172.17.0.7/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 1880 -j ACCEPT
-A DOCKER -d 172.28.0.2/32 ! -i br-b11461b5b028 -o br-b11461b5b028 -p tcp -m tcp --dport 80 -j ACCEPT
-A DOCKER -d 172.23.0.14/32 ! -i br-66ffa5c1cba5 -o br-66ffa5c1cba5 -p tcp -m tcp --dport 6543 -j ACCEPT
-A DOCKER -d 172.23.0.14/32 ! -i br-66ffa5c1cba5 -o br-66ffa5c1cba5 -p tcp -m tcp --dport 5432 -j ACCEPT
-A DOCKER -d 172.23.0.5/32 ! -i br-66ffa5c1cba5 -o br-66ffa5c1cba5 -p tcp -m tcp --dport 8000 -j ACCEPT
-A DOCKER -d 172.26.0.3/32 ! -i br-b0fec361ccaa -o br-b0fec361ccaa -p tcp -m tcp --dport 6167 -j ACCEPT
-A DOCKER -d 172.26.0.2/32 ! -i br-b0fec361ccaa -o br-b0fec361ccaa -p tcp -m tcp --dport 80 -j ACCEPT
-A DOCKER -d 172.17.0.8/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8000 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p udp -m udp --dport 10001 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8880 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8843 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8443 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8080 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 6789 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p udp -m udp --dport 5514 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p udp -m udp --dport 3478 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p udp -m udp --dport 1900 -j ACCEPT
-A DOCKER -d 172.25.0.3/32 ! -i br-b98821f7dc38 -o br-b98821f7dc38 -p tcp -m tcp --dport 8000 -j ACCEPT
-A DOCKER -d 172.18.0.3/32 ! -i br-442a0bfc65f8 -o br-442a0bfc65f8 -p tcp -m tcp --dport 1433 -j ACCEPT
-A DOCKER -d 172.20.0.3/32 ! -i br-afa37ac8b33d -o br-afa37ac8b33d -p tcp -m tcp --dport 8081 -j ACCEPT
-A DOCKER -d 172.20.0.3/32 ! -i br-afa37ac8b33d -o br-afa37ac8b33d -p tcp -m tcp --dport 1883 -j ACCEPT
-A DOCKER -d 172.17.0.4/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8086 -j ACCEPT
-A DOCKER -d 172.21.0.2/32 ! -i br-df15d8e19ec7 -o br-df15d8e19ec7 -p tcp -m tcp --dport 6379 -j ACCEPT
-A DOCKER -d 172.30.0.3/32 ! -i br-521eab9a3a5e -o br-521eab9a3a5e -p tcp -m tcp --dport 8283 -j ACCEPT
-A DOCKER -d 172.17.0.3/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3000 -j ACCEPT
-A DOCKER ! -i br-32062158f584 -o br-32062158f584 -j DROP
-A DOCKER ! -i docker0 -o docker0 -j DROP
-A DOCKER ! -i br-521eab9a3a5e -o br-521eab9a3a5e -j DROP
-A DOCKER ! -i br-df15d8e19ec7 -o br-df15d8e19ec7 -j DROP
-A DOCKER ! -i br-afa37ac8b33d -o br-afa37ac8b33d -j DROP
-A DOCKER ! -i br-442a0bfc65f8 -o br-442a0bfc65f8 -j DROP
-A DOCKER ! -i br-b98821f7dc38 -o br-b98821f7dc38 -j DROP
-A DOCKER ! -i br-b0fec361ccaa -o br-b0fec361ccaa -j DROP
-A DOCKER ! -i br-66ffa5c1cba5 -o br-66ffa5c1cba5 -j DROP
-A DOCKER ! -i br-b11461b5b028 -o br-b11461b5b028 -j DROP
-A DOCKER ! -i br-2df4e541b877 -o br-2df4e541b877 -j DROP
-A DOCKER ! -i br-0910a98c6158 -o br-0910a98c6158 -j DROP
-A DOCKER-BRIDGE -o br-32062158f584 -j DOCKER
-A DOCKER-BRIDGE -o docker0 -j DOCKER
-A DOCKER-BRIDGE -o br-521eab9a3a5e -j DOCKER
-A DOCKER-BRIDGE -o br-df15d8e19ec7 -j DOCKER
-A DOCKER-BRIDGE -o br-afa37ac8b33d -j DOCKER
-A DOCKER-BRIDGE -o br-442a0bfc65f8 -j DOCKER
-A DOCKER-BRIDGE -o br-b98821f7dc38 -j DOCKER
-A DOCKER-BRIDGE -o br-b0fec361ccaa -j DOCKER
-A DOCKER-BRIDGE -o br-66ffa5c1cba5 -j DOCKER
-A DOCKER-BRIDGE -o br-b11461b5b028 -j DOCKER
-A DOCKER-BRIDGE -o br-2df4e541b877 -j DOCKER
-A DOCKER-BRIDGE -o br-0910a98c6158 -j DOCKER
-A DOCKER-CT -o br-32062158f584 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-521eab9a3a5e -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-df15d8e19ec7 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-afa37ac8b33d -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-442a0bfc65f8 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-b98821f7dc38 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-b0fec361ccaa -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-66ffa5c1cba5 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-b11461b5b028 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-2df4e541b877 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-0910a98c6158 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-FORWARD -j DOCKER-CT
-A DOCKER-FORWARD -j DOCKER-INTERNAL
-A DOCKER-FORWARD -j DOCKER-BRIDGE
-A DOCKER-FORWARD -i br-32062158f584 -j ACCEPT
-A DOCKER-FORWARD -i docker0 -j ACCEPT
-A DOCKER-FORWARD -i br-521eab9a3a5e -j ACCEPT
-A DOCKER-FORWARD -i br-df15d8e19ec7 -j ACCEPT
-A DOCKER-FORWARD -i br-afa37ac8b33d -j ACCEPT
-A DOCKER-FORWARD -i br-442a0bfc65f8 -j ACCEPT
-A DOCKER-FORWARD -i br-b98821f7dc38 -j ACCEPT
-A DOCKER-FORWARD -i br-b0fec361ccaa -j ACCEPT
-A DOCKER-FORWARD -i br-66ffa5c1cba5 -j ACCEPT
-A DOCKER-FORWARD -i br-b11461b5b028 -j ACCEPT
-A DOCKER-FORWARD -i br-2df4e541b877 -j ACCEPT
-A DOCKER-FORWARD -i br-0910a98c6158 -j ACCEPT
-A DOCKER-USER -p tcp -j f2b-route-proxy
-A f2b-route-proxy -s 13.70.107.184/32 -j REJECT --reject-with icmp-port-unreachable
-A f2b-route-proxy -s 45.138.12.51/32 -j REJECT --reject-with icmp-port-unreachable
-A f2b-route-proxy -s 20.214.191.94/32 -j REJECT --reject-with icmp-port-unreachable
-A f2b-route-proxy -j RETURN
-149
View File
@@ -1,149 +0,0 @@
-P INPUT ACCEPT
-P FORWARD DROP
-P OUTPUT ACCEPT
-N DOCKER
-N DOCKER-BRIDGE
-N DOCKER-CT
-N DOCKER-FORWARD
-N DOCKER-INTERNAL
-N DOCKER-USER
-N HAL-MESH-ONLY
-N ufw-after-forward
-N ufw-after-input
-N ufw-after-logging-forward
-N ufw-after-logging-input
-N ufw-after-logging-output
-N ufw-after-output
-N ufw-before-forward
-N ufw-before-input
-N ufw-before-logging-forward
-N ufw-before-logging-input
-N ufw-before-logging-output
-N ufw-before-output
-N ufw-reject-forward
-N ufw-reject-input
-N ufw-reject-output
-N ufw-track-forward
-N ufw-track-input
-N ufw-track-output
-A INPUT -j ufw-before-logging-input
-A INPUT -j ufw-before-input
-A INPUT -j ufw-after-input
-A INPUT -j ufw-after-logging-input
-A INPUT -j ufw-reject-input
-A INPUT -j ufw-track-input
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-FORWARD
-A FORWARD -j ufw-before-logging-forward
-A FORWARD -j ufw-before-forward
-A FORWARD -j ufw-after-forward
-A FORWARD -j ufw-after-logging-forward
-A FORWARD -j ufw-reject-forward
-A FORWARD -j ufw-track-forward
-A OUTPUT -j ufw-before-logging-output
-A OUTPUT -j ufw-before-output
-A OUTPUT -j ufw-after-output
-A OUTPUT -j ufw-after-logging-output
-A OUTPUT -j ufw-reject-output
-A OUTPUT -j ufw-track-output
-A DOCKER -d 172.17.0.18/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8686 -j ACCEPT
-A DOCKER -d 172.17.0.14/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8989 -j ACCEPT
-A DOCKER -d 172.17.0.15/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 7878 -j ACCEPT
-A DOCKER -d 172.17.0.5/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 9117 -j ACCEPT
-A DOCKER -d 172.17.0.13/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 6789 -j ACCEPT
-A DOCKER -d 172.19.0.2/32 ! -i br-32062158f584 -o br-32062158f584 -p tcp -m tcp --dport 8080 -j ACCEPT
-A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 5432 -j ACCEPT
-A DOCKER -d 172.27.0.2/32 ! -i br-0910a98c6158 -o br-0910a98c6158 -p tcp -m tcp --dport 5678 -j ACCEPT
-A DOCKER -d 172.17.0.21/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3579 -j ACCEPT
-A DOCKER -d 172.17.0.19/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8181 -j ACCEPT
-A DOCKER -d 172.17.0.17/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8787 -j ACCEPT
-A DOCKER -d 172.17.0.16/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 6767 -j ACCEPT
-A DOCKER -d 172.17.0.12/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3000 -j ACCEPT
-A DOCKER -d 172.17.0.11/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 80 -j ACCEPT
-A DOCKER -d 172.17.0.10/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 9443 -j ACCEPT
-A DOCKER -d 172.17.0.10/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 9000 -j ACCEPT
-A DOCKER -d 172.17.0.9/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3000 -j ACCEPT
-A DOCKER -d 172.17.0.7/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 1880 -j ACCEPT
-A DOCKER -d 172.28.0.2/32 ! -i br-b11461b5b028 -o br-b11461b5b028 -p tcp -m tcp --dport 80 -j ACCEPT
-A DOCKER -d 172.23.0.14/32 ! -i br-66ffa5c1cba5 -o br-66ffa5c1cba5 -p tcp -m tcp --dport 6543 -j ACCEPT
-A DOCKER -d 172.23.0.14/32 ! -i br-66ffa5c1cba5 -o br-66ffa5c1cba5 -p tcp -m tcp --dport 5432 -j ACCEPT
-A DOCKER -d 172.23.0.5/32 ! -i br-66ffa5c1cba5 -o br-66ffa5c1cba5 -p tcp -m tcp --dport 8000 -j ACCEPT
-A DOCKER -d 172.26.0.3/32 ! -i br-b0fec361ccaa -o br-b0fec361ccaa -p tcp -m tcp --dport 6167 -j ACCEPT
-A DOCKER -d 172.26.0.2/32 ! -i br-b0fec361ccaa -o br-b0fec361ccaa -p tcp -m tcp --dport 80 -j ACCEPT
-A DOCKER -d 172.17.0.8/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8000 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p udp -m udp --dport 10001 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8880 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8843 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8443 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8080 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 6789 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p udp -m udp --dport 5514 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p udp -m udp --dport 3478 -j ACCEPT
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p udp -m udp --dport 1900 -j ACCEPT
-A DOCKER -d 172.25.0.3/32 ! -i br-b98821f7dc38 -o br-b98821f7dc38 -p tcp -m tcp --dport 8000 -j ACCEPT
-A DOCKER -d 172.18.0.3/32 ! -i br-442a0bfc65f8 -o br-442a0bfc65f8 -p tcp -m tcp --dport 1433 -j ACCEPT
-A DOCKER -d 172.20.0.3/32 ! -i br-afa37ac8b33d -o br-afa37ac8b33d -p tcp -m tcp --dport 8081 -j ACCEPT
-A DOCKER -d 172.20.0.3/32 ! -i br-afa37ac8b33d -o br-afa37ac8b33d -p tcp -m tcp --dport 1883 -j ACCEPT
-A DOCKER -d 172.17.0.4/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8086 -j ACCEPT
-A DOCKER -d 172.21.0.2/32 ! -i br-df15d8e19ec7 -o br-df15d8e19ec7 -p tcp -m tcp --dport 6379 -j ACCEPT
-A DOCKER -d 172.30.0.3/32 ! -i br-521eab9a3a5e -o br-521eab9a3a5e -p tcp -m tcp --dport 8283 -j ACCEPT
-A DOCKER -d 172.17.0.3/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3000 -j ACCEPT
-A DOCKER ! -i br-32062158f584 -o br-32062158f584 -j DROP
-A DOCKER ! -i docker0 -o docker0 -j DROP
-A DOCKER ! -i br-521eab9a3a5e -o br-521eab9a3a5e -j DROP
-A DOCKER ! -i br-df15d8e19ec7 -o br-df15d8e19ec7 -j DROP
-A DOCKER ! -i br-afa37ac8b33d -o br-afa37ac8b33d -j DROP
-A DOCKER ! -i br-442a0bfc65f8 -o br-442a0bfc65f8 -j DROP
-A DOCKER ! -i br-b98821f7dc38 -o br-b98821f7dc38 -j DROP
-A DOCKER ! -i br-b0fec361ccaa -o br-b0fec361ccaa -j DROP
-A DOCKER ! -i br-66ffa5c1cba5 -o br-66ffa5c1cba5 -j DROP
-A DOCKER ! -i br-b11461b5b028 -o br-b11461b5b028 -j DROP
-A DOCKER ! -i br-2df4e541b877 -o br-2df4e541b877 -j DROP
-A DOCKER ! -i br-0910a98c6158 -o br-0910a98c6158 -j DROP
-A DOCKER-BRIDGE -o br-32062158f584 -j DOCKER
-A DOCKER-BRIDGE -o docker0 -j DOCKER
-A DOCKER-BRIDGE -o br-521eab9a3a5e -j DOCKER
-A DOCKER-BRIDGE -o br-df15d8e19ec7 -j DOCKER
-A DOCKER-BRIDGE -o br-afa37ac8b33d -j DOCKER
-A DOCKER-BRIDGE -o br-442a0bfc65f8 -j DOCKER
-A DOCKER-BRIDGE -o br-b98821f7dc38 -j DOCKER
-A DOCKER-BRIDGE -o br-b0fec361ccaa -j DOCKER
-A DOCKER-BRIDGE -o br-66ffa5c1cba5 -j DOCKER
-A DOCKER-BRIDGE -o br-b11461b5b028 -j DOCKER
-A DOCKER-BRIDGE -o br-2df4e541b877 -j DOCKER
-A DOCKER-BRIDGE -o br-0910a98c6158 -j DOCKER
-A DOCKER-CT -o br-32062158f584 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-521eab9a3a5e -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-df15d8e19ec7 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-afa37ac8b33d -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-442a0bfc65f8 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-b98821f7dc38 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-b0fec361ccaa -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-66ffa5c1cba5 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-b11461b5b028 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-2df4e541b877 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o br-0910a98c6158 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-FORWARD -j DOCKER-CT
-A DOCKER-FORWARD -j DOCKER-INTERNAL
-A DOCKER-FORWARD -j DOCKER-BRIDGE
-A DOCKER-FORWARD -i br-32062158f584 -j ACCEPT
-A DOCKER-FORWARD -i docker0 -j ACCEPT
-A DOCKER-FORWARD -i br-521eab9a3a5e -j ACCEPT
-A DOCKER-FORWARD -i br-df15d8e19ec7 -j ACCEPT
-A DOCKER-FORWARD -i br-afa37ac8b33d -j ACCEPT
-A DOCKER-FORWARD -i br-442a0bfc65f8 -j ACCEPT
-A DOCKER-FORWARD -i br-b98821f7dc38 -j ACCEPT
-A DOCKER-FORWARD -i br-b0fec361ccaa -j ACCEPT
-A DOCKER-FORWARD -i br-66ffa5c1cba5 -j ACCEPT
-A DOCKER-FORWARD -i br-b11461b5b028 -j ACCEPT
-A DOCKER-FORWARD -i br-2df4e541b877 -j ACCEPT
-A DOCKER-FORWARD -i br-0910a98c6158 -j ACCEPT
-A DOCKER-USER -i enp6s0 -p tcp -m conntrack --ctstate NEW -j HAL-MESH-ONLY
-A HAL-MESH-ONLY -m conntrack --ctorigdstport 6881 -j RETURN
-A HAL-MESH-ONLY -m conntrack --ctorigdstport 80 -j RETURN
-A HAL-MESH-ONLY -m conntrack --ctorigdstport 443 -j RETURN
-A HAL-MESH-ONLY -s 10.0.0.0/8 -j RETURN
-A HAL-MESH-ONLY -s 172.16.0.0/12 -j RETURN
-A HAL-MESH-ONLY -s 192.168.0.0/16 -j RETURN
-A HAL-MESH-ONLY -m comment --comment "HAL: not public -> mesh only" -j DROP
-327
View File
@@ -1,327 +0,0 @@
table ip mangle {
chain FORWARD {
type filter hook forward priority mangle; policy accept;
}
}
# Warning: table ip nat is managed by iptables-nft, do not touch!
table ip nat {
chain PREROUTING {
type nat hook prerouting priority dstnat; policy accept;
xt match "addrtype" counter packets 12072 bytes 4564241 jump DOCKER
}
chain OUTPUT {
type nat hook output priority dstnat; policy accept;
ip daddr != 127.0.0.0/8 xt match "addrtype" counter packets 1854 bytes 111240 jump DOCKER
}
chain POSTROUTING {
type nat hook postrouting priority srcnat; policy accept;
ip saddr 172.17.0.0/16 oifname != "docker0" counter packets 903 bytes 61577 xt target "MASQUERADE"
ip saddr 172.21.0.0/16 oifname != "br-86a5d6b30e2b" counter packets 344 bytes 27744 xt target "MASQUERADE"
ip saddr 172.25.0.0/16 oifname != "br-61495e14a004" counter packets 374 bytes 33016 xt target "MASQUERADE"
ip saddr 172.30.0.0/16 oifname != "br-5107796ee9b4" counter packets 352 bytes 28224 xt target "MASQUERADE"
ip saddr 172.18.0.0/16 oifname != "br-cfd337ac4e58" counter packets 339 bytes 27444 xt target "MASQUERADE"
ip saddr 172.19.0.0/16 oifname != "br-8f0c6ee01425" counter packets 351 bytes 28164 xt target "MASQUERADE"
ip saddr 172.22.0.0/16 oifname != "br-75ac3c36e87f" counter packets 333 bytes 27084 xt target "MASQUERADE"
ip saddr 172.20.0.0/16 oifname != "br-0529801521bc" counter packets 343 bytes 27404 xt target "MASQUERADE"
}
chain DOCKER {
iifname != "br-61495e14a004" tcp dport 5680 counter packets 2 bytes 120 xt target "DNAT"
ip daddr 127.0.0.1 iifname != "br-61495e14a004" tcp dport 15673 counter packets 0 bytes 0 xt target "DNAT"
ip daddr 127.0.0.1 iifname != "docker0" tcp dport 55432 counter packets 0 bytes 0 xt target "DNAT"
}
}
# Warning: table ip filter is managed by iptables-nft, do not touch!
table ip filter {
chain DOCKER-FORWARD {
counter packets 702328 bytes 1801910999 jump DOCKER-CT
counter packets 337315 bytes 23928864 jump DOCKER-INTERNAL
counter packets 337315 bytes 23928864 jump DOCKER-BRIDGE
iifname "br-75ac3c36e87f" counter packets 0 bytes 0 accept
iifname "br-86a5d6b30e2b" counter packets 0 bytes 0 accept
iifname "br-8f0c6ee01425" counter packets 0 bytes 0 accept
iifname "br-cfd337ac4e58" counter packets 0 bytes 0 accept
iifname "br-0529801521bc" counter packets 0 bytes 0 accept
iifname "br-5107796ee9b4" counter packets 0 bytes 0 accept
iifname "br-61495e14a004" counter packets 0 bytes 0 accept
iifname "docker0" counter packets 337315 bytes 23928864 accept
}
chain FORWARD {
type filter hook forward priority filter; policy drop;
counter packets 702328 bytes 1801910999 jump DOCKER-USER
counter packets 702328 bytes 1801910999 jump DOCKER-FORWARD
}
chain DOCKER-USER {
ip protocol tcp counter packets 702510 bytes 1801965868 jump f2b-sshd
oifname "incusbr0" counter packets 0 bytes 0 accept
iifname "incusbr0" counter packets 0 bytes 0 accept
}
chain f2b-sshd {
counter packets 10423854 bytes 13891318049 return
}
chain INPUT {
type filter hook input priority filter; policy accept;
ip protocol tcp counter packets 9721344 bytes 12089352181 jump f2b-sshd
}
chain DOCKER {
ip daddr 172.17.0.2 iifname != "docker0" oifname "docker0" tcp dport 5432 counter packets 0 bytes 0 accept
ip daddr 172.25.0.2 iifname != "br-61495e14a004" oifname "br-61495e14a004" tcp dport 15672 counter packets 0 bytes 0 accept
ip daddr 172.25.0.2 iifname != "br-61495e14a004" oifname "br-61495e14a004" tcp dport 5672 counter packets 0 bytes 0 accept
iifname != "br-75ac3c36e87f" oifname "br-75ac3c36e87f" counter packets 0 bytes 0 drop
iifname != "br-86a5d6b30e2b" oifname "br-86a5d6b30e2b" counter packets 0 bytes 0 drop
iifname != "br-8f0c6ee01425" oifname "br-8f0c6ee01425" counter packets 0 bytes 0 drop
iifname != "br-cfd337ac4e58" oifname "br-cfd337ac4e58" counter packets 0 bytes 0 drop
iifname != "br-0529801521bc" oifname "br-0529801521bc" counter packets 0 bytes 0 drop
iifname != "br-5107796ee9b4" oifname "br-5107796ee9b4" counter packets 0 bytes 0 drop
iifname != "br-61495e14a004" oifname "br-61495e14a004" counter packets 0 bytes 0 drop
iifname != "docker0" oifname "docker0" counter packets 0 bytes 0 drop
}
chain DOCKER-BRIDGE {
oifname "br-75ac3c36e87f" counter packets 0 bytes 0 jump DOCKER
oifname "br-86a5d6b30e2b" counter packets 0 bytes 0 jump DOCKER
oifname "br-8f0c6ee01425" counter packets 0 bytes 0 jump DOCKER
oifname "br-cfd337ac4e58" counter packets 0 bytes 0 jump DOCKER
oifname "br-0529801521bc" counter packets 0 bytes 0 jump DOCKER
oifname "br-5107796ee9b4" counter packets 0 bytes 0 jump DOCKER
oifname "br-61495e14a004" counter packets 0 bytes 0 jump DOCKER
oifname "docker0" counter packets 0 bytes 0 jump DOCKER
}
chain DOCKER-CT {
oifname "br-75ac3c36e87f" xt match "conntrack" counter packets 0 bytes 0 accept
oifname "br-86a5d6b30e2b" xt match "conntrack" counter packets 0 bytes 0 accept
oifname "br-8f0c6ee01425" xt match "conntrack" counter packets 0 bytes 0 accept
oifname "br-cfd337ac4e58" xt match "conntrack" counter packets 0 bytes 0 accept
oifname "br-0529801521bc" xt match "conntrack" counter packets 0 bytes 0 accept
oifname "br-5107796ee9b4" xt match "conntrack" counter packets 0 bytes 0 accept
oifname "br-61495e14a004" xt match "conntrack" counter packets 0 bytes 0 accept
oifname "docker0" xt match "conntrack" counter packets 365013 bytes 1777982135 accept
}
chain DOCKER-INTERNAL {
}
}
# Warning: table ip6 nat is managed by iptables-nft, do not touch!
table ip6 nat {
chain PREROUTING {
type nat hook prerouting priority dstnat; policy accept;
xt match "addrtype" counter packets 363 bytes 67927 jump DOCKER
}
chain OUTPUT {
type nat hook output priority dstnat; policy accept;
ip6 daddr != ::1 xt match "addrtype" counter packets 0 bytes 0 jump DOCKER
}
chain DOCKER {
}
}
table ip6 filter {
chain DOCKER-FORWARD {
counter packets 0 bytes 0 jump DOCKER-CT
counter packets 0 bytes 0 jump DOCKER-INTERNAL
counter packets 0 bytes 0 jump DOCKER-BRIDGE
}
chain FORWARD {
type filter hook forward priority filter; policy accept;
counter packets 0 bytes 0 jump DOCKER-USER
counter packets 0 bytes 0 jump DOCKER-FORWARD
}
chain DOCKER-USER {
}
chain DOCKER {
}
chain DOCKER-BRIDGE {
}
chain DOCKER-CT {
}
chain DOCKER-INTERNAL {
}
}
table ip raw {
chain PREROUTING {
type filter hook prerouting priority raw; policy accept;
ip daddr 172.25.0.2 iifname != "br-61495e14a004" counter packets 0 bytes 0 drop
ip daddr 127.0.0.1 iifname != "lo" tcp dport 15673 counter packets 0 bytes 0 drop
ip daddr 172.17.0.2 iifname != "docker0" counter packets 0 bytes 0 drop
ip daddr 127.0.0.1 iifname != "lo" tcp dport 55432 counter packets 0 bytes 0 drop
}
}
table inet incus {
set bridges {
type ifname
elements = { "incusbr0" }
}
chain pstrt.incusbr0 {
type nat hook postrouting priority srcnat; policy accept;
ip saddr 10.7.169.0/24 oifname @bridges accept
ip saddr 10.7.169.0/24 ip daddr != 10.7.169.0/24 masquerade
ip6 saddr fd42:cbc4:e123:f6::/64 oifname @bridges accept
ip6 saddr fd42:cbc4:e123:f6::/64 ip6 daddr != fd42:cbc4:e123:f6::/64 masquerade
}
chain fwd.incusbr0 {
type filter hook forward priority filter; policy accept;
ip version 4 oifname "incusbr0" accept
ip version 4 iifname "incusbr0" accept
ip6 version 6 oifname "incusbr0" accept
ip6 version 6 iifname "incusbr0" accept
}
chain in.incusbr0 {
type filter hook input priority filter; policy accept;
iifname "incusbr0" tcp dport 53 accept
iifname "incusbr0" udp dport 53 accept
iifname "incusbr0" icmp type { destination-unreachable, time-exceeded, parameter-problem } accept
iifname "incusbr0" udp dport 67 accept
iifname "incusbr0" ip protocol udp udp checksum set 0
iifname "incusbr0" icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-solicit, nd-neighbor-solicit, nd-neighbor-advert, mld2-listener-report } accept
iifname "incusbr0" udp dport 547 accept
}
chain out.incusbr0 {
type filter hook output priority filter; policy accept;
oifname "incusbr0" tcp sport 53 accept
oifname "incusbr0" udp sport 53 accept
oifname "incusbr0" icmp type { destination-unreachable, time-exceeded, parameter-problem } accept
oifname "incusbr0" udp sport 67 accept
oifname "incusbr0" ip protocol udp udp checksum set 0
oifname "incusbr0" icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, echo-request, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, mld2-listener-report } accept
oifname "incusbr0" udp sport 547 accept
}
}
table ip fct_filter {
chain OUTPUT {
type filter hook output priority filter; policy accept;
}
chain FCT-QUARANTINE-EMS {
}
chain FCT-QUARANTINE-FAZ {
}
chain FORWARD {
type filter hook forward priority filter; policy accept;
}
chain FCT-WEBFILTER-QUIC-CHAIN {
}
chain INPUT {
type filter hook input priority filter; policy accept;
}
chain FCT-QUARANTINE {
}
chain FCT-DNS-QUIC-FILTER {
}
chain FCT-VPN-CHAIN {
}
}
table ip fct_nat {
chain OUTPUT {
type nat hook output priority dstnat; policy accept;
}
chain FCT-DNS-UDP-CHAIN-STAGE-2 {
}
chain FCT-DNS-UDP-CHAIN-STAGE-1 {
}
chain FCT-TCP-CHAIN {
}
chain FCT-DNS-DOH-CHAIN-STAGE-1 {
}
chain FCT-WEBFILTER-CHAIN {
}
chain FCT-DNS-DOH-CHAIN-STAGE-2 {
}
}
table ip6 fct_filter {
chain FCT-QUARANTINE {
}
chain INPUT {
type filter hook input priority filter; policy accept;
}
chain FORWARD {
type filter hook forward priority filter; policy accept;
}
chain OUTPUT {
type filter hook output priority filter; policy accept;
}
}
table ip fct_mangle {
chain PREROUTING {
type filter hook prerouting priority mangle; policy accept;
}
chain FCT-UDP-STAGE-1 {
}
chain OUTPUT {
type route hook output priority mangle; policy accept;
}
chain FCT-UDP-STAGE-2 {
}
chain FCT-UDP-OUTPUT {
}
}
table inet mesh {
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iif "lo" accept
iifname != { "mesh0", "wlp3s0" } accept
icmp type echo-request accept
icmpv6 type { echo-request, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept
iifname != { "mesh0", "wlp3s0" } udp dport { 53, 67 } accept
iifname != { "mesh0", "wlp3s0" } tcp dport 53 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 22 accept
tcp dport 22 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 53 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } udp dport 53 accept
}
chain output {
type filter hook output priority filter; policy accept;
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iifname != { "mesh0", "wlp3s0" } accept
iifname "mesh0" oifname "mesh0" accept
ct original proto-dst 22 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 53 accept
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 53 accept
}
}
+1 -11
View File
@@ -49,13 +49,8 @@ type Identity struct {
Membership Membership `json:"membership"`
// Overlay is this node's key on the private network. Generated here, like the identity above,
// and for the same reason: the mesh computes a graph it cannot impersonate — or, on an adopted
// node that took a found tunnel over, that tunnel's key (novox/hq ADR 0105).
// and for the same reason: the mesh computes a graph it cannot impersonate.
Overlay OverlayKey `json:"overlay"`
// OverlayBefore is the public half of the overlay key this node held before it took a found
// tunnel's, so a take run again names the key the mesh still records. Empty on a node that
// never took one.
OverlayBefore string `json:"overlay_before,omitempty"`
}
// Membership is how this node reaches the mesh it belongs to, and who it believes.
@@ -76,11 +71,6 @@ type Membership struct {
// Not the token's secret: that is spent, and a credential that lives for ever should not be
// the same string as one that was meant to be used once.
Password string `json:"password"`
// Transport is which bus this membership is for. Empty is the bus the mesh ran on before
// the move — so every membership written before this field existed reads as correct, not as
// unset — and "nats" is the one being moved to (novox/hq design 28, task 5.2).
Transport string `json:"transport,omitempty"`
}
// Queue is where this node listens. Its account may read this and nothing else.
-21
View File
@@ -43,27 +43,6 @@ func GenerateOverlayKey() (OverlayKey, error) {
}, nil
}
// OverlayKeyFrom makes this node's overlay key from a private key it did not generate: the found
// tunnel's, on an adopted node whose private network takes that tunnel over (novox/hq ADR 0105).
// The one case where the mesh takes a credential it did not mint. From here on it is stored and
// sealed exactly as a generated one — in the identity file and the key file, readable by root
// alone — and the mesh receives only the public half, derived here from the private one so the
// two cannot disagree.
func OverlayKeyFrom(privateBase64 string) (OverlayKey, error) {
raw, err := base64.StdEncoding.DecodeString(privateBase64)
if err != nil {
return OverlayKey{}, fmt.Errorf("the found tunnel's private key is not base64: %w", err)
}
private, err := ecdh.X25519().NewPrivateKey(raw)
if err != nil {
return OverlayKey{}, fmt.Errorf("the found tunnel's private key is not a Curve25519 key: %w", err)
}
return OverlayKey{
Public: base64.StdEncoding.EncodeToString(private.PublicKey().Bytes()),
Private: base64.StdEncoding.EncodeToString(private.Bytes()),
}, nil
}
// OverlayKeyPath is where the private half lives: a file of its own, referenced by the interface
// configuration rather than embedded in it.
//
-28
View File
@@ -1,28 +0,0 @@
package identity
import (
"strings"
"testing"
)
// novox/hq ADR 0105: the found tunnel's private key becomes the node's overlay key, stored as a
// generated one is, and the public half the mesh records is derived from it — so the peers that
// know the tunnel by that key keep reaching it.
func TestAnOverlayKeyTakenFromAFoundTunnelIsTheSameKey(t *testing.T) {
generated, err := GenerateOverlayKey()
if err != nil {
t.Fatal(err)
}
taken, err := OverlayKeyFrom(generated.Private)
if err != nil {
t.Fatal(err)
}
if taken.Public != generated.Public || taken.Private != generated.Private {
t.Fatalf("a key taken from a private half is not that key: %+v vs %+v", taken, generated)
}
for _, bad := range []string{"", "not base64!", "c2hvcnQ="} {
if _, err := OverlayKeyFrom(bad); err == nil || !strings.Contains(err.Error(), "found tunnel") {
t.Errorf("%q was taken as a key: %v", bad, err)
}
}
}
-53
View File
@@ -1,53 +0,0 @@
package link
import (
"context"
"fmt"
"time"
)
// The enrolment conversation, as the host's own words for it.
//
// **Its own seam rather than part of Link**, because almost nothing about it is the same. The
// credential is a one-time secret rather than this node's own; there is no declaration to hear; the
// whole exchange is a single question asked and possibly asked again. And the stakes differ: a node
// that fails here is not in the mesh at all, where a node that fails in Link has merely lost touch
// with one it belongs to.
// Approach is how a node reaches a mesh it does not yet belong to.
//
// The three things a token carries about where to go, and nothing about who is asking: the address,
// the certificate that address must present, and which bus is at the other end — the mesh's own
// (hearing.go), and a token naming any other is refused before anything is sent.
type Approach struct {
Address string
Fingerprint string
Transport string
}
// Asking is one open enrolment conversation.
type Asking interface {
// Ask puts the request to the mesh and waits for one answer, or says why none came.
//
// Called again, with the same bytes, while the mesh says "try again": the keys this node
// generated are the ones it keeps, so the same request is the same enrolment and the mesh holds
// the token for it (novox/hq issue 083).
Ask(ctx context.Context, request []byte, wait time.Duration) ([]byte, error)
// Close lets go of the connection made with the token.
Close()
}
// Present opens an enrolment conversation with the mesh.
//
// The connection is made before anything is sent, and the certificate is checked while it is being
// made — so a node pointed at the wrong bus finds out before its token has left the machine (ADR
// 0004).
func Present(ctx context.Context, to Approach, node, secret string,
timeout time.Duration) (Asking, error) {
if to.Transport != OnNATS {
return nil, fmt.Errorf("this token is for the %q bus, and the mesh's bus is %s", to.Transport, OnNATS)
}
return presentNats(ctx, to, node, secret, timeout)
}
-186
View File
@@ -1,186 +0,0 @@
package link
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/nats-io/nats.go"
)
// The enrolment conversation on the bus being built.
//
// **The reply address is the whole of what changes**, and it changes for a reason the transport
// forces rather than a preference. Core NATS request/reply puts the caller's inbox in the message's
// reply field and a plain responder answers it — but this request goes into a stream, and a message
// a JetStream consumer delivers has had that field claimed for the consumer's own ack address. So by
// the time the controller reads the request, the transport's reply field names where the
// *controller* must acknowledge. Verified against a running server (design 25 §2).
//
// The address therefore travels as a field of the request, and this subscribes it before publishing:
// a node that published first could miss an answer to a question nobody was listening for.
// enrolInbox is where a node enrolling waits.
//
// Under `_INBOX.enrol.<node>.`, which is exactly what its enrolment user may subscribe and no
// wider — so an answer sealed to one machine cannot be read by another enrolling beside it. The
// random tail is this attempt's own: a reply left over from an attempt that timed out is not the
// answer to this question, which is what the correlation id does on the other transport.
func enrolInbox(node string) (string, error) {
tail := make([]byte, 8)
if _, err := rand.Read(tail); err != nil {
return "", fmt.Errorf("cannot make a reply address: %w", err)
}
return "_INBOX.enrol." + node + "." + hex.EncodeToString(tail), nil
}
type natsAsking struct {
conn *nats.Conn
js nats.JetStreamContext
inbox string
answers *nats.Subscription
lost chan error
}
func presentNats(_ context.Context, to Approach, node, secret string,
timeout time.Duration) (Asking, error) {
config, err := PinnedConfig(to.Fingerprint)
if err != nil {
return nil, err
}
inbox, err := enrolInbox(node)
if err != nil {
return nil, err
}
lost := make(chan error, 1)
// The user is this token's own — `enrol.<node>`, which may publish the enrolment subject and
// subscribe its own inbox and nothing else (design 25 §6). The secret is its password, the same
// string the request claims, so the server proves somebody holds the token and the request
// proves the same thing to the controller without it having to ask the server who connected.
// **Its own inbox space, because that is the only one it may listen in** (novox/hq
// 04-ISSUES/146). A JetStream publish waits for the stream's acknowledgement on an inbox the
// client picks, and the client's default is `_INBOX.<random>` — which this user may not
// subscribe to, so the enrolment failed with a permissions violation on a subject nobody had
// chosen. The permission is `_INBOX.enrol.<node>.>` (design 25 §6), so the client is told to
// pick its inboxes there; the reply address below is in the same space for the same reason.
conn, err := nats.Connect(natsURL(to.Address),
nats.Secure(config),
nats.CustomInboxPrefix("_INBOX.enrol."+node),
nats.UserInfo("enrol."+node, secret),
nats.Name("mesh-host/enrol/"+node),
nats.Timeout(timeout),
nats.NoReconnect(),
nats.DisconnectErrHandler(func(_ *nats.Conn, err error) {
select {
case lost <- fmt.Errorf("the bus closed the connection: %w", err):
default:
}
}),
)
if err != nil {
if errors.Is(err, ErrWrongCertificate) {
return nil, err
}
// Not quoted back with the credential: the secret is one-time and still a secret.
return nil, fmt.Errorf("cannot reach the bus at %s as %s: %w", to.Address, node, err)
}
js, err := conn.JetStream()
if err != nil {
conn.Close()
return nil, fmt.Errorf("the bus at %s has no JetStream: %w", to.Address, err)
}
// Subscribed before anything is published, so an answer cannot arrive before there is anywhere
// for it to land.
answers, err := conn.SubscribeSync(inbox)
if err != nil {
conn.Close()
return nil, fmt.Errorf("this node cannot listen for the mesh's answer: %w", err)
}
if err := conn.Flush(); err != nil {
conn.Close()
return nil, fmt.Errorf("this node's reply address did not reach the bus: %w", err)
}
return &natsAsking{conn: conn, js: js, inbox: inbox, answers: answers, lost: lost}, nil
}
func (a *natsAsking) Close() {
if a.answers != nil {
_ = a.answers.Unsubscribe()
}
if a.conn != nil {
a.conn.Close()
}
}
// Ask publishes the request with this attempt's reply address written into it, and waits there.
func (a *natsAsking) Ask(ctx context.Context, request []byte, wait time.Duration) ([]byte, error) {
addressed, err := withReplyTo(request, a.inbox)
if err != nil {
return nil, err
}
publish, cancel := context.WithTimeout(ctx, wait)
defer cancel()
// Into the stream and awaited: an enrolment the bus never accepted must fail here rather than be
// assumed, because the node has nothing else to go on.
//
// **Once, however many times it is sent** (novox/hq 04-ISSUES/146). The client re-publishes when
// an acknowledgement is slow, and the mesh enrolled the machine on each copy — minting a second
// credential, which replaced the first, which is the one the node had already been given. The
// machine then reconnected for ever as a user whose password the mesh had rotated out from under
// it, and the controller's log said "enrolled anchor" twice in the same second.
//
// The id is the message: the same bytes carry the same id, so the stream discards the client's
// own retry, and a genuine second attempt — which carries a new reply address — is a different
// message and is let through.
sum := sha256.Sum256(addressed)
if _, err := a.js.Publish(EnrolSubject, addressed,
nats.MsgId(hex.EncodeToString(sum[:])), nats.Context(publish)); err != nil {
return nil, fmt.Errorf("cannot ask the mesh to enrol this node: %w", err)
}
// Waited for rather than assumed. A published message that nothing answers means the controller
// is not running, and a node that carried on regardless would believe it had joined a mesh that
// has never heard of it.
//
// **The wait may legitimately be several store-window cycles long**: the controller naks the
// request with a delay while its store is restarting, and the node is waiting on the other side
// of that — which is exactly the combination that would have delivered the answer to a caller
// who had given up, had the address travelled in the transport's field.
answered, cancelAnswer := context.WithTimeout(ctx, wait)
defer cancelAnswer()
for {
msg, err := a.answers.NextMsgWithContext(answered)
switch {
case err == nil:
return msg.Data, nil
case errors.Is(err, context.DeadlineExceeded):
select {
case reason := <-a.lost:
return nil, reason
default:
}
return nil, fmt.Errorf(
"the bus accepted this node's connection and nothing answered within %s. The mesh's "+
"bus is running and its controller is not", wait)
case errors.Is(err, context.Canceled):
return nil, ctx.Err()
default:
select {
case reason := <-a.lost:
return nil, reason
default:
}
return nil, fmt.Errorf("waiting for the mesh's answer: %w", err)
}
}
}
-135
View File
@@ -1,135 +0,0 @@
package link
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/nats-io/nats.go"
)
// The enrolment round trip against a real server.
//
// **This is the test that keeps a reason from becoming folklore.** The reply address travels in the
// request's payload because a JetStream consumer's delivery has had the transport's reply field
// claimed for its own ack address — which is a fact about a server, not a rule anybody can check by
// reading. Both halves are asserted here: that the field really is eaten, and that the answer
// reaches the node anyway.
//
// docker run -d --rm --name t -p 14223:4222 nats:2.10-alpine -js
// MESH_TEST_NATS=nats://127.0.0.1:14223 go test ./internal/link/ -run TestNatsAnEnrolment
// asking is a conversation on a bus with no TLS. Built directly rather than through Present because
// the pin is what Present adds and PinnedConfig's own tests cover it; what is under test here is the
// address the answer comes back on.
func asking(t *testing.T, conn *nats.Conn, js nats.JetStreamContext, node string) *natsAsking {
t.Helper()
inbox, err := enrolInbox(node)
if err != nil {
t.Fatal(err)
}
answers, err := conn.SubscribeSync(inbox)
if err != nil {
t.Fatal(err)
}
if err := conn.Flush(); err != nil {
t.Fatal(err)
}
a := &natsAsking{conn: conn, js: js, inbox: inbox, answers: answers, lost: make(chan error, 1)}
t.Cleanup(a.Close)
return a
}
// theMeshAnswers stands in for the controller: it consumes the enrolment off the stream, reads the
// reply address out of the payload — never from the transport field — and answers there. It reports
// what the transport field actually held, which is the claim design 25 §2 rests on.
func theMeshAnswers(t *testing.T, conn *nats.Conn, js nats.JetStreamContext,
reply EnrolReply) <-chan string {
t.Helper()
sawReplyField := make(chan string, 1)
sub, err := js.Subscribe(EnrolSubject, func(msg *nats.Msg) {
select {
case sawReplyField <- msg.Reply:
default:
}
var addressed struct {
ReplyTo string `json:"reply_to"`
}
if err := json.Unmarshal(msg.Data, &addressed); err != nil || addressed.ReplyTo == "" {
_ = msg.Ack()
return
}
body, _ := json.Marshal(reply)
// Published explicitly to the address the payload named, never msg.Respond — which would
// send it to whatever the transport's reply field holds, and that is the point.
_ = conn.Publish(addressed.ReplyTo, body)
_ = msg.Ack()
}, nats.Durable("controller-standin"), nats.ManualAck())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
return sawReplyField
}
// An enrolment is answered on the address the request carried, and the transport's own reply field
// held something else entirely.
func TestNatsAnEnrolmentIsAnsweredOnTheAddressInItsPayload(t *testing.T) {
conn, js := aBus(t)
const node = "joining"
sawReplyField := theMeshAnswers(t, conn, js, EnrolReply{Accepted: true, Node: node, Password: "p"})
a := asking(t, conn, js, node)
request, _ := json.Marshal(EnrolRequest{Node: node, Secret: "t"})
answer, err := a.Ask(context.Background(), request, 8*time.Second)
if err != nil {
t.Fatalf("no answer reached the node: %v", err)
}
var reply EnrolReply
if err := json.Unmarshal(answer, &reply); err != nil {
t.Fatal(err)
}
if !reply.Accepted || reply.Node != node {
t.Fatalf("the answer was not the mesh's: %+v", reply)
}
// And the field the answer would have gone to, had it used the transport's: the consumer's own
// ack address. If a future server stopped doing this, the payload-borne address would still
// work and this line is what would say the reason had changed.
select {
case field := <-sawReplyField:
if field == a.inbox {
t.Fatalf("the transport's reply field held this node's inbox (%s), so the payload "+
"address is no longer load-bearing — check design 25 §2 before relying on it", field)
}
if field == "" {
t.Fatal("the transport's reply field was empty rather than claimed, which is a third " +
"behaviour from the two design 25 §2 describes")
}
case <-time.After(2 * time.Second):
t.Fatal("the stand-in never saw the request")
}
}
// A request that names no reply address is not answered, and the node says so as a mesh that is not
// running rather than hanging. The controller has nowhere to send an answer, which is the failure
// the payload field exists to make impossible — asserted so that a request built without it fails
// loudly here rather than quietly on a machine.
func TestNatsAnEnrolmentWithNoReplyAddressIsNotAnswered(t *testing.T) {
conn, js := aBus(t)
const node = "silent"
theMeshAnswers(t, conn, js, EnrolReply{Accepted: true, Node: node})
a := asking(t, conn, js, node)
// Published without going through Ask, so the reply address is genuinely absent.
request, _ := json.Marshal(EnrolRequest{Node: node, Secret: "t"})
if _, err := js.Publish(EnrolSubject, request); err != nil {
t.Fatal(err)
}
if _, err := a.Ask(context.Background(), []byte(`{"node":"`+node+`"}`), 0); err == nil {
t.Fatal("a node with no answer coming was told it had one")
}
}
-71
View File
@@ -1,71 +0,0 @@
package link
import (
"context"
"fmt"
"time"
"github.com/nats-io/nats.go"
)
// Bus is what a host needs of the mesh's bus, in the mesh's own words.
//
// A host says exactly two things unprompted: what it applied, and that it is here. They are not
// the same kind of statement and the difference is the whole of this interface — one must arrive
// and one must not be insisted on.
//
// **The host still imports nothing of the mesh's own** (novox/hq ADR 0005): this is its own
// interface over its own client libraries, not a contract shared with the controller. The two
// agree because a conformance fixture holds them to one envelope, which is the only kind of
// agreement that survives being in different repositories.
type Bus interface {
// Report says what this node applied. **It must arrive.** A report that fails leaves the
// mesh believing the node never answered while the node believes it did, and the two go on
// disagreeing with nothing anywhere saying so — the shape of fault this project keeps
// finding. Returns false when it could not be delivered, so the caller can say so.
Report(ctx context.Context, node string, body []byte) error
// Alive says this node is here, and nothing else. **Losing one is nothing**: the next is a
// minute away and the mesh reads a gap rather than counting arrivals. Insisting on delivery
// would turn a harmless miss into a logged failure every minute.
Alive(ctx context.Context, node string, body []byte) error
}
// --- The bus the mesh runs on today -----------------------------------------------------------
// OverNATS is the bus as a connection. A report goes through JetStream because it must survive
// the controller's store restarting; a heartbeat does not, because it must not.
type OverNATS struct {
Conn *nats.Conn
JS nats.JetStreamContext
}
// ReportSubject and AliveSubject are this node's own, and no other node's: a host's account may
// publish `mesh.control.<its own node>.>` and nothing wider, so the subject is the authority on
// which node a report is about.
func ReportSubject(node string) string { return "mesh.control." + node + ".report" }
func AliveSubject(node string) string { return "mesh.control." + node + ".alive" }
func (b OverNATS) Report(ctx context.Context, node string, body []byte) error {
// Into the CONTROL stream and awaited: this is the message the store-window guarantee is
// about (novox/hq ADR 0083). The controller naks with a delay while its store is away and
// the message is redelivered; a publish the bus never accepted must fail here rather than
// be assumed.
if _, err := b.JS.Publish(ReportSubject(node), body, nats.Context(ctx)); err != nil {
return fmt.Errorf("reporting: %w", err)
}
return nil
}
func (b OverNATS) Alive(ctx context.Context, node string, body []byte) error {
// Core, deliberately: a heartbeat in a stream is the mesh's least valuable message competing
// for retention with its most valuable, and a lost one is the next one.
if err := b.Conn.Publish(AliveSubject(node), body); err != nil {
return err
}
// Flushed rather than fired and forgotten, so "could not tell the mesh" means the write
// failed rather than that nobody has looked yet.
flush, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
return b.Conn.FlushWithContext(flush)
}
+103 -64
View File
@@ -6,7 +6,10 @@ import (
"encoding/json"
"errors"
"fmt"
"net/url"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
// The wire format shared with the control plane, which defines it separately because this binary
@@ -49,41 +52,6 @@ type EnrolRequest struct {
// holds the private half of PublicKey. The mesh asks for it before letting an enrolment finish
// on a token this key already spent (novox/hq issue 083).
Proof []byte `json:"proof,omitempty"`
// ReplyTo is where the mesh's answer goes, as a field of the request rather than the
// transport's own reply address (design 25 §2). Written by the transport that needs it —
// withReplyTo, once per attempt — because a request going into a stream has had the transport's
// reply field claimed for the consumer's ack address before the controller ever reads it.
//
// Empty on the bus the mesh runs on today, where the delivery carries the reply queue and the
// field means what it has always meant. Named here so both sides of the wire hold the same
// field name, which is what the shape test on each side is for.
ReplyTo string `json:"reply_to,omitempty"`
// Tunnel is the tunnel this node found and whose key it took as its overlay key (novox/hq ADR
// 0105): everything about it but that key. Sent with the keys because it is one of them —
// OverlayKey above IS this tunnel's public key when this is set — and the mesh composes the
// hub's address, the range and the carried peers from it before the first declaration.
Tunnel *Tunnel `json:"tunnel,omitempty"`
}
// Tunnel is a found tunnel as it travels: no private key.
type Tunnel struct {
Interface string `json:"interface"`
Unit string `json:"unit"`
Config string `json:"config"`
Port int `json:"port"`
Address string `json:"address"`
Range string `json:"range"`
MTU int `json:"mtu,omitempty"`
PublicKey string `json:"public_key"`
Peers []TunnelPeer `json:"peers,omitempty"`
}
// TunnelPeer is one peer of a found tunnel: its key, and the address the tunnel routes to it.
type TunnelPeer struct {
PublicKey string `json:"public_key"`
Address string `json:"address"`
}
// EnrolReply is what the mesh says back.
@@ -155,35 +123,112 @@ func answered(reply EnrolReply, asking time.Duration) (again bool, err error) {
// was issued and the secret is its password. So this is not how the node gets in — it is what it
// says once it is in, and the secret travels again because the control plane must not have to ask
// the broker who connected.
func Enrol(ctx context.Context, to Approach, node, secret string, public []byte,
func Enrol(ctx context.Context, address, pin, node, secret string, public []byte,
overlayKey, sealingKey, servingKey string, profile map[string]any, proof []byte,
tunnel *Tunnel, timeout time.Duration) (EnrolReply, error) {
timeout time.Duration) (EnrolReply, error) {
asking, err := Present(ctx, to, node, secret, timeout)
config, err := PinnedConfig(pin)
if err != nil {
return EnrolReply{}, err
}
// The account name is the node's, and the password is the token's secret. Escaped because a
// name or secret containing a colon or an at-sign would otherwise change which host this
// connects to — a credential silently redirecting a connection is the worst shape this could
// take.
dsn := fmt.Sprintf("amqps://%s:%s@%s/",
url.QueryEscape(node), url.QueryEscape(secret), address)
conn, err := amqp.DialConfig(dsn, amqp.Config{
TLSClientConfig: config,
Dial: amqp.DefaultDial(timeout),
})
if err != nil {
if errors.Is(err, ErrWrongCertificate) {
return EnrolReply{}, err
}
// Not quoted back: the DSN carries the one-time secret.
return EnrolReply{}, fmt.Errorf("cannot reach the broker at %s as %s: %w", address, node, err)
}
defer conn.Close()
channel, err := conn.Channel()
if err != nil {
return EnrolReply{}, err
}
defer channel.Close()
// This node's own queue, which its account is scoped to and nothing else may read.
queue, err := channel.QueueDeclare(QueueFor(node), true, false, false, false, nil)
if err != nil {
return EnrolReply{}, fmt.Errorf(
"cannot declare this node's queue %s: %w", QueueFor(node), err)
}
replies, err := channel.Consume(queue.Name, "", true, false, false, false, nil)
if err != nil {
return EnrolReply{}, err
}
defer asking.Close()
request := EnrolRequest{Node: node, Secret: secret, PublicKey: public,
OverlayKey: overlayKey, SealingKey: sealingKey, ServingKey: servingKey, Profile: profile,
Proof: proof, Tunnel: tunnel}
Proof: proof}
body, err := json.Marshal(request)
if err != nil {
return EnrolReply{}, err
}
// Asked, and asked again with the same request while the mesh says "try again": the keys this
// node generated are the ones it keeps, so the same request is the same enrolment, and the mesh
// holds the token for it (novox/hq issue 083).
began := time.Now()
for {
answer, err := asking.Ask(ctx, body, timeout)
// Asked, and asked again with the same request while the mesh says "try again": the keys
// this node generated are the ones it keeps, so the same request is the same enrolment, and
// the mesh holds the token for it (novox/hq issue 083).
ask := func() (string, error) {
correlation := fmt.Sprintf("%s-%d", node, time.Now().UnixNano())
publish, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if err := channel.PublishWithContext(publish, Exchange, KeyEnrol, false, false,
amqp.Publishing{
ContentType: "application/json",
CorrelationId: correlation,
ReplyTo: queue.Name,
Body: body,
}); err != nil {
return "", fmt.Errorf("cannot publish to the %s exchange: %w", Exchange, err)
}
return correlation, nil
}
correlation, err := ask()
if err != nil {
return EnrolReply{}, err
}
began := time.Now()
// Waited for rather than assumed. A published message that nothing answers means the control
// plane is not running, and a node that carried on regardless would believe it had joined a
// mesh that has never heard of it.
deadline := time.NewTimer(timeout)
defer deadline.Stop()
closed := conn.NotifyClose(make(chan *amqp.Error, 1))
for {
select {
case <-ctx.Done():
return EnrolReply{}, ctx.Err()
case reason := <-closed:
return EnrolReply{}, fmt.Errorf("the broker closed the connection: %v", reason)
case <-deadline.C:
return EnrolReply{}, fmt.Errorf(
"the broker accepted this node's connection and nothing answered within %s. The "+
"mesh's broker is running and its control plane is not", timeout)
case delivery, ok := <-replies:
if !ok {
return EnrolReply{}, errors.New("the broker stopped delivering")
}
// Anything else on this queue is not the answer to this question.
if delivery.CorrelationId != correlation {
continue
}
var reply EnrolReply
if err := json.Unmarshal(answer, &reply); err != nil {
if err := json.Unmarshal(delivery.Body, &reply); err != nil {
return EnrolReply{}, fmt.Errorf("the mesh's answer could not be read: %w", err)
}
again, err := answered(reply, time.Since(began))
@@ -198,22 +243,16 @@ func Enrol(ctx context.Context, to Approach, node, secret string, public []byte,
return EnrolReply{}, ctx.Err()
case <-time.After(AskAgainAfter):
}
if correlation, err = ask(); err != nil {
return EnrolReply{}, err
}
if !deadline.Stop() {
select {
case <-deadline.C:
default:
}
}
deadline.Reset(timeout)
}
}
}
// withReplyTo writes this attempt's reply address into the request, as a field of its own.
//
// **Written into the bytes rather than carried beside them**, because the whole point is that the
// address survives a stream: a JetStream consumer's delivery has had the transport's reply field
// claimed for its own ack address, so a reply address that is not in the payload is one the
// controller cannot read (design 25 §2). Done by decoding and re-encoding rather than by setting the
// field before marshalling, so one request can be asked again with a fresh address each time without
// the caller knowing that is what happens.
func withReplyTo(request []byte, inbox string) ([]byte, error) {
var fields map[string]any
if err := json.Unmarshal(request, &fields); err != nil {
return nil, fmt.Errorf("this node's own enrolment request cannot be read back: %w", err)
}
fields["reply_to"] = inbox
return json.Marshal(fields)
}
-75
View File
@@ -1,75 +0,0 @@
package link
import (
"context"
"fmt"
"time"
)
// What a host hears, as the host's own words for it.
//
// The outbound half is behind `Bus` (bus.go); this is the other half — dialling, and the
// declarations that arrive. The run loop reads its own words for a declaration rather than the
// client library's delivery type, so nothing past this file knows what carried it.
//
// **The host still imports nothing of the mesh's own** (novox/hq ADR 0005). This is its own
// interface over its own libraries, and it agrees with the controller only because a conformance
// fixture holds both to one envelope.
// Link is this node's live connection to its mesh: what it hears, and what it says.
//
// One interface rather than two, because dialling once is what keeps both halves of a node on the
// same connection.
type Link interface {
// Bus is what this node says: what it applied, and that it is here.
Bus
// Declarations is what the mesh tells this node to be.
Declarations() <-chan Declaration
// Lost says the link ended, and why.
//
// **Read rather than discovered.** A node that finds out by noticing silence is a node that
// believed it was in the mesh for as long as the silence lasted, which is the one state ADR
// 0004 says must never look like being connected.
Lost() <-chan error
// Close lets go of whatever was dialled.
Close()
}
// Declaration is one thing the mesh told this node to be.
//
// **Handled, once — after the report is published.** A node that dies between applying and
// reporting leaves the declaration with the mesh and applies it again on return, which is safe
// because applying is reconciliation: it converges rather than repeating.
//
// There is one way of being done rather than two. A declaration set aside because a newer arrived
// with it is settled exactly as an applied one is, and the difference between them is a fact the
// *report* carries — a second method here would be a distinction the bus does not make.
type Declaration interface {
// Body is the signed declaration as it arrived, bytes unchanged: a node verifies what it
// received rather than what it re-encoded.
Body() []byte
// Handled settles it. Called after the report for it has been published, either way.
Handled() error
}
// Open opens this node's link to its mesh.
//
// Named Open rather than Dial because Dial is this package's raw TLS dial, which the enrolment path
// uses to see a certificate before it trusts anything.
//
// **The mesh has one bus** (novox/hq ADR 0131): the one the broker seat delivers. A membership
// still records which transport it was minted for, so a host can say what it is dialling, and a
// membership recorded for anything else is a membership this host cannot use.
func Open(ctx context.Context, m Membership, timeout time.Duration) (Link, error) {
if m.Transport != OnNATS {
return nil, fmt.Errorf("this membership is for %q, and the mesh's bus is %s", m.Transport, OnNATS)
}
return dialNats(ctx, m, timeout)
}
// OnNATS is the bus a membership names: the mesh's own, and the only one.
const OnNATS = "nats"
-193
View File
@@ -1,193 +0,0 @@
package link
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
)
// The host's link on the bus being built.
//
// Two things a host does here that it cannot do on the other bus, and one it must not try.
//
// **It declares nothing.** On the bus the mesh has, a host declares its own queue on connecting,
// because a queue that is not there means a node that hears nothing. Here the object it reads
// through is a durable consumer, and a host's account reaches no part of the JetStream API — by
// design, because the controller is the only writer of consumer definitions (design 25 §3). So the
// host **binds** to a consumer the controller made when this node enrolled, and a missing one is
// said as what it is rather than quietly created with whatever configuration this client happens to
// default to.
//
// **It gets order for free, and keeps the drain anyway.** The declaration subject is last-per-subject
// (design 29 §4), so a node that was away receives exactly the current declaration rather than a
// queue of superseded ones, and the stream's sequence orders them definitively — the wire-level
// answer to novox/hq issue 107. What the drain in run.go still answers is the live case: three
// pushes to a *connected* node are three deliveries whatever the stream later retains.
// EnrolSubject is where a joining machine asks. One subject for every node, because a machine
// enrolling has no name the mesh has agreed to yet — which is why its authority to publish here is
// the whole of what its enrolment user may do.
const EnrolSubject = "mesh.control.enrol"
// DeclareSubject is where this node's declaration lands. Its own, and no other node's: a host's
// account subscribes exactly this and the subject is the authority on which node a declaration is
// for.
func DeclareSubject(node string) string { return "mesh.node." + node + ".declare" }
// natsURL is a bus address as the client wants it. A membership records host and port, because that
// is what genesis sealed into it and what the other transport takes; the scheme is this transport's
// own business.
func natsURL(address string) string {
if strings.Contains(address, "://") {
return address
}
return "nats://" + address
}
// natsLink is this node's connection as a JetStream subscription.
type natsLink struct {
conn *nats.Conn
js nats.JetStreamContext
sub *nats.Subscription
node string
arrived chan Declaration
lost chan error
}
func dialNats(ctx context.Context, m Membership, timeout time.Duration) (Link, error) {
// Pinned exactly as the other transport is, and for once the Go client makes that easy: it
// takes a *tls.Config, so the same PinnedConfig with the same VerifyPeerCertificate does the
// work. **The constraint recorded against the tool runtime does not apply here** — that client
// takes PEM strings with no verify hook, which is why the bus's certificate must carry a name
// matching the address *modules* dial it by. A host checks the fingerprint and nothing else.
config, err := PinnedConfig(m.Fingerprint)
if err != nil {
return nil, err
}
opts := []nats.Option{
nats.Secure(config),
// The mesh names a machine's bus user "node.<name>" (the controller's principal scheme), and
// the server refused the bare name the first time a machine dialled it: "authentication
// error - User". The same string the mesh composed into the user list, or nothing connects.
nats.UserInfo("node."+m.Node, m.Password),
// Replies to what this client asks the server arrive on its inbox, and the mesh grants a
// machine exactly its own: the same prefix the user list was composed with.
nats.CustomInboxPrefix("_INBOX.node." + m.Node),
nats.Name("mesh-host/" + m.Node),
nats.Timeout(timeout),
// A node that has silently lost its route notices, rather than holding a connection the
// server forgot about and believing it is still in the mesh.
nats.PingInterval(10 * time.Second),
nats.MaxPingsOutstanding(2),
// Reconnection is the caller's: Hold already decides when to try again and how long to
// wait, and a client quietly reconnecting underneath it would make that reasoning a
// duplicate of the library's.
nats.NoReconnect(),
}
conn, err := nats.Connect(natsURL(m.Broker), opts...)
if err != nil {
if errors.Is(err, ErrWrongCertificate) {
return nil, err
}
return nil, fmt.Errorf("cannot reach the bus at %s: %w", m.Broker, err)
}
js, err := conn.JetStream()
if err != nil {
conn.Close()
return nil, fmt.Errorf("the bus at %s has no JetStream: %w", m.Broker, err)
}
l := &natsLink{
conn: conn, js: js, node: m.Node,
arrived: make(chan Declaration, drainDepth),
lost: make(chan error, 1),
}
// Bound to the consumer the controller made for this node, named after the node because that is
// what the node's own ack grant allows (`$JS.ACK.NODES.<node>.>`).
feed := make(chan *nats.Msg, drainDepth)
// The subject as well as the binding: the client checks what is asked for against the
// consumer's own filter, and an empty subject is refused rather than taken to mean "whatever
// that consumer delivers".
sub, err := js.ChanSubscribe(DeclareSubject(m.Node), feed, nats.Bind("NODES", m.Node))
if err != nil {
conn.Close()
return nil, fmt.Errorf(
"this node cannot read its declarations: %w. The mesh creates that when a node enrols, "+
"and a host may not create one itself — so this is the mesh's to answer, not this "+
"machine's", err)
}
l.sub = sub
conn.SetDisconnectErrHandler(func(_ *nats.Conn, err error) {
select {
case l.lost <- fmt.Errorf("the link dropped: %w", err):
default:
}
})
conn.SetClosedHandler(func(*nats.Conn) {
select {
case l.lost <- errors.New("the link closed"):
default:
}
})
go func() {
defer close(l.arrived)
for {
select {
case <-ctx.Done():
return
case msg, ok := <-feed:
if !ok {
select {
case l.lost <- errors.New("the bus stopped delivering"):
default:
}
return
}
select {
case l.arrived <- natsDeclaration{msg}:
case <-ctx.Done():
return
}
}
}
}()
return l, nil
}
func (l *natsLink) Declarations() <-chan Declaration { return l.arrived }
func (l *natsLink) Lost() <-chan error { return l.lost }
func (l *natsLink) Close() {
if l.sub != nil {
_ = l.sub.Unsubscribe()
}
if l.conn != nil {
l.conn.Close()
}
}
func (l *natsLink) Report(ctx context.Context, node string, body []byte) error {
return OverNATS{Conn: l.conn, JS: l.js}.Report(ctx, node, body)
}
func (l *natsLink) Alive(ctx context.Context, node string, body []byte) error {
return OverNATS{Conn: l.conn, JS: l.js}.Alive(ctx, node, body)
}
// natsDeclaration is one declaration off the NODES stream.
type natsDeclaration struct{ msg *nats.Msg }
func (d natsDeclaration) Body() []byte { return d.msg.Data }
// Handled acknowledges it. The ack goes to this node's own ack subject, which is the one thing
// besides its reports a node's account may publish.
func (d natsDeclaration) Handled() error { return d.msg.Ack() }
-228
View File
@@ -1,228 +0,0 @@
package link
import (
"context"
"crypto/ed25519"
"encoding/json"
"fmt"
"os"
"testing"
"time"
"github.com/nats-io/nats.go"
)
// The host's link against a real server, because every claim here is about one.
//
// Whether binding to a consumer the host did not create works, whether a declaration on the node's
// own subject arrives, whether acknowledging it removes it from the consumer's pending — none of
// that can be reasoned out, and the first two are the ones that would leave a node silently hearing
// nothing:
//
// docker run -d --rm --name t -p 14223:4222 nats:2.10-alpine -js
// MESH_TEST_NATS=nats://127.0.0.1:14223 go test ./internal/link/ -run TestNats
func aBus(t *testing.T) (*nats.Conn, nats.JetStreamContext) {
t.Helper()
url := os.Getenv("MESH_TEST_NATS")
if url == "" {
t.Skip("MESH_TEST_NATS unset")
}
conn, err := nats.Connect(url)
if err != nil {
t.Fatal(err)
}
t.Cleanup(conn.Close)
js, err := conn.JetStream()
if err != nil {
t.Fatal(err)
}
// **Ensured and purged, not deleted and recreated.** Delete-then-add looked like a reset and is
// not one: a test that did that inherited the previous test's messages, and the symptom was a
// declaration counted as delivered twice — which reads as a redelivery bug in the code under
// test rather than as a dirty stream. Purge is defined to empty a stream; recreating one is a
// race with the server's own teardown.
for _, want := range []*nats.StreamConfig{
{Name: "NODES", Subjects: []string{"mesh.node.*.declare"}, MaxMsgsPerSubject: 1},
{Name: "CONTROL", Subjects: []string{"mesh.control.*.report", "mesh.control.enrol"},
Retention: nats.WorkQueuePolicy},
} {
if _, err := js.StreamInfo(want.Name); err != nil {
if _, err := js.AddStream(want); err != nil {
t.Fatal(err)
}
}
if err := js.PurgeStream(want.Name); err != nil {
t.Fatal(err)
}
}
return conn, js
}
// theMeshMakes is the consumer the controller creates when a node enrols. Made here by the test
// because the host may not: its account reaches no part of the JetStream API, which is the whole
// reason this binds rather than subscribes.
//
// Removed afterwards, and each test names its own node: two tests sharing a consumer name share its
// delivery count and its pending list, and the first thing that goes wrong reads as a fault in the
// host rather than in the test beside it.
func theMeshMakes(t *testing.T, js nats.JetStreamContext, node string) {
t.Helper()
t.Cleanup(func() { _ = js.DeleteConsumer("NODES", node) })
if _, err := js.AddConsumer("NODES", &nats.ConsumerConfig{
Durable: node,
FilterSubject: DeclareSubject(node),
AckPolicy: nats.AckExplicitPolicy,
AckWait: 300 * time.Second,
DeliverSubject: "_DELIVER." + node,
}); err != nil {
t.Fatal(err)
}
}
func signedBy(t *testing.T, key ed25519.PrivateKey, declaration []byte) []byte {
t.Helper()
body, err := json.Marshal(Signed{
Declaration: declaration, Signature: ed25519.Sign(key, declaration),
})
if err != nil {
t.Fatal(err)
}
return body
}
// A declaration on this node's own subject reaches the host, is applied, and acknowledging it
// empties the consumer — which is what tells the mesh the node has it.
func TestNatsADeclarationReachesTheHostAndIsSettled(t *testing.T) {
conn, js := aBus(t)
const node = "settling"
theMeshMakes(t, js, node)
public, private, _ := ed25519.GenerateKey(nil)
m := Membership{Node: node, Signer: public}
// Dialled directly rather than through Open: the test server has no TLS, and what is being
// checked is the subscription and the settling, not the pin — which PinnedConfig owns and its
// own tests cover.
l := &natsLink{conn: conn, js: js, node: node,
arrived: make(chan Declaration, drainDepth), lost: make(chan error, 1)}
feed := make(chan *nats.Msg, drainDepth)
sub, err := js.ChanSubscribe(DeclareSubject(node), feed, nats.Bind("NODES", node))
if err != nil {
t.Fatalf("the host could not bind to the consumer the mesh made for it: %v", err)
}
defer func() { _ = sub.Unsubscribe() }()
go func() {
for msg := range feed {
l.arrived <- natsDeclaration{msg}
}
}()
if _, err := js.Publish(DeclareSubject(node),
signedBy(t, private, []byte(`{"declared":"d1"}`))); err != nil {
t.Fatal(err)
}
select {
case d := <-l.Declarations():
report := handleBody(context.Background(), m, d.Body(),
func(context.Context, []byte, []byte) Report {
return Report{Applied: []string{"store"}}
})
if report.Refused != "" {
t.Fatalf("a declaration the mesh signed was refused: %s", report.Refused)
}
if err := d.Handled(); err != nil {
t.Fatalf("the node could not acknowledge its own declaration: %v", err)
}
case <-time.After(8 * time.Second):
t.Fatal("no declaration reached the host")
}
// **Nothing pending is the property**; a delivery count is not. Delivery is at-least-once by
// design, so pinning "delivered exactly once" would be asserting something the mesh does not
// rely on. What matters is that the acknowledgement landed, so the mesh can tell the node has
// it — and that no redelivery was needed to get there, which is what would say the node was
// too slow to answer for its own ack wait.
deadline := time.Now().Add(5 * time.Second)
var last string
for time.Now().Before(deadline) {
info, err := js.ConsumerInfo("NODES", node)
switch {
case err != nil:
last = err.Error()
case info.NumAckPending == 0 && info.NumRedelivered == 0:
return
default:
last = fmt.Sprintf("pending %d, redelivered %d", info.NumAckPending, info.NumRedelivered)
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("the declaration was not settled, so the mesh cannot tell the node has it: %s", last)
}
// **A node that was away gets exactly the current declaration and nothing older.** Three pushed
// while nothing is listening leave one on the stream, and it is the newest — the wire-level answer
// to novox/hq issue 107, and the half of the drain that stops being the host's problem.
func TestNatsANodeThatWasAwayGetsOnlyTheNewest(t *testing.T) {
_, js := aBus(t)
const node = "returning"
_, private, _ := ed25519.GenerateKey(nil)
for _, id := range []string{"d1", "d2", "d3"} {
if _, err := js.Publish(DeclareSubject(node),
signedBy(t, private, []byte(`{"declared":"`+id+`"}`))); err != nil {
t.Fatal(err)
}
}
info, err := js.StreamInfo("NODES")
if err != nil {
t.Fatal(err)
}
if info.State.Msgs != 1 {
t.Fatalf("%d declarations survived for one node; a node that was away would apply a backlog "+
"of things nobody wants any more", info.State.Msgs)
}
theMeshMakes(t, js, node)
feed := make(chan *nats.Msg, drainDepth)
sub, err := js.ChanSubscribe(DeclareSubject(node), feed, nats.Bind("NODES", node))
if err != nil {
t.Fatal(err)
}
defer func() { _ = sub.Unsubscribe() }()
select {
case msg := <-feed:
if declaredIn(msg.Data) != "d3" {
t.Fatalf("the node was given %q rather than the newest", declaredIn(msg.Data))
}
case <-time.After(8 * time.Second):
t.Fatal("the node that was away was given nothing")
}
}
// A report goes through the stream and a heartbeat does not: the one that must survive the
// controller's store restarting is kept, and the one that must not is not.
func TestNatsAReportIsKeptAndAHeartbeatIsNot(t *testing.T) {
conn, js := aBus(t)
bus := OverNATS{Conn: conn, JS: js}
ctx := context.Background()
body, _ := json.Marshal(Report{Node: "anchor", Declared: "d1"})
if err := bus.Report(ctx, "anchor", body); err != nil {
t.Fatal(err)
}
beat, _ := json.Marshal(Alive{Node: "anchor"})
if err := bus.Alive(ctx, "anchor", beat); err != nil {
t.Fatal(err)
}
info, err := js.StreamInfo("CONTROL")
if err != nil {
t.Fatal(err)
}
if info.State.Msgs != 1 {
t.Fatalf("%d messages were kept; a report must be and a heartbeat must not", info.State.Msgs)
}
}
-46
View File
@@ -1,46 +0,0 @@
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)
}
}
+1 -132
View File
@@ -1,10 +1,6 @@
package link
import (
"strconv"
"strings"
"time"
)
import "time"
// The wire formats shared with the control plane, which defines them separately because this
// binary requires nothing present and does not import it. A test on each side asserts the field
@@ -104,108 +100,6 @@ type Report struct {
// published container port. Only an adopted node reports it; it is what converging the node
// previews, so nothing closes without being named first.
Reachable []Reach `json:"reachable,omitempty"`
// Tunnel is what this adopted node says about the tunnel it found and carried (novox/hq ADR
// 0105): which interface, its port, range and peer count, whether the found interface is down
// and the mesh's up in its place, and where the found configuration's original was kept.
Tunnel *CarriedTunnel `json:"tunnel,omitempty"`
// Filters is what filters this machine now, every table and chain that refuses traffic with its
// owner — the mesh's, the found firewall's, the container runtime's own, a ban list, or other
// (novox/hq ADR 0168). Every node reports it, adopted or converged, so the mesh can say
// truthfully what filters a converged machine and name what it did not write.
Filters []Filter `json:"filters,omitempty"`
// FoundFirewall is the state of the firewall a converged machine was found with: whether it is
// in force now, and how it came to be inactive — the mesh disabled it, or a reconcile found it so
// (ADR 0168). Nil on a machine found with none, and on an adopted one, where Firewall says it.
FoundFirewall *FoundFirewall `json:"found_firewall,omitempty"`
// Strays is what runs on the machine that the mesh neither wrote nor holds (novox/hq ADR
// 0163): containers nobody declared and nobody holds, the ones a cutover leaves behind.
Strays []Stray `json:"strays,omitempty"`
// Profile is what this machine can do, detected again by the apply that reports (novox/hq
// ADR 0161) — the same shape enrolment sends — so a capability gained or lost since enrolment,
// a network manager switched, reaches the mesh at the next push rather than never.
Profile map[string]any `json:"profile,omitempty"`
// Host is the version of the host that produced this report (novox/hq ADR 0141).
//
// Without it nothing can say a machine is behind, so "every machine current with its source"
// could not include the host — the one component the mesh did not deliver. It is a fact the
// machine states about itself, like the firewall it found and the links that face outside.
Host string `json:"host,omitempty"`
// Outward is the links on this machine that face outside it — the ones carrying a default
// route (novox/hq ADR 0140). Every node reports it, adopted or converged, because a converged
// node's filter is written around it.
//
// **It replaces a list of addresses.** The filter used to block everything passing through the
// machine and then allow the machine's own containers back by naming the ranges they sit on.
// A range describes one machine and goes stale in silence; the link carrying the default route
// is read afresh on every report and does not change when a module is added or removed.
//
// Empty means this machine has no route off itself. The mesh then composes no filter for it and
// leaves the one it has, rather than writing a rule around a link with no name — a rule set
// that does not load is a machine filtering nothing while its unit reports success.
Outward []string `json:"outward,omitempty"`
// Rekey is this node taking a found tunnel's key as its overlay key after enrolment (novox/hq
// ADR 0105). Not an account of the machine: a report carrying one says nothing else.
Rekey *Rekey `json:"rekey,omitempty"`
}
// CarriedTunnel is this node's account of the tunnel it took over. State is one of the Carried
// states below; Note is what the host did about it, when it did something.
type CarriedTunnel struct {
Interface string `json:"interface"`
Port int `json:"port"`
Range string `json:"range"`
Peers int `json:"peers"`
State string `json:"state"`
Note string `json:"note,omitempty"`
Kept string `json:"kept,omitempty"`
}
// The states a carried tunnel can be in: the found interface still up and the mesh's not; the
// found one down and the mesh's up with its key; or the found one down and the mesh's not up — the
// one state where the peers reach nothing, said as its own word so nothing reads it as either of
// the others.
const (
CarriedNotTaken = "not-taken"
CarriedTaken = "taken"
CarriedDown = "down"
)
// Rekey is this node saying it took a found tunnel's key as its overlay key after enrolling
// (novox/hq ADR 0105): the path for a node that enrolled before the mesh knew to take a tunnel
// over, since re-enrolling would rotate every key it holds. Signed with the identity key over
// RekeyProof, so a report forged on a stolen broker account cannot move this node's overlay key.
type Rekey struct {
// Previous is the overlay key this node held until now, as the mesh records it; the mesh
// refuses a rekey naming another, which is how a replayed one is refused.
Previous string `json:"previous"`
OverlayKey string `json:"overlay_key"`
Tunnel *Tunnel `json:"tunnel"`
Proof []byte `json:"proof"`
}
// RekeyProof is what a node signs when it rekeys — the node, the key it leaves, the key it takes
// and the tunnel it took it from — so a proof cannot be moved to another node or another tunnel.
// Byte for byte the mesh's own (mesh-controller internal/link RekeyProof).
func RekeyProof(node, previous, key string, tunnel *Tunnel) []byte {
var t Tunnel
if tunnel != nil {
t = *tunnel
}
peers := make([]string, 0, len(t.Peers))
for _, p := range t.Peers {
peers = append(peers, p.PublicKey+"@"+p.Address)
}
return []byte("novox-mesh-rekey\x00" + node + "\x00" + previous + "\x00" + key + "\x00" +
t.Interface + "\x00" + t.Unit + "\x00" + t.Config + "\x00" + strconv.Itoa(t.Port) + "\x00" +
t.Address + "\x00" + t.Range + "\x00" + t.PublicKey + "\x00" + strings.Join(peers, ","))
}
// Held is one file or container found on an adopted node and kept as it was.
@@ -220,16 +114,6 @@ type Held struct {
Changed string `json:"changed,omitempty"`
// Kept is where a file's original was kept.
Kept string `json:"kept,omitempty"`
// Facts is the found thing beside what the module declares — what a take compares (novox/hq
// ADR 0163). The same shape the host keeps; the controller reads it as data.
Facts map[string]any `json:"facts,omitempty"`
}
// A Stray is a container the mesh neither wrote nor holds (ADR 0163).
type Stray struct {
Kind string `json:"kind"`
Name string `json:"name"`
Detail string `json:"detail,omitempty"`
}
// Reach is one thing reachable on the machine: a listening socket, or a published container port.
@@ -244,18 +128,3 @@ type Reach struct {
Published bool `json:"published,omitempty"`
ContainerPort int `json:"container-port,omitempty"`
}
// A Filter is one place on the machine that refuses traffic, with its owner (novox/hq ADR 0168):
// the same shape the host's firewall package reads, carried as data.
type Filter struct {
Where string `json:"where"`
Owner string `json:"owner"`
Refuses string `json:"refuses"`
}
// FoundFirewall is the state of a converged machine's found firewall (ADR 0168).
type FoundFirewall struct {
Kind string `json:"kind"`
Active bool `json:"active"`
RetiredBy string `json:"retired_by,omitempty"`
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
// verified runs what Run does to a delivery body, without a broker: unmarshal, check the
// signature, and only then apply. Isolating it keeps this test about the check rather than about
// the bus, which is tested against a real one in the lab.
// AMQP, which is tested against a real broker in the lab.
func verified(t *testing.T, signer ed25519.PublicKey, body []byte) (Report, bool) {
t.Helper()
applied := false
+19 -32
View File
@@ -3,46 +3,33 @@ package link
import (
"testing"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
// said is one declaration as a test hands it over, with no transport under it — which is what the
// seam bought: the drain's reasoning was reachable only through a real broker before.
type said struct {
body []byte
handled bool
}
func (s *said) Body() []byte { return s.body }
func (s *said) Handled() error { s.handled = true; return nil }
func arriving(bodies ...string) chan Declaration {
ch := make(chan Declaration, 8)
for _, b := range bodies {
ch <- &said{body: []byte(b)}
}
return ch
}
// A machine asked to be five things becomes the last one: what is already waiting supersedes what
// arrived first, and everything set aside is named so it can be reported.
func TestWhatIsAlreadyWaitingSupersedesWhatArrivedFirst(t *testing.T) {
waiting := arriving("two", "three", "four")
apply, superseded := newest(waiting, &said{body: []byte("one")}, 50*time.Millisecond)
if string(apply.Body()) != "four" {
t.Fatalf("applied %q, not the newest", apply.Body())
deliveries := make(chan amqp.Delivery, 8)
for _, id := range []string{"two", "three", "four"} {
deliveries <- amqp.Delivery{Body: []byte(id)}
}
if len(superseded) != 3 || string(superseded[0].Body()) != "one" ||
string(superseded[2].Body()) != "three" {
apply, superseded := newest(deliveries, amqp.Delivery{Body: []byte("one")}, 50*time.Millisecond)
if string(apply.Body) != "four" {
t.Fatalf("applied %q, not the newest", apply.Body)
}
if len(superseded) != 3 || string(superseded[0].Body) != "one" || string(superseded[2].Body) != "three" {
t.Fatalf("set aside %d: %v", len(superseded), superseded)
}
}
// One declaration with nothing behind it is applied as it always was, after the window.
func TestALoneDeclarationIsAppliedAfterTheWindow(t *testing.T) {
deliveries := make(chan amqp.Delivery, 1)
began := time.Now()
apply, superseded := newest(arriving(), &said{body: []byte("only")}, 30*time.Millisecond)
if string(apply.Body()) != "only" || len(superseded) != 0 {
t.Fatalf("got %q with %d set aside", apply.Body(), len(superseded))
apply, superseded := newest(deliveries, amqp.Delivery{Body: []byte("only")}, 30*time.Millisecond)
if string(apply.Body) != "only" || len(superseded) != 0 {
t.Fatalf("got %q with %d set aside", apply.Body, len(superseded))
}
if time.Since(began) < 30*time.Millisecond {
t.Fatal("did not wait the window for a straggler")
@@ -51,13 +38,13 @@ func TestALoneDeclarationIsAppliedAfterTheWindow(t *testing.T) {
// A straggler within the window is taken; one after it is the next push.
func TestAStragglerWithinTheWindowIsTaken(t *testing.T) {
waiting := arriving()
deliveries := make(chan amqp.Delivery, 2)
go func() {
time.Sleep(20 * time.Millisecond)
waiting <- &said{body: []byte("late")}
deliveries <- amqp.Delivery{Body: []byte("late")}
}()
apply, superseded := newest(waiting, &said{body: []byte("first")}, 100*time.Millisecond)
if string(apply.Body()) != "late" || len(superseded) != 1 {
t.Fatalf("got %q with %d set aside", apply.Body(), len(superseded))
apply, superseded := newest(deliveries, amqp.Delivery{Body: []byte("first")}, 100*time.Millisecond)
if string(apply.Body) != "late" || len(superseded) != 1 {
t.Fatalf("got %q with %d set aside", apply.Body, len(superseded))
}
}
-50
View File
@@ -1,50 +0,0 @@
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)
}
}
+3 -14
View File
@@ -73,19 +73,8 @@ func PinnedConfig(pin string) (*tls.Config, error) {
}, nil
}
// 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) {
// 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) {
config, err := PinnedConfig(pin)
if err != nil {
return nil, err
@@ -97,7 +86,7 @@ func dialPinned(address, pin string, timeout time.Duration) (*tls.Conn, error) {
if errors.Is(err, ErrWrongCertificate) {
return nil, err
}
return nil, fmt.Errorf("cannot reach %s: %w", address, err)
return nil, fmt.Errorf("cannot reach the broker at %s: %w", address, err)
}
return conn, nil
}
+4 -4
View File
@@ -63,7 +63,7 @@ func server(t *testing.T) (address string, fingerprint string) {
func TestTheRightBrokerIsAccepted(t *testing.T) {
address, pin := server(t)
conn, err := dialPinned(address, pin, 5*time.Second)
conn, err := Dial(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 := dialPinned(address, other, 5*time.Second)
_, err := Dial(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 := dialPinned(listener.Addr().String(), elsewhere, 5*time.Second); err == nil {
if _, err := Dial(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 = dialPinned(address, pin, 2*time.Second)
_, err = Dial(address, pin, 2*time.Second)
if err == nil {
t.Fatal("dialling a closed port succeeded")
}

Some files were not shown because too many files have changed in this diff Show More