96 comments across the two repos named records that no longer exist. Each now points at the consolidated record that holds its reasoning -- ADR 0034 (a test defends a decision) is 0017, the eight host records are 0005, the four lab records are 0016. Worth noting for next time: these are references from outside HQ, so renumbering there is not free. It cost 38 files here.
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 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","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 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)
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|