Files
mesh-host/internal/apply/vocabulary_test.go
T
jschoubben 4a43e21794 A network is a shape, so that it can be removed
novox/hq ADR 0029, and work breakdown 1.3. A module of several
containers had no way to let them reach each other by name: a container
declaration could join a network and nothing could create one.

An action was the obvious alternative and is refused on removal —
"an action has no footprint the host can undo", so a network made that
way outlives every module that is ever unassigned, and the mesh cannot
tell. A resource the mesh can create and never clean up is one it should
not create.

A name and nothing else. Not a driver, a subnet or a gateway: each is
something a module would have to know about the machine it lands on, and
a module naming a subnet collides with whatever else chose the same one.

It needs no new ordering rule. Resources apply in declaration order and
orphans are removed in reverse, so a network written before the
containers that join it is created first and removed last — after they
are gone. A runtime refusing to remove one still in use is reported
rather than swallowed, because that means something undeclared is
holding it.

The vocabulary guard fired on the change, as designed, and now names the
record instead of a number: nine shapes, with the argument beside the
count.

Creation reads back rather than trusting an exit status (ADR 0018): a
runtime that reports success and made nothing leaves every container
that joins it failing to start, one step from the cause.
2026-08-31 18:55:06 +02:00

320 lines
11 KiB
Go

