diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index a6972f2..8e84528 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -176,23 +176,23 @@ func run(ctx context.Context, command string, opts options) error { // 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() + d, err := bundle.Load(builtFor) if err != nil { return err } return runApply(ctx, opts, d, "the carried bundle") case "bundle": - if bundle.IsEmpty() { + if bundle.IsEmpty(builtFor) { fmt.Println("this host carries no bundle") return nil } - _, err := bundle.Load() + _, err := bundle.Load(builtFor) 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()) + os.Stdout.Write(bundle.Raw(builtFor)) return nil case "owned": diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go index bdebaae..5b21b6a 100644 --- a/internal/bundle/bundle.go +++ b/internal/bundle/bundle.go @@ -12,33 +12,49 @@ package bundle import ( _ "embed" "errors" + "fmt" "strings" "github.com/novox/mesh-host/internal/declaration" ) -// substrate is the pinned tier-1 descriptor, appliable with no mesh present. +// One bundle per operating system, because its CONTENTS are per system even though its +// mechanism is not: package names, unit names and service names all differ +// (novox/hq ADR 0060). All three are embedded and the host applies the one it was built for — +// an arch host never reads the alpine bundle. // -// A host built without one carries the placeholder below, and says so rather than applying +// A host whose bundle is only comments carries nothing, 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 +//go:embed substrate-arch.lock +var archLock []byte -// ErrEmpty means this host was built without a bundle. +//go:embed substrate-alpine.lock +var alpineLock []byte + +//go:embed substrate-android.lock +var androidLock []byte + +var locks = map[string][]byte{ + "arch": archLock, + "alpine": alpineLock, + "android": androidLock, +} + +// ErrEmpty means this host carries no 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") + "this host carries no bundle. A host 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 } +func Raw(system string) []byte { return locks[system] } // 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") { +// empty for this purpose: a placeholder is a comment, and treating it as content would mean a +// host claims to carry a substrate it does not. +func IsEmpty(system string) bool { + for _, line := range strings.Split(string(locks[system]), "\n") { line = strings.TrimSpace(line) if line != "" && !strings.HasPrefix(line, "//") { return false @@ -52,14 +68,17 @@ func IsEmpty() bool { // 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() { +func Load(system string) (*declaration.Declaration, error) { + if _, known := locks[system]; !known { + return nil, fmt.Errorf("no bundle is built for %q", system) + } + if IsEmpty(system) { return nil, ErrEmpty } // ParseTrusted: the bundle arrives with the binary, so it may carry actions the link may // not (novox/hq ADR 0047). The bootstrap needs them — creating the control plane's database // happens before there is any mesh to ask for one. - return declaration.ParseTrusted(stripComments(substrate)) + return declaration.ParseTrusted(stripComments(locks[system])) } // stripComments removes whole-line `//` comments so a bundle can be annotated. diff --git a/internal/bundle/bundle_test.go b/internal/bundle/bundle_test.go index 3a05e73..f5f97de 100644 --- a/internal/bundle/bundle_test.go +++ b/internal/bundle/bundle_test.go @@ -15,10 +15,10 @@ 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() { + if !IsEmpty("arch") { t.Fatal("the default build claims to carry a substrate") } - _, err := Load() + _, err := Load("arch") if !errors.Is(err, ErrEmpty) { t.Fatalf("an empty bundle did not refuse: %v", err) } @@ -80,3 +80,34 @@ func TestWhatValidatesIsWhatIsApplied(t *testing.T) { func parseFor(raw []byte) (any, error) { return declarationParse(stripComments(raw)) } + +func TestEverySystemHasABundleAndAndroidsRefuses(t *testing.T) { + // The bundle's contents are per system even though its mechanism is not (novox/hq ADR + // 0060), so a host must find one built for it — and a host built for a system with no + // bundle at all must say that rather than behave like an empty one. + for _, system := range []string{"arch", "alpine", "android"} { + if _, err := Load(system); err == nil { + t.Errorf("%s: a placeholder bundle loaded as if it had contents", system) + } else if !errors.Is(err, ErrEmpty) { + t.Errorf("%s: refused for the wrong reason: %v", system, err) + } + } + + if _, err := Load("debian"); err == nil { + t.Error("a bundle was loaded for a system nobody has built") + } else if errors.Is(err, ErrEmpty) { + t.Error("an unbuilt system was reported as an empty bundle; those are different things") + } +} + +func TestAndroidsBundleSaysWhyThereIsNone(t *testing.T) { + // Not a placeholder waiting to be filled in. An android host implements neither `package` + // nor `container` nor `service`, so every step of the bootstrap is a shape it does not + // have — a partial host can JOIN a mesh and cannot BE the first node. + text := string(Raw("android")) + for _, want := range []string{"cannot raise a mesh", "JOIN", "first node"} { + if !strings.Contains(text, want) { + t.Errorf("the android bundle does not explain itself; missing %q", want) + } + } +} diff --git a/internal/bundle/substrate-alpine.lock b/internal/bundle/substrate-alpine.lock new file mode 100644 index 0000000..2e045fd --- /dev/null +++ b/internal/bundle/substrate-alpine.lock @@ -0,0 +1,11 @@ +// substrate-alpine.lock — the pinned tier-1 descriptor the ALPINE host carries. +// +// Per system, because its CONTENTS are: this one names apk packages and OpenRC services where +// the arch bundle names pacman packages and systemd units (novox/hq ADR 0060). +// +// 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. diff --git a/internal/bundle/substrate-android.lock b/internal/bundle/substrate-android.lock new file mode 100644 index 0000000..cae1625 --- /dev/null +++ b/internal/bundle/substrate-android.lock @@ -0,0 +1,12 @@ +// substrate-android.lock — deliberately not a bundle. +// +// An android host cannot raise a mesh, and this file says so rather than being an empty +// placeholder waiting to be filled in. +// +// The substrate is a container runtime, a store and the control plane (novox/hq +// 07-the-substrate.md). An android host implements `file`, `directory` and `action` and refuses +// `package`, `container` and `service` (ADR 0060) — so every step of the bootstrap is a shape it +// does not have. No amount of filling this in changes that. +// +// **A partial host can JOIN a mesh and cannot BE the first node.** That is a real distinction +// and it belongs here, where somebody looking for the android bundle will find it. diff --git a/internal/bundle/substrate.lock b/internal/bundle/substrate-arch.lock similarity index 62% rename from internal/bundle/substrate.lock rename to internal/bundle/substrate-arch.lock index f2914da..0dfc53f 100644 --- a/internal/bundle/substrate.lock +++ b/internal/bundle/substrate-arch.lock @@ -1,4 +1,7 @@ -// substrate.lock — the pinned tier-1 descriptor this host carries. +// substrate-arch.lock — the pinned tier-1 descriptor the ARCH host carries. +// +// Per system, because its CONTENTS are: package names, unit names and service names all differ +// (novox/hq ADR 0060). The mechanism is shared; what it names is not. // // 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 diff --git a/internal/system/android.go b/internal/system/android.go index 6c6aea7..ec39b4f 100644 --- a/internal/system/android.go +++ b/internal/system/android.go @@ -24,11 +24,19 @@ import ( // and an unlocked bootloader. On a normal device nothing can register with it. // - **container** — no container runtime, and no kernel access to give one. // -// **Being STARTED on Android is not solved by this file, and it is the real gap.** Everywhere -// else an init runs the launcher at boot. Here the equivalent is the app framework — a -// foreground service, or something under Termux — both of which the system may kill when it -// wants memory. That is a different mechanism from every other node rather than a variant of -// one, and nothing here designs it. +// **This host is EPISODIC** (novox/hq ADR 0062). Everywhere else an init runs the launcher at +// boot and the launcher supervises the host. Android grants neither: nothing to register with +// without root, and nothing worth supervising, because a supervisor would be killed alongside +// what it supervises. +// +// So it runs when the platform allows and is killed when the platform wants the memory — and +// that is **disconnection**, which ADR 0036 already made an ordinary situation rather than an +// exception. It needs no keep-alive and no new mechanism: the store is already authoritative +// while disconnected, reconcile already happens on start, and the mesh already reports *last +// heard from* rather than alarming on silence. +// +// It also **cannot be the first node** — every step of raising a substrate is a shape it +// refuses — and its bundle says so rather than being an empty placeholder. type android struct{} func (android) Name() string { return "android" }