Stage 2 — the bundle a host carries

novox/hq ADR 0038: one behaviour, two sources of declaration. This is the source
that does not need a mesh — the first node's path.

The bundle is embedded in the binary rather than shipped beside it, because
"copy it onto a machine and run it is the whole installation" stops being true
the moment a second file has to arrive with it. `make host BUNDLE=...` builds a
host carrying one; `mesh-host reconcile` applies it; `mesh-host bundle` shows it.

A default build carries nothing and REFUSES to reconcile, saying why. 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.

Proved on a sealed machine: no route out, no name resolution, one binary copied
on, and it configured itself from what it carried. Idempotent on the second run.

One bug found by running rather than reasoning, and it is a shape worth naming:
`mesh-host bundle` validated the carried bundle through a path that strips
comments, while `reconcile` handed the raw bytes to the parser. 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 one path now, and a
test asserts that what validates is what is applied.

What this does NOT prove is stated in the README rather than left implied: the
claim under stage 2 is that one host can raise the substrate alone, and the
substrate is four container services. There is no container type, because a
container needs an image and where images come from is open; what belongs in a
substrate is not known, because the closure for a one-node mesh is what research
011 and 012 exist to answer; and the machine used to test this cannot install a
container runtime through a sealed network.

The mechanism is finished. The claim is not, and shipping a host that claimed a
substrate it has never raised would be the fault this whole project is about.

65 tests.
This commit is contained in:
2026-08-26 22:06:54 +02:00
parent 9d8239afe8
commit 08a1263a81
6 changed files with 264 additions and 19 deletions
+41 -16
View File
@@ -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
}