Two gaps. The bundle's contents are per system even though its mechanism is not, so there are now three: substrate-arch.lock, substrate-alpine.lock and substrate-android.lock. All three are embedded and a host reads only the one it was built for. Arch and Alpine remain placeholders -- the closure for a one-node mesh is still research 011/012's open question, and inventing it here would be worse than an honest placeholder. Android's is not a placeholder. It says a partial host cannot raise a mesh and why: every step of a bootstrap is a package, a container, or an action against one, and those are exactly the shapes it refuses. So a partial host can JOIN a mesh and cannot BE the first node. That belongs where somebody looking for the android bundle will find it. Also separated two things that were being conflated: "this system has no bundle" and "this system was never built". Loading a bundle for debian is not ErrEmpty, and the test asserts they differ. 0062 -- a host may be episodic. There is no way to keep a process running on an ordinary Android device: init needs root, a foreground service can be killed for memory. The answer is not to fight that. It is that being killed IS disconnection, which ADR 0036 already made an ordinary situation -- and everything the design does for a laptop that closes is what an episodic host needs, at a shorter period. An authoritative local store, reconcile on start, last-heard-from reported without an alarm. So the gap closes by requiring less rather than building something. No keep- alive, no Android daemon, no fighting the platform's process management. Two consequences recorded rather than glossed. Last-heard-from is a much weaker signal on an episodic host, so a healthy phone reads as a dead server unless the reader knows which kind it is looking at. And a declaration may take a long time to land, which makes 0058's separation of outstanding from failed load-bearing rather than tidy. Left open deliberately: how an episodic host is actually started, and -- first -- what an Android node is for. Building the start mechanism before deciding that would be building it for nobody.
114 lines
4.8 KiB
Go
114 lines
4.8 KiB
Go
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("arch") {
|
|
t.Fatal("the default build claims to carry a substrate")
|
|
}
|
|
_, err := Load("arch")
|
|
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))
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|