Unify trunk on main: initialization → main #3
@@ -2,7 +2,11 @@
|
|||||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo development)
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo development)
|
||||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
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
|
check: fmt vet test build
|
||||||
|
|
||||||
@@ -16,9 +20,22 @@ vet:
|
|||||||
test:
|
test:
|
||||||
go test ./... -count=1
|
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:
|
build:
|
||||||
CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o mesh-host ./cmd/mesh-host
|
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:
|
clean:
|
||||||
rm -f mesh-host
|
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 profile what this machine can be asked to do
|
||||||
mesh-host inventory what this machine is, and what it holds
|
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
|
mesh-host owned what this host has applied and still owns
|
||||||
--json machine-readable
|
--json machine-readable
|
||||||
--state where this node keeps what it knows
|
--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
|
already been done — the machine is in whatever state that left it, and pretending otherwise is
|
||||||
the fault this exists to prevent.
|
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.
|
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
|
## A capability is detected, never assumed
|
||||||
|
|
||||||
The reason this is the first thing built rather than a detail of it.
|
The reason this is the first thing built rather than a detail of it.
|
||||||
|
|||||||
+41
-16
@@ -19,6 +19,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/novox/mesh-host/internal/apply"
|
"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/declaration"
|
||||||
"github.com/novox/mesh-host/internal/inventory"
|
"github.com/novox/mesh-host/internal/inventory"
|
||||||
"github.com/novox/mesh-host/internal/profile"
|
"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
|
profile what this machine can be asked to do
|
||||||
inventory what this machine is, and what it holds
|
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
|
owned what this host has applied and still owns
|
||||||
version
|
version
|
||||||
|
|
||||||
@@ -146,7 +149,39 @@ func run(ctx context.Context, command string, opts options) error {
|
|||||||
return nil
|
return nil
|
||||||
|
|
||||||
case "apply":
|
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":
|
case "owned":
|
||||||
known, err := store.Load(opts.state)
|
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
|
// 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
|
// 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.
|
// not record it would believe it owns less than it does and leave that behind forever.
|
||||||
func runApply(ctx context.Context, opts options) error {
|
func runApply(ctx context.Context, opts options, d *declaration.Declaration, source string) 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
|
|
||||||
}
|
|
||||||
|
|
||||||
known, err := store.Load(opts.state)
|
known, err := store.Load(opts.state)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -260,7 +285,7 @@ func runApply(ctx context.Context, opts options) error {
|
|||||||
|
|
||||||
if opts.dryRun {
|
if opts.dryRun {
|
||||||
fmt.Printf("%s: %d resource(s), version %d — accepted, nothing applied\n",
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,9 +311,9 @@ func runApply(ctx context.Context, opts options) error {
|
|||||||
return writeJSON(report)
|
return writeJSON(report)
|
||||||
}
|
}
|
||||||
if !report.Changed() {
|
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
|
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
|
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