Jochen asked why we don't simply have dedicated structs. We should, and the
flat struct was me extending an existing pattern rather than questioning it.
Before: one Resource struct carrying path, content, mode, unit, state, package,
image, name, env, ports, volumes, args, command, verify and in. Because a file
and a container shared it, nothing stopped {"type":"file","image":"postgres"},
so a `uses` map listed which fields each kind was allowed to carry -- a second
place to keep current, and the kind nobody updates is the one that silently
accepts a field the host will never read.
Now: Directory, File, Service, Package, Container and Action are separate
structs behind a Resource interface. File has no Image field, so the mistake is
not detected -- it is unrepresentable. Adding a field to a kind is the whole of
adding it; there is nowhere else that has to agree.
Parsing is two passes: read the envelope and each resource's raw bytes, peek at
"type" to choose the struct, then decode into it. Peeking is lenient on purpose
-- reading strictly there would report an unknown field before knowing which
fields are known.
Unknown fields are found by comparing the JSON keys against the struct's own
json tags rather than by catching the decoder's error. The decoder stops at the
first unknown field, and RefusalError promises every problem at once: a caller
fixing one field at a time learns the next only by running again. Caught by
testing the refactor against a real declaration -- a container carrying both
`unit` and `mode` reported only one of them.
apply.go switches on the concrete type instead of a string, so a new kind that
has no applier is a compile error rather than a runtime default branch.
No behaviour change otherwise. All existing tests pass unmodified except two
that reached for fields the interface no longer exposes.
267 lines
10 KiB
Go
267 lines
10 KiB
Go
package declaration
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Each test names the decision it defends (novox/hq ADR 0034). The decision here is ADR 0043,
|
|
// 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","owner":"root"}
|
|
]}`)
|
|
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "owner") {
|
|
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 substrate bootstrap needs (novox/hq 07-the-substrate.md) ---
|
|
|
|
func TestAnActionOverTheLinkIsRefused(t *testing.T) {
|
|
// novox/hq ADR 0047. 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 0046: 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)
|
|
}
|
|
}
|
|
|
|
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 TestTheVocabularyIsTheSixShapesTheBootstrapNeeds(t *testing.T) {
|
|
// novox/hq 07-the-substrate.md names six shapes and the bootstrap uses all of them.
|
|
// Asserted so that removing one is a failing test rather than a discovery during a
|
|
// first-node install.
|
|
speaks := map[Type]bool{}
|
|
for _, t := range Vocabulary() {
|
|
speaks[t] = true
|
|
}
|
|
for _, want := range []Type{
|
|
TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction,
|
|
} {
|
|
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)
|
|
}
|
|
}
|
|
if len(speaks) != 6 {
|
|
t.Errorf("the vocabulary is %d shapes; every addition widens what a compromised "+
|
|
"control plane can express, so a change here is a decision: %s",
|
|
len(speaks), vocabulary())
|
|
}
|
|
}
|