430 lines
18 KiB
Go
430 lines
18 KiB
Go
package declaration
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Each test names the decision it defends (novox/hq ADR 0017). The decision here is ADR 0005,
|
|
// and the property it turns on is that unknown is REFUSED, never skipped.
|
|
|
|
func valid() string {
|
|
return `{"declaration":1,"resources":[
|
|
{"id":"etc","type":"directory","path":"/etc/mesh","mode":"0755"},
|
|
{"id":"conf","type":"file","path":"/etc/mesh/host.conf","content":"a\n","mode":"0640"},
|
|
{"id":"svc","type":"service","unit":"mesh-host.service","state":"running"}
|
|
]}`
|
|
}
|
|
|
|
func refusalFor(t *testing.T, raw string) *RefusalError {
|
|
t.Helper()
|
|
_, err := Parse([]byte(raw))
|
|
if err == nil {
|
|
t.Fatal("expected a refusal")
|
|
}
|
|
var refusal *RefusalError
|
|
if !errors.As(err, &refusal) {
|
|
t.Fatalf("expected a RefusalError, got %T: %v", err, err)
|
|
}
|
|
return refusal
|
|
}
|
|
|
|
func TestAValidDeclarationParsesInOrder(t *testing.T) {
|
|
d, err := Parse([]byte(valid()))
|
|
if err != nil {
|
|
t.Fatalf("unexpected refusal: %v", err)
|
|
}
|
|
// Order is stated, not derived. The host must not sort.
|
|
got := []string{d.Resources[0].Identity(), d.Resources[1].Identity(), d.Resources[2].Identity()}
|
|
want := []string{"etc", "conf", "svc"}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("resources reordered: %v, want %v", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnUnknownTypeRefusesTheWholeDeclaration(t *testing.T) {
|
|
// The property everything else rests on. A host that skipped what it did not understand
|
|
// would apply most of a declaration and report success — a node that looks configured and
|
|
// is not, which is 04-ISSUES/003 with the declaration on the other side of the wire.
|
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
|
{"id":"ok","type":"directory","path":"/etc/mesh"},
|
|
{"id":"what","type":"blockchain","path":"/etc/mesh"}
|
|
]}`)
|
|
|
|
joined := strings.Join(refusal.Problems, "\n")
|
|
if !strings.Contains(joined, "blockchain") {
|
|
t.Errorf("the unknown type was not named: %v", refusal.Problems)
|
|
}
|
|
// And it must say what IS understood, or the reader goes to the source to find out.
|
|
if !strings.Contains(joined, "directory") || !strings.Contains(joined, "service") {
|
|
t.Errorf("the refusal does not say what this host understands: %v", refusal.Problems)
|
|
}
|
|
}
|
|
|
|
func TestAnUnknownFieldIsRefused(t *testing.T) {
|
|
// A field the host does not know is a thing the control plane believes it asked for.
|
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
|
{"id":"conf","type":"file","path":"/etc/x","content":"a","immutable":true}
|
|
]}`)
|
|
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "immutable") {
|
|
t.Errorf("the unknown field was not named: %v", refusal.Problems)
|
|
}
|
|
}
|
|
|
|
func TestAFieldTheTypeDoesNotUseIsRefusedNotIgnored(t *testing.T) {
|
|
// The same fault in miniature: set and ignored means the control plane believes it asked
|
|
// for something the host will never do.
|
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
|
{"id":"svc","type":"service","unit":"a.service","state":"running","path":"/etc/x"}
|
|
]}`)
|
|
joined := strings.Join(refusal.Problems, "\n")
|
|
if !strings.Contains(joined, "path") || !strings.Contains(joined, "Refused rather than ignored") {
|
|
t.Errorf("a field a service does not use was accepted: %v", refusal.Problems)
|
|
}
|
|
}
|
|
|
|
func TestAnUnknownVersionIsRefusedWhole(t *testing.T) {
|
|
// An older host handed a newer vocabulary must not quietly do half of it.
|
|
refusal := refusalFor(t, `{"declaration":99,"resources":[
|
|
{"id":"a","type":"directory","path":"/etc/mesh"}
|
|
]}`)
|
|
joined := strings.Join(refusal.Problems, "\n")
|
|
if !strings.Contains(joined, "99") || !strings.Contains(joined, "version 1") {
|
|
t.Errorf("the version mismatch was not stated plainly: %v", refusal.Problems)
|
|
}
|
|
// Nothing else is reported, because everything else assumes a vocabulary this host does
|
|
// not have — a list of complaints derived from the wrong grammar is noise.
|
|
if len(refusal.Problems) != 1 {
|
|
t.Errorf("expected only the version problem, got: %v", refusal.Problems)
|
|
}
|
|
}
|
|
|
|
func TestEveryProblemIsReportedAtOnce(t *testing.T) {
|
|
// A declaration is generated, so a person reading a refusal is debugging the generator.
|
|
// Fixing one problem at a time and re-running to find the next wastes their afternoon.
|
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
|
{"id":"","type":"directory","path":"/a"},
|
|
{"id":"b","type":"file"},
|
|
{"id":"c","type":"service","unit":"x.service","state":"dancing"}
|
|
]}`)
|
|
if len(refusal.Problems) < 3 {
|
|
t.Errorf("expected every problem at once, got: %v", refusal.Problems)
|
|
}
|
|
}
|
|
|
|
func TestIdentityIsRequiredAndUnique(t *testing.T) {
|
|
// Identity is what lets the store know this is the same resource it applied last time,
|
|
// which is what makes removal possible at all.
|
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
|
{"id":"same","type":"directory","path":"/a"},
|
|
{"id":"same","type":"directory","path":"/b"}
|
|
]}`)
|
|
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "already used") {
|
|
t.Errorf("a duplicate identity was accepted: %v", refusal.Problems)
|
|
}
|
|
}
|
|
|
|
func TestModeIsRefusedUnlessItMeansWhatItLooksLike(t *testing.T) {
|
|
// "644" and "0644" differ, and the one that looks right in a manifest is the four-digit
|
|
// form. Accepting both would make a mode mean two things.
|
|
for _, mode := range []string{"644", "0999", "rwxr-xr-x", "07777777"} {
|
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
|
{"id":"f","type":"file","path":"/a","mode":"`+mode+`"}
|
|
]}`)
|
|
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "mode") {
|
|
t.Errorf("mode %q was accepted: %v", mode, refusal.Problems)
|
|
}
|
|
}
|
|
if _, err := Parse([]byte(`{"declaration":1,"resources":[
|
|
{"id":"f","type":"file","path":"/a","mode":"0644"}
|
|
]}`)); err != nil {
|
|
t.Errorf("a well-formed mode was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestARefusalSaysNothingWasApplied(t *testing.T) {
|
|
// The reader's first question is whether the machine was left half-changed.
|
|
refusal := refusalFor(t, `{"declaration":1,"resources":[{"id":"x","type":"nope"}]}`)
|
|
if !strings.Contains(refusal.Error(), "none of it was applied") {
|
|
t.Errorf("the refusal does not say the machine is untouched: %s", refusal.Error())
|
|
}
|
|
}
|
|
|
|
func TestAnEmptyDeclarationIsAMistake(t *testing.T) {
|
|
refusalFor(t, `{"declaration":1,"resources":[]}`)
|
|
}
|
|
|
|
// --- the vocabulary the foundation bootstrap needs (novox/hq 07-the-foundation.md) ---
|
|
|
|
func TestAnActionOverTheLinkIsRefused(t *testing.T) {
|
|
// novox/hq ADR 0005. The link may push declarations of known shape and never a command to
|
|
// run. This is the boundary the whole security argument rests on, so it is asserted
|
|
// directly rather than inferred from the type list.
|
|
raw := []byte(`{"declaration":1,"resources":[
|
|
{"id":"schema","type":"action","command":["psql","-f","x.sql"],"verify":["psql","-c","select 1"]}
|
|
]}`)
|
|
|
|
if _, err := Parse(raw); err == nil {
|
|
t.Fatal("an action arriving over the link was accepted")
|
|
} else if !strings.Contains(err.Error(), "the link may not carry one") {
|
|
t.Errorf("refused for the wrong reason: %v", err)
|
|
}
|
|
|
|
// And the same bytes from the bundle are fine — the asymmetry IS the decision.
|
|
if _, err := ParseTrusted(raw); err != nil {
|
|
t.Errorf("the bundle may carry an action, and this one was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAnActionWithoutVerifyIsRefused(t *testing.T) {
|
|
// An action that runs and reports success without reading anything back is the fault this
|
|
// host exists to prevent. Verify is also the idempotency check, so an action without one
|
|
// cannot be applied twice safely either.
|
|
_, err := ParseTrusted([]byte(`{"declaration":1,"resources":[
|
|
{"id":"schema","type":"action","command":["psql","-f","x.sql"]}
|
|
]}`))
|
|
if err == nil {
|
|
t.Fatal("an action with no verify was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "needs a verify") {
|
|
t.Errorf("refused for the wrong reason: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAnImageMustBePinnedByDigest(t *testing.T) {
|
|
// novox/hq ADR 0006: reproducibility comes from pinning the identity of a thing. A bundle
|
|
// naming a tag pins nothing — it names whatever that tag points at on the day it runs.
|
|
for _, image := range []string{
|
|
"postgres:17",
|
|
"postgres",
|
|
"postgres@sha256:short",
|
|
"@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
|
} {
|
|
_, err := ParseTrusted([]byte(`{"declaration":1,"resources":[
|
|
{"id":"store","type":"container","name":"store","image":"` + image + `"}
|
|
]}`))
|
|
if err == nil {
|
|
t.Errorf("image %q was accepted and is not pinned", image)
|
|
}
|
|
}
|
|
|
|
good := "postgres@sha256:" + strings.Repeat("a", 64)
|
|
if _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[
|
|
{"id":"store","type":"container","name":"store","image":"` + good + `"}
|
|
]}`)); err != nil {
|
|
t.Errorf("a properly pinned image was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// An image the machine already holds, addressed by the digest of its own configuration.
|
|
//
|
|
// **The form a first machine needs.** A manifest digest is assigned by a registry on push, so
|
|
// insisting on one means a registry has to exist before the thing that lets a mesh have a registry
|
|
// can start. The mesh's own control plane is built from source and lives in no public registry; a
|
|
// machine that built it, or was handed it, names it by what it is — a content address, not a name,
|
|
// and immutable in exactly the way the rule asks for.
|
|
func TestAnImageTheMachineHoldsIsNamedByItsOwnDigest(t *testing.T) {
|
|
held := "sha256:" + strings.Repeat("b", 64)
|
|
if _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[
|
|
{"id":"control","type":"container","name":"mesh-controller","image":"` + held + `"}
|
|
]}`)); err != nil {
|
|
t.Errorf("an image named by its own digest was refused: %v", err)
|
|
}
|
|
|
|
// Still a digest, though. A truncated one names several images, and which one ran would be
|
|
// whichever the runtime happened to match first.
|
|
for _, bad := range []string{"sha256:abc", "sha256:", "sha256:" + strings.Repeat("b", 63)} {
|
|
if _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[
|
|
{"id":"control","type":"container","name":"mesh-controller","image":"` + bad + `"}
|
|
]}`)); err == nil {
|
|
t.Errorf("image id %q was accepted and is not a digest", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) {
|
|
// The field-set check must cover the types added last, not only the three it was written
|
|
// for. A package that carries a `content` is a control plane believing it asked for
|
|
// something that will never happen.
|
|
for _, body := range []string{
|
|
`{"id":"p","type":"package","package":"docker","content":"x"}`,
|
|
`{"id":"p","type":"package","package":"docker","image":"x"}`,
|
|
`{"id":"c","type":"container","name":"n","image":"i@sha256:` + strings.Repeat("a", 64) + `","unit":"x.service"}`,
|
|
`{"id":"a","type":"action","command":["x"],"verify":["y"],"path":"/tmp/x"}`,
|
|
} {
|
|
_, err := ParseTrusted([]byte(`{"declaration":1,"resources":[` + body + `]}`))
|
|
if err == nil {
|
|
t.Errorf("a resource carrying a field its type does not use was accepted: %s", body)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), "Refused rather than ignored") {
|
|
t.Errorf("refused for the wrong reason: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTheVocabularyIsTheTwelveShapesTheMeshNeeds(t *testing.T) {
|
|
// Six of them the bootstrap uses (novox/hq 07-the-foundation.md), and removing one is a
|
|
// failing test rather than a discovery during a first-node install.
|
|
//
|
|
// Two were added on 2026-08-30 and the count is asserted precisely because adding one is a
|
|
// decision. `user` and `archive` exist because most of what a person installs is not a
|
|
// service: a shell, a chat client, a desktop are a package plus configuration **in
|
|
// somebody's home**, and a mesh with no user can only own /etc. `archive` is for the case
|
|
// inlining cannot serve — a theme is hundreds of files, and inlining them would rewrite all
|
|
// of them whenever one changed.
|
|
speaks := map[Type]bool{}
|
|
for _, t := range Vocabulary() {
|
|
speaks[t] = true
|
|
}
|
|
for _, want := range []Type{
|
|
TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction,
|
|
TypeUser, TypeArchive, TypeNetwork, TypeAccess, TypeProcess, TypeOpening,
|
|
} {
|
|
if !speaks[want] {
|
|
t.Errorf("the host no longer speaks %q", want)
|
|
}
|
|
if newOf(want) == nil {
|
|
t.Errorf("%q is in the vocabulary and cannot be constructed", want)
|
|
}
|
|
}
|
|
// `network` is the ninth, and novox/hq ADR 0029 is the decision that made it one: an action
|
|
// could create a network and nothing could remove it, because an action leaves no footprint
|
|
// the host can undo — so the network would outlive every module that was ever unassigned.
|
|
//
|
|
// `access` is the tenth, and novox/hq ADR 0051 is its decision: shared, pre-existing data is
|
|
// the operator's, and a module is granted use of it without owning it — a shape the host must
|
|
// tell apart from a directory precisely because it must NOT create, chown or remove it.
|
|
//
|
|
// `daemon` is the eleventh, and it exists because the mechanism was leaking into every module.
|
|
// Running code of one's own meant a `container` and therefore an image; running a script meant
|
|
// a `service` and a unit somebody else had to install. One intent — run this and keep it
|
|
// running — expressed two unrelated ways, with the hosting chosen before anything could be
|
|
// declared. A daemon says what to run; the machine's own supervisor is how, and the mesh owns
|
|
// the unit because it is the mesh's own code (novox/hq 03-DESIGN/01-to-be/18-building-a-module.md).
|
|
//
|
|
// It is a full-host shape rather than a portable one: it needs a process supervisor to install
|
|
// into. It does NOT need a container runtime, which is the point — only software that
|
|
// genuinely needs isolation asks for a container.
|
|
//
|
|
// `opening` is the twelfth, and novox/hq ADR 0100 is its decision: on an adopted node the
|
|
// firewall found there stays in force, and what the mesh needs reachable is converged through
|
|
// it as a state the host marks as the mesh's — never a command, which the link may not carry.
|
|
if len(speaks) != 12 {
|
|
t.Errorf("the vocabulary is %d shapes rather than 12; every addition widens what a compromised "+
|
|
"control plane can express, so a change here is a decision: %s",
|
|
len(speaks), vocabulary())
|
|
}
|
|
}
|
|
|
|
func TestADeclarationFromDiskMayBeAnnotated(t *testing.T) {
|
|
// The bundle in this repository is mostly explanation of why each digest is what it is, and
|
|
// it could be built into a binary and not applied from disk — two readers for one file. The
|
|
// failure was `invalid character '/'`, which names the symptom and not the cause.
|
|
raw := []byte(`// why this exists
|
|
{
|
|
"declaration": 1,
|
|
// and why this resource is here
|
|
"resources": [
|
|
{"id": "f", "type": "file", "path": "/etc/x", "content": "hello"}
|
|
]
|
|
}`)
|
|
d, err := ParseFileTrusted(raw)
|
|
if err != nil {
|
|
t.Fatalf("a file with comments was refused: %v", err)
|
|
}
|
|
if len(d.Resources) != 1 {
|
|
t.Fatalf("got %d resources", len(d.Resources))
|
|
}
|
|
// And the wire format is untouched: over the link it is exactly JSON, because a format with
|
|
// a second thing to strip is a format with a second thing to disagree about.
|
|
if _, err := Parse(raw); err == nil {
|
|
t.Fatal("the link accepted a declaration with comments in it")
|
|
}
|
|
}
|
|
|
|
func TestSomethingInsideAValueIsNotAComment(t *testing.T) {
|
|
// Every image reference has a `//` in it somewhere near. Only whole lines are dropped.
|
|
d, err := ParseFileTrusted([]byte(`{"declaration":1,"resources":[
|
|
{"id":"f","type":"file","path":"/etc/x","content":"see https://example.invalid/ for why"}]}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
file, ok := d.Resources[0].(*File)
|
|
if !ok {
|
|
t.Fatalf("got %T", d.Resources[0])
|
|
}
|
|
if !strings.Contains(file.Content, "https://example.invalid/") {
|
|
t.Fatalf("a value was mangled: %q", file.Content)
|
|
}
|
|
}
|
|
|
|
// A declaration followed by anything at all is refused whole.
|
|
//
|
|
// A JSON decoder reads one value and stops, so a file holding a declaration and then a stray line
|
|
// parses as the declaration and the rest is never looked at. The machine applies something,
|
|
// reports success, and what it applied is not what the file says.
|
|
//
|
|
// Not hypothetical: a test harness appended a line to the foundation bundle by accident, every
|
|
// apply kept working, and nothing said so for the entire time it was wrong.
|
|
func TestSomethingAfterTheDeclarationIsRefused(t *testing.T) {
|
|
good := `{"declaration":1,"resources":[{"id":"a","type":"file","path":"/tmp/a",` +
|
|
`"content":"x","mode":"0644"}]}`
|
|
if _, err := ParseFileTrusted([]byte(good)); err != nil {
|
|
t.Fatalf("an ordinary declaration was refused: %v", err)
|
|
}
|
|
for _, after := range []string{
|
|
"MESHBUNDLE 2>&1; echo \"__exit=$?\"",
|
|
good,
|
|
"garbage",
|
|
} {
|
|
_, err := ParseFileTrusted([]byte(good + "\n" + after))
|
|
if err == nil {
|
|
t.Fatalf("a file with %q after the declaration was accepted", after)
|
|
}
|
|
}
|
|
// Trailing whitespace is not "something after it", and refusing it would make every file
|
|
// written by an editor unusable.
|
|
if _, err := ParseFileTrusted([]byte(good + "\n\n \n")); err != nil {
|
|
t.Fatalf("a declaration with a trailing newline was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// A name that would read as a reference to something else is refused before it reaches a runtime.
|
|
func TestANetworkNameIsRefusedIfItIsNotOne(t *testing.T) {
|
|
for _, name := range []string{"", "mail/private", "host:mail", "a b"} {
|
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
|
{"id":"private","type":"network","name":"`+name+`"}
|
|
]}`)
|
|
if len(refusal.Problems) == 0 {
|
|
t.Errorf("a network named %q was accepted", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A placeholder with nothing to fill it is refused before anything is written.
|
|
//
|
|
// The alternative is a configuration file containing the literal `${secret:x}`, which the program
|
|
// reads as a value and fails on somewhere with no connection to this.
|
|
func TestAFileAskingForASecretItWasNotGivenIsRefused(t *testing.T) {
|
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
|
{"id":"c","type":"file","path":"/tmp/x","content":"token=${secret:absent}"}
|
|
]}`)
|
|
if len(refusal.Problems) == 0 {
|
|
t.Fatal("a file naming a secret nobody gave it was accepted")
|
|
}
|
|
}
|
|
|
|
// And a secret nobody asked for, because somebody believes it is in a file where it is not.
|
|
func TestASecretTheContentNeverUsesIsRefused(t *testing.T) {
|
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
|
{"id":"c","type":"file","path":"/tmp/x","content":"nothing here","secrets":{"spare":"S"}}
|
|
]}`)
|
|
if len(refusal.Problems) == 0 {
|
|
t.Fatal("a secret the content never mentions was accepted")
|
|
}
|
|
}
|