package apply
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// The shapes added so that most of what a person installs is expressible.
//
// A shell, a chat client, a desktop are a package plus configuration in somebody's home, and a
// mesh with no user can manage /etc and nothing anybody looks at.
func declare(t *testing.T, resources string) *declaration.Declaration {
t.Helper()
d, err := declaration.Parse([]byte(`{"declaration":1,"resources":[` + resources + `]}`))
if err != nil {
t.Fatal(err)
}
return d
}
func TestAFileMayBeBytesRatherThanText(t *testing.T) {
// A wallpaper, a font, an icon. Stored as its own encoding it would be a wallpaper nothing
// can open.
dir := t.TempDir()
original := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff}
d := declare(t, `{"id":"w","type":"file","path":"`+dir+`/wall.png","bytes":"`+
base64.StdEncoding.EncodeToString(original)+`"}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{},
store.OriginCarried, noServices, nil, nil); err != nil {
t.Fatal(err)
}
on, err := os.ReadFile(dir + "/wall.png")
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(on, original) {
t.Fatalf("the bytes did not survive: %x", on)
}
}
func TestAFileSaysWhatIsInItExactlyOnce(t *testing.T) {
// Three ways of saying it and no precedence between them, so "what is in this file" is
// answerable by looking rather than by knowing which field wins.
_, err := declaration.Parse([]byte(`{"declaration":1,"resources":[
{"id":"f","type":"file","path":"/etc/x","content":"a","bytes":"YQ=="}]}`))
if err == nil {
t.Fatal("a file that was both text and bytes was accepted")
}
if !strings.Contains(err.Error(), "exactly once") {
t.Fatalf("unhelpful refusal: %v", err)
}
}
func TestBytesThatAreNotBase64AreRefused(t *testing.T) {
dir := t.TempDir()
d := declare(t, `{"id":"w","type":"file","path":"`+dir+`/x","bytes":"not base64!!"}`)
_, _, err := Apply(context.Background(), archHost(t), d, store.State{},
store.OriginCarried, noServices, nil, nil)
if err == nil {
t.Fatal("a file carrying nonsense was written")
}
if _, statErr := os.Stat(dir + "/x"); statErr == nil {
t.Fatal("something was written before the failure")
}
}
// A gzipped tar, and its digest, built here so the test does not depend on a fixture nobody can
// regenerate.
func anArchive(t *testing.T, files map[string]string) ([]byte, string) {
t.Helper()
var raw bytes.Buffer
zipped := gzip.NewWriter(&raw)
writer := tar.NewWriter(zipped)
for name, body := range files {
if err := writer.WriteHeader(&tar.Header{
Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg,
}); err != nil {
t.Fatal(err)
}
if _, err := writer.Write([]byte(body)); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
if err := zipped.Close(); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(raw.Bytes())
return raw.Bytes(), "sha256:" + hex.EncodeToString(sum[:])
}
func serving(t *testing.T, body []byte) string {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(body)
}))
t.Cleanup(server.Close)
return server.URL + "/theme.tar.gz"
}
func TestAnArchiveIsUnpacked(t *testing.T) {
body, digest := anArchive(t, map[string]string{
"config/theme.conf": "dark", "config/icons/one.svg": "<svg/>",
})
dir := t.TempDir()
d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, body)+
`","digest":"`+digest+`","path":"`+dir+`/theme"}`)
report, _, err := Apply(context.Background(), archHost(t), d, store.State{},
store.OriginCarried, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
if !report.Changed() {
t.Fatal("nothing changed")
}
on, err := os.ReadFile(dir + "/theme/config/theme.conf")
if err != nil {
t.Fatal(err)
}
if string(on) != "dark" {
t.Fatalf("got %q", on)
}
}
func TestAnArchiveThatIsNotWhatWasDeclaredIsRefusedBeforeAnythingIsWritten(t *testing.T) {
// The only thing making bytes from a network the mesh does not control safe to unpack is
// that they hash to what was declared.
body, _ := anArchive(t, map[string]string{"a": "b"})
dir := t.TempDir()
d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, body)+
`","digest":"sha256:`+strings.Repeat("ab", 32)+`","path":"`+dir+`/theme"}`)
_, _, err := Apply(context.Background(), archHost(t), d, store.State{},
store.OriginCarried, noServices, nil, nil)
if err == nil {
t.Fatal("an archive that was not what was declared was unpacked")
}
if entries, _ := os.ReadDir(dir); len(entries) != 0 {
t.Fatal("something was written before the digest was checked")
}
}
func TestAnArchiveCannotWriteOutsideWhereItWasUnpacked(t *testing.T) {
// The oldest bug in unpacking. Checked against the resolved root rather than by looking for
// "..", because there is more than one way to name a path that escapes.
body, digest := anArchive(t, map[string]string{"../../escaped": "no"})
dir := t.TempDir()
d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, body)+
`","digest":"`+digest+`","path":"`+dir+`/theme"}`)
_, _, err := Apply(context.Background(), archHost(t), d, store.State{},
store.OriginCarried, noServices, nil, nil)
if err != nil && !strings.Contains(err.Error(), "outside") {
t.Fatalf("refused for the wrong reason: %v", err)
}
if _, statErr := os.Stat(dir + "/escaped"); statErr == nil {
t.Fatal("a file landed outside the directory it was unpacked into")
}
if err == nil {
t.Fatal("an escaping entry was accepted")
}
}
func TestAnUnpackedArchiveIsNotFetchedAgainForNothing(t *testing.T) {
// The digest is the whole identity of an archive, so a matching record means the tree came
// from these exact bytes. Applying twice must not report work.
body, digest := anArchive(t, map[string]string{"a": "b"})
dir := t.TempDir()
d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, body)+
`","digest":"`+digest+`","path":"`+dir+`/theme"}`)
_, state, err := Apply(context.Background(), archHost(t), d, store.State{},
store.OriginCarried, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
again, _, err := Apply(context.Background(), archHost(t), d, state,
store.OriginCarried, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
if again.Changed() {
said, _ := json.Marshal(again)
t.Fatalf("the second apply did work: %s", said)
}
}
// Defends novox/hq ADR 0012: the mesh creates no symlinks — a derived file is a copy.
//
// The archive is the one path where a symlink could arrive without anybody declaring it, which is
// why the refusal lives here. ADR 0012 was earned by production data loss through a symlink
// resolved inside a container volume path.
func TestAnArchiveWithSomethingThatIsNotAFileIsRefused(t *testing.T) {
// A theme needing a symlink would otherwise arrive silently incomplete, and a device node in
// an archive is not something to unpack quietly onto a machine.
var raw bytes.Buffer
zipped := gzip.NewWriter(&raw)
writer := tar.NewWriter(zipped)
if err := writer.WriteHeader(&tar.Header{
Name: "link", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd", Mode: 0o777,
}); err != nil {
t.Fatal(err)
}
writer.Close()
zipped.Close()
sum := sha256.Sum256(raw.Bytes())
digest := "sha256:" + hex.EncodeToString(sum[:])
dir := t.TempDir()
d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, raw.Bytes())+
`","digest":"`+digest+`","path":"`+dir+`/theme"}`)
_, _, err := Apply(context.Background(), archHost(t), d, store.State{},
store.OriginCarried, noServices, nil, nil)
if err == nil {
t.Fatal("a symlink was unpacked")
}
if !strings.Contains(err.Error(), "files and directories") {
t.Fatalf("refused for the wrong reason: %v", err)
}
}
func TestAnArchiveMustBePinned(t *testing.T) {
_, err := declaration.Parse([]byte(`{"declaration":1,"resources":[
{"id":"t","type":"archive","source":"https://example.invalid/a.tgz","path":"/opt/t"}]}`))
if err == nil {
t.Fatal("an unpinned archive was accepted")
}
if !strings.Contains(err.Error(), "digest") {
t.Fatalf("unhelpful refusal: %v", err)
}
}
// Defends novox/hq ADR 0029: a network is a shape so that it can be removed.
//
// The whole argument for widening the vocabulary is lifecycle — an action could create one and
// nothing could ever take it away — so removal is the assertion that matters, not creation.
func TestANetworkIsCreatedAndThenRemovedWhenNoLongerDeclared(t *testing.T) {
var calls []string
there := map[string]bool{}
run := func(_ context.Context, name string, args ...string) (string, error) {
calls = append(calls, name+" "+strings.Join(args, " "))
if name != "docker" || len(args) < 2 || args[0] != "network" {
return "", nil // the runtime probe
}
switch args[1] {
case "inspect":
if !there[args[2]] {
return "", fmt.Errorf("no such network")
}
case "create":
there[args[2]] = true
case "rm":
delete(there, args[2])
}
return "", nil
}
d := declare(t, `{"id":"private","type":"network","name":"mail"}`)
_, state, err := Apply(context.Background(), archHost(t), d, store.State{},
store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if !there["mail"] {
t.Fatal("the network was not created")
}
// The module is unassigned: the mesh now declares nothing.
empty := declare(t, `{"id":"unrelated","type":"directory","path":"`+t.TempDir()+`"}`)
if _, _, err := Apply(context.Background(), archHost(t), empty, state,
store.OriginDeclared, run, nil, nil); err != nil {
t.Fatal(err)
}
if there["mail"] {
t.Fatal("the network outlived the module that declared it, which is the entire reason " +
"this is a shape rather than an action")
}
}
// A network is created once and left alone when it is already there.
func TestANetworkAlreadyThereIsNotRebuilt(t *testing.T) {
var created int
run := func(_ context.Context, name string, args ...string) (string, error) {
if name == "docker" && len(args) > 1 && args[0] == "network" && args[1] == "create" {
created++
}
return "", nil // inspect succeeds: it is already there
}
d := declare(t, `{"id":"private","type":"network","name":"mail"}`)
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{},
store.OriginDeclared, run, nil, nil); err != nil {
t.Fatal(err)
}
if created != 0 {
t.Fatalf("a network that was already there was created %d time(s); the mesh owns the "+
"name and not the thing, so it does not tear one down and rebuild it", created)
}
}