Unify trunk on main: initialization → main #3
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+41
-16
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user