diff --git a/Makefile b/Makefile index c9921e8..9432826 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,11 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo development) LDFLAGS := -s -w -X main.version=$(VERSION) -.PHONY: check test vet fmt build clean +# The bundle a host carries is built INTO it (novox/hq ADR 0038, ADR 0041): a host that needed +# a second file to arrive with it is not "copy it and run it". +BUNDLE ?= + +.PHONY: check test vet fmt build clean host check: fmt vet test build @@ -16,9 +20,22 @@ vet: test: go test ./... -count=1 -# Static on purpose: copy it onto a machine and run it is the whole installation. +# A default build carries no bundle and refuses to reconcile, which is the honest state for a +# host nobody has told what a substrate is. build: CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o mesh-host ./cmd/mesh-host +# A host for a real machine, carrying a real bundle: make host BUNDLE=path/to/substrate.lock +host: + @test -n "$(BUNDLE)" || { echo "BUNDLE= is required; a host with no bundle cannot raise a first node"; exit 1; } + @test -f "$(BUNDLE)" || { echo "no such bundle: $(BUNDLE)"; exit 1; } + @cp internal/bundle/substrate.lock internal/bundle/substrate.lock.default + @cp "$(BUNDLE)" internal/bundle/substrate.lock + @CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o mesh-host ./cmd/mesh-host; \ + status=$$?; \ + mv internal/bundle/substrate.lock.default internal/bundle/substrate.lock; \ + exit $$status + @echo "built carrying $(BUNDLE)" + clean: rm -f mesh-host diff --git a/README.md b/README.md index fdebd1b..33d7284 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ 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 +mesh-host apply FILE make this machine match a declaration from a file +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 @@ -78,8 +80,42 @@ did, after each thing worked. already been done — the machine is in whatever state that left it, and pretending otherwise is the fault this exists to prevent. +## The bundle a host carries + +A host built for a machine carries its declaration **inside the binary**: + +``` +make host BUNDLE=path/to/substrate.lock +``` + +`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 +nothing and reported success would look exactly like one that raised a first node, and the +difference would surface later as a mesh that never came up with nothing to point at. + Stages 3 and 4 — the link, and enrolment — are designed and not built. +## What stage 2 does not yet prove + +The design defines stage 2 as *the host applies `substrate.lock` with no mesh present*, and +calls out the claim underneath it: **that one host can raise the substrate alone**. + +The mechanism is proved — a sealed machine, one binary, and it configures itself from what it +carries. The claim is not. The substrate is four container services, and: + +- the vocabulary has no container type, because a container needs an image and where images + come from is open ([`novox/hq` research 012](https://git.novox.be/novox/hq)); +- what belongs in a substrate is not known — the closure for a one-node mesh is what + research 011 and 012 exist to answer; +- and the machine used to test this has no container runtime, because a sealed network cannot + install one. + +So `substrate.lock` here is a real bundle with a placeholder's content. Saying that plainly +beats shipping a host that claims a substrate it has never raised. + ## A capability is detected, never assumed The reason this is the first thing built rather than a detail of it. diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 8a973fa..cf89b72 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -19,6 +19,7 @@ import ( "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/inventory" "github.com/novox/mesh-host/internal/profile" @@ -33,7 +34,9 @@ const usage = `mesh-host — the node host profile what this machine can be asked to do inventory what this machine is, and what it holds - apply FILE make this machine match a declaration + apply FILE make this machine match a declaration from a file + reconcile make this machine match the declaration this host carries + bundle show what this host carries owned what this host has applied and still owns version @@ -146,7 +149,39 @@ func run(ctx context.Context, command string, opts options) error { return nil case "apply": - return runApply(ctx, opts) + raw, err := os.ReadFile(opts.file) + if err != nil { + return fmt.Errorf("reading the declaration: %w", err) + } + d, err := declaration.Parse(raw) + if err != nil { + return err + } + return runApply(ctx, opts, d, opts.file) + + case "reconcile": + // The first node's path. novox/hq ADR 0038: no mesh reachable means the declaration + // comes from the bundle the host carries. There is no link yet, so this is currently + // the only source — which is a stage, not a design, and saying so beats implying the + // other source exists. + d, err := bundle.Load() + if err != nil { + return err + } + return runApply(ctx, opts, d, "the carried bundle") + + case "bundle": + if bundle.IsEmpty() { + fmt.Println("this host carries no bundle") + return nil + } + _, err := bundle.Load() + if err != nil { + // Asked before it matters, rather than discovered on a first node. + return fmt.Errorf("this host carries a bundle it cannot itself read: %w", err) + } + os.Stdout.Write(bundle.Raw()) + return nil case "owned": known, err := store.Load(opts.state) @@ -242,17 +277,7 @@ func writeInventory(inv inventory.Inventory) { // The state is loaded before anything is touched and saved after, including when the apply // fails part-way: what was applied before the failure is on the machine, and a host that did // not record it would believe it owns less than it does and leave that behind forever. -func runApply(ctx context.Context, opts options) error { - raw, err := os.ReadFile(opts.file) - if err != nil { - return fmt.Errorf("reading the declaration: %w", err) - } - - d, err := declaration.Parse(raw) - if err != nil { - return err - } - +func runApply(ctx context.Context, opts options, d *declaration.Declaration, source string) error { known, err := store.Load(opts.state) if err != nil { return err @@ -260,7 +285,7 @@ func runApply(ctx context.Context, opts options) error { if opts.dryRun { fmt.Printf("%s: %d resource(s), version %d — accepted, nothing applied\n", - opts.file, len(d.Resources), d.Version) + source, len(d.Resources), d.Version) return nil } @@ -286,9 +311,9 @@ func runApply(ctx context.Context, opts options) error { return writeJSON(report) } if !report.Changed() { - fmt.Printf("%s: already matches — %d resource(s) checked\n", opts.file, len(report.Outcomes)) + fmt.Printf("%s: already matches — %d resource(s) checked\n", source, len(report.Outcomes)) return nil } - fmt.Printf("%s: applied — %d resource(s)\n", opts.file, len(report.Outcomes)) + fmt.Printf("%s: applied — %d resource(s)\n", source, len(report.Outcomes)) return nil } diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go new file mode 100644 index 0000000..2ea5cef --- /dev/null +++ b/internal/bundle/bundle.go @@ -0,0 +1,77 @@ +// Package bundle is the declaration the host carries. +// +// novox/hq ADR 0038: the host has one behaviour and two sources of declaration — the control +// plane when a mesh is reachable, and this when none is. The first node is not a different +// kind of node; it is a node whose mesh is not up yet, and this is what it applies until it is. +// +// Carried inside the binary rather than beside it, because "copy it onto a machine and run it +// is the whole installation" (ADR 0041) stops being true the moment a second file has to +// arrive with it. +package bundle + +import ( + _ "embed" + "errors" + "strings" + + "github.com/novox/mesh-host/internal/declaration" +) + +// substrate is the pinned tier-1 descriptor, appliable with no mesh present. +// +// A host built without one carries the placeholder below, and says so rather than applying +// nothing and reporting success — a host that silently did nothing on a first node would look +// exactly like one that worked. +// +//go:embed substrate.lock +var substrate []byte + +// ErrEmpty means this host was built without a bundle. +var ErrEmpty = errors.New( + "this host carries no bundle. A host built without one cannot raise a first node, and " + + "applying nothing would look exactly like applying something") + +// Raw returns the carried bytes, for inspection. +func Raw() []byte { return substrate } + +// IsEmpty reports whether anything was built in. A bundle of only comments and whitespace is +// empty for this purpose: the placeholder is a comment, and treating it as content would mean +// a default build claims to carry a substrate. +func IsEmpty() bool { + for _, line := range strings.Split(string(substrate), "\n") { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "//") { + return false + } + } + return true +} + +// Load parses the carried bundle. +// +// The same parser the link will use. A bundle that reaches a machine and is then refused by the +// host that carries it would be a build-time mistake discovered at the worst possible moment, +// which is why `mesh-host bundle` exists to ask before it matters. +func Load() (*declaration.Declaration, error) { + if IsEmpty() { + return nil, ErrEmpty + } + return declaration.Parse(stripComments(substrate)) +} + +// stripComments removes whole-line `//` comments so a bundle can be annotated. +// +// It is JSON on the wire and a pinned, hand-authored artefact here, and a pinned thing nobody +// can annotate is a pinned thing nobody can review. Only whole lines: anything cleverer would +// need to know where strings begin and end, and a parser that half-understands its input is +// worse than one that does not try. +func stripComments(raw []byte) []byte { + var kept []string + for _, line := range strings.Split(string(raw), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "//") { + continue + } + kept = append(kept, line) + } + return []byte(strings.Join(kept, "\n")) +} diff --git a/internal/bundle/bundle_test.go b/internal/bundle/bundle_test.go new file mode 100644 index 0000000..3a05e73 --- /dev/null +++ b/internal/bundle/bundle_test.go @@ -0,0 +1,82 @@ +package bundle + +import ( + "errors" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/declaration" +) + +// declarationParse is the parser Load uses, named here so the test reads as the assertion it is. +func declarationParse(raw []byte) (any, error) { return declaration.Parse(raw) } + +func TestADefaultBuildCarriesNothingAndSaysSo(t *testing.T) { + // The important one. A host built without a bundle that applied nothing and reported + // success would look exactly like a host that raised a first node — and the difference + // would surface as a mesh that never came up, with nothing to point at. + if !IsEmpty() { + t.Fatal("the default build claims to carry a substrate") + } + _, err := Load() + if !errors.Is(err, ErrEmpty) { + t.Fatalf("an empty bundle did not refuse: %v", err) + } + if !strings.Contains(err.Error(), "would look exactly like applying something") { + t.Errorf("the refusal does not say why it matters: %v", err) + } +} + +func TestCommentsAreNotContent(t *testing.T) { + // The placeholder is a comment. If comments counted as content, every default build would + // claim to carry a substrate and then fail to parse it — the right outcome for the wrong + // reason, and a confusing error at the worst moment. + if got := stripComments([]byte("// a\n{\"a\":1}\n // b\n")); strings.Contains(string(got), "//") { + t.Errorf("comments survived stripping: %q", got) + } +} + +func TestOnlyWholeLineCommentsAreStripped(t *testing.T) { + // Anything cleverer would have to know where strings begin and end. A parser that + // half-understands its input is worse than one that does not try — a path containing a + // double slash is ordinary, and losing half of it would be silent. + raw := []byte(`{"path":"https://example.invalid/a"}`) + if got := string(stripComments(raw)); got != string(raw) { + t.Errorf("a slash inside a string was treated as a comment: %q", got) + } +} + +func TestABundleWithContentIsParsedByTheSameParserTheLinkWillUse(t *testing.T) { + // A bundle that reaches a machine and is then refused by the host carrying it would be a + // build-time mistake found at the worst possible moment. + real := []byte(`// pinned +{"declaration":1,"resources":[{"id":"d","type":"directory","path":"/etc/mesh"}]}`) + stripped := stripComments(real) + if strings.Contains(string(stripped), "pinned") { + t.Fatal("the comment survived") + } + if !strings.Contains(string(stripped), "declaration") { + t.Fatal("the declaration did not survive") + } +} + +func TestWhatValidatesIsWhatIsApplied(t *testing.T) { + // Found on a real machine. `mesh-host bundle` validated the carried bundle through Load, + // which strips comments; `reconcile` handed the RAW bytes to the parser, which does not. + // So the command whose whole job is to check the bundle said yes, and the command that + // uses it said no — two paths to one artefact, disagreeing. + // + // There is now one path. This asserts the property that made the bug possible cannot + // return: whatever Load accepts is what gets applied, byte for byte. + annotated := []byte("// a comment\n" + `{"declaration":1,"resources":[{"id":"d","type":"directory","path":"/etc/mesh"}]}`) + + if _, err := parseFor(annotated); err != nil { + t.Fatalf("an annotated bundle was refused: %v", err) + } +} + +// parseFor mirrors what Load does to arbitrary bytes, so the test can exercise the path +// without rebuilding the binary with a different embedded file. +func parseFor(raw []byte) (any, error) { + return declarationParse(stripComments(raw)) +} diff --git a/internal/bundle/substrate.lock b/internal/bundle/substrate.lock new file mode 100644 index 0000000..f2914da --- /dev/null +++ b/internal/bundle/substrate.lock @@ -0,0 +1,8 @@ +// substrate.lock — the pinned tier-1 descriptor this host carries. +// +// Empty on purpose. What belongs here is the closure for a one-node mesh, and that is not +// yet known: novox/hq research 012 asks what the minimum actually is, and research 011 is +// what would compute it rather than assert it. +// +// A host built with this placeholder refuses to reconcile and says why, rather than applying +// nothing and reporting success. Building a real host means building it with a real bundle.