Copy the node-engine's declaration parser into mesh-sdk, from mesh-host main (issue 1353)

This commit is contained in:
2026-10-20 06:01:48 +00:00
parent 3387abf50e
commit e5e3e62f28
23 changed files with 4814 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
package declaration
import (
"strings"
"testing"
)
// Defends novox/hq ADR 0100: every declaration says whether the node is adopted and which of its
// modules are taken, and the host refuses one it cannot read that from unambiguously.
const adoptedResources = `"resources":[
{"id":"hello-web.page","type":"file","path":"/var/lib/hello-web/index.html","content":"a\n"},
{"id":"hello-web.server","type":"container","name":"hello-web","image":"sha256:` + sixtyFour + `"},
{"id":"hello-web.data","type":"directory","path":"/var/lib/hello-web"}
]`
const sixtyFour = "0000000000000000000000000000000000000000000000000000000000000000"
func TestAnAdoptionIsReadWithTheDeclaration(t *testing.T) {
d, err := Parse([]byte(`{"adoption":{"taken":["postgres"],"untaken":{"hello-web":["hello-web.page","hello-web.server"]}},
"declaration":1,` + adoptedResources + `}`))
if err != nil {
t.Fatal(err)
}
if d.Adoption == nil {
t.Fatal("the adoption was dropped")
}
if len(d.Adoption.Taken) != 1 || d.Adoption.Taken[0] != "postgres" {
t.Errorf("taken read as %v", d.Adoption.Taken)
}
if module, ok := d.Adoption.UntakenModuleOf("hello-web.server"); !ok || module != "hello-web" {
t.Errorf("the container's untaken module read as %q, %v", module, ok)
}
if _, ok := d.Adoption.UntakenModuleOf("hello-web.data"); ok {
t.Error("a resource the adoption does not name was said to be untaken")
}
}
func TestADeclarationWithNoAdoptionIsConverged(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,` + adoptedResources + `}`))
if err != nil {
t.Fatal(err)
}
if d.Adoption != nil {
t.Errorf("a declaration saying nothing about adoption read as adopted: %+v", d.Adoption)
}
if _, ok := d.Adoption.UntakenModuleOf("hello-web.page"); ok {
t.Error("a converged node has an untaken module")
}
}
func TestAnAdoptionNamingAnUnknownIDIsRefused(t *testing.T) {
refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"hello-web":["hello-web.missing"]}},
"declaration":1,`+adoptedResources+`}`)
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "hello-web.missing") {
t.Errorf("the unknown id was not named: %v", refusal.Problems)
}
}
func TestAnAdoptionMayNameAResourceOfAnyKind(t *testing.T) {
// A directory, a service or an action can reach what was found as surely as a file can, so
// the controller lists every resource of an untaken module (novox/hq ADR 0103).
d, err := Parse([]byte(`{"adoption":{"taken":[],"untaken":{"hello-web":["hello-web.data"]}},
"declaration":1,` + adoptedResources + `}`))
if err != nil {
t.Fatalf("a directory of an untaken module was refused: %v", err)
}
if module, ok := d.Adoption.UntakenModuleOf("hello-web.data"); !ok || module != "hello-web" {
t.Errorf("the directory is not its module's: %q %v", module, ok)
}
}
func TestAnIDUnderTwoModulesIsRefused(t *testing.T) {
refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"a":["hello-web.page"],"b":["hello-web.page"]}},
"declaration":1,`+adoptedResources+`}`)
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "both") {
t.Errorf("an id under two modules was accepted: %v", refusal.Problems)
}
}
func TestAModuleBothTakenAndUntakenIsRefused(t *testing.T) {
refusal := refusalFor(t, `{"adoption":{"taken":["hello-web"],"untaken":{"hello-web":["hello-web.page"]}},
"declaration":1,`+adoptedResources+`}`)
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "both taken and untaken") {
t.Errorf("a module both taken and untaken was accepted: %v", refusal.Problems)
}
}
func TestTheMeshsOwnResourcesAreNeverUntaken(t *testing.T) {
refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"x":["adoption.guard"]}},
"declaration":1,"resources":[
{"id":"adoption.guard","type":"file","path":"/etc/mesh/guard.nft","content":"x"}]}`)
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "belongs to no module") {
t.Errorf("an adoption. id was accepted as untaken: %v", refusal.Problems)
}
}
func TestAnAdoptionWithAnUnknownFieldIsRefused(t *testing.T) {
refusalFor(t, `{"adoption":{"taken":[],"held":["x"]},"declaration":1,`+adoptedResources+`}`)
}
func TestACarriedBundleCannotSayTheNodeIsAdopted(t *testing.T) {
_, err := ParseTrusted([]byte(`{"adoption":{"taken":[]},"declaration":1,` + adoptedResources + `}`))
if err == nil || !strings.Contains(err.Error(), "only the mesh can say") {
t.Fatalf("a bundle claiming adoption was not refused: %v", err)
}
}
// novox/hq ADR 0105: a service may take over a found tunnel, said whole and on an adopted node.
func TestTakingOverATunnelIsSaidWholeAndForARunningService(t *testing.T) {
adoptedWith := func(service string) error {
_, err := Parse([]byte(`{"declaration":1,"adoption":{"taken":[]},"resources":[` + service + `]}`))
return err
}
good := `{"id":"up","type":"service","unit":"wg-quick@mesh0","state":"running",
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"/etc/wireguard/wg0.conf"}}`
if err := adoptedWith(good); err != nil {
t.Fatalf("a whole takeover on an adopted node was refused: %v", err)
}
for name, bad := range map[string]string{
"its own unit": `{"id":"up","type":"service","unit":"wg-quick@wg0","state":"running",
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"/etc/wireguard/wg0.conf"}}`,
"no config": `{"id":"up","type":"service","unit":"wg-quick@mesh0","state":"running",
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0"}}`,
"a stopped service": `{"id":"up","type":"service","unit":"wg-quick@mesh0","state":"stopped",
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"/etc/wireguard/wg0.conf"}}`,
} {
if err := adoptedWith(bad); err == nil {
t.Errorf("a takeover naming %s was accepted", name)
}
}
}
+52
View File
@@ -0,0 +1,52 @@
package declaration
import (
"strings"
"testing"
)
// Defends novox/hq issue 128: a file written into a block carries only its lines, in content,
// never a line the host keeps as its own marker, and says where a new region goes only as a block.
func TestAFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines(t *testing.T) {
for name, c := range map[string]struct{ resource, refusal string }{
"a begin marker in content": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a\n# BEGIN mesh f\nb\n"}`, "# BEGIN mesh f"},
"an end marker in content": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a\n# END mesh other\n"}`, "# END mesh other"},
"a marker with a CR": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"# BEGIN mesh x\r\n"}`, "marker"},
"sealed": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","sealed":"abc"}`, "not sealed"},
"bytes": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","bytes":"YQ=="}`, "not sealed, bytes"},
"secrets": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"${secret:s}","secrets":{"s":"abc"}}`, "secrets"},
"create-once": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a","create-once":true}`, "create-once"},
"at on a whole file": {`{"id":"f","type":"file","path":"/etc/hosts","content":"a","at":"start"}`, `at "start"`},
"at on a JSON file": {`{"id":"f","type":"file","path":"/etc/x.json","into":"json","content":"{}","at":"end"}`, `at "end"`},
"at somewhere else": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a","at":"middle"}`, `"start" or "end"`},
"an id ending in a space": {`{"id":"f ","type":"file","path":"/etc/hosts","into":"block","content":"a"}`, "whitespace"},
"an unknown format": {`{"id":"f","type":"file","path":"/etc/hosts","into":"lines","content":"a"}`, `"json" or "block"`},
} {
_, err := Parse([]byte(`{"declaration":1,"resources":[` + c.resource + `]}`))
if err == nil || !strings.Contains(err.Error(), c.refusal) {
t.Errorf("%s: want a refusal naming %q, got %v", name, c.refusal, err)
}
}
}
func TestAFileWrittenIntoABlockIsRead(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"mesh-wireguard.fact-node-names","type":"file","path":"/etc/hosts","into":"block","content":"10.42.0.1 ace\n# BEGIN devtool x\n"},
{"id":"dhcpcd.options","type":"file","path":"/etc/dhcpcd.conf","into":"block","content":"nohook resolv.conf\n","at":"start"},
{"id":"empty","type":"file","path":"/etc/x","into":"block","content":"","at":"end"}
]}`))
if err != nil {
t.Fatal(err)
}
if f := d.Resources[0].(*File); f.Into != IntoBlock || f.At != "" {
t.Errorf("read as into %q at %q", f.Into, f.At)
}
if f := d.Resources[1].(*File); f.At != AtStart {
t.Errorf("at was read as %q", f.At)
}
if begin, end := BlockMarkers("mesh-wireguard.fact-node-names"); begin != "# BEGIN mesh mesh-wireguard.fact-node-names" ||
end != "# END mesh mesh-wireguard.fact-node-names" {
t.Errorf("the markers are %q and %q", begin, end)
}
}
+87
View File
@@ -0,0 +1,87 @@
package declaration
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"time"
)
// Busy is how a long-running container says it holds work that would be lost if it were stopped now
// (novox/hq ADR 0335 §1): a command run inside it, by its shell, as an exec health check is, that exits 0
// while it is busy and non-zero when it is idle. Being asked is also the container's notice that it is
// about to be replaced: from the first ask it takes no new work, so its answer ends once the work in hand
// ends.
//
// **Not part of what the container is.** Busy is not in the container's spec: a changed command changes
// when the container is replaced, never whether — a container is not recreated for its busy command alone.
type Busy struct {
Command string `json:"command"`
// Timeout is how long one ask may take; empty is BusyTimeoutDefault.
Timeout string `json:"timeout,omitempty"`
}
// The bounds of an ask (novox/hq ADR 0335).
const (
BusyTimeoutDefault = 10 * time.Second
// BusyTimeoutCeiling is the longest one ask may take: an ask runs inside an apply, and an apply waits
// for nothing longer than a look.
BusyTimeoutCeiling = time.Minute
// BusyBound is how long a container that says busy is left running after the first apply that would
// have replaced it (ADR 0335 §3). The node-engine's, not the module's: no module holds its own
// replacement for ever.
BusyBound = 15 * time.Minute
)
// UnmarshalJSON reads a busy strictly, as everything a declaration carries is read.
func (b *Busy) UnmarshalJSON(raw []byte) error {
type plain Busy
var p plain
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return fmt.Errorf("busy: %w", err)
}
*b = Busy(p)
return nil
}
// Within is how long one ask may take. Validated on arrival, so an unreadable timeout is the default.
func (b *Busy) Within() time.Duration {
if d, err := time.ParseDuration(b.Timeout); err == nil && d > 0 {
return d
}
return BusyTimeoutDefault
}
// problems holds a container's busy to its shape: a one-line command, a timeout within its bounds, and only
// on a container that stays up.
func (b *Busy) problems(where string, longRunning bool) []string {
if b == nil {
return nil
}
var problems []string
say := func(format string, args ...any) {
problems = append(problems, where+": "+fmt.Sprintf(format, args...))
}
if !longRunning {
say("busy is asked of a container that stays up; a step or a scheduled run is never replaced mid-run")
}
if strings.TrimSpace(b.Command) == "" {
say("busy needs a command")
}
if strings.ContainsAny(b.Command, "\n\r") {
say("busy's command is one line")
}
if b.Timeout != "" {
d, err := time.ParseDuration(b.Timeout)
switch {
case err != nil:
say("busy's timeout is a duration, not %q", b.Timeout)
case d <= 0 || d > BusyTimeoutCeiling:
say("an ask of busy takes more than nothing and at most %s, not %s", BusyTimeoutCeiling, d)
}
}
return problems
}
+182
View File
@@ -0,0 +1,182 @@
package declaration
// A minimal five-field cron, enough to say when a scheduled step is due (novox/hq ADR 0053).
//
// **Written rather than pulled in.** The host depends on nothing it does not have to
// (novox/hq ADR 0005), and a scheduled step needs exactly two questions answered — *is this minute
// a match* and *when is the next one* — over the ordinary five fields (minute, hour, day-of-month,
// month, day-of-week) with `*`, lists (`,`), ranges (`-`) and steps (`/`). That is small enough to
// keep in the vocabulary the host already owns, and a cron library would be a dependency carried
// for the parts of it nobody here uses.
//
// **The host both validates and evaluates.** The control plane refuses a malformed cron near its
// author (novox/hq ADR 0053), and the host refuses it again on arrival for the same near-versus-far
// reason every other field is checked here: a declaration the host does not fully understand is
// refused whole rather than half-applied. The evaluation half — `Next` — is what the scheduler
// fires on.
import (
"fmt"
"strconv"
"strings"
"time"
)
// Cron is a parsed five-field expression, each field held as a bitset of the values it permits.
//
// The two day fields carry a flag for whether they were written as a bare `*`, because standard
// cron gives them a special rule: when day-of-month and day-of-week are *both* restricted, a day
// matches if *either* does; when one is `*`, only the other is consulted. Getting that wrong is the
// classic cron surprise, so the flag is kept rather than rediscovered.
type Cron struct {
minute uint64
hour uint64
dom uint64
month uint64
dow uint64
domStar bool
dowStar bool
}
// ParseCron reads a five-field cron expression, or says why it is not one.
func ParseCron(expr string) (*Cron, error) {
fields := strings.Fields(expr)
if len(fields) != 5 {
return nil, fmt.Errorf(
"a schedule is a five-field cron expression (minute hour day-of-month month "+
"day-of-week), and %q has %d field(s)", expr, len(fields))
}
minute, _, err := parseCronField(fields[0], 0, 59)
if err != nil {
return nil, fmt.Errorf("schedule minute field: %w", err)
}
hour, _, err := parseCronField(fields[1], 0, 23)
if err != nil {
return nil, fmt.Errorf("schedule hour field: %w", err)
}
dom, domStar, err := parseCronField(fields[2], 1, 31)
if err != nil {
return nil, fmt.Errorf("schedule day-of-month field: %w", err)
}
month, _, err := parseCronField(fields[3], 1, 12)
if err != nil {
return nil, fmt.Errorf("schedule month field: %w", err)
}
// Day-of-week is 0-6 with Sunday at 0, and 7 is also accepted for Sunday — the convention every
// cron keeps, so a crontab copied from elsewhere is not refused for saying 7.
dow, dowStar, err := parseCronField(fields[4], 0, 7)
if err != nil {
return nil, fmt.Errorf("schedule day-of-week field: %w", err)
}
if dow&(1<<7) != 0 {
dow |= 1 << 0
dow &^= 1 << 7
}
return &Cron{
minute: minute, hour: hour, dom: dom, month: month, dow: dow,
domStar: domStar, dowStar: dowStar,
}, nil
}
// parseCronField turns one field into the bitset of values it permits, and reports whether it was a
// bare `*` (which the day fields treat specially).
func parseCronField(spec string, min, max int) (uint64, bool, error) {
if spec == "" {
return 0, false, fmt.Errorf("is empty")
}
star := spec == "*"
var bits uint64
for _, part := range strings.Split(spec, ",") {
if part == "" {
return 0, false, fmt.Errorf("%q has an empty element between commas", spec)
}
// A step may follow either `*` or a range: `*/15`, `0-30/5`.
step := 1
rangePart := part
if slash := strings.IndexByte(part, '/'); slash >= 0 {
rangePart = part[:slash]
n, err := strconv.Atoi(part[slash+1:])
if err != nil || n < 1 {
return 0, false, fmt.Errorf("step in %q is not a positive number", part)
}
step = n
}
lo, hi := min, max
switch {
case rangePart == "*":
// Full range, already set.
case strings.IndexByte(rangePart, '-') >= 0:
dash := strings.IndexByte(rangePart, '-')
a, errA := strconv.Atoi(rangePart[:dash])
b, errB := strconv.Atoi(rangePart[dash+1:])
if errA != nil || errB != nil {
return 0, false, fmt.Errorf("range %q is not two numbers", rangePart)
}
lo, hi = a, b
default:
n, err := strconv.Atoi(rangePart)
if err != nil {
return 0, false, fmt.Errorf("%q is not a number", rangePart)
}
lo, hi = n, n
}
if lo < min || hi > max || lo > hi {
return 0, false, fmt.Errorf(
"%q is outside the allowed range %d-%d", part, min, max)
}
for v := lo; v <= hi; v += step {
bits |= 1 << uint(v)
}
}
return bits, star, nil
}
// Matches reports whether a scheduled step is due at this minute.
func (c *Cron) Matches(t time.Time) bool {
if c.minute&(1<<uint(t.Minute())) == 0 {
return false
}
if c.hour&(1<<uint(t.Hour())) == 0 {
return false
}
if c.month&(1<<uint(int(t.Month()))) == 0 {
return false
}
domMatch := c.dom&(1<<uint(t.Day())) != 0
dowMatch := c.dow&(1<<uint(int(t.Weekday()))) != 0
// The standard day rule: both restricted means either may match; one as `*` defers to the other.
switch {
case c.domStar && c.dowStar:
return true
case c.domStar:
return dowMatch
case c.dowStar:
return domMatch
default:
return domMatch || dowMatch
}
}
// Next is the first minute strictly after t at which the step is due, and false if there is none
// within a bound generous enough to cover a once-a-year, leap-day schedule.
//
// Minute-by-minute rather than a closed form: the day rule above makes a closed form fiddly and
// error-prone, and this is called once per fire — rarely — so simple and obviously correct wins.
func (c *Cron) Next(after time.Time) (time.Time, bool) {
t := after.Truncate(time.Minute).Add(time.Minute)
// Five years of minutes: enough that "29 2 * * *" (Feb 29) always finds its next leap year.
for i := 0; i < 5*366*24*60; i++ {
if c.Matches(t) {
return t, true
}
t = t.Add(time.Minute)
}
return time.Time{}, false
}
+100
View File
@@ -0,0 +1,100 @@
package declaration
import (
"testing"
"time"
)
// A scheduled step is due when its cron says so, and the host both refuses a malformed cron on
// arrival and computes the next due minute the scheduler fires on (novox/hq ADR 0053). These test
// the parsing and the evaluation; the firing is tested against the scheduler.
func TestParseCronRefusesMalformed(t *testing.T) {
for _, expr := range []string{
"", // nothing
"* * * *", // four fields
"* * * * * *", // six fields
"60 * * * *", // minute out of range
"* 24 * * *", // hour out of range
"* * 0 * *", // day-of-month below 1
"* * 32 * *", // day-of-month above 31
"* * * 13 *", // month out of range
"* * * * 8", // day-of-week above 7
"a * * * *", // not a number
"*/0 * * * *", // zero step
"5-1 * * * *", // inverted range
"1,,2 * * * *", // empty element
} {
if _, err := ParseCron(expr); err == nil {
t.Errorf("a malformed cron %q was accepted", expr)
}
}
}
func TestParseCronAcceptsTheOrdinaryForms(t *testing.T) {
for _, expr := range []string{
"* * * * *", // every minute
"0 3 * * *", // 03:00 daily
"*/15 * * * *", // every 15 minutes
"0 0 1 1 *", // new year
"0 9-17 * * 1-5", // business hours, weekdays
"0 0 * * 7", // Sunday as 7
"0,30 * * * *", // twice an hour
} {
if _, err := ParseCron(expr); err != nil {
t.Errorf("a valid cron %q was refused: %v", expr, err)
}
}
}
func TestCronNextIsTheNextMatchingMinute(t *testing.T) {
// A Monday at 12:00:30, so "next" must round up to a whole minute and land on the first match
// strictly after now — never re-firing the minute we are already in.
now := time.Date(2026, 9, 7, 12, 0, 30, 0, time.UTC) // 2026-09-07 is a Monday
cases := []struct {
expr string
want time.Time
}{
{"* * * * *", time.Date(2026, 9, 7, 12, 1, 0, 0, time.UTC)},
{"0 3 * * *", time.Date(2026, 9, 8, 3, 0, 0, 0, time.UTC)},
{"*/15 * * * *", time.Date(2026, 9, 7, 12, 15, 0, 0, time.UTC)},
{"0 0 1 1 *", time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)},
}
for _, c := range cases {
cron, err := ParseCron(c.expr)
if err != nil {
t.Fatalf("%q: %v", c.expr, err)
}
got, ok := cron.Next(now)
if !ok {
t.Errorf("%q: no next time found", c.expr)
continue
}
if !got.Equal(c.want) {
t.Errorf("%q: next is %s, want %s", c.expr, got, c.want)
}
}
}
func TestCronDayFieldsAreOredWhenBothRestricted(t *testing.T) {
// The standard cron surprise: when day-of-month and day-of-week are both set, a day matches if
// EITHER does. "1 * 13 * 5" is due on the 13th OR on any Friday. 2026-09-07 is a Monday the 7th
// — neither — and 2026-09-11 is a Friday, and 2026-11-13 is the 13th.
cron, err := ParseCron("0 0 13 * 5")
if err != nil {
t.Fatal(err)
}
friday := time.Date(2026, 9, 11, 0, 0, 0, 0, time.UTC)
if !cron.Matches(friday) {
t.Error("a Friday did not match a cron restricted to the 13th OR Friday")
}
thirteenth := time.Date(2026, 11, 13, 0, 0, 0, 0, time.UTC) // a non-Friday 13th
if !cron.Matches(thirteenth) {
t.Error("the 13th did not match a cron restricted to the 13th OR Friday")
}
neither := time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC)
if cron.Matches(neither) {
t.Error("a Monday the 7th matched a cron restricted to the 13th OR Friday")
}
}
File diff suppressed because it is too large Load Diff
+595
View File
@@ -0,0 +1,595 @@
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")
}
}
// The runtime's resolver and address flags take only addresses; a name would be refused at
// create, after the old container was already gone. Refused on arrival instead — and an address
// without a user-defined network is refused for the same reason.
func TestAContainersResolverAndAddressAreAddressesOrRefused(t *testing.T) {
refused := func(body string) []string {
_, err := Parse([]byte(`{"declaration":1,"resources":[` + body + `]}`))
if err == nil {
return nil
}
return []string{err.Error()}
}
base := `"id":"c","type":"container","name":"x",` +
`"image":"a@sha256:0000000000000000000000000000000000000000000000000000000000000000"`
if p := refused(`{` + base + `,"network":"m","dns":["resolver.local"]}`); len(p) == 0 {
t.Error("a resolver named by name was accepted; the runtime takes only addresses")
}
if p := refused(`{` + base + `,"ip":"192.168.203.7"}`); len(p) == 0 {
t.Error("a static address with no network was accepted; the runtime refuses it")
}
if p := refused(`{` + base + `,"network":"m","dns":["192.168.203.254"],"ip":"192.168.203.7"}`); len(p) != 0 {
t.Errorf("a well-formed resolver and address were refused: %v", p)
}
}
func TestAnExplicitlyEmptyDeclarationIsAccepted(t *testing.T) {
// A deliberately-empty declaration (novox/hq issue 127) says owns_nothing, and is applied so
// the node drops what it last held — distinct from an accidental empty body, which is refused.
if _, err := Parse([]byte(`{"declaration":1,"owns_nothing":true,"resources":[]}`)); err != nil {
t.Fatalf("an explicitly-empty declaration must be accepted: %v", err)
}
// Without the marker, an empty declaration is still refused as a likely mistake.
_, err := Parse([]byte(`{"declaration":1,"resources":[]}`))
if err == nil || !strings.Contains(err.Error(), "no resources") {
t.Fatalf("an unmarked empty declaration must still be refused; got %v", err)
}
}
// A container's kept networks are names, not its own network, and not for a step (novox/hq ADR
// 0163, rule 4); and the mesh may say which modules it left out, which a carried bundle may not.
func TestKeptNetworksAndLeftOutModulesAreReadStrictly(t *testing.T) {
pinnedImage := "postgres@sha256:" + strings.Repeat("a", 64)
d, err := Parse([]byte(`{"declaration":1,"left_out":["web"],"resources":[
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","networks":["predecessor_default"]}
]}`))
if err != nil {
t.Fatal(err)
}
if got := d.Resources[0].(*Container).Networks; len(got) != 1 || got[0] != "predecessor_default" {
t.Fatalf("the kept network was not read: %v", got)
}
if m, left := d.LeftOutModuleOf("web.server"); !left || m != "web" {
t.Fatalf("web.server is not web's: %q %v", m, left)
}
if _, left := d.LeftOutModuleOf("webapp.server"); left {
t.Fatal("webapp.server was taken for web's")
}
for name, raw := range map[string]string{
"a bad network name": `{"declaration":1,"resources":[
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","networks":["a/b"]}]}`,
"its own network": `{"declaration":1,"resources":[
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","network":"own","networks":["own"]}]}`,
"a step": `{"declaration":1,"resources":[
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","run-once":true,"networks":["x"]}]}`,
"a nameless module": `{"declaration":1,"left_out":[""],"resources":[
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `"}]}`,
} {
if _, err := Parse([]byte(raw)); err == nil {
t.Errorf("%s was accepted", name)
}
}
if _, err := ParseTrusted([]byte(`{"declaration":1,"left_out":["web"],"resources":[
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `"}]}`)); err == nil ||
!strings.Contains(err.Error(), "only the mesh can say that") {
t.Fatalf("a carried bundle leaving modules out was accepted: %v", err)
}
}
// A container may ask for a capability by name, and nothing else (novox/hq ADR 0170).
func TestACapabilityIsNamedOrRefused(t *testing.T) {
image := "postgres@sha256:" + strings.Repeat("a", 64)
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"fw","type":"container","name":"fw","image":"` + image + `","network":"host","capabilities":["NET_ADMIN","CAP_NET_RAW"]}
]}`))
if err != nil {
t.Fatal(err)
}
if got := d.Resources[0].(*Container).Capabilities; len(got) != 2 || got[0] != "NET_ADMIN" {
t.Fatalf("capabilities read as %v", got)
}
for _, bad := range []string{`"net_admin"`, `"ALL;rm -rf /"`, `"privileged"`} {
if _, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"fw","type":"container","name":"fw","image":"` + image + `","capabilities":[` + bad + `]}]}`)); err == nil {
t.Errorf("%s was accepted as a capability", bad)
}
}
}
// A container may send its output to the machine's journal, and nowhere else but the runtime's own
// file (novox/hq ADR 0179): what reads the machine's logs then reads the container's too.
func TestAContainerMayLogToTheJournalAndNowhereElse(t *testing.T) {
image := "postgres@sha256:" + strings.Repeat("a", 64)
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"front","type":"container","name":"front","image":"` + image + `","logging":"journald"}
]}`))
if err != nil {
t.Fatal(err)
}
if got := d.Resources[0].(*Container).Logging; got != "journald" {
t.Fatalf("logging read as %q", got)
}
for _, bad := range []string{`"syslog"`, `"none"`, `"json-file"`} {
if _, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"front","type":"container","name":"front","image":"` + image + `","logging":` + bad + `}]}`)); err == nil {
t.Errorf("%s was accepted as a place to log", bad)
}
}
}
// Issue 1024, ADR 0422: a directory or archive says how it treats files it did not write.
func TestADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"d","type":"directory","path":"/home/a/x","unknown":"move-out","knows":["keep","sub/"]},
{"id":"r","type":"archive","source":"https://x/y","digest":"sha256:` + strings.Repeat("a", 64) + `","path":"/home/a/y","unknown":"module"}
]}`))
if err != nil {
t.Fatalf("refused: %v", err)
}
dir := d.Resources[0].(*Directory)
if dir.Unknown != UnknownMoveOut || len(dir.Knows) != 2 {
t.Errorf("directory: %+v", dir)
}
if ar := d.Resources[1].(*Archive); ar.Unknown != UnknownModule {
t.Errorf("archive: %+v", ar)
}
for _, v := range []string{UnknownMoveOut, UnknownModule, UnknownProgram} {
if _, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"d","type":"directory","path":"/x","unknown":"` + v + `"}]}`)); err != nil {
t.Errorf("%s refused: %v", v, err)
}
}
}
func TestBadUnknownAndKnowsFormsAreRefusedNamingTheResource(t *testing.T) {
digest := `"digest":"sha256:` + strings.Repeat("a", 64) + `"`
cases := map[string]string{
"bad value": `{"id":"d","type":"directory","path":"/x","unknown":"delete"}`,
"empty entry": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":[""]}`,
"absolute": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["/etc"]}`,
"empty comp": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["a//b"]}`,
"dot": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["./a"]}`,
"dotdot": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["a/../b"]}`,
"repeat": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["a","a"]}`,
"no unknown": `{"id":"d","type":"directory","path":"/x","knows":["a"]}`,
"archive bad": `{"id":"d","type":"archive","source":"s",` + digest + `,"path":"/x","unknown":"nope"}`,
"archive knws": `{"id":"d","type":"archive","source":"s",` + digest + `,"path":"/x","knows":["a"]}`,
}
for name, res := range cases {
t.Run(name, func(t *testing.T) {
refusal := refusalFor(t, `{"declaration":1,"resources":[`+res+`]}`)
if !strings.Contains(strings.Join(refusal.Problems, "\n"), `resource "d"`) {
t.Errorf("resource not named: %v", refusal.Problems)
}
})
}
}
+33
View File
@@ -0,0 +1,33 @@
package declaration
import (
"os"
"testing"
)
// The control plane's actual output, put through the host's actual parser.
//
// Skipped unless MESH_EMITTED names a file, so this is a check somebody runs deliberately rather
// than a dependency between two repositories. It exists because "the host needs no new
// vocabulary" is the load-bearing claim of every computed and contributed resource, and the only
// honest way to know it is to hand the host one and see.
func TestWhatTheControlPlaneEmittedIsSomethingThisHostAccepts(t *testing.T) {
path := os.Getenv("MESH_EMITTED")
if path == "" {
t.Skip("set MESH_EMITTED to a declaration the control plane produced")
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
d, err := Parse(raw)
if err != nil {
t.Fatalf("the host refuses what the control plane sends:\n%v", err)
}
if len(d.Resources) == 0 {
t.Fatal("parsed, and empty")
}
for _, r := range d.Resources {
t.Logf("accepted %s %s → %s", r.Kind(), r.Identity(), r.Target())
}
}
+188
View File
@@ -0,0 +1,188 @@
package declaration
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"time"
)
// Health is how a long-running resource is ready, as the controller composed it from the module's
// `health` (novox/hq ADR 0240 rule 2, to-be 48 §2–§3, Phase B): one kind and its timing, the endpoint
// already the port this machine published it on.
//
// **The node-engine runs every kind and owns every verdict.** http and tcp it makes itself, from the
// machine to the port; unit it reads from the service manager it already reads; exec and runtime it hands
// to the container runtime as the container's own check, with this timing, and reads the state; tool it
// asks of its own node tools. Nothing else on the machine sets a container's check.
//
// Refused here as the controller refuses it near the author, in the same bounds: an engine that took a
// check it could not judge would say a module ready that nothing looked at.
type Health struct {
Kind string `json:"kind"`
// Endpoint is the module's name for what Port is: for the words a verdict is said in.
Endpoint string `json:"endpoint,omitempty"`
Port int `json:"port,omitempty"`
Path string `json:"path,omitempty"`
Status int `json:"status,omitempty"`
Body string `json:"body,omitempty"`
Scheme string `json:"scheme,omitempty"`
Command string `json:"command,omitempty"`
Tool string `json:"tool,omitempty"`
Interval string `json:"interval"`
Timeout string `json:"timeout"`
Looks int `json:"looks"`
Grace string `json:"grace"`
// Needs is the provision the check exercises (to-be 48 §6): said with every verdict, so the
// controller can hold what it finds under the provider's own condition.
Needs string `json:"needs,omitempty"`
}
// UnmarshalJSON reads a health strictly, as everything a declaration carries is read: a field this host
// does not know is a part of the check the controller believes it asked for, and nothing would look at it.
func (h *Health) UnmarshalJSON(raw []byte) error {
type plain Health
var p plain
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return fmt.Errorf("health: %w", err)
}
*h = Health(p)
return nil
}
// The kinds.
const (
HealthRuntime = "runtime"
HealthHTTP = "http"
HealthTCP = "tcp"
HealthExec = "exec"
HealthUnit = "unit"
HealthTool = "tool"
)
// The bounds (ADR 0240 rule 2) — the controller's, held again here.
const (
HealthIntervalFloor = 10 * time.Second
HealthLooksFloor = 2
HealthWithin = 5 * time.Minute
)
// Every, Within and GraceOf are the timing, read. Validated on arrival, so a parse error here is
// impossible on a declaration that was accepted; it reads as zero.
func (h *Health) Every() time.Duration { d, _ := time.ParseDuration(h.Interval); return d }
func (h *Health) Within() time.Duration { d, _ := time.ParseDuration(h.Timeout); return d }
func (h *Health) GraceOf() time.Duration { d, _ := time.ParseDuration(h.Grace); return d }
// RunByRuntime says the container runtime runs this check as the container's own: exec and runtime.
func (h *Health) RunByRuntime() bool {
return h != nil && (h.Kind == HealthExec || h.Kind == HealthRuntime)
}
// Words is the check in a few words, as a verdict is said: "http /healthz on web".
func (h *Health) Words() string {
switch h.Kind {
case HealthHTTP:
return "http " + h.Path + " on " + orPort(h.Endpoint, h.Port)
case HealthTCP:
return "tcp on " + orPort(h.Endpoint, h.Port)
case HealthTool:
return "its tool " + h.Tool
case HealthRuntime:
return "its image's own check"
case HealthExec:
return "its command"
case HealthUnit:
return "its unit"
}
return h.Kind
}
func orPort(endpoint string, port int) string {
if endpoint != "" {
return endpoint
}
return fmt.Sprint(port)
}
// problems holds a resource's health to its kind and bounds. container says whether the resource is a
// container; longRunning whether it stays up.
func (h *Health) problems(where string, container, longRunning bool) []string {
if h == nil {
return nil
}
var problems []string
say := func(format string, args ...any) {
problems = append(problems, where+": "+fmt.Sprintf(format, args...))
}
if !longRunning {
say("health is judged on what stays up; a step or a scheduled run is judged by its own outcome")
}
switch h.Kind {
case HealthHTTP, HealthTCP:
if h.Port < 1 || h.Port > 65535 {
say("a %s check needs the port it looks at", h.Kind)
}
case HealthExec:
if !container {
say("an exec check runs inside a container")
}
if strings.TrimSpace(h.Command) == "" {
say("an exec check needs a command")
}
case HealthRuntime:
if !container {
say("a runtime check is a container image's own")
}
case HealthUnit:
if container {
say("a unit check is a service's or a process's own")
}
case HealthTool:
if strings.TrimSpace(h.Tool) == "" {
say("a tool check names the tool")
}
default:
say("health of kind %q; it is runtime, http, tcp, exec, unit or tool", h.Kind)
}
if h.Kind == HealthHTTP {
if !strings.HasPrefix(h.Path, "/") {
say("an http check asks a path starting with /")
}
if h.Status != 0 && (h.Status < 100 || h.Status > 599) {
say("an http check expects status %d, which is not one", h.Status)
}
if h.Scheme != "" && h.Scheme != "http" && h.Scheme != "https" {
say("an http check is over http or https, not %q", h.Scheme)
}
}
if strings.ContainsAny(h.Command, "\n\r") {
say("an exec check's command is one line")
}
every, everyErr := time.ParseDuration(h.Interval)
within, withinErr := time.ParseDuration(h.Timeout)
grace, graceErr := time.ParseDuration(h.Grace)
switch {
case everyErr != nil || withinErr != nil || graceErr != nil:
say("health's interval, timeout and grace are durations")
default:
if every < HealthIntervalFloor {
say("a check looks no more often than every %s, not every %s", HealthIntervalFloor, every)
}
if within <= 0 || within >= every {
say("a look takes more than nothing and less than its interval")
}
if grace < 0 {
say("a grace is not negative")
}
if h.Looks >= HealthLooksFloor && grace+time.Duration(h.Looks)*every > HealthWithin {
say("a grace and the failing looks take at most %s", HealthWithin)
}
}
if h.Looks < HealthLooksFloor {
say("a check is unhealthy after at least %d failing looks, not %d", HealthLooksFloor, h.Looks)
}
return problems
}
+49
View File
@@ -0,0 +1,49 @@
package declaration
import (
"strings"
"testing"
)
// A resource's health is held to its kind and bounds on arrival, as the controller holds it near the
// author (novox/hq ADR 0240 rule 2): an engine that took a check it could not judge would say a module ready
// that nothing looked at.
func TestAHealthIsHeldToItsKindAndBounds(t *testing.T) {
pinned := "postgres@sha256:" + strings.Repeat("a", 64)
parse := func(resource string) error {
_, err := Parse([]byte(`{"declaration":1,"resources":[` + resource + `]}`))
return err
}
container := func(health string, more string) string {
return `{"id":"m.db","type":"container","name":"db","image":"` + pinned + `"` + more + `,"health":` + health + `}`
}
timing := `"interval":"30s","timeout":"5s","looks":3,"grace":"1m0s"`
for _, ok := range []string{
container(`{"kind":"http","endpoint":"web","port":31001,"path":"/",`+timing+`}`, ""),
container(`{"kind":"exec","command":"pg_isready",`+timing+`}`, ""),
container(`{"kind":"runtime",`+timing+`}`, ""),
`{"id":"m.d","type":"service","unit":"d.service","state":"running","health":{"kind":"unit",` + timing + `}}`,
} {
if err := parse(ok); err != nil {
t.Errorf("refused %s: %v", ok, err)
}
}
for _, c := range []struct{ resource, says string }{
{container(`{"kind":"http","path":"/",`+timing+`}`, ""), "needs the port"},
{container(`{"kind":"exec",`+timing+`}`, ""), "needs a command"},
{container(`{"kind":"unit",`+timing+`}`, ""), "a service's or a process's own"},
{container(`{"kind":"ping",`+timing+`}`, ""), `of kind "ping"`},
{container(`{"kind":"tcp","port":1,"interval":"5s","timeout":"1s","looks":3,"grace":"0s"}`, ""), "no more often than every 10s"},
{container(`{"kind":"tcp","port":1,"interval":"30s","timeout":"30s","looks":3,"grace":"0s"}`, ""), "less than its interval"},
{container(`{"kind":"tcp","port":1,"interval":"30s","timeout":"5s","looks":1,"grace":"0s"}`, ""), "at least 2 failing looks"},
{container(`{"kind":"tcp","port":1,"interval":"60s","timeout":"5s","looks":3,"grace":"3m"}`, ""), "at most 5m0s"},
{container(`{"kind":"runtime",`+timing+`}`, `,"run-once":true`), "judged on what stays up"},
{`{"id":"m.d","type":"service","unit":"d.service","state":"running","health":{"kind":"runtime",` + timing + `}}`, "a container image's own"},
{container(`{"kind":"tcp","port":1,`+timing+`,"retries":2}`, ""), "retries"},
} {
err := parse(c.resource)
if err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%s: want a refusal saying %q, got %v", c.resource, c.says, err)
}
}
}
+61
View File
@@ -0,0 +1,61 @@
package declaration
import (
"strings"
"testing"
)
const anImage = "docker.io/library/x@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
// The fields ADR 0325 adds, read as the controller sends them (novox/hq ADR 0325 §2, §3, §5, issue 567).
func TestIdentitiesUsersAndARunAsAreReadAsSent(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,
"identities":[{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001}],
"resources":[
{"id":"radarr.app","type":"container","name":"radarr","image":"` + anImage + `",
"identity":{"mode":"env","user_env":"PUID","group_env":"PGID","uid":61001,"gid":61001}},
{"id":"plex.app","type":"container","name":"plex","image":"` + anImage + `","identity":{"mode":"run","uid":61002,"gid":61002}},
{"id":"radarr.movies","type":"access","path":"/srv/media/movies","mode":"read-write","taken":true,
"users":[{"identity":"mesh-radarr","uid":61001,"use":"read-write","from":"container"},
{"identity":"nox","uid":61003,"use":"read","from":"tool-runner"}]},
{"id":"old.movies","type":"access","path":"/srv/media/movies","mode":"read-write","grant":{"account":"nox"}}]}`))
if err != nil {
t.Fatal(err)
}
if len(d.Identities) != 1 || d.Identities[0].UID != 61001 {
t.Fatalf("identities: %+v", d.Identities)
}
if c := d.Resources[0].(*Container); c.RunAs == nil || c.RunAs.UserEnv != "PUID" {
t.Fatalf("a container's identity: %+v", c.RunAs)
}
if a := d.Resources[2].(*Access); !a.Taken || len(a.Users) != 2 || a.Users[1].From != FromToolRunner {
t.Fatalf("an access's users: %+v", a)
}
}
// What the engine refuses on arrival: a root number, an identity not named for its module, a field it does not
// know, a user that writes a read access, a variable set twice.
func TestWhatADeclarationMayNotSayOfAnIdentity(t *testing.T) {
for want, raw := range map[string]string{
"never root": `"identities":[{"module":"m","name":"mesh-m","uid":0,"gid":61001}],"resources":[]`,
"named mesh-<module>": `"identities":[{"module":"m","name":"m","uid":61001,"gid":61001}],` +
`"resources":[]`,
"is mesh-a's too": `"identities":[{"module":"a","name":"mesh-a","uid":61001,"gid":61001},` +
`{"module":"b","name":"mesh-b","uid":61001,"gid":61002}],"resources":[]`,
"unknown field": `"identities":[{"module":"m","name":"mesh-m","uid":61001,"gid":61001,"home":"/"}],` +
`"resources":[]`,
"writes an access declared read": `"resources":[{"id":"m.a","type":"access","path":"/srv/x","mode":"read",` +
`"users":[{"identity":"mesh-m","uid":61001,"use":"read-write","from":"container"}]}]`,
"uid 0": `"resources":[{"id":"m.a","type":"access","path":"/srv/x","users":[{"identity":"root","uid":0,` +
`"use":"read","from":"container"}]}]`,
"one value": `"resources":[{"id":"m.c","type":"container","name":"c","image":"` + anImage + `",` +
`"env":{"PUID":"1000"},"identity":{"mode":"env","user_env":"PUID","uid":61001,"gid":61001}}]`,
`"env" or "run"`: `"resources":[{"id":"m.c","type":"container","name":"c","image":"` + anImage + `",` +
`"identity":{"mode":"sudo","uid":61001,"gid":61001}}]`,
} {
_, err := Parse([]byte(`{"declaration":1,` + raw + `}`))
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("want a refusal saying %q, got %v", want, err)
}
}
}
+35
View File
@@ -0,0 +1,35 @@
package declaration
import (
"strings"
"testing"
)
// Defends novox/hq ADR 0102: a file written into carries only a JSON object of its keys, in a
// format the host speaks, and a service may name what it is reloaded on.
func TestAFileWrittenIntoIsRefusedUnlessItIsAnObjectOfKeys(t *testing.T) {
for name, c := range map[string]struct{ resource, refusal string }{
"another format": {`{"id":"f","type":"file","path":"/etc/x","into":"toml","content":"a = 1"}`, `into "toml"`},
"not an object": {`{"id":"f","type":"file","path":"/etc/x","into":"json","content":"[1,2]"}`, "JSON object"},
"with create-once": {`{"id":"f","type":"file","path":"/etc/x","into":"json","content":"{}","create-once":true}`, "create-once"},
} {
_, err := Parse([]byte(`{"declaration":1,"resources":[` + c.resource + `]}`))
if err == nil || !strings.Contains(err.Error(), c.refusal) {
t.Errorf("%s: want a refusal naming %q, got %v", name, c.refusal, err)
}
}
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"f","type":"file","path":"/etc/x","into":"json","content":"{\"k\":1}"},
{"id":"s","type":"service","unit":"docker.service","state":"running","reload-on":["f"]}
]}`))
if err != nil {
t.Fatal(err)
}
if f := d.Resources[0].(*File); f.Into != IntoJSON {
t.Errorf("into was read as %q", f.Into)
}
if s := d.Resources[1].(*Service); len(s.ReloadOn) != 1 || s.ReloadOn[0] != "f" {
t.Errorf("reload-on was read as %v", s.ReloadOn)
}
}
+60
View File
@@ -0,0 +1,60 @@
package declaration
import "testing"
// 1132/AC1: a resource says its module, and the declaration says each module's build; a resource that does
// not say gets today's guess, so an older controller still works.
func TestAResourceSaysItsModuleAndTheDeclarationItsBuilds(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,
"builds":{"novox.be":"b-9","web":"b-4"},
"resources":[
{"id":"novox.be.data","type":"directory","path":"/srv/a","module":"novox.be"},
{"id":"web.conf","type":"file","path":"/etc/w","content":"x","module":"web"},
{"id":"old.dir","type":"directory","path":"/srv/old"},
{"id":"base","type":"directory","path":"/srv/base"}]}`))
if err != nil {
t.Fatal(err)
}
for id, want := range map[string]string{"novox.be.data": "novox.be", "web.conf": "web", "old.dir": "old"} {
got, ok := d.ModuleOfID(id)
if !ok || got != want {
t.Errorf("%s belongs to %q (%v), want %q", id, got, ok, want)
}
}
if m, ok := d.ModuleOfID("base"); ok {
t.Errorf("a resource with no module and no dot belongs to %q, want none", m)
}
if d.Builds["web"] != "b-4" || d.Builds["novox.be"] != "b-9" || len(d.Builds) != 2 {
t.Errorf("builds read as %v", d.Builds)
}
}
// 1132/AC1: the module the declaration gives wins over the guess, even when the id would guess another.
func TestTheDeclaredModuleWinsOverTheGuess(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"a.b.c","type":"directory","path":"/srv/a","module":"a.b"},
{"id":"x.y","type":"directory","path":"/srv/x","module":"other"}]}`))
if err != nil {
t.Fatal(err)
}
if m, _ := d.ModuleOfID("x.y"); m != "other" {
t.Errorf("x.y belongs to %q, want the declared %q", m, "other")
}
}
// 1132/AC1: every kind accepts the field.
func TestEveryKindAcceptsAModule(t *testing.T) {
for _, typ := range []Type{TypeDirectory, TypeFile, TypeUser, TypeNetwork, TypeAccess, TypeArchive, TypeProcess,
TypeService, TypeOpening, TypePackage, TypeContainer, TypeAction} {
r := newOf(typ)
if r == nil {
t.Fatalf("no kind %s", typ)
}
if _, ok := r.(interface{ DeclaredModule() string }); !ok {
t.Errorf("%s does not say its module", typ)
}
if unknown := unknownFields([]byte(`{"id":"m.x","type":"`+string(typ)+`","module":"m"}`), r); len(unknown) != 0 {
t.Errorf("%s refuses %v", typ, unknown)
}
}
}
+59
View File
@@ -0,0 +1,59 @@
package declaration
import (
"strings"
"testing"
)
// A declaration says where it stands — the controller's lease epoch and its sequence — inside what is
// signed (novox/hq to-be 45 §6). Absent is no claim; below zero is no order the mesh assigns.
func withOrder(order string) string {
return `{"declaration":1,` + order + `"resources":[{"id":"etc","type":"directory","path":"/etc/mesh","mode":"0755"}]}`
}
func TestADeclarationCarriesItsEpochAndSequence(t *testing.T) {
d, err := Parse([]byte(withOrder(`"epoch":57,"sequence":3,`)))
if err != nil {
t.Fatalf("a declaration carrying its order was refused: %v", err)
}
if d.Epoch != 57 || d.Sequence != 3 {
t.Fatalf("read epoch %d, sequence %d", d.Epoch, d.Sequence)
}
// An older controller's: no order, and no claim.
d, err = Parse([]byte(withOrder(``)))
if err != nil || d.Epoch != 0 || d.Sequence != 0 {
t.Fatalf("a declaration with no order read as %+v, %v", d, err)
}
}
func TestAnOrderBelowZeroIsRefused(t *testing.T) {
for _, order := range []string{`"epoch":-1,`, `"sequence":-4,`} {
refusal := refusalFor(t, withOrder(order))
if !strings.Contains(refusal.Error(), "below zero") {
t.Errorf("%s: refused for something else: %v", order, refusal)
}
}
}
// **The assignment generation it was composed from, and whether it is a put-back** (novox/hq issue 234):
// absent is no claim; below zero is none the mesh assigns; a carried bundle puts nothing back.
func TestADeclarationCarriesItsGenerationAndWhetherItIsAPutBack(t *testing.T) {
d, err := Parse([]byte(withOrder(`"sequence":3,"generation":41,"put_back":true,`)))
if err != nil {
t.Fatalf("a declaration carrying its generation was refused: %v", err)
}
if d.Generation != 41 || !d.PutBack {
t.Fatalf("read generation %d, put-back %v", d.Generation, d.PutBack)
}
if d, err = Parse([]byte(withOrder(``))); err != nil || d.Generation != 0 || d.PutBack {
t.Fatalf("a declaration with no generation read as %+v, %v", d, err)
}
if refusal := refusalFor(t, withOrder(`"generation":-2,`)); !strings.Contains(refusal.Error(), "below zero") {
t.Errorf("a generation below zero was refused for something else: %v", refusal)
}
if _, err := ParseTrusted([]byte(withOrder(`"generation":3,"put_back":true,`))); err == nil ||
!strings.Contains(err.Error(), "put-back") {
t.Errorf("a carried bundle said it was a put-back and was not refused for it: %v", err)
}
}
+130
View File
@@ -0,0 +1,130 @@
package declaration
import (
"strings"
"testing"
)
// Defends novox/hq ADR 0349 §5 (issue 667): a file the provisioner places says who owns it at runtime
// — owned, seed or managed — and the declaration names the files the provisioner fills. The words are
// read and judged here before any controller sends them.
func TestAFileSaysWhoOwnsItAtRuntime(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"o","type":"file","path":"/etc/o","content":"a","owner-at-runtime":"owned"},
{"id":"s","type":"file","path":"/etc/s","content":"a","owner-at-runtime":"seed"},
{"id":"s2","type":"file","path":"/etc/s2","content":"a","owner-at-runtime":"seed","create-once":true},
{"id":"mj","type":"file","path":"/etc/mj","into":"json","content":"{\"k\":1}","owner-at-runtime":"managed"},
{"id":"mb","type":"file","path":"/etc/mb","into":"block","content":"a\n","owner-at-runtime":"managed"}
]}`))
if err != nil {
t.Fatal(err)
}
want := map[string]string{"o": RuntimeOwned, "s": RuntimeSeed, "s2": RuntimeSeed, "mj": RuntimeManaged,
"mb": RuntimeManaged}
for _, r := range d.Resources {
f := r.(*File)
if f.OwnerAtRuntime != want[f.ID] || f.Ownership() != want[f.ID] {
t.Errorf("%s: read as %q, ownership %q; want %q", f.ID, f.OwnerAtRuntime, f.Ownership(), want[f.ID])
}
}
}
// A file that does not say is owned by its old form, so the word means exactly what that form does.
func TestAFileThatDoesNotSayIsOwnedByItsOldForm(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"o","type":"file","path":"/etc/o","content":"a"},
{"id":"s","type":"file","path":"/etc/s","content":"a","create-once":true},
{"id":"mj","type":"file","path":"/etc/mj","into":"json","content":"{}"},
{"id":"mb","type":"file","path":"/etc/mb","into":"block","content":"a\n"}
]}`))
if err != nil {
t.Fatal(err)
}
want := map[string]string{"o": RuntimeOwned, "s": RuntimeSeed, "mj": RuntimeManaged, "mb": RuntimeManaged}
for _, r := range d.Resources {
f := r.(*File)
if f.OwnerAtRuntime != "" || f.Ownership() != want[f.ID] {
t.Errorf("%s: said %q, ownership %q; want unsaid and %q", f.ID, f.OwnerAtRuntime, f.Ownership(), want[f.ID])
}
if f.Seeded() != (want[f.ID] == RuntimeSeed) {
t.Errorf("%s: seeded %v", f.ID, f.Seeded())
}
}
}
func TestAnOwnershipThatContradictsTheFileIsRefused(t *testing.T) {
for name, c := range map[string]struct{ resource, refusal string }{
"another word": {`{"id":"f","type":"file","path":"/etc/x","content":"a","owner-at-runtime":"mine"}`,
`owner-at-runtime "mine"`},
"owned and create-once": {`{"id":"f","type":"file","path":"/etc/x","content":"a","owner-at-runtime":"owned","create-once":true}`,
"create-once"},
"owned and into": {`{"id":"f","type":"file","path":"/etc/x","into":"json","content":"{}","owner-at-runtime":"owned"}`,
"into"},
"seed and into": {`{"id":"f","type":"file","path":"/etc/x","into":"block","content":"a","owner-at-runtime":"seed"}`,
"into"},
"managed and whole": {`{"id":"f","type":"file","path":"/etc/x","content":"a","owner-at-runtime":"managed"}`,
"into"},
"managed and create-once": {`{"id":"f","type":"file","path":"/etc/x","into":"json","content":"{}","owner-at-runtime":"managed","create-once":true}`,
"create-once"},
// The rule that was there before stays: a file written into is never sealed or created once.
"managed and sealed": {`{"id":"f","type":"file","path":"/etc/x","into":"json","content":"{}","sealed":"x","owner-at-runtime":"managed"}`,
"sealed"},
} {
_, err := Parse([]byte(`{"declaration":1,"resources":[` + c.resource + `]}`))
if err == nil || !strings.Contains(err.Error(), c.refusal) {
t.Errorf("%s: want a refusal naming %q, got %v", name, c.refusal, err)
}
}
}
func TestTheDeclarationNamesTheFilesTheProvisionerFills(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"filled_files":["/etc/a","/etc/b"],"resources":[
{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"},
{"id":"b","type":"file","path":"/etc/b","into":"block","content":"b\n","owner-at-runtime":"managed"},
{"id":"c","type":"file","path":"/etc/c","content":"c"}
]}`))
if err != nil {
t.Fatal(err)
}
if len(d.Filled) != 2 || d.Filled[0] != "/etc/a" || d.Filled[1] != "/etc/b" {
t.Fatalf("filled files read as %v", d.Filled)
}
if !d.Fills("/etc/a") || !d.Fills("/etc/b") || d.Fills("/etc/c") {
t.Errorf("Fills: a %v, b %v, c %v", d.Fills("/etc/a"), d.Fills("/etc/b"), d.Fills("/etc/c"))
}
// A declaration that names none fills none, as every declaration before this did.
d, err = Parse([]byte(`{"declaration":1,"resources":[{"id":"c","type":"file","path":"/etc/c","content":"c"}]}`))
if err != nil {
t.Fatal(err)
}
if d.Filled != nil || d.Fills("/etc/c") {
t.Errorf("a declaration naming no filled files fills %v", d.Filled)
}
}
func TestAFilledFileMustBeADeclaredFileThatSaysItsOwner(t *testing.T) {
for name, c := range map[string]struct{ filled, resources, refusal string }{
"not declared": {`["/etc/none"]`,
`{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}`,
`"/etc/none"`},
"a directory, not a file": {`["/etc/d"]`,
`{"id":"d","type":"directory","path":"/etc/d"}`,
`"/etc/d"`},
"says no owner": {`["/etc/a"]`,
`{"id":"a","type":"file","path":"/etc/a","content":"a"}`,
"owner-at-runtime"},
"named twice": {`["/etc/a","/etc/a"]`,
`{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}`,
"twice"},
"no path": {`[""]`,
`{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}`,
"no path"},
} {
_, err := Parse([]byte(`{"declaration":1,"filled_files":` + c.filled + `,"resources":[` + c.resources + `]}`))
if err == nil || !strings.Contains(err.Error(), c.refusal) {
t.Errorf("%s: want a refusal naming %q, got %v", name, c.refusal, err)
}
}
}
+163
View File
@@ -0,0 +1,163 @@
package declaration
import (
"strings"
"testing"
)
func aProcess() *Process {
return &Process{
ID: "server", Type: TypeProcess, Name: "greeter",
Source: "https://store.invalid/greeter/daemon",
Digest: "sha256:" + strings.Repeat("a", 64),
Run: []string{"node", "index.js"},
}
}
// A process is part of the vocabulary, or a declaration carrying one is refused whole.
func TestAProcessIsSomethingTheHostSpeaks(t *testing.T) {
var found bool
for _, kind := range Vocabulary() {
if kind == TypeProcess {
found = true
}
}
if !found {
t.Fatal("a process cannot be declared, so a module that declares one is refused")
}
if newOf(TypeProcess) == nil {
t.Fatal("the decoder has no daemon, so one would be refused as an unknown kind")
}
}
// **Pinned by digest, like everything else that crosses a network.** A bundle fetched by a
// reference somebody can repoint is not pinned, and it is the one thing on a machine that would
// then be running code nobody reviewed.
func TestAProcesssBundleMustBePinned(t *testing.T) {
for _, bad := range []string{"", "latest", "sha256:short", strings.Repeat("a", 64)} {
d := aProcess()
d.Digest = bad
if problems := d.validate("a process", false); len(problems) == 0 {
t.Fatalf("a process pinned by %q was accepted", bad)
}
}
}
// What to run is named, never inferred. Guessing an entrypoint from which files are present makes
// a process change what it runs when somebody adds a file.
func TestAProcessMustSayWhatToRun(t *testing.T) {
d := aProcess()
d.Run = nil
if problems := d.validate("a process", false); len(problems) == 0 {
t.Fatal("a process with no command was accepted")
}
}
// Its name becomes a unit name and a path, so a separator in it would write somewhere nobody meant.
func TestAProcesssNameCannotEscapeItsUnit(t *testing.T) {
// "." and ".." are one path element each, and the mesh's own bundle directory and its parent:
// removing a process named ".." would delete every bundle the mesh has, and more (novox/hq ADR
// 0118). A leading dash is an option to the service manager, not a unit.
for _, bad := range []string{"", "../escape", "two words", "a/b", ".", "..", "-", "--now"} {
d := aProcess()
d.Name = bad
if problems := d.validate("a process", false); len(problems) == 0 {
t.Fatalf("a process called %q was accepted", bad)
}
}
}
// And a well-formed one is accepted, or the tests above prove only that everything is refused.
func TestAWellFormedDaemonIsAccepted(t *testing.T) {
if problems := aProcess().validate("a process", false); len(problems) != 0 {
t.Fatalf("a well-formed daemon was refused: %v", problems)
}
}
// **The modes are exclusive, and saying so is the point of having one kind.** Something that runs
// once does not run on a schedule; something not running between fires cannot be restarted when a
// file changes. A container's modes carry the same rule, and this is the same rule because it is
// the same thing hosted differently.
func TestTheModesAreExclusive(t *testing.T) {
both := aProcess()
both.RunOnce = true
both.Schedule = "0 3 * * *"
if problems := both.validate("a process", false); len(problems) == 0 {
t.Fatal("a process that runs once and on a schedule was accepted")
}
watching := aProcess()
watching.Schedule = "0 3 * * *"
watching.RestartOn = []string{"some-file"}
if problems := watching.validate("a process", false); len(problems) == 0 {
t.Fatal("a scheduled process was given something to restart on, and it is never running")
}
}
// A cadence that is not a cadence is refused near its author, rather than by a machine at the far
// end of a declaration.
func TestAScheduleMustBeACadence(t *testing.T) {
for _, bad := range []string{"often", "0 3 * *", "99 3 * * *"} {
p := aProcess()
p.Schedule = bad
if problems := p.validate("a process", false); len(problems) == 0 {
t.Fatalf("a process scheduled %q was accepted", bad)
}
}
}
// And each mode on its own is accepted, or the tests above prove only that everything is refused.
func TestEachModeOnItsOwnIsAccepted(t *testing.T) {
once := aProcess()
once.RunOnce = true
if problems := once.validate("a process", false); len(problems) != 0 {
t.Fatalf("a step was refused: %v", problems)
}
every := aProcess()
every.Schedule = "0 3 * * *"
if problems := every.validate("a process", false); len(problems) != 0 {
t.Fatalf("a scheduled process was refused: %v", problems)
}
}
// **The one that cannot be escaped, only refused.**
//
// Everything else a unit file reinterprets can be escaped: a percent specifier doubled, whitespace
// quoted, a quote backslashed. A newline cannot — it ends the line, and what follows is read as a
// unit DIRECTIVE. A value carrying one could write ExecStart= and have the machine run something
// nobody declared.
//
// So it is refused here, near whoever wrote it, rather than rendered into a unit at the far end of
// a declaration.
func TestAnEnvironmentValueCannotCarryALineBreak(t *testing.T) {
for _, bad := range []string{
"safe\nExecStart=/usr/bin/whatever",
"carriage\rreturn",
} {
p := aProcess()
p.Env = map[string]string{"X": bad}
problems := p.validate("a process", false)
if len(problems) == 0 {
t.Fatalf("a value containing %q was accepted", bad)
}
var said bool
for _, problem := range problems {
if strings.Contains(problem, "line break") {
said = true
}
}
if !said {
t.Fatalf("refused for some other reason, which would stop being true: %v", problems)
}
}
}
// And ordinary awkward values are accepted, because escaping is what handles those — refusing them
// too would make a module unable to hold a generated password.
func TestOrdinaryAwkwardValuesAreAccepted(t *testing.T) {
p := aProcess()
p.Env = map[string]string{"PASSWORD": `a%H b"c\d`}
if problems := p.validate("a process", false); len(problems) != 0 {
t.Fatalf("a password containing the characters passwords contain was refused: %v", problems)
}
}
+37
View File
@@ -0,0 +1,37 @@
package declaration
import (
"strings"
"testing"
)
// Defends novox/hq ADR 0349 §4 (issue 668): the declaration names the account the provisioner's client runs as,
// the only one whose write of a filled file the node-engine accepts. Read here before any controller sends it.
func TestTheDeclarationNamesTheProvisionersAccount(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"provisioner_account":"mesh-node-provisioner","filled_files":["/etc/a"],
"resources":[{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}]}`))
if err != nil {
t.Fatal(err)
}
if d.Provisioner != "mesh-node-provisioner" {
t.Errorf("the provisioner's account read as %q", d.Provisioner)
}
d, err = Parse([]byte(`{"declaration":1,"resources":[{"id":"a","type":"file","path":"/etc/a","content":"a"}]}`))
if err != nil {
t.Fatal(err)
}
if d.Provisioner != "" {
t.Errorf("a declaration naming no account names %q", d.Provisioner)
}
}
func TestAProvisionersAccountThatIsNoAccountIsRefused(t *testing.T) {
for name, account := range map[string]string{"root": "root", "not a name": "a b", "a path": "../x"} {
_, err := Parse([]byte(`{"declaration":1,"provisioner_account":"` + account + `",
"resources":[{"id":"a","type":"file","path":"/etc/a","content":"a"}]}`))
if err == nil || !strings.Contains(err.Error(), "provisioner_account") {
t.Errorf("%s: want a refusal naming provisioner_account, got %v", name, err)
}
}
}
+63
View File
@@ -0,0 +1,63 @@
package declaration
import (
"strings"
"testing"
)
// novox/hq issue 213: a process says what it takes the place of, so the host can keep the old one
// answering until the new one does. What it may name is narrow, and each refusal is said here.
const aReplacingProcess = `{"id":"m.controller","type":"process","name":"m","source":"https://store.invalid/m",
"digest":"sha256:` + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + `","run":["./m","serve"]%s}`
func replacing(extra string) string {
return `{"declaration":1,"resources":[` + strings.Replace(aReplacingProcess, "%s", extra, 1) + `]}`
}
func TestAProcessMayNameWhatItReplaces(t *testing.T) {
d, err := Parse([]byte(replacing(`,"replaces":["m.server"]`)))
if err != nil {
t.Fatalf("a process replacing an undeclared container was refused: %v", err)
}
if p := d.Resources[0].(*Process); len(p.Replaces) != 1 || p.Replaces[0] != "m.server" {
t.Fatalf("replaces was not read: %+v", p)
}
}
// What is replaced is what the declaration no longer says. A resource still declared is applied,
// and handing it over as well would remove something the same declaration asks to keep.
func TestAProcessMayNotReplaceSomethingStillDeclared(t *testing.T) {
raw := `{"declaration":1,"resources":[
{"id":"m.server","type":"directory","path":"/tmp/x"},
` + strings.Replace(aReplacingProcess, "%s", `,"replaces":["m.server"]`, 1) + `]}`
if _, err := Parse([]byte(raw)); err == nil || !strings.Contains(err.Error(), "still declares") {
t.Fatalf("a process replacing a declared resource was accepted: %v", err)
}
}
func TestOnlyAProcessThatStaysUpReplacesAnything(t *testing.T) {
for _, mode := range []string{`,"run-once":true`, `,"schedule":"0 3 * * *"`} {
if _, err := Parse([]byte(replacing(mode + `,"replaces":["m.server"]`))); err == nil {
t.Errorf("a process with %s was allowed to replace something", mode)
}
}
}
func TestAProcessCannotReplaceItselfOrNothing(t *testing.T) {
for _, bad := range []string{`,"replaces":["m.controller"]`, `,"replaces":[""]`} {
if _, err := Parse([]byte(replacing(bad))); err == nil {
t.Errorf("replaces %s was accepted", bad)
}
}
}
func TestOneThingHasOneReplacement(t *testing.T) {
second := strings.NewReplacer(`"id":"m.controller"`, `"id":"m.other"`, `"name":"m"`, `"name":"other"`).
Replace(strings.Replace(aReplacingProcess, "%s", `,"replaces":["m.server"]`, 1))
raw := `{"declaration":1,"resources":[` +
strings.Replace(aReplacingProcess, "%s", `,"replaces":["m.server"]`, 1) + "," + second + `]}`
if _, err := Parse([]byte(raw)); err == nil {
t.Fatal("two processes replacing one resource were accepted")
}
}
+26
View File
@@ -0,0 +1,26 @@
package declaration
import (
"strings"
"testing"
)
// A user's root (novox/hq ADR 0266): "never" or absent, and nothing else.
func TestAUsersRootIsNeverOrAbsent(t *testing.T) {
for _, c := range []struct {
root string
ok bool
}{{`,"root":"never"`, true}, {``, true}, {`,"root":"always"`, false}, {`,"root":"no"`, false}} {
d, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"claude-code.agent","type":"user","name":"agent"` +
c.root + `}]}`))
if c.ok != (err == nil) {
t.Errorf("%s: err %v", c.root, err)
}
if err != nil && !strings.Contains(err.Error(), `"never"`) {
t.Errorf("%s: the refusal does not name the allowed value: %v", c.root, err)
}
if c.ok && c.root != "" && d.Resources[0].(*User).Root != RootNever {
t.Errorf("%s: read as %+v", c.root, d.Resources[0])
}
}
}
@@ -0,0 +1,53 @@
package declaration
import (
"strings"
"testing"
)
// The host refuses a schedule it does not fully understand, on arrival, for the same reason the
// control plane refuses it near its author (novox/hq ADR 0053): a declaration half-understood is
// refused whole rather than half-applied.
func schedulePinned() string { return "registry.example/runtime@sha256:" + strings.Repeat("a", 64) }
func TestAScheduledContainerCarriesTheCronField(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","schedule":"0 3 * * *"}
]}`))
if err != nil {
t.Fatalf("a valid scheduled container was refused: %v", err)
}
c, ok := d.Resources[0].(*Container)
if !ok {
t.Fatalf("the scheduled resource is not a container: %T", d.Resources[0])
}
if c.Schedule != "0 3 * * *" {
t.Errorf("the schedule did not survive parsing: %q", c.Schedule)
}
}
func TestAMalformedScheduleIsRefused(t *testing.T) {
_, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","schedule":"every night"}
]}`))
if err == nil {
t.Fatal("a container with a malformed schedule was accepted")
}
if !strings.Contains(err.Error(), "cron") && !strings.Contains(err.Error(), "field") {
t.Errorf("refused for the wrong reason: %v", err)
}
}
func TestAContainerCannotBeBothRunOnceAndScheduled(t *testing.T) {
// A container runs once and gates, or on a cadence, or stays up — never two (novox/hq ADR 0053).
_, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","run-once":true,"schedule":"0 3 * * *"}
]}`))
if err == nil {
t.Fatal("a container that was both run-once and scheduled was accepted")
}
if !strings.Contains(err.Error(), "run-once") && !strings.Contains(err.Error(), "scheduled") {
t.Errorf("refused for the wrong reason: %v", err)
}
}
+30
View File
@@ -0,0 +1,30 @@
package declaration
import (
"strings"
"testing"
)
// Defends novox/hq ADR 0117: a service may leave its unit's lifecycle to the machine, and then
// says nothing but its triggers.
func TestAServiceWithoutAStateSaysOnlyItsTriggers(t *testing.T) {
for name, c := range map[string]struct{ resource, refusal string }{
"no trigger": {`{"id":"s","type":"service","unit":"NetworkManager.service"}`, "declares nothing"},
"with boot": {`{"id":"s","type":"service","unit":"NetworkManager.service","boot":"enabled","reload-on":["f"]}`, "boot and takes-over"},
"with takes-over": {`{"id":"s","type":"service","unit":"a.service","reload-on":["f"],"takes-over":{"interface":"wg0","unit":"b.service","config":"/etc/x"}}`, "boot and takes-over"},
"an unknown state": {`{"id":"s","type":"service","unit":"a.service","state":"paused"}`, "omits state to leave the unit's lifecycle to the machine"},
} {
_, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"f","type":"file","path":"/etc/x","content":"x"},` + c.resource + `]}`))
if err == nil || !strings.Contains(err.Error(), c.refusal) {
t.Errorf("%s: want a refusal naming %q, got %v", name, c.refusal, err)
}
}
d, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"f","type":"file","path":"/etc/x","content":"x"},
{"id":"s","type":"service","unit":"NetworkManager.service","restart-on":["f"]}]}`))
if err != nil {
t.Fatal(err)
}
if s := d.Resources[1].(*Service); !s.Stateless() {
t.Error("a service without a state was not read as stateless")
}
}
+23
View File
@@ -0,0 +1,23 @@
package declaration
import "testing"
// `trusted` is the catalogue's word on a file (novox/hq issue 339): a controller takes it out before it sends a
// declaration, and one that does not — an older one, or one rolled back to — must not cost the node its whole
// declaration. So a file accepts it and the engine does nothing with it; any other resource still refuses it.
func TestAFileAcceptsAndIgnoresTrusted(t *testing.T) {
for _, v := range []string{"true", "false"} {
d, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"m.f","type":"file","path":"/etc/m.conf",` +
`"content":"x","trusted":` + v + `}]}`))
if err != nil {
t.Fatalf("trusted %s: %v", v, err)
}
if f, ok := d.Resources[0].(*File); !ok || f.Content != "x" {
t.Fatalf("trusted %s: %+v", v, d.Resources[0])
}
}
if _, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"m.d","type":"directory","path":"/srv/m",` +
`"trusted":true}]}`)); err == nil {
t.Fatal("a directory took trusted")
}
}
+49
View File
@@ -0,0 +1,49 @@
package declaration
import (
"strings"
"testing"
)
// novox/hq ADR 0177: a unit is in the system manager or an account's own; a user-scoped one names
// the account, a system one may not, and any other word is refused.
func TestAUserScopedUnitNamesItsAccountAndASystemOneMayNot(t *testing.T) {
cases := []struct{ scope, user, wants string }{
{"user", "ops", ""},
{"", "", ""},
{"system", "", ""},
{"user", "", "names the account"},
{"", "ops", "names no user"},
{"session", "ops", "scope \"session\""},
}
for _, c := range cases {
s := &Service{ID: "m.u", Type: TypeService, Unit: "u.service", State: "running", Scope: c.scope, User: c.user}
problems := s.validate("m.u", false)
got := strings.Join(problems, "; ")
if c.wants == "" && len(problems) != 0 {
t.Fatalf("scope %q user %q refused: %s", c.scope, c.user, got)
}
if c.wants != "" && !strings.Contains(got, c.wants) {
t.Fatalf("scope %q user %q: wanted a refusal saying %q, got %q", c.scope, c.user, c.wants, got)
}
}
}
// novox/hq ADR 0177: lingering is a field of the account, absent meaning nothing asserted.
func TestAnAccountMayBeDeclaredToLinger(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"m.login","type":"user","name":"ops","linger":true}]}`))
if err != nil {
t.Fatal(err)
}
u, ok := d.Resources[0].(*User)
if !ok || u.Linger == nil || !*u.Linger {
t.Fatalf("linger not read: %+v", d.Resources[0])
}
d, err = Parse([]byte(`{"declaration":1,"resources":[{"id":"m.login","type":"user","name":"ops"}]}`))
if err != nil {
t.Fatal(err)
}
if u := d.Resources[0].(*User); u.Linger != nil {
t.Fatal("an account that said nothing about lingering asserts it")
}
}