diff --git a/go/declaration/adoption_test.go b/go/declaration/adoption_test.go new file mode 100644 index 0000000..e422516 --- /dev/null +++ b/go/declaration/adoption_test.go @@ -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) + } + } +} diff --git a/go/declaration/block_test.go b/go/declaration/block_test.go new file mode 100644 index 0000000..1512a00 --- /dev/null +++ b/go/declaration/block_test.go @@ -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) + } +} diff --git a/go/declaration/busy.go b/go/declaration/busy.go new file mode 100644 index 0000000..3b73485 --- /dev/null +++ b/go/declaration/busy.go @@ -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 +} diff --git a/go/declaration/cron.go b/go/declaration/cron.go new file mode 100644 index 0000000..69b97df --- /dev/null +++ b/go/declaration/cron.go @@ -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< 0 && unknown == "" { + problems = append(problems, where+": knows is given without unknown, and says nothing without it") + } + seen := map[string]bool{} + for _, k := range knows { + switch { + case k == "": + problems = append(problems, where+": knows holds an empty entry") + continue + case strings.HasPrefix(k, "/"): + problems = append(problems, fmt.Sprintf("%s: knows entry %q is absolute; it is relative to the path", where, k)) + continue + } + bad := false + for _, c := range strings.Split(strings.TrimSuffix(k, "/"), "/") { + if c == "" || c == "." || c == ".." { + bad = true + } + } + if bad { + problems = append(problems, fmt.Sprintf("%s: knows entry %q holds an empty, . or .. component", where, k)) + continue + } + if seen[k] { + problems = append(problems, fmt.Sprintf("%s: knows entry %q is repeated", where, k)) + } + seen[k] = true + } + return problems +} + +func (d *Directory) Identity() string { return d.ID } +func (d *Directory) Kind() Type { return TypeDirectory } +func (d *Directory) Target() string { return d.Path } + +func (d *Directory) validate(where string, _ bool) []string { + var problems []string + if d.Path == "" { + problems = append(problems, where+": a directory needs a path") + } + // A directory's mode may carry the setgid bit (2xxx), so what is made beneath it keeps its group (novox/hq ADR + // 0334 §1); never setuid or sticky, which mean nothing the mesh asks of a directory it owns. + mode := d.Mode + if len(mode) == 4 && mode[0] == '2' { + mode = "0" + mode[1:] + } + problems = append(problems, checkUnknown(where, "directory", d.Unknown, d.Knows)...) + return append(problems, checkMode(where, mode)...) +} + +// File is a file with literal content. The host renders nothing. +type File struct { + ID string `json:"id"` + Type Type `json:"type"` + // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent + // is an older controller, and the module is then guessed from the id (ModuleGuess). + Module string `json:"module,omitempty"` + Path string `json:"path"` + Content string `json:"content"` + Mode string `json:"mode,omitempty"` + + // CreateOnce says the content is a seed: written when the file is absent, and left alone — + // content, mode and owner — whenever it is present. + // + // **Two intentions had one vocabulary** (novox/hq issue 035, ADR 0087). "This file has this + // content, for ever" is what an ordinary file says, and the host holds the machine to it. A + // module that needs a file to exist before a program first starts — an access list the + // program then persists into, a bootstrap configuration it rewrites — needs the other thing, + // and with only the first available, every reconcile restored the seed behind the running + // program and erased what had grown in it, reporting success. What grows in a seeded file + // is somebody else's work the mesh asked for; the mesh removes nothing it did not create + // (ADR 0030), and it does not overwrite that either. + CreateOnce bool `json:"create-once,omitempty"` + + // Into says the file is shared with software the mesh did not install, and the content is + // the mesh's part of it: written into what is there, never over it (novox/hq ADR 0102). Only + // "json" is spoken — the content is a JSON object whose keys the host sets in the file's + // object, keeping every other key as it found it and recording what each of its keys held + // before, so undeclaring the file gives those back. + // + // "block" is the same idea for a file that is not structured (novox/hq issue 128): the + // content is the mesh's lines, and the host owns only the region between `# BEGIN mesh ` + // and `# END mesh `, keeping every line outside it byte for byte. The machine's hosts file + // is the case that needed it — on a workstation the distribution, a local development tool and + // the operator all write into it, and the mesh writing it whole took their lines away at the + // next change to the mesh's names, silently. Marked blocks are the shape the other tools in + // that file already use, and `#` is the comment character of every file this serves. + // + // Written into, in either format, the file's mode and owner are the machine's: a declared mode + // and owner apply only to a file the host creates, and a file that was there keeps its own. + Into string `json:"into,omitempty"` + + // At is where a file written into a block has its region added when the file does not hold + // one yet: "end", the default, or "start". A region already there stays where it is, whatever + // this says — moving it would move the lines around it, and those are the machine's. + // + // **Some files give a line its meaning by what stands above it.** dhcpcd's configuration scopes + // every line after `interface X` to that interface, and a real one ends with exactly that — an + // interface and its static address. A region added at the end would make the mesh's global + // options (`nohook resolv.conf`, `denyinterfaces mesh0`) options of one interface, and dhcpcd + // would read them without complaint. At the start, nothing stands above them. + At string `json:"at,omitempty"` + + // Sealed is content encrypted to this node's sealing key, for a file the mesh must deliver + // without being able to read. + // + // The one thing here the host cannot simply write. Everything else in a declaration is + // visible to whatever carried it — the broker relays the message, and the message is signed + // so it cannot be forged, but signing does not make it unreadable. A password travelling in + // `content` would be a password the broker sees, which is the transitive trust the design + // refuses everywhere else (novox/hq ADR 0004). + // + // Exclusive with Content: a file is one or the other, so that "was this secret" is answerable + // by looking rather than by knowing which field won. + Sealed string `json:"sealed,omitempty"` + + // VaultSealed is content the vault sealed to this node's sealing key, in the mesh's sealed box (novox/hq ADR + // 0206): a core module's own credential, which the controller carries in the declaration and cannot open + // (ADR 0349 §16, issue 755). The same promise as Sealed, from the vault rather than the controller; sent only to + // an engine that states VaultBoxContract. Exclusive with Content, Sealed and Bytes. + VaultSealed *seal.Box `json:"vault-sealed,omitempty"` + + // Trusted is the catalogue's word on whether the settings this file's content took are trusted (novox/hq + // issue 339). It is the controller's to act on, and a controller takes it out before it sends a declaration. + // Accepted here, and nothing is done with it, so that a controller that passes it on — an older one, or one + // rolled back to — never costs a node its whole declaration. Not part of the file's digest. + Trusted *bool `json:"trusted,omitempty"` + + // Secrets are sealed values put into Content where it says `${secret:name}`. + // + // **The one place a secret and a configuration meet, and it happens on the machine.** A + // program that wants its token inside a JSON document cannot be given a file that is entirely + // a token, and the mesh cannot compose the document itself — it discarded the value + // (novox/hq ADR 0024). So the module supplies the document with a hole in it, the mesh + // delivers the value sealed, and the host is the only thing that ever sees both. + // + // **Substitution is textual and the host learns no formats.** That is deliberate: a mechanism + // that understood JSON would be asked to understand YAML next, and then INI, which is how the + // arrangement this replaces became something nobody could hold in their head. The module knows + // its own format, because it wrote the rest of the file. + // + // The sharp edge, stated rather than discovered: a value containing a quote or a backslash + // will not be escaped for whatever syntax surrounds it. + Secrets map[string]string `json:"secrets,omitempty"` + + // Bytes is content that is not text, base64-encoded — a wallpaper, a font, an icon. + // + // A third way of saying what is in a file, and the three are exclusive. It would have been + // tempting to let Content carry base64 and add a flag, and then "what is in this file" would + // depend on a field somewhere else. + Bytes string `json:"bytes,omitempty"` + + // Owner is the user this belongs to, by name. Absent means root. + Owner string `json:"owner,omitempty"` + + // OwnerAtRuntime says who owns the file once it is placed (novox/hq ADR 0349 §5): "owned", the mesh's + // whole, written back whenever it differs; "seed", written once and then the program's; or "managed", + // only its named keys or its marked block the mesh's, the program keeping the rest. + // + // **Three words for what the forms above already do.** Owned is an ordinary file, seed is create-once + // and managed is a file written into, so the word changes no write: a file that says it is applied + // exactly as its old form is, and one that does not say is owned by its old form (Ownership). The word + // is what the provisioner's files say of themselves, which the old forms only implied. Seed is enough + // alone; managed still needs `into`, which says the format. A word that contradicts the form is refused. + OwnerAtRuntime string `json:"owner-at-runtime,omitempty"` +} + +// The words a file says who owns it at runtime with (novox/hq ADR 0349 §5). +const ( + RuntimeOwned = "owned" + RuntimeSeed = "seed" + RuntimeManaged = "managed" +) + +// Ownership is who owns the file at runtime: the word it says, or the one its old form implies — a file +// written into is managed, one created once is a seed, and any other is owned. +func (f *File) Ownership() string { + switch { + case f.OwnerAtRuntime != "": + return f.OwnerAtRuntime + case f.Into != "": + return RuntimeManaged + case f.CreateOnce: + return RuntimeSeed + default: + return RuntimeOwned + } +} + +// Seeded reports whether the file is written only when absent: created once, by create-once or by the +// word seed. +func (f *File) Seeded() bool { return f.CreateOnce || f.OwnerAtRuntime == RuntimeSeed } + +// Secret reports whether this file arrived sealed, which is what decides both that it must be +// opened before writing and that its contents must never appear in a report. +func (f *File) Secret() bool { return f.Sealed != "" || f.VaultSealed != nil } + +func (f *File) Identity() string { return f.ID } +func (f *File) Kind() Type { return TypeFile } +func (f *File) Target() string { return f.Path } + +// placeholder is what Content says where a sealed value belongs: ${secret:name}. +var placeholder = regexp.MustCompile(`\$\{secret:([a-z0-9][a-z0-9-]*)\}`) + +// SecretsUsed are the names Content asks for, in the order they first appear. +func (f *File) SecretsUsed() []string { + var used []string + seen := map[string]bool{} + for _, m := range placeholder.FindAllStringSubmatch(f.Content, -1) { + if !seen[m[1]] { + seen[m[1]] = true + used = append(used, m[1]) + } + } + return used +} + +func (f *File) validate(where string, _ bool) []string { + var problems []string + if f.Path == "" { + problems = append(problems, where+": a file needs a path") + } + switch f.Into { + case "": + case IntoJSON: + var object map[string]json.RawMessage + if err := json.Unmarshal([]byte(f.Content), &object); err != nil || object == nil { + problems = append(problems, where+ + ": a file written into JSON carries a JSON object of the keys it sets") + } + case IntoBlock: + // The markers are how the host finds its region again. A marker in the content would be + // a second region, or the end of this one, the next time the file is read — and the host + // would then rewrite, or on undeclare take out, lines that were never the mesh's. + for _, line := range strings.Split(f.Content, "\n") { + if strings.HasPrefix(line, BlockBegin) || strings.HasPrefix(line, BlockEnd) { + problems = append(problems, fmt.Sprintf( + "%s: a file written into a block carries the mesh's lines, and %q is a marker the "+ + "host keeps for itself", where, strings.TrimRight(line, "\r"))) + break + } + } + // The id is written into the markers, so it has to stay on one line — and whitespace at + // either end of it is whitespace the host would have to match exactly in a line some + // editor may trim. + if strings.ContainsAny(f.ID, "\r\n") { + problems = append(problems, where+ + ": a file written into a block names its region by its id, and this id spans lines") + } else if strings.TrimSpace(f.ID) != f.ID { + problems = append(problems, where+ + ": a file written into a block names its region by its id, and this id begins or ends in whitespace") + } + default: + problems = append(problems, fmt.Sprintf( + "%s: into %q; a file is written into \"json\" or \"block\", or omits it to be written whole", + where, f.Into)) + } + switch { + case f.At == "": + case f.Into != IntoBlock: + problems = append(problems, fmt.Sprintf( + "%s: at %q; only a file written into a block has a place its region is added", where, f.At)) + case f.At != AtStart && f.At != AtEnd: + problems = append(problems, fmt.Sprintf( + "%s: at %q; a block is added at \"start\" or \"end\", or omits it to be added at the end", + where, f.At)) + } + if f.Into == IntoJSON || f.Into == IntoBlock { + if f.Secret() || f.Bytes != "" || len(f.Secrets) > 0 || f.CreateOnce { + problems = append(problems, where+ + ": a file written into says only its part, in content — not sealed, bytes, "+ + "secrets or create-once") + } + } + problems = append(problems, f.checkOwnership(where)...) + var said []string + for name, value := range map[string]string{ + "content": f.Content, "sealed": f.Sealed, "bytes": f.Bytes, + } { + if value != "" { + said = append(said, name) + } + } + if f.VaultSealed != nil { + said = append(said, "vault-sealed") + if f.VaultSealed.V != seal.Version { + problems = append(problems, fmt.Sprintf("%s: vault-sealed is a sealed box of version %d, and this "+ + "node-engine opens version %d", where, f.VaultSealed.V, seal.Version)) + } + if len(f.Secrets) > 0 { + problems = append(problems, where+": a file the vault sealed whole has no holes for secrets") + } + } + if len(said) > 1 { + sort.Strings(said) + problems = append(problems, where+ + ": a file says what is in it exactly once, and this says it as "+ + strings.Join(said, " and ")+ + " — otherwise nobody can tell by looking which one landed on the machine") + } + // A file whose content names a secret must be given exactly the secrets it names. + // + // **Both directions, and both are refusals rather than warnings.** A placeholder with nothing + // to fill it would write `${secret:x}` into a configuration file, which the program reads as + // a value and fails on somewhere unrelated. A secret nobody uses means whoever wrote this + // believes a credential is in a file where it is not. + if len(f.Secrets) > 0 && f.Content == "" { + problems = append(problems, where+ + ": secrets were given and there is no content to put them in") + } + used := f.SecretsUsed() + for _, name := range used { + if f.Secrets[name] == "" { + problems = append(problems, fmt.Sprintf( + "%s: the content asks for the secret %q and none was given", where, name)) + } + } + for name := range f.Secrets { + if !slices.Contains(used, name) { + problems = append(problems, fmt.Sprintf( + "%s: the secret %q was given and the content never asks for it", where, name)) + } + } + return append(problems, checkMode(where, f.Mode)...) +} + +// checkOwnership refuses a word that contradicts the file's form (novox/hq ADR 0349 §5): owned is a whole +// file written back, so not created once nor written into; a seed is a whole file created once, so not +// written into; managed is a file written into, which says its format. Managed and created once together +// is already refused by the rule that a file written into is not created once. +func (f *File) checkOwnership(where string) []string { + switch f.OwnerAtRuntime { + case "": + case RuntimeOwned: + var also []string + if f.CreateOnce { + also = append(also, "create-once") + } + if f.Into != "" { + also = append(also, "into") + } + if len(also) > 0 { + return []string{fmt.Sprintf("%s: owned at runtime is the mesh's whole file, written back whenever it "+ + "differs, and this also says %s", where, strings.Join(also, " and "))} + } + case RuntimeSeed: + if f.Into != "" { + return []string{where + ": a seed is a whole file written once, and this says into; a file the " + + "program shares with the mesh is managed"} + } + case RuntimeManaged: + if f.Into == "" { + return []string{where + ": a managed file names the format its keys or block are written into, " + + "with into \"json\" or \"block\""} + } + default: + return []string{fmt.Sprintf("%s: owner-at-runtime %q; a placed file is %q, %q or %q, or omits it to be "+ + "owned by its form", where, f.OwnerAtRuntime, RuntimeOwned, RuntimeSeed, RuntimeManaged)} + } + return nil +} + +// User is a login on the machine. +// +// The thing that makes a shell, a chat client or a desktop expressible at all: each is a package +// plus configuration in somebody's home, and until this the mesh could only own /etc. +// +// It also makes "zsh is my login shell" **declared state** rather than an action. `chsh` is a +// command, the link may not carry one (novox/hq ADR 0005), and a shell that could only be set by +// hand would be a shell the mesh cannot manage — which is most of the reason to manage a machine +// at all. +type User struct { + ID string `json:"id"` + Type Type `json:"type"` + // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent + // is an older controller, and the module is then guessed from the id (ModuleGuess). + Module string `json:"module,omitempty"` + Name string `json:"name"` + + // Shell this user logs in with. Absent means the host asserts nothing and leaves whatever is + // there — the same rule Service.Boot follows, for the same reason: a field that always + // asserts cannot express "I do not care". + Shell string `json:"shell,omitempty"` + + // Groups this user must be in. Additive: the host puts the user in these and does not remove + // it from others, because a machine's own groups are not the mesh's to know about. + Groups []string `json:"groups,omitempty"` + + // Home directory. Absent means the system's default for a new user, and is not changed for + // one that exists — moving somebody's home is not something a declaration should do quietly. + Home string `json:"home,omitempty"` + + // Linger is whether the account's own service manager runs with nobody logged in (novox/hq ADR + // 0177). A user-scoped unit lives in that manager, and the manager runs only from the account's + // first login to its last logout — so a server's user unit, where nobody ever logs in, never + // runs without it, and a workstation's runs only while its person is there, which on a desktop + // is what is wanted. Absent asserts nothing, as Shell's does: true has the account linger, false + // has it not. On the account rather than on the unit, because it is the account's: two units of + // one account cannot disagree about it, and undeclaring one of them must not stop the other. + Linger *bool `json:"linger,omitempty"` + + // Root says whether this account may become root without a person (novox/hq ADR 0266). "never" + // is the agents' own account: the login an agent session runs as on a machine where it must not + // reach root by itself. Empty asserts nothing, as Shell's does. + // + // **A statement the engine judges, never one it acts on.** The apply gives an account it creates + // no password, no sudo rule and no group beyond those declared, as it always has, and takes none + // away from one it finds: a sudo rule or a group granted by hand is a person's to remove, and a + // declaration that silently stripped them would be the mesh deciding what a person's machine + // grants. What "never" adds is the look: on every look the engine reads whether the account can + // become root by itself — by its uid, a group that grants root, any sudo rule, or a secret the mesh + // placed that it can read — and says it unhealthy while it can (internal/accounts), so the + // controller can tell a machine where it holds from one where it does not. + Root string `json:"root,omitempty"` +} + +// RootNever is the one value Root takes besides empty: the account never becomes root without a person. +const RootNever = "never" + +// Network is a named network on this machine. +// +// **A name and nothing else.** Not a driver, a subnet or a gateway: each of those is something a +// module would have to know about the machine it lands on, and a module naming a subnet is a +// module that collides with whatever else chose the same one. The runtime picks; the mesh names +// (novox/hq ADR 0029). +type Network struct { + ID string `json:"id"` + Type Type `json:"type"` + // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent + // is an older controller, and the module is then guessed from the id (ModuleGuess). + Module string `json:"module,omitempty"` + Name string `json:"name"` +} + +func (n *Network) Identity() string { return n.ID } +func (n *Network) Kind() Type { return TypeNetwork } +func (n *Network) Target() string { return n.Name } + +func (n *Network) validate(where string, _ bool) []string { + var problems []string + if n.Name == "" { + problems = append(problems, where+": a network needs a name") + } + // The runtimes accept more than this, and the mesh does not: a name with a slash or a colon + // in it reads as a reference to something else entirely wherever it is later printed. + for _, r := range n.Name { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && + r != '-' && r != '_' && r != '.' { + problems = append(problems, where+ + ": a network name is letters, digits, dashes, underscores and dots, and "+ + n.Name+" is not") + break + } + } + return problems +} + +// Modes an access may be granted at. Plain words, not the octal a directory's mode is: an access +// is not a thing the host chmods, it is a statement of how this module reaches what the operator +// owns. +const ( + AccessRead = "read" + AccessReadWrite = "read-write" +) + +// Access is a pre-existing, operator-owned path this module is granted use of but does not own. +// +// **The distinction 04-ISSUES/036 and 026 turn on.** A `directory` resource is the mesh's own — +// it creates it, sets its owner and mode, and removes it when empty ([ADR 0030](novox/hq)). A +// media library, a download spool is the operator's: it existed before the mesh, several modules +// read and write it at once, and the mesh must not create, chown, chmod or remove it. The host +// confirms it is there and mounts it; nothing else. Library data is a library's (novox/hq ADR 0328), +// not an access. +type Access struct { + ID string `json:"id"` + Type Type `json:"type"` + // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent + // is an older controller, and the module is then guessed from the id (ModuleGuess). + Module string `json:"module,omitempty"` + Path string `json:"path"` + // Mode is how this module reaches the path: read or read-write. Absent narrows to read. + Mode string `json:"mode,omitempty"` + + // Users and Taken are what a controller speaking ADR 0325 sends (the identities that use the folder, and + // that the operator's warrant was carried out): accepted, so its declaration is not refused whole, and not + // acted on since ADR 0328 superseded ADR 0325 — no ACL entry is kept. + Users []AccessUser `json:"users,omitempty"` + Taken bool `json:"taken,omitempty"` + + // Grant is what a controller older than ADR 0325 still sends (ADR 0312's group grant, superseded): accepted + // so its declaration is not refused whole, and never acted on — no account is put in a group for data (§5). + Grant json.RawMessage `json:"grant,omitempty"` +} + +// The uses an access's user has (ADR 0051 §3), and where it runs (ADR 0325 §4). +const ( + FromContainer = "container" + FromToolRunner = "tool-runner" +) + +// AccessUser is one identity that uses an accessed folder: its name and uid as the controller allocated them, its +// use, and where it runs. A tool runner's account may come with no uid (0 or absent): the engine reads it by name. +type AccessUser struct { + Identity string `json:"identity"` + UID int `json:"uid,omitempty"` + Use string `json:"use"` + From string `json:"from"` +} + +// UnmarshalJSON reads a user as strictly as a resource is read. +func (u *AccessUser) UnmarshalJSON(raw []byte) error { + type plain AccessUser + var p plain + if err := strictDecode(raw, &p); err != nil { + return fmt.Errorf("an access's user: %w", err) + } + *u = AccessUser(p) + return nil +} + +func (a *Access) Identity() string { return a.ID } +func (a *Access) Kind() Type { return TypeAccess } +func (a *Access) Target() string { return a.Path } + +func (a *Access) validate(where string, _ bool) []string { + var problems []string + if !strings.HasPrefix(a.Path, "/") { + problems = append(problems, where+": an access needs an absolute path, and "+ + a.Path+" is not one") + } + switch a.Mode { + case "", AccessRead, AccessReadWrite: + default: + problems = append(problems, fmt.Sprintf( + "%s: an access is %q or %q, not %q", where, AccessRead, AccessReadWrite, a.Mode)) + } + for _, u := range a.Users { + switch { + case u.Identity == "" || !accountName.MatchString(u.Identity): + problems = append(problems, fmt.Sprintf("%s: a user of an access is an account's name, and %q is not one", + where, u.Identity)) + case u.UID < 0 || (u.UID == 0 && u.From != FromToolRunner): + // Never root (novox/hq ADR 0325 §11): an entry for uid 0 would be a right root has anyway, and a + // number below it is no account. A tool runner's account alone may come without its number: the + // controller cannot know the operator's account's, and the engine reads it by name + // (apply.ResolveRunners), refusing one that is root's. + problems = append(problems, fmt.Sprintf("%s: the user %s has uid %d, and a module's identity is never "+ + "root or below it (novox/hq ADR 0325 §11)", where, u.Identity, u.UID)) + } + if u.Use != AccessRead && u.Use != AccessReadWrite { + problems = append(problems, fmt.Sprintf("%s: the user %s uses it %q, and a use is %q or %q", where, + u.Identity, u.Use, AccessRead, AccessReadWrite)) + } + if u.Use == AccessReadWrite && a.Mode != AccessReadWrite { + problems = append(problems, fmt.Sprintf("%s: the user %s writes an access declared %s", where, + u.Identity, orRead(a.Mode))) + } + if u.From != FromContainer && u.From != FromToolRunner { + problems = append(problems, fmt.Sprintf("%s: the user %s runs from %q, and that is %q or %q", where, + u.Identity, u.From, FromContainer, FromToolRunner)) + } + } + return problems +} + +// orRead is an access's mode as said: absent narrows to read. +func orRead(mode string) string { + if mode == "" { + return AccessRead + } + return mode +} + +func (u *User) Identity() string { return u.ID } +func (u *User) Kind() Type { return TypeUser } +func (u *User) Target() string { return u.Name } + +func (u *User) validate(where string, _ bool) []string { + var problems []string + if u.Name == "" { + problems = append(problems, where+": a user needs a name") + } + if u.Shell != "" && !strings.HasPrefix(u.Shell, "/") { + problems = append(problems, where+ + ": a login shell is an absolute path, and "+u.Shell+" is not one") + } + if u.Home != "" && !strings.HasPrefix(u.Home, "/") { + problems = append(problems, where+": a home directory is an absolute path") + } + if u.Root != "" && u.Root != RootNever { + problems = append(problems, fmt.Sprintf("%s: root is %q or absent, and %q is neither", where, RootNever, u.Root)) + } + return problems +} + +// Archive is a set of files, fetched by digest and unpacked. +// +// For the case inlining cannot serve: a theme, an icon set, a tree of configuration. Hundreds of +// files inlined would make every declaration enormous and rewrite all of it when one changed. +// +// **Pinned by digest, and the digest is checked before anything is unpacked.** The same discipline +// the bootstrap uses for images, and for the same reason — this is fetched over a network the +// mesh does not control, and a reference that can be made to point elsewhere is not a reference. +type Archive struct { + ID string `json:"id"` + Type Type `json:"type"` + // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent + // is an older controller, and the module is then guessed from the id (ModuleGuess). + Module string `json:"module,omitempty"` + // Source is where to fetch it from. + Source string `json:"source"` + // Digest is sha256 of the archive, as "sha256:". + Digest string `json:"digest"` + // Path is the directory it is unpacked into. + Path string `json:"path"` + // Owner is the user the unpacked files belong to. Absent means root. + Owner string `json:"owner,omitempty"` + // Unknown and Knows are as a directory's (issue 1024, ADR 0422). + Unknown string `json:"unknown,omitempty"` + Knows []string `json:"knows,omitempty"` +} + +func (a *Archive) Identity() string { return a.ID } +func (a *Archive) Kind() Type { return TypeArchive } +func (a *Archive) Target() string { return a.Path } + +func (a *Archive) validate(where string, _ bool) []string { + var problems []string + if a.Source == "" { + problems = append(problems, where+": an archive needs somewhere to fetch it from") + } + if a.Path == "" { + problems = append(problems, where+": an archive needs somewhere to unpack into") + } + if !strings.HasPrefix(a.Digest, "sha256:") || len(a.Digest) != len("sha256:")+64 { + // Refused rather than fetched and trusted. Everything else pinned in this vocabulary is + // pinned by digest, and an archive that was not would be the one way in. + problems = append(problems, where+ + ": an archive is pinned by digest, as sha256:<64 hex characters>") + } + problems = append(problems, checkUnknown(where, "archive", a.Unknown, a.Knows)...) + return problems +} + +// Process is the module's own code, run on the machine, in one of three modes. +// +// The difference from Service is who owns the unit: a Service puts an EXISTING unit into a state +// and deliberately does not install one, which is right for software that ships its own. This is +// the mesh's own code — a bundle it built — so there is no unit until the mesh writes it, and +// nothing else will. +// +// The difference from Container is the hosting, and a module should not have to choose: what this +// says is what to run, and the machine's own supervisor is how. Code that genuinely needs a +// container's isolation declares a container and says so. +// +// **Three modes, matching a container's**, because they are the same thing at different cadences: +// stays up, runs once, runs on a schedule. A module's scheduled task, its run-once migration, its +// health check and its tool host are all this — and each being its own resource kind would be four +// entries in a vocabulary where every entry widens what a compromised control plane can express. +type Process struct { + ID string `json:"id"` + Type Type `json:"type"` + // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent + // is an older controller, and the module is then guessed from the id (ModuleGuess). + Module string `json:"module,omitempty"` + // Name is what the unit is called, and what an operator will see in the process table. + Name string `json:"name"` + // Source is where to fetch the bundle from, and Digest is what it must hash to. The same + // discipline as an archive, for the same reason: this crosses a network the mesh does not + // control. + Source string `json:"source"` + Digest string `json:"digest"` + // Run is the command, relative to the unpacked bundle. The first element is the program. + // + // **Named by the module, never inferred.** Guessing an entrypoint from which files exist makes + // a daemon change what it runs when somebody adds a file. + Run []string `json:"run"` + // Env and EnvFile are what it runs with. A file rather than inline values is how a credential + // reaches a daemon without passing through the declaration. + Env map[string]string `json:"env,omitempty"` + EnvFile []string `json:"env-file,omitempty"` + // User is who it runs as. Absent means root, which is what the mesh's own modules need for + // the things they do to a machine. + User string `json:"user,omitempty"` + // RestartOn names resources whose change means this must be restarted — the same rule a + // service follows, and for the same reason: a running process does not re-read its + // configuration, so replacing a file and finding the process already up leaves the machine + // behaving the way it did before while every check passes. + RestartOn []string `json:"restart-on,omitempty"` + + // RunOnce marks code the host runs to completion rather than leaves running: a migration, a + // seed, a first-boot step. What follows it is gated on it finishing, because a step that did + // not make the machine ready must not be followed by the thing that needed it. + RunOnce bool `json:"run-once,omitempty"` + + // Schedule runs it on a cadence — a five-field cron expression (novox/hq ADR 0053). The + // recurring twin of RunOnce: the same code, run again rather than left running. + // + // Exclusive with RunOnce and with RestartOn, for the same reason a container's is: something + // that runs once does not run on a schedule, and something that is not running cannot be + // restarted when a file changes. + Schedule string `json:"schedule,omitempty"` + + // Replaces names resources this declaration no longer declares that this process takes the + // place of (novox/hq issue 213). Such a resource is not removed with the other orphans, before + // anything is applied: it is removed only once this process is applied and still running a + // moment later, and kept when it is not. So the thing being replaced answers until the thing + // replacing it does — the controller moving from its container to a process is the case: the + // container removed first left nothing answering the mesh's verbs for as long as fetching, + // unpacking and starting the process took, and for ever if the process did not start. + // + // For a process that stays up; a step or a scheduled run is not running a moment later by + // design, so there is nothing to hand over to. + Replaces []string `json:"replaces,omitempty"` + + // Witness is how the node-engine judges a new build of this process, and restores the build + // before it when the new one is not healthy in bound (novox/hq to-be 45 §8): "lease" — the + // controller this machine started holds the controller's lease; "ping" — this machine's runtime + // answers the services protocol's PING; "none". Absent is the default for the process's name: + // the mesh's two core processes are judged, nothing else is. For a process that stays up. + Witness string `json:"witness,omitempty"` + + // NotReversible says why this build may not be rolled back, when it may not: the build before it + // would run against what this one changes — a migration it runs that the older build cannot read + // (to-be 45 §8, rule 8). A build so declared that is not healthy in bound is left running and said + // as urgent; the build before it is never started against the newer data. + NotReversible string `json:"not-reversible,omitempty"` + + // Health is how this resource is ready (novox/hq ADR 0240 rule 2, Phase B): one kind and its + // timing, judged by the node-engine beside liveness. Absent: judged alive or not, and nothing more. + Health *Health `json:"health,omitempty"` +} + +func (d *Process) Identity() string { return d.ID } +func (d *Process) Kind() Type { return TypeProcess } +func (d *Process) Target() string { return d.Name } + +// ProcessNameProblem says what is wrong with a process name, or nothing. +// +// The name becomes a unit name, a file under the unit directory and a directory under the mesh's +// own — the one removing the process deletes, whole (novox/hq ADR 0118). So a name that is not one +// plain path element is refused: with a separator it writes somewhere nobody meant, and "." or +// ".." IS the mesh's directory or its parent — removing a process named ".." would delete every +// bundle the mesh has, and more. One with a leading dash is read by the service manager as an +// option, not a unit. Exported because the removal checks the recorded name again: a record is +// what the host wrote, and a host of an older version wrote it under looser rules. +func ProcessNameProblem(name string) string { + switch { + case name == "": + return "a process needs a name, which is what its unit is called" + case strings.ContainsAny(name, "/ \t"): + return "a process name becomes a unit name, so it cannot contain a path separator or a space" + case name == "." || name == "..": + return fmt.Sprintf("a process name becomes a directory under the mesh's own, and %q would be "+ + "that directory or its parent", name) + case strings.HasPrefix(name, "-"): + return "a process name cannot begin with a dash: the service manager would read it as an option" + } + return "" +} + +func (d *Process) validate(where string, _ bool) []string { + problems := d.Health.problems(where, false, !d.RunOnce && d.Schedule == "") + if problem := ProcessNameProblem(d.Name); problem != "" { + problems = append(problems, where+": "+problem) + } + if d.Source == "" { + problems = append(problems, where+": a process needs somewhere to fetch its bundle from") + } + if !strings.HasPrefix(d.Digest, "sha256:") || len(d.Digest) != len("sha256:")+64 { + // The same rule an archive follows, and for the same reason: this crosses a network the + // mesh does not control, and a reference that can be made to point elsewhere is not one. + problems = append(problems, where+ + ": a process bundle is pinned by digest, as sha256:<64 hex characters>") + } + if len(d.Run) == 0 { + problems = append(problems, where+": a process needs to say what to run") + } + switch d.Witness { + case "", "lease", "ping", "none": + default: + problems = append(problems, fmt.Sprintf("%s: witness %q is not one this host keeps: lease, ping or none", + where, d.Witness)) + } + if d.Witness != "" && d.Witness != "none" && (d.RunOnce || d.Schedule != "") { + problems = append(problems, where+": a witness judges a process that stays up; a step or a "+ + "scheduled run is not running between its runs") + } + if strings.ContainsAny(d.NotReversible, "\n\r") { + problems = append(problems, where+": not-reversible is one line") + } + for _, part := range d.Run { + if part == "" { + problems = append(problems, where+": a process command has an empty element") + break + } + } + // **A newline cannot be represented in a unit's environment, so it is refused rather than + // mangled.** Everything else a unit file reinterprets — a percent specifier, whitespace + // splitting assignments, a quote ending one early — can be escaped. 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. + // + // Refused here, near whoever wrote it, rather than at the far end of a declaration. + for key, value := range d.Env { + if strings.ContainsAny(value, "\n\r") { + problems = append(problems, fmt.Sprintf( + "%s: the value of %s contains a line break, which cannot be written into a unit's "+ + "environment — what followed it would be read as a unit directive", where, key)) + } + if key == "" { + problems = append(problems, where+": an environment value with no name") + } + } + + if d.Schedule != "" { + if d.RunOnce { + problems = append(problems, where+ + ": a process runs once or on a schedule, not both") + } + if len(d.RestartOn) > 0 { + problems = append(problems, where+ + ": a scheduled process is not running between its fires, so there is nothing to "+ + "restart when something it reads changes") + } + if _, err := ParseCron(d.Schedule); err != nil { + problems = append(problems, where+": "+err.Error()) + } + } + if len(d.Replaces) > 0 && (d.RunOnce || d.Schedule != "") { + problems = append(problems, where+ + ": only a process that stays up replaces something — a step or a scheduled run is not "+ + "running a moment later, so what it replaced would be removed with nothing in its place, "+ + "or never") + } + for _, id := range d.Replaces { + switch { + case strings.TrimSpace(id) == "": + problems = append(problems, where+": replaces names an empty id") + case id == d.ID: + problems = append(problems, where+": a process cannot replace itself") + } + } + return problems +} + +// Service is a unit the host puts into a state. It does not install the unit. +// +// Two states, and they are orthogonal rather than one scale. A unit can be enabled and stopped +// (it will come back at boot), or disabled and running (started by hand, gone after a reboot). +// Folding them into one field would make the second expressible only by accident. +type Service struct { + ID string `json:"id"` + Type Type `json:"type"` + // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent + // is an older controller, and the module is then guessed from the id (ModuleGuess). + Module string `json:"module,omitempty"` + Unit string `json:"unit"` + // State is "running" or "stopped" — or absent, and then **the unit's lifecycle is the + // machine's; the mesh only reflects its triggers** (novox/hq ADR 0117). The uplink modules + // declare the machine's own network manager this way: the mesh writes into its configuration + // and needs it to read that again, and nothing more. Stated, the host would start the manager + // on a machine that uses another one — two managers fighting over the same links — and, when + // the module was unassigned, stop it: the machine's network, the channel the mesh itself + // arrives on, gone at the moment of a routine change. So a service without a state is never + // started, stopped, enabled or disabled, is reloaded or restarted only when a trigger changed + // and it is already running, and undeclared is simply forgotten. It says nothing unless it + // names a trigger, and it may not say boot or takes-over, which are both lifecycle. + State string `json:"state,omitempty"` + // Boot is "enabled" or "disabled" — whether the unit starts at boot. Optional: absent means + // the host asserts nothing about it and leaves whatever is there. + // + // Without this the host could start a unit and not make it survive a reboot, which is a + // declaration that reports success and stops being true at the next power cut. + Boot string `json:"boot,omitempty"` + + // Scope is whose service manager the unit belongs to: "system" (absent means system), or + // "user" — the operator account's own manager (novox/hq ADR 0177). A workstation's per-user + // daemons — a window manager's reload watcher, an audio mask, a memory guard — are units in + // that manager, and until this they had no form the mesh could send. A user-scoped unit names + // its User; the host talks to that account's manager and never starts a system unit by the + // same name. + Scope string `json:"scope,omitempty"` + // User is the account whose manager a user-scoped unit lives in. Required with scope "user", + // refused otherwise; a module names it ${machine:account} and never the person. + User string `json:"user,omitempty"` + // RestartOn names resources whose change means this service must be restarted. + // + // Because a running service does not re-read its configuration. Replace the file, find the + // service already running, do nothing, and the machine keeps behaving the way it did before — + // while every check passes, because the file is right and the service is up. That is not + // hypothetical: it is how a third node joining a mesh left the first two carrying a network + // that no longer existed, and every part of it reported success. + // + // This is declared state rather than a command. The declaration says the running service must + // reflect these files; the host works out that it does not and acts. A *command* to restart + // would be an action, and the link may not carry one (novox/hq ADR 0005) — so this is not a + // way around that rule, it is the shape the rule leaves. + RestartOn []string `json:"restart-on,omitempty"` + + // ReloadOn names resources whose change means this service must be reloaded — for a service + // that re-reads its configuration when told to, where a restart would stop what it runs: the + // container runtime, whose restart stops every container on the machine (novox/hq ADR 0102). + // A change that is also in RestartOn restarts it, which covers a reload. + ReloadOn []string `json:"reload-on,omitempty"` + + // TakesOver names the found tunnel this service replaces (novox/hq ADR 0105): before this unit + // is started, the named unit is stopped and disabled — never flushed — and its configuration + // file is kept like any held file — until the take is proven by a peer's handshake, and then + // retired, its original staying kept (novox/hq ADR 0119). Only on an adopted node, and only + // said by the controller, which knows the found tunnel's key is this node's own: without that, + // starting this unit on the found one's port would drop every peer's packets. + TakesOver *TakeOver `json:"takes-over,omitempty"` + + // Health is how this resource is ready (novox/hq ADR 0240 rule 2, Phase B): one kind and its + // timing, judged by the node-engine beside liveness. Absent: judged alive or not, and nothing more. + Health *Health `json:"health,omitempty"` +} + +// TakeOver is a found tunnel a service replaces: its interface, the unit that raised it, and its +// configuration file. +type TakeOver struct { + Interface string `json:"interface"` + Unit string `json:"unit"` + Config string `json:"config"` +} + +// Stateless reports whether the unit's lifecycle is the machine's, and the mesh only reflects the +// service's triggers (novox/hq ADR 0117). +func (s *Service) Stateless() bool { return s.State == "" } + +func (s *Service) Identity() string { return s.ID } +func (s *Service) Kind() Type { return TypeService } +func (s *Service) Target() string { return s.Unit } + +// ScopeSystem and ScopeUser are the two managers a unit may belong to (novox/hq ADR 0177). +const ( + ScopeSystem = "system" + ScopeUser = "user" +) + +// UserScoped is whether this unit lives in an account's own service manager. +func (s *Service) UserScoped() bool { return s.Scope == ScopeUser } + +func (s *Service) validate(where string, _ bool) []string { + problems := s.Health.problems(where, false, s.State == "running") + if s.Unit == "" { + problems = append(problems, where+": a service needs a unit") + } + switch s.Scope { + case "", ScopeSystem: + if s.User != "" { + problems = append(problems, where+": a system unit names no user; only a user-scoped unit does") + } + case ScopeUser: + if s.User == "" { + problems = append(problems, where+": a user-scoped unit names the account whose manager it lives in") + } + default: + problems = append(problems, fmt.Sprintf( + "%s: scope %q; a unit is in the \"system\" manager or the operator account's \"user\" one", where, s.Scope)) + } + switch { + case s.State == "running" || s.State == "stopped": + case s.State != "": + problems = append(problems, fmt.Sprintf( + "%s: state %q; a service is \"running\" or \"stopped\", or omits state to leave the "+ + "unit's lifecycle to the machine", where, s.State)) + case s.Boot != "" || s.TakesOver != nil: + problems = append(problems, where+": a service that omits state leaves the unit's lifecycle "+ + "to the machine, and boot and takes-over are both its lifecycle") + case len(s.RestartOn) == 0 && len(s.ReloadOn) == 0: + problems = append(problems, where+": a service that omits state leaves the unit's lifecycle "+ + "to the machine, and names no restart-on or reload-on — it declares nothing") + } + if s.Boot != "" && s.Boot != "enabled" && s.Boot != "disabled" { + problems = append(problems, fmt.Sprintf( + "%s: boot %q; a service is \"enabled\" or \"disabled\" at boot, or omits it to "+ + "leave the machine's own setting alone", where, s.Boot)) + } + if t := s.TakesOver; t != nil { + switch { + case t.Unit == "" || t.Config == "" || t.Interface == "": + problems = append(problems, where+": takes-over names the found tunnel's interface, unit "+ + "and config, and this leaves one out") + case t.Unit == s.Unit: + problems = append(problems, fmt.Sprintf("%s: takes-over names %s, which is this service's own unit", + where, t.Unit)) + case s.State != "running" && s.State != "": + problems = append(problems, where+": a service that takes over a tunnel is running — stopping "+ + "the found one for a service that will not run would leave the peers with nothing") + } + } + return problems +} + +// What a file is written into (novox/hq ADR 0102): a JSON object whose keys the mesh sets, or a +// text file in which the mesh owns one marked block of lines (novox/hq issue 128). +const ( + IntoJSON = "json" + IntoBlock = "block" +) + +// The lines that delimit the mesh's region in a file written into a block, each followed by the +// resource's id. Exact lines, never patterns: another tool's `# BEGIN …` block in the same file is +// that tool's, and a marker that merely resembled the mesh's must not be taken for it. +const ( + BlockBegin = "# BEGIN mesh " + BlockEnd = "# END mesh " +) + +// Where a file written into a block has its region added, when it has none yet. +const ( + AtStart = "start" + AtEnd = "end" +) + +// BlockMarkers are the two lines, without their line ends, that delimit a resource's region. +func BlockMarkers(id string) (begin, end string) { + return BlockBegin + id, BlockEnd + id +} + +// Opening is a port reachable on an adopted node, from where, and on which path. +// +// **From** is everywhere or mesh — the private network, by its interface. **Path** is incoming, +// for something listening on the machine, or forwarded, for a published container port: the found +// firewall sees a published port after the runtime has translated it, so a forwarded opening names +// the container's own port in To as well as the machine's in Port. +type Opening struct { + ID string `json:"id"` + Type Type `json:"type"` + // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent + // is an older controller, and the module is then guessed from the id (ModuleGuess). + Module string `json:"module,omitempty"` + Port int `json:"port"` + Protocol string `json:"protocol"` + From string `json:"from"` + Path string `json:"path"` + To int `json:"to,omitempty"` +} + +// Where an opening admits from, and the path it is on. +const ( + FromEverywhere = "everywhere" + FromMesh = "mesh" + PathIncoming = "incoming" + PathForwarded = "forwarded" +) + +func (o *Opening) Identity() string { return o.ID } +func (o *Opening) Kind() Type { return TypeOpening } + +func (o *Opening) Target() string { + if o.Path == PathForwarded { + return fmt.Sprintf("%s/%d forwarded to %d from %s", o.Protocol, o.Port, o.To, o.From) + } + return fmt.Sprintf("%s/%d %s from %s", o.Protocol, o.Port, o.Path, o.From) +} + +func (o *Opening) validate(where string, _ bool) []string { + var problems []string + if o.Port < 1 || o.Port > 65535 { + problems = append(problems, fmt.Sprintf("%s: an opening's port is 1-65535, not %d", where, o.Port)) + } + if o.Protocol != "tcp" && o.Protocol != "udp" { + problems = append(problems, fmt.Sprintf("%s: an opening is tcp or udp, not %q", where, o.Protocol)) + } + if o.From != FromEverywhere && o.From != FromMesh { + problems = append(problems, fmt.Sprintf( + "%s: an opening is from %q or %q, not %q", where, FromEverywhere, FromMesh, o.From)) + } + switch o.Path { + case PathIncoming: + if o.To != 0 { + problems = append(problems, where+ + ": an incoming opening names no container port; only a forwarded one does") + } + case PathForwarded: + if o.To < 1 || o.To > 65535 { + problems = append(problems, where+ + ": a forwarded opening names the container's port it reaches, as to, 1-65535") + } + default: + problems = append(problems, fmt.Sprintf( + "%s: an opening's path is %q or %q, not %q", where, PathIncoming, PathForwarded, o.Path)) + } + if !strings.HasPrefix(o.ID, AdoptionPrefix) { + problems = append(problems, fmt.Sprintf( + "%s: an opening is the mesh's own, so its id starts %q", where, AdoptionPrefix)) + } + return problems +} + +// Package is a package that should be present. +// +// Present is the whole of what it asserts, never a version: version is the package manager's +// business and the mesh does not hold a second opinion about it. +type Package struct { + ID string `json:"id"` + Type Type `json:"type"` + // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent + // is an older controller, and the module is then guessed from the id (ModuleGuess). + Module string `json:"module,omitempty"` + Package string `json:"package"` + // Absent declares that the package is NOT installed (novox/hq ADR 0180): the host removes it + // when it is, and leaves a machine that never had it alone. For the one case a module replaces + // software the machine was found with and the operator has decided it does not come back — the + // firewall front end a converged machine's filter module retired. Nothing to undo when the + // declaration drops it: the host does not install what a declaration stopped saying is absent. + Absent bool `json:"absent,omitempty"` +} + +func (p *Package) Identity() string { return p.ID } +func (p *Package) Kind() Type { return TypePackage } +func (p *Package) Target() string { return p.Package } + +func (p *Package) validate(where string, _ bool) []string { + if p.Package == "" { + return []string{where + ": a package needs a package name"} + } + return nil +} + +// Container is a container that should be running, from an image pinned by digest. +type Container struct { + ID string `json:"id"` + Type Type `json:"type"` + // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent + // is an older controller, and the module is then guessed from the id (ModuleGuess). + Module string `json:"module,omitempty"` + Name string `json:"name"` + // Image is pinned by digest (novox/hq ADR 0006) — a tag moves and a digest does not. + Image string `json:"image"` + Env map[string]string `json:"env,omitempty"` + // EnvFile names files the runtime reads environment from, in order. + // + // **Because a secret may not travel in Env.** A declaration reaches a node over the broker, + // and `env` is plain text in it — so a password there is a password the broker sees, which is + // the transitive trust refused everywhere else (novox/hq ADR 0004). A sealed file reaches the + // machine unreadable, the host writes it, and the runtime reads it: the mesh never holds it + // and neither does anything between them. + // + // It is also simply how third-party software takes credentials. Nothing that ships in a + // container will read a path the mesh invented; every one of them reads its environment. + EnvFile []string `json:"env-file,omitempty"` + Ports []string `json:"ports,omitempty"` + Volumes []string `json:"volumes,omitempty"` + Args []string `json:"args,omitempty"` + // Names this container can reach, as `name:address`. + // + // **Because a container does not inherit the machine's names.** It gets its own `/etc/hosts` + // holding only its own hostname, so every internal name the mesh wrote for this machine is + // invisible to the thing the machine is running. That was hit for real: a database client on + // one node could not resolve another node, on a mesh where both names were correct and + // present on both machines. + // + // **A file rather than a resolver, which is the decision the mesh already made about names** + // and this extends rather than overturns: it works on every runtime, needs no package, and + // has no failure mode of its own. A resolver becomes necessary when names are wanted that are + // not one-per-node — service names, wildcards — and that is still not true. + // + // Set by the mesh, not by a module: which machines exist is a fact about the mesh, and a + // module that listed them would be a module that goes stale when one joins. + Hosts []string `json:"hosts,omitempty"` + + // Network is the container's network, passed to the runtime unchanged. + // + // Needed because the control plane must reach the store and the broker on the machine it was + // raised on, before there is any mesh to arrange that. The alternative was publishing ports + // and guessing an address that works from inside a container, which is the same thing with a + // worse failure mode. + Network string `json:"network,omitempty"` + + // Dns is the resolvers this container asks, passed to the runtime unchanged. + // + // **Because some software refuses to run behind the runtime's forwarding resolver.** A mail + // server's admin demands a DNSSEC-validating resolver, and the runtime's own (127.0.0.11) + // forwards to whatever the machine has — so a module that ships its own validating resolver + // must be able to point its other containers at it. Addresses, not names: the runtime's flag + // takes only addresses, which is also why IP below exists — the resolver has to be somewhere + // its siblings can name before any of them can resolve anything. + Dns []string `json:"dns,omitempty"` + + // Capabilities are the Linux capabilities this container is granted beyond the runtime's + // default set, by name (novox/hq ADR 0170): a holder's runtime that changes the machine's packet + // filter asks for NET_ADMIN. Exactly these, named in the spec so a change recreates the + // container; a privileged container stays undeclarable. + Capabilities []string `json:"capabilities,omitempty"` + + // Logging names where the runtime sends this container's output: "journald" sends it to the + // machine's journal, under the container's name, where what reads the machine's logs — its + // intrusion prevention first of all (novox/hq ADR 0179) — can read it the way it reads the + // machine's own services. Empty keeps the runtime's default, which is a file of the runtime's + // own that nothing but the runtime reads. Part of the spec: a container that logs elsewhere + // is a different container, and the runtime cannot change a running one's driver. + Logging string `json:"logging,omitempty"` + + // Networks are networks this container also joins once created, by name — a found network a + // per-machine setting keeps for a taken container (novox/hq ADR 0163, rule 4), so a + // neighbour that resolves it there keeps resolving it until the neighbour is taken too. + // Joined after creation, because a runtime starts a container on one network; part of the + // container's spec, so a network kept or let go recreates it. + Networks []string `json:"networks,omitempty"` + + // IP is this container's address on its network, passed to the runtime unchanged. + // + // Only meaningful on a user-defined network, and refused by the runtime elsewhere. Exists for + // exactly one shape: a container others must reach *before* name resolution works — a + // module's own DNS resolver being the case that forced it (see Dns). + IP string `json:"ip,omitempty"` + + // RestartOn names resources whose change means this container must be recreated — the same + // field a service has, for the same reason (novox/hq 04-ISSUES/009). A container reads a + // mounted file once at start; a changed file leaves the running process holding the old value, + // while every check passes because the file on disk is right. The host recreates the container + // when one of these resources changed this pass, even if the spec matches. + // + // What a running container reads at creation — its env-files, and a file mounted into it + // directly — is part of its spec by content since novox/hq 04-ISSUES/103, and needs no naming + // here. A directory mounted into it is NOT looked inside, not even for files the host wrote + // there: whether a service reads such a file once or watches it live is the service's, and + // RestartOn is how a module says "once, at start" — a config the host renders under the + // module's state directory, a step whose result it consumes. On a run-once step it means *run + // again*: a step that fetches a fact from a provider names the binding it reads, and is run + // again when the provider moved (novox/hq ADR 0099). + RestartOn []string `json:"restart-on,omitempty"` + + // RunOnce marks a container the host runs to completion rather than leaves running: a step, + // not a service (novox/hq ADR 0052). The host runs it, requires it to exit 0, and records that + // it did — and because the declaration is applied in order and a failed step halts the apply, + // whatever is declared after a run-once container starts only once the step has finished. It is + // how a module runs its own code at first boot — seed a store, migrate, health-gate — under its + // own account (ADR 0047), before the container that depends on it. The record that it ran is + // the digest of this declaration, so a re-apply does not re-run it unless the declaration + // changed. + RunOnce bool `json:"run-once,omitempty"` + + // Schedule marks a container the host runs on a recurring cadence — a five-field cron + // expression (novox/hq ADR 0053). It is the recurring twin of RunOnce: the same container, run + // to completion, but again and again on the clock rather than once. Installing it does not run + // it — the schedule is state that is present, like a running service, so the apply is current as + // soon as it is recorded and does NOT gate what follows. The host's scheduler fires the + // container when the cron is due, re-established from this declaration each apply because the + // declaration is the source of truth (ADR 0018). A run that exits non-zero is recorded and never + // fails the apply or flips the node's state; a run still going when the next is due is skipped + // rather than stacked. It is exclusive with RunOnce and with restart-on: a container runs once + // and gates, runs on a cadence, or stays up — never two of these. + Schedule string `json:"schedule,omitempty"` + + // WhileStopped names resources of the same module — containers — that must be held still for + // the duration of this step's run (novox/hq ADR 0189). The host stops each before the run and + // starts each again after it, **whatever the step did**: a step that failed must leave the + // service running, because the one real risk of this field is a window that never closes. + // + // **For the work a service cannot have done underneath it.** The artifact store's collector + // walks the storage and requires every writer stopped; a run-once step runs beside containers + // and a scheduled one is the same container again, so until this there was no way for a module + // to say it. The predecessor said it with a shell script, which is how the mesh inherited a + // store that has never collected anything. + // + // **Its own module's containers, and only on a schedule.** A module that could quiesce a + // neighbour could stop the mesh. And at apply time the host already has a window — the + // declaration is applied in order and a run-once step gates what follows — so a one-time + // offline job says *before*, not *instead of*; a recurring window is the case order cannot + // express, and the only one this serves. + WhileStopped []string `json:"while-stopped,omitempty"` + + // Health is how this resource is ready (novox/hq ADR 0240 rule 2, Phase B): one kind and its + // timing, judged by the node-engine beside liveness. Absent: judged alive or not, and nothing more. + Health *Health `json:"health,omitempty"` + + // Busy is how this container says it holds work a replacement would lose (novox/hq ADR 0335): asked + // before it is replaced, and while it says busy it is left running, within the bound. Absent: never + // busy, and replaced at once, as before. + Busy *Busy `json:"busy,omitempty"` + + // RunAs is who the container runs as, resolved by the controller from the module's identity and the + // image's declared interface (novox/hq ADR 0325 §2, §3): told by environment (the image reads PUID and + // PGID, say) or by the runtime's own user option. Part of the spec, so a change recreates it. Absent: the + // container runs as its image says. Its key is `identity`, the manifest's word; the field is not, because + // a resource's Identity is its id. + RunAs *ContainerIdentity `json:"identity,omitempty"` +} + +// The ways an image is told who to run as (novox/hq ADR 0325 §2). +const ( + IdentityByEnv = "env" + IdentityByRun = "run" +) + +// ContainerIdentity is who a container runs as, and how its image is told. +type ContainerIdentity struct { + // Mode is IdentityByEnv or IdentityByRun. + Mode string `json:"mode"` + // UserEnv and GroupEnv are the variables the image reads the user's and the group's number from, for + // IdentityByEnv: "PUID", "PGID". + UserEnv string `json:"user_env,omitempty"` + GroupEnv string `json:"group_env,omitempty"` + UID int `json:"uid"` + GID int `json:"gid"` +} + +// UnmarshalJSON reads an identity as strictly as a resource is read. +func (i *ContainerIdentity) UnmarshalJSON(raw []byte) error { + type plain ContainerIdentity + var p plain + if err := strictDecode(raw, &p); err != nil { + return fmt.Errorf("a container's identity: %w", err) + } + *i = ContainerIdentity(p) + return nil +} + +// envName is a variable's name as a runtime takes it. +var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +func (i *ContainerIdentity) problems(where string, env map[string]string) []string { + if i == nil { + return nil + } + var problems []string + switch i.Mode { + case IdentityByEnv: + if !envName.MatchString(i.UserEnv) || (i.GroupEnv != "" && !envName.MatchString(i.GroupEnv)) { + problems = append(problems, fmt.Sprintf("%s: an identity told by environment names the variables "+ + "it is told in, and %q and %q are not both names", where, i.UserEnv, i.GroupEnv)) + } + for _, name := range []string{i.UserEnv, i.GroupEnv} { + if _, set := env[name]; set && name != "" { + problems = append(problems, fmt.Sprintf("%s: env sets %s, which the identity sets; one value "+ + "for one variable", where, name)) + } + } + case IdentityByRun: + if i.UserEnv != "" || i.GroupEnv != "" { + problems = append(problems, where+": an identity told by the runtime's user option names no variable") + } + default: + problems = append(problems, fmt.Sprintf("%s: an identity is told by %q or %q, not %q", where, + IdentityByEnv, IdentityByRun, i.Mode)) + } + // Never root (novox/hq ADR 0325 §11): refused here, so nothing of the declaration is made or started. + if i.UID <= 0 || i.GID <= 0 { + problems = append(problems, fmt.Sprintf("%s: the identity is uid %d, gid %d, and a module's identity is "+ + "never root, nor a number below it (novox/hq ADR 0325 §11)", where, i.UID, i.GID)) + } + return problems +} + +func (c *Container) Identity() string { return c.ID } +func (c *Container) Kind() Type { return TypeContainer } +func (c *Container) Target() string { return c.Name } + +func (c *Container) validate(where string, _ bool) []string { + problems := c.Health.problems(where, true, !c.RunOnce && c.Schedule == "") + problems = append(problems, c.Busy.problems(where, !c.RunOnce && c.Schedule == "")...) + if c.Name == "" { + problems = append(problems, where+": a container needs a name") + } + // A run-once step may name what it reads under restart-on. For a step the word means *run + // again*: what a container reads is part of its digest, so a step whose named resource changed + // is a different step and runs again (novox/hq ADR 0099). Not refused. + // A container runs once and gates, on a cadence, or stays up — never two of these + // (novox/hq ADR 0053). run-once and schedule are the two "runs to completion" lifecycles and + // contradict each other, and a scheduled step does not stay running to be brought back by + // restart-on either. Refused here on arrival, as the control plane refuses it near its author. + if c.RunOnce && c.Schedule != "" { + problems = append(problems, where+": a container is run-once or scheduled, not both; "+ + "run-once runs once and gates what follows, a schedule runs it again on a cadence") + } + if c.Schedule != "" && len(c.RestartOn) > 0 { + problems = append(problems, where+": a scheduled container cannot also declare restart-on; "+ + "it runs to completion on its cadence rather than staying running to be restarted") + } + if c.Schedule != "" { + if _, err := ParseCron(c.Schedule); err != nil { + problems = append(problems, where+": "+err.Error()) + } + } + // A maintenance window belongs to a recurring step (novox/hq ADR 0189). Refused on anything + // else here, where the field is; that it names containers of the same module, and not itself, + // is judged against the whole declaration (see whileStoppedNames). + if len(c.WhileStopped) > 0 && c.Schedule == "" { + problems = append(problems, where+": while-stopped needs a schedule; at apply the host "+ + "already has a window — the declaration is applied in order and a run-once step gates "+ + "what follows — so a one-time offline job is declared before what it works on") + } + for _, id := range c.WhileStopped { + if id == c.ID { + problems = append(problems, where+": while-stopped names "+strconv.Quote(id)+ + ", which is this step itself") + } + } + // The runtime's flags take addresses, and a name here would be handed to it verbatim and + // refused at create — after the old container was already removed. Refused on arrival instead. + for _, d := range c.Dns { + if net.ParseIP(d) == nil { + problems = append(problems, where+": dns "+strconv.Quote(d)+" is not an address; "+ + "the runtime's resolver flag takes only addresses") + } + } + if c.IP != "" { + if net.ParseIP(c.IP) == nil { + problems = append(problems, where+": ip "+strconv.Quote(c.IP)+" is not an address") + } + if c.Network == "" { + problems = append(problems, where+": an ip needs a network; the runtime refuses a "+ + "static address anywhere but a user-defined one") + } + } + for _, cap := range c.Capabilities { + if !capabilityName.MatchString(cap) { + problems = append(problems, where+": capabilities names "+strconv.Quote(cap)+", which is not a "+ + "capability's name (CAP_NET_ADMIN or NET_ADMIN)") + } + } + if c.Logging != "" && c.Logging != "journald" { + problems = append(problems, where+": logging is "+strconv.Quote(c.Logging)+", and the only place a "+ + "container's output can be sent besides the runtime's own file is \"journald\"") + } + for _, n := range c.Networks { + problems = append(problems, (&Network{Name: n}).validate(where+": networks", false)...) + if n == c.Network { + problems = append(problems, where+": networks names "+n+", which is already the container's network") + } + } + if len(c.Networks) > 0 && (c.RunOnce || c.Schedule != "") { + problems = append(problems, where+": networks is for a container that keeps running; a step "+ + "runs and exits, and joins nothing afterwards") + } + problems = append(problems, c.RunAs.problems(where+": container "+c.Name, c.Env)...) + return append(problems, checkImage(where, c.Image)...) +} + +// Action runs something the bundle declared, and the host never learns what it means. +type Action struct { + ID string `json:"id"` + Type Type `json:"type"` + // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent + // is an older controller, and the module is then guessed from the id (ModuleGuess). + Module string `json:"module,omitempty"` + Command []string `json:"command"` + // Verify is not optional and is not a courtesy. It is the read-back AND the idempotency + // check: the host does not know what a database is, so "is it already there" is a question + // only the declaration can ask (novox/hq ADR 0005). + Verify []string `json:"verify"` + // In names a container to run inside. Empty means the machine itself. + In string `json:"in,omitempty"` +} + +func (a *Action) Identity() string { return a.ID } +func (a *Action) Kind() Type { return TypeAction } + +func (a *Action) Target() string { + target := strings.Join(a.Command, " ") + if a.In != "" { + return "in " + a.In + ": " + target + } + return target +} + +func (a *Action) validate(where string, allowActions bool) []string { + // The bound the whole security argument rests on (novox/hq ADR 0005). + if !allowActions { + return []string{where + + ": an action arrived over the link, and the link may not carry one. The host " + + "applies declarations of known shape; a command to run is not one. A bundle may " + + "carry an action because it arrives with the binary — anyone able to put a " + + "hostile action there could have put it in the host itself"} + } + var problems []string + if len(a.Command) == 0 { + problems = append(problems, where+": an action needs a command") + } + if len(a.Verify) == 0 { + problems = append(problems, where+ + ": an action needs a verify. An action that runs and reports success without "+ + "reading anything back is the fault this host exists to prevent, and verify is "+ + "also how the host knows whether the action is already done") + } + return problems +} + +// newOf returns an empty resource of a kind, or nil if the kind is unknown. +// +// This is the whole vocabulary, in one place. A kind that is not here cannot be declared. +func newOf(t Type) Resource { + switch t { + case TypeDirectory: + return &Directory{} + case TypeFile: + return &File{} + case TypeService: + return &Service{} + case TypePackage: + return &Package{} + case TypeContainer: + return &Container{} + case TypeAction: + return &Action{} + case TypeNetwork: + return &Network{} + case TypeUser: + return &User{} + case TypeArchive: + return &Archive{} + case TypeAccess: + return &Access{} + case TypeProcess: + return &Process{} + case TypeOpening: + return &Opening{} + } + return nil +} + +// Vocabulary is every kind this host speaks. +func Vocabulary() []Type { + return []Type{ + TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDirectory, TypeFile, + TypeNetwork, TypeOpening, TypePackage, TypeProcess, TypeService, TypeUser, + } +} + +// Declaration is what a machine should be, in the order it should be made so. +type Declaration struct { + Version int + // For names the node this is meant for. A host with an identity refuses one addressed + // elsewhere; a host without one — the first node, applying the bundle it carries — has + // nothing to check against. + For string + // Resources, in the order they are applied. The host does not sort them: ordering is a + // decision, and deciding is not what the host does (novox/hq ADR 0005). + Resources []Resource + + // Adoption says this node is adopted, and which of its modules have been taken. Nil is a + // converged node — which is every node the mesh raised before adoption existed, and so the + // only form an older controller ever sends (novox/hq ADR 0100). + Adoption *Adoption + + // Sequence orders this declaration against every other the mesh has sent this node: each + // send is one higher than the last, assigned under the control plane's hold on the node + // (novox/hq 04-ISSUES/107). Zero is a declaration that carries no order — every one an older + // controller sent, and the bundle genesis applies — and a host makes no ordering claim about + // one of those. + // + // **The one property a declaration needs that its signature does not give it.** A signature + // says the mesh sent this; it cannot say the mesh sent it AFTER the one the host is holding. + // Before this, "older" was inferred from arrival within a batch and a 750ms window, and a + // backlog longer than the batch, or a slow broker, applied a declaration the mesh had already + // superseded. + Sequence int64 + + // Epoch is the controller's lease epoch this declaration was sent under (novox/hq to-be 45 §6): + // the revision at which the sending controller took the lease. A controller that lost its lease + // and goes on sending sends an older epoch than the holder's, and the node-engine refuses what + // is older than what it applied. Zero is a declaration from a controller without a lease — every + // one sent before the lease existed — and carries no claim. + // + // Inside what is signed, beside the sequence, so a message cannot be given a newer epoch than + // the controller gave it. + Epoch int64 + + // LeftOut names the modules of this machine's set the mesh left out of this declaration, + // because a setting stored for one cannot compose with its definition (novox/hq ADR 0163, + // rule 6). A machine is told everything or nothing about what it IS told; this is what it is + // not told, said. The host keeps what it holds for a left-out module and touches none of + // what it wrote for it — its resources are absent from the declaration, and absence would + // otherwise read as removal. + LeftOut []string + + // Generation is the assignment generation this declaration was composed from: the controller's + // counter, raised in the same transaction as every change to what is assigned where (novox/hq + // issue 234). The sequence orders arrival and cannot tell a later send that carries an older view + // of the assignments; this can. A node-engine refuses a declaration composed from a generation + // older than the highest it applied — unless it is a put-back — because applying it would + // undeclare what the mesh still assigns. Zero is a declaration from a controller that claims none, + // and is applied as before. + Generation int64 + + // PutBack says this declaration is a gate putting a machine back (novox/hq issue 234): it carries + // the generation of what it puts back, which may be older than what the machine applied, and is + // not refused for it. + PutBack bool + + // Identities is the identity of every module assigned to this machine (novox/hq ADR 0328 §4): an account + // and a group `mesh-` with the numbers the controller allocated once for the module, the same on + // every machine. The engine makes each before any resource is applied, and removes what it made of one no + // longer declared. Absent is a controller older than ADR 0325. + Identities []Identity + + // Groups is the group of every library this machine is reached by (novox/hq ADR 0328 §3): `mesh-` + // with the number the controller allocated once, mesh-wide. The engine makes each, and removes what it made of + // one no longer declared. Absent is a controller older than ADR 0328. + Groups []Group + + // Libraries is every library this machine is reached by (ADR 0328 §1, §6, §9): its root here, its group, whether + // this is its home, whether the operator's warrant to take it was carried out, the sub-paths its modules ask for, + // and the tool runner's account to put in its group (§11). Absent is a controller older than ADR 0328. + Libraries []Library + + // Filled is the paths of the files the provisioner fills on this machine (novox/hq ADR 0349 §4, §5): each a + // file this declaration declares, which says who owns it at runtime. The provisioner's client has the + // node-engine write only these. Absent is a controller that hands no file to the provisioner, and every + // file is written from the declaration as before. + Filled []string + + // Provisioner is the account the provisioner's client runs as on this machine, by name (novox/hq ADR 0349 §4): + // the only account whose write of a filled file the node-engine's local write socket accepts. Absent is a + // controller that names none, and every such write is refused. + Provisioner string + + // Builds is the build id of every module this declaration names, by module (novox/hq ADR 0429 §4, issue 1132). + Builds map[string]string + + // declared is what each resource says of its module, by id; nil on a declaration built by hand. + declared map[string]string +} + +// ModuleGuess is the module a resource id names when the declaration does not say: everything before its last dot. +// +// The mesh composes a module's resource ids as `.`, and **a module's name may contain a dot** +// (`novox.be` is one on this mesh) while a resource's own id never does, so the owner is everything before the *last* +// dot. False for what the mesh declares in its own right: the foundation's resources carry no dot, and the +// adoption's are named for the mesh. This is the guess an older controller's declaration is read by; a declaration +// that says `module` is read by ModuleOfID, which never guesses for a resource that says (issue 1132). +func ModuleGuess(id string) (string, bool) { + if strings.HasPrefix(id, AdoptionPrefix) { + return "", false + } + at := strings.LastIndex(id, ".") + if at <= 0 { + return "", false + } + return id[:at], true +} + +// ModuleOfID is the module a resource of this declaration belongs to: the module it says (novox/hq ADR 0429 §4, +// issue 1132), and otherwise the guess from its id (ModuleGuess), so a declaration from an older controller reads as +// it always did. False for what belongs to no module. +func (d *Declaration) ModuleOfID(id string) (string, bool) { + if d != nil { + if d.declared == nil { + for _, r := range d.Resources { + if r.Identity() == id && r.DeclaredModule() != "" { + return r.DeclaredModule(), true + } + } + } else if m, ok := d.declared[id]; ok { + return m, true + } + } + return ModuleGuess(id) +} + +// indexModules keeps what each resource says of its module, so ModuleOfID is a lookup. +func (d *Declaration) indexModules() { + d.declared = map[string]string{} + for _, r := range d.Resources { + if m := r.DeclaredModule(); m != "" { + d.declared[r.Identity()] = m + } + } +} + +// checkProvisioner refuses a provisioner client's account that is no account name, or is root: the account whose +// writes the node-engine accepts must be one that could not write the file without it. +func checkProvisioner(name string) []string { + switch { + case name == "": + return nil + case !accountName.MatchString(name): + return []string{fmt.Sprintf("provisioner_account %q is not an account name", name)} + case name == "root": + return []string{"provisioner_account is root; the provisioner's client runs as an account of its own"} + } + return nil +} + +// Fills reports whether the provisioner fills the file at path on this machine. +func (d *Declaration) Fills(path string) bool { return slices.Contains(d.Filled, path) } + +// checkFilled refuses a filled path that is not a declared file saying who owns it at runtime, or that is named +// twice or empty. Every file declared at the path must say it: the provisioner writes the path, and a file there +// the mesh writes some other way would be a second writer of it (ADR 0349 §5). +func checkFilled(filled []string, resources []Resource) []string { + var problems []string + files := map[string][]*File{} + for _, r := range resources { + if f, ok := r.(*File); ok { + files[f.Path] = append(files[f.Path], f) + } + } + seen := map[string]bool{} + for _, path := range filled { + switch { + case path == "": + problems = append(problems, "filled_files names a file with no path") + continue + case seen[path]: + problems = append(problems, fmt.Sprintf("filled_files names %q twice", path)) + continue + } + seen[path] = true + if len(files[path]) == 0 { + problems = append(problems, fmt.Sprintf("filled_files names %q, and this declaration declares no "+ + "file there", path)) + continue + } + for _, f := range files[path] { + if f.OwnerAtRuntime == "" { + problems = append(problems, fmt.Sprintf("resource %q: the provisioner fills %q, and a file it "+ + "fills says who owns it at runtime with owner-at-runtime", f.ID, path)) + } + } + } + return problems +} + +// Identity is one module's identity, as the controller allocated it (ADR 0328 §4): its user `mesh-` with +// uid, its own group of the same name and number, its primary group gid (its own, or its first library's group), +// and the groups of its other libraries as added groups. +type Identity struct { + Module string `json:"module"` + Name string `json:"name"` + UID int `json:"uid"` + GID int `json:"gid"` + Groups []int `json:"groups,omitempty"` + // Runner is the tool runner's account, put in the module's own group (novox/hq ADR 0334 §2) when its tools run + // on this machine. Absent for a module that declares no tools here, and from a controller older than ADR 0334. + Runner string `json:"runner,omitempty"` +} + +// Group is one library's group, as the controller allocated it (ADR 0328 §3). +type Group struct { + Name string `json:"name"` + GID int `json:"gid"` +} + +// GroupPrefix starts every library group's name, as IdentityPrefix starts every module's. +const GroupPrefix = "mesh-" + +// LibraryAccessPrefix starts the access a library's wait and its take act name: `library:`. +const LibraryAccessPrefix = "library:" + +// Library is one library as this machine sees it (ADR 0328). +type Library struct { + Library string `json:"library"` + // Root is the library's root on this machine: on its home the data itself, elsewhere the mount point the mounts + // module mounts it at. + Root string `json:"root"` + Group string `json:"group"` + GID int `json:"gid"` + // Home is that the library's data lives on this machine: only there does the engine keep it. + Home bool `json:"home"` + // Taken is the controller saying the operator's warrant to take it was carried out (§7). + Taken bool `json:"taken,omitempty"` + // Paths are the sub-paths its modules ask for, relative to the root. + Paths []string `json:"paths,omitempty"` + // Runner is the tool runner's account, put in the library's group (§11). Absent when no module's tools use it. + Runner string `json:"runner,omitempty"` +} + +// checkLibraries judges the groups and libraries on arrival: every library's group declared, named for it, never +// root's number; paths that stay below the root; a runner that is an account's name; and no two roots that nest +// (§13). +func checkLibraries(groups []Group, libs []Library) []string { + var problems []string + byName := map[string]int{} + numbers := map[int]string{} + for _, g := range groups { + where := fmt.Sprintf("group %q", g.Name) + if !strings.HasPrefix(g.Name, GroupPrefix) || !accountName.MatchString(g.Name) { + problems = append(problems, fmt.Sprintf("%s: a library's group is named %s", where, GroupPrefix)) + } + if g.GID <= 0 { + problems = append(problems, fmt.Sprintf("%s: gid %d, and a library's group is never root, nor a number "+ + "below it (novox/hq ADR 0328 §15); nothing of this declaration was made", where, g.GID)) + } + if _, twice := byName[g.Name]; twice { + problems = append(problems, where+": declared twice") + } + if other, used := numbers[g.GID]; used { + problems = append(problems, fmt.Sprintf("%s: gid %d is %s's too", where, g.GID, other)) + } + byName[g.Name], numbers[g.GID] = g.GID, g.Name + } + seen := map[string]bool{} + for _, l := range libs { + where := fmt.Sprintf("library %q", l.Library) + switch { + case l.Library == "" || strings.ContainsAny(l.Library, "/ \t\n"): + problems = append(problems, where+": a library is named by one word") + case seen[l.Library]: + problems = append(problems, where+": declared twice") + } + seen[l.Library] = true + if !filepath.IsAbs(l.Root) || filepath.Clean(l.Root) != l.Root || l.Root == "/" { + problems = append(problems, fmt.Sprintf("%s: its root %q is not an absolute, clean path below /", where, + l.Root)) + } + if gid, ok := byName[l.Group]; !ok || gid != l.GID { + problems = append(problems, fmt.Sprintf("%s: its group %s (gid %d) is not one this declaration makes", + where, l.Group, l.GID)) + } + if l.Group != GroupPrefix+l.Library { + problems = append(problems, fmt.Sprintf("%s: its group is %s%s, and %s is not", where, GroupPrefix, + l.Library, l.Group)) + } + for _, p := range l.Paths { + if p == "" || filepath.IsAbs(p) || filepath.Clean(p) != p || p == ".." || strings.HasPrefix(p, "../") { + problems = append(problems, fmt.Sprintf("%s: the path %q is not a clean path below its root", where, p)) + } + } + if l.Runner != "" && !accountName.MatchString(l.Runner) { + problems = append(problems, fmt.Sprintf("%s: its runner %q is not an account's name", where, l.Runner)) + } + } + for i, a := range libs { + for _, b := range libs[i+1:] { + if a.Root != "" && b.Root != "" && (a.Root == b.Root || strings.HasPrefix(a.Root, b.Root+"/") || + strings.HasPrefix(b.Root, a.Root+"/")) { + problems = append(problems, fmt.Sprintf("library %q: its root and library %q's nest, and libraries "+ + "never overlap (novox/hq ADR 0328 §13)", a.Library, b.Library)) + } + } + } + return problems +} + +// IdentityPrefix starts every module identity's name. +const IdentityPrefix = "mesh-" + +// checkIdentities judges the identities on arrival: each named for its module, never root, and no name or number +// twice — two modules with one account would write as each other. +func checkIdentities(ids []Identity, groups []Group) []string { + var problems []string + names, uids := map[string]bool{}, map[int]string{} + libraryGroups := map[int]bool{} + for _, g := range groups { + libraryGroups[g.GID] = true + } + for _, id := range ids { + where := fmt.Sprintf("identity %q", id.Name) + if id.Module == "" || id.Name != IdentityPrefix+id.Module || !accountName.MatchString(id.Name) { + problems = append(problems, fmt.Sprintf("%s: a module's identity is named %s, and this one is "+ + "for the module %q", where, IdentityPrefix, id.Module)) + } + if id.UID <= 0 || id.GID <= 0 { + problems = append(problems, fmt.Sprintf("%s: uid %d, gid %d, and a module's identity is never root, "+ + "nor a number below it (novox/hq ADR 0328 §15); nothing of this declaration was made", where, + id.UID, id.GID)) + } + if names[id.Name] { + problems = append(problems, where+": declared twice") + } + if other, used := uids[id.UID]; used { + problems = append(problems, fmt.Sprintf("%s: uid %d is %s's too", where, id.UID, other)) + } + // Its primary group is its own (gid == uid) or a library's this declaration makes (ADR 0328 §4); so is + // every added group. A module's own number is never a library group's. + if id.GID != id.UID && !libraryGroups[id.GID] { + problems = append(problems, fmt.Sprintf("%s: its primary group gid %d is neither its own (%d) nor a "+ + "library's this declaration makes", where, id.GID, id.UID)) + } + if libraryGroups[id.UID] { + problems = append(problems, fmt.Sprintf("%s: uid %d is a library group's number too", where, id.UID)) + } + for _, g := range id.Groups { + if !libraryGroups[g] { + problems = append(problems, fmt.Sprintf("%s: its added group gid %d is not a library's this "+ + "declaration makes", where, g)) + } + } + if id.Runner != "" && !accountName.MatchString(id.Runner) { + problems = append(problems, fmt.Sprintf("%s: its runner %q is not an account's name", where, id.Runner)) + } + names[id.Name], uids[id.UID] = true, id.Name + } + return problems +} + +// LeftOutModuleOf says which left-out module a recorded resource belongs to, if any: its id is the +// module's name, a dot, and the module's own id for it. A module's name may contain a dot, so the +// longest left-out name that prefixes the id wins; a false match keeps a thing an apply would +// otherwise remove, which is the conservative mistake. +func (d *Declaration) LeftOutModuleOf(id string) (string, bool) { + best := "" + for _, m := range d.LeftOut { + if strings.HasPrefix(id, m+".") && len(m) > len(best) { + best = m + } + } + return best, best != "" +} + +// Adoption is a node's mode, as the controller records it: the node is adopted, and these are +// the modules taken on it so far (novox/hq ADR 0100). +// +// **Authoritative, and only ever stated by the controller.** A host does not work out whether it +// is adopted; it is told, in every declaration, so a host restarted from the declaration it kept +// is in the same mode it was in before. +// +// Untaken names, per module assigned here and not yet taken, the ids of its resources. Any kind +// may be listed: a file, a directory, a service's unit or a container can already be on the +// machine, and an action run inside a held container reaches what was found (novox/hq ADR 0103); +// the host decides per kind what can be held. The host cannot split a resource id into its +// module, because module names may contain dots, so the controller says which ids belong to which +// module rather than leaving the host to guess. +type Adoption struct { + Taken []string `json:"taken"` + Untaken map[string][]string `json:"untaken,omitempty"` +} + +// capabilityName is what a Linux capability is called: upper case, underscores, an optional CAP_ +// prefix. The runtime accepts either spelling. +var capabilityName = regexp.MustCompile(`^(CAP_)?[A-Z][A-Z0-9_]*$`) + +// AdoptionPrefix is the id prefix of what the mesh itself declares because a node is adopted — +// its openings and its guard. Nothing under it belongs to a module, so none of it is ever held. +const AdoptionPrefix = "adoption." + +// UntakenModuleOf says which untaken module declares a resource, if any. +func (a *Adoption) UntakenModuleOf(id string) (string, bool) { + if a == nil { + return "", false + } + for module, ids := range a.Untaken { + if slices.Contains(ids, id) { + return module, true + } + } + return "", false +} + +// checkAdoption holds what an adoption says against the resources beside it. Every problem is a +// refusal: a host that misread which module is untaken would replace a predecessor's service the +// operator never took. +func checkAdoption(a *Adoption, resources []Resource, allowActions bool) []string { + if a == nil { + return nil + } + if allowActions { + // The bundle is carried with the binary and raises a foundation before any mesh exists. + // Whether a node is adopted is the controller's record, and a bundle that claimed it would + // be the host deciding its own mode (novox/hq ADR 0100). + return []string{"a carried bundle says the node is adopted, and only the mesh can say " + + "that: a node's mode is the controller's record, sent in every declaration"} + } + kinds := map[string]Type{} + for _, r := range resources { + kinds[r.Identity()] = r.Kind() + } + var problems []string + for _, module := range a.Taken { + if _, both := a.Untaken[module]; both { + problems = append(problems, fmt.Sprintf( + "adoption: the module %q is said to be both taken and untaken", module)) + } + } + owner := map[string]string{} + modules := make([]string, 0, len(a.Untaken)) + for module := range a.Untaken { + modules = append(modules, module) + } + sort.Strings(modules) + for _, module := range modules { + for _, id := range a.Untaken[module] { + if strings.HasPrefix(id, AdoptionPrefix) { + problems = append(problems, fmt.Sprintf( + "adoption: %q is the mesh's own and belongs to no module, so it cannot be untaken", id)) + continue + } + if first, twice := owner[id]; twice { + problems = append(problems, fmt.Sprintf( + "adoption: %q is said to belong to both %q and %q", id, first, module)) + continue + } + owner[id] = module + if _, declared := kinds[id]; !declared { + problems = append(problems, fmt.Sprintf( + "adoption: %q of the untaken module %q is not in this declaration", id, module)) + } + } + } + return problems +} + +// RefusalError refuses a whole declaration, naming every problem at once. +// +// Every problem rather than the first: a caller fixing one at a time learns the next only by +// running again, and a declaration is generated, so a person reading this is debugging the +// generator. +type RefusalError struct { + Problems []string +} + +func (e *RefusalError) Error() string { + return fmt.Sprintf( + "this declaration is refused, and none of it was applied:\n - %s\n\n"+ + "A host that applied the parts it understood would leave a machine that looks "+ + "configured and is not.", + strings.Join(e.Problems, "\n - ")) +} + +// Parse reads a declaration that arrived over the link, and refuses anything it does not fully +// understand — including any action, which the link may not carry (novox/hq ADR 0005). +func Parse(raw []byte) (*Declaration, error) { return parse(raw, false) } + +// ParseTrusted reads a declaration from a source already as privileged as the host itself: the +// bundle it carries, or a file handed to it by someone who is running it as root. +// +// Actions are permitted here and nowhere else. The asymmetry is deliberate and is the entire +// content of ADR 0005: refusing actions from the bundle buys nothing, because whoever built the +// bundle built the binary; refusing them from the link buys the bound on what a compromised +// control plane can express. +func ParseTrusted(raw []byte) (*Declaration, error) { return parse(raw, true) } + +// envelope is the declaration with its resources still unread. +// +// Two passes, because which fields are legal depends on the "type" inside each resource. The +// first pass takes the envelope and each resource's bytes; the second decodes each one into +// the struct for its kind, strictly. +type envelope struct { + Version int `json:"declaration"` + For string `json:"for,omitempty"` + Adoption *Adoption `json:"adoption,omitempty"` + // OwnsNothing is the control plane saying, explicitly, that this node's declaration is empty + // on purpose — it owns nothing the mesh put there (novox/hq issue 127). Without it an empty + // resources list is refused as a likely mistake; with it the node applies the empty + // declaration and drops what it last held. The two are distinguished because a truncated or + // mis-composed body arrives as empty too, and a host that could not tell them apart would let + // a bug quietly strip a machine. + OwnsNothing bool `json:"owns_nothing,omitempty"` + Resources []json.RawMessage `json:"resources"` + // Sequence is optional on the wire, so a controller that does not send one is still + // understood: absent reads as zero, which is "no ordering claimed" rather than "first". + Sequence int64 `json:"sequence,omitempty"` + // Epoch is optional on the wire as the sequence is: absent is a controller without a lease. + // **An older host refuses this key**, decoding strictly; a controller sends it only to a host + // whose reports carry a report sequence, which a host that reads it does. + Epoch int64 `json:"epoch,omitempty"` + // LeftOut is optional on the wire too, and absent when nothing was left out (ADR 0163). + LeftOut []string `json:"left_out,omitempty"` + // Generation and PutBack are optional on the wire too (novox/hq issue 234): absent is a controller + // that claims no generation. **An older host refuses these keys**, decoding strictly; a controller + // sends them only to a host whose reports say `reads_generation`. + Generation int64 `json:"generation,omitempty"` + PutBack bool `json:"put_back,omitempty"` + // Identities is optional on the wire too (novox/hq ADR 0325 §3, ADR 0328 §4). **An older host refuses this key**; a + // controller sends it only to a host whose health says link.IdentityContract. + Identities []Identity `json:"identities,omitempty"` + // Groups and Libraries are optional too (novox/hq ADR 0328). **An older host refuses these keys**; a controller + // sends them only to a host whose health says link.LibraryContract. + Groups []Group `json:"groups,omitempty"` + Libraries []Library `json:"libraries,omitempty"` + // Filled is optional too (novox/hq ADR 0349 §5), as is a file's `owner-at-runtime`. **An older host refuses + // these keys**; a controller sends them only to a host whose health says link.OwnershipContract. + Filled []string `json:"filled_files,omitempty"` + // Provisioner is optional too (novox/hq ADR 0349 §4). **An older host refuses this key**; a controller sends it + // only to a host whose health says link.ProvisionerContract. + Provisioner string `json:"provisioner_account,omitempty"` + // Builds is optional too (novox/hq ADR 0429 §4, issue 1132): the build id of each module the declaration names, and + // every resource's `module`. **An older host refuses these keys**; a controller sends them only to a host whose + // reports say `reads_modules`. + Builds map[string]string `json:"builds,omitempty"` +} + +func parse(raw []byte, allowActions bool) (*Declaration, error) { + var env envelope + if err := strictDecode(raw, &env); err != nil { + return nil, &RefusalError{Problems: []string{"not a declaration: " + err.Error()}} + } + + if env.Version != Version { + // Everything below assumes the vocabulary, so there is nothing further to say. + return nil, &RefusalError{Problems: []string{fmt.Sprintf( + "declaration version %d; this host speaks version %d. Refused whole rather than "+ + "partly, so a newer vocabulary is never half-applied by an older host", + env.Version, Version)}} + } + + d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence, + Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack, + Identities: env.Identities, Groups: env.Groups, Libraries: env.Libraries, Filled: env.Filled, + Provisioner: env.Provisioner, Builds: env.Builds} + problems := checkIdentities(env.Identities, env.Groups) + problems = append(problems, checkProvisioner(env.Provisioner)...) + problems = append(problems, checkLibraries(env.Groups, env.Libraries)...) + if env.Generation < 0 { + // Below zero would read as "none claimed" and pass every refusal of an older generation. + problems = append(problems, fmt.Sprintf("an assignment generation below zero (%d) is not one the "+ + "mesh assigns", env.Generation)) + } + if env.PutBack && allowActions { + // A put-back is a gate's, sent by the mesh; a carried bundle puts nothing back. + problems = append(problems, "a carried bundle says it is a put-back, and only the mesh's gate can say that") + } + if env.Sequence < 0 || env.Epoch < 0 { + // Below zero is no order any controller assigns, and read as "none claimed" it would let the + // declaration past every refusal of what is older. + problems = append(problems, fmt.Sprintf("an order below zero (epoch %d, sequence %d) is not "+ + "one the mesh assigns", env.Epoch, env.Sequence)) + } + if len(env.LeftOut) > 0 && allowActions { + // The bundle is carried with the binary and leaves nothing out: which module a setting + // stopped composing for is the mesh's record (ADR 0163). + problems = append(problems, "a carried bundle says modules were left out, and only the "+ + "mesh can say that") + } + for _, m := range env.LeftOut { + if strings.TrimSpace(m) == "" { + problems = append(problems, "left_out names a module with no name") + } + } + + if len(env.Resources) == 0 && !env.OwnsNothing { + problems = append(problems, "no resources. An empty declaration is a mistake, not a "+ + "machine with nothing on it — say so with an explicit empty list if that is meant") + } + + seen := map[string]int{} + for i, rawResource := range env.Resources { + // Peek, leniently. This pass only needs to know which struct to decode into; reading + // strictly here would report an unknown field before knowing which fields are known. + var head struct { + ID string `json:"id"` + Type Type `json:"type"` + } + _ = json.Unmarshal(rawResource, &head) + + where := fmt.Sprintf("resource %d", i) + if head.ID != "" { + where = fmt.Sprintf("resource %q", head.ID) + } + + if head.ID == "" { + problems = append(problems, where+": no id. Identity is what lets the host know "+ + "this is the same resource it applied last time") + } else if first, ok := seen[head.ID]; ok { + problems = append(problems, fmt.Sprintf( + "%s: id already used by resource %d. Two resources with one identity cannot "+ + "both be tracked", where, first)) + } else { + seen[head.ID] = i + } + + resource := newOf(head.Type) + if resource == nil { + problems = append(problems, fmt.Sprintf( + "%s: unknown type %q. This host understands %s", where, head.Type, vocabulary())) + continue + } + + // A field the kind does not have is refused, and the struct is what says so — there + // is no list of exclusions for anyone to keep current. + // + // Asked separately rather than taken from the decoder's error, because the decoder + // stops at the first unknown field and this record promises every problem at once. A + // caller fixing one field at a time learns the next only by running again. + if unknown := unknownFields(rawResource, resource); len(unknown) > 0 { + for _, field := range unknown { + problems = append(problems, fmt.Sprintf( + "%s: a %s does not use %q, and it is set. Refused rather than ignored", + where, head.Type, field)) + } + continue + } + if err := json.Unmarshal(rawResource, resource); err != nil { + problems = append(problems, fmt.Sprintf("%s: %s", where, err)) + continue + } + + problems = append(problems, resource.validate(where, allowActions)...) + d.Resources = append(d.Resources, resource) + } + problems = append(problems, checkWhileStopped(d.Resources)...) + problems = append(problems, checkAdoption(env.Adoption, d.Resources, allowActions)...) + problems = append(problems, checkReplaces(d.Resources)...) + problems = append(problems, checkFilled(env.Filled, d.Resources)...) + if env.Adoption == nil { + for _, r := range d.Resources { + if r.Kind() == TypeOpening { + // On a converged node the mesh's own filter admits what is declared, and the + // found firewall is retired; an opening there would be a rule in a firewall the + // mesh has disabled (novox/hq ADR 0100). + problems = append(problems, fmt.Sprintf( + "resource %q: an opening is for an adopted node, and this declaration does not "+ + "say the node is adopted", r.Identity())) + } + if svc, ok := r.(*Service); ok && svc.TakesOver != nil { + // A tunnel is taken over on an adopted node, where what is found is kept: on a + // converged one there is nothing found to take over, and stopping a unit the + // mesh did not declare would be the host deciding (novox/hq ADR 0105). + problems = append(problems, fmt.Sprintf( + "resource %q: taking over a tunnel is for an adopted node, and this declaration "+ + "does not say the node is adopted", r.Identity())) + } + } + } + + if len(problems) > 0 { + return nil, &RefusalError{Problems: problems} + } + d.indexModules() + return d, nil +} + +// checkWhileStopped judges a maintenance window against the whole declaration (novox/hq ADR 0189). +// +// A step may hold still only a container that is **here** — in this same declaration, which is to +// say on this machine and placed by the mesh. That is what makes it the module's own: a node's +// declaration carries one module's resources beside another's, so the id must also be a container +// and not a file or a directory, which there would be nothing to stop. +// +// Refused on arrival rather than discovered at the first fire. A window that names something the +// host cannot stop is a window that opens at 03:00 and reports nothing until somebody reads a log. +func checkWhileStopped(resources []Resource) []string { + containers := map[string]bool{} + for _, r := range resources { + if r.Kind() == TypeContainer { + containers[r.Identity()] = true + } + } + var problems []string + for _, r := range resources { + c, ok := r.(*Container) + if !ok { + continue + } + for _, id := range c.WhileStopped { + if containers[id] { + continue + } + problems = append(problems, fmt.Sprintf( + "resource %q: while-stopped names %q, and this declaration has no container by "+ + "that id. A step may hold still only a container placed on this machine "+ + "beside it", c.ID, id)) + } + } + return problems +} + +// accountName is what an identity's or an access user's name may be: the portable account name the user tools +// take, optionally ending in `$`, never starting with a dash — it is handed to them as an argument. +var accountName = regexp.MustCompile(`^[A-Za-z0-9_.][A-Za-z0-9_.-]*\$?$`) + +func strictDecode(raw []byte, into any) error { + // DisallowUnknownFields is the whole point rather than strictness for its own sake: a + // field the host does not know is a thing the control plane believes it asked for. + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(into); err != nil { + return err + } + // And **nothing after it**. A decoder reads one value and stops, so a file holding a + // declaration followed by anything at all — a truncated rewrite, two declarations + // concatenated, a stray line from whatever wrote the file — parses as the first value and the + // rest is never looked at. + // + // That is the same fault this host refuses everywhere else, in its quietest form: the machine + // applies something, reports success, and what it applied is not what the file says. Found + // when a test harness appended a line to a bundle by accident and every apply kept working. + if _, err := dec.Token(); err != io.EOF { + return fmt.Errorf( + "there is more in this file after the declaration ends. Refused whole: a file with " + + "something after it may be a truncated rewrite or two declarations run together, " + + "and applying the first would be applying something nobody wrote") + } + return nil +} + +// unknownFields names every JSON key the kind's struct has no field for. +// +// The struct's own tags are the list of what is legal, so adding a field to a kind is the +// whole of adding it — there is nowhere else that has to agree. +func unknownFields(raw []byte, into Resource) []string { + var got map[string]json.RawMessage + if err := json.Unmarshal(raw, &got); err != nil { + return nil // not an object; the decode below will say so properly + } + + known := map[string]bool{} + t := reflect.TypeOf(into).Elem() + for i := 0; i < t.NumField(); i++ { + name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",") + if name != "" && name != "-" { + known[name] = true + } + } + + var unknown []string + for field := range got { + if !known[field] { + unknown = append(unknown, field) + } + } + sort.Strings(unknown) + return unknown +} + +func checkMode(where, mode string) []string { + if mode == "" { + return nil + } + if len(mode) != 4 || mode[0] != '0' { + return []string{fmt.Sprintf( + "%s: mode %q; write it as four octal digits such as \"0644\", so it means the "+ + "same thing here as it does in the manifest it came from", where, mode)} + } + for _, c := range mode[1:] { + if c < '0' || c > '7' { + return []string{fmt.Sprintf("%s: mode %q is not octal", where, mode)} + } + } + return nil +} + +// checkImage insists on content, not on a name. +// +// A tag moves and a digest does not. The bundle's whole claim is that what it names is exact +// (novox/hq ADR 0006), and a bundle pinning `postgres:17` pins nothing — it names whatever +// that tag points at on the day the host happens to run. +// +// **Two forms say something exact, and only one of them needs a registry.** `name@sha256:…` is a +// manifest digest, which a registry assigns on push. A bare `sha256:…` is an image the machine +// already holds, addressed by the digest of its own configuration — equally immutable, equally +// unforgeable, and requiring nothing to have served it. +// +// That second form is what a first machine needs. The mesh's own control plane exists in no public +// registry and never will: it is built from source, and until this mesh has a registry of its own +// there is nowhere to push it to and therefore no manifest digest to name it by. Insisting on one +// would mean a registry has to exist before the thing that lets a mesh have a registry can start — +// which is not a pin, it is a dependency the rule accidentally created. A machine that built an +// image, or was handed one, can name it by what it is. +func checkImage(where, image string) []string { + if image == "" { + return []string{where + ": a container needs an image"} + } + // An image this machine holds, named by the digest of its own configuration. + if strings.HasPrefix(image, "sha256:") { + if len(image) != len("sha256:")+64 { + return []string{fmt.Sprintf( + "%s: image id %q is not a sha256 digest", where, image)} + } + return nil + } + name, digest, found := strings.Cut(image, "@") + if !found || name == "" { + return []string{fmt.Sprintf( + "%s: image %q is not pinned. Write it as name@sha256:… — or as sha256:… for an image "+ + "this machine already holds. A tag moves, and a bundle that pinned a tag would "+ + "not be pinned", where, image)} + } + if !strings.HasPrefix(digest, "sha256:") || len(digest) != len("sha256:")+64 { + return []string{fmt.Sprintf( + "%s: image digest %q is not a sha256 digest", where, digest)} + } + return nil +} + +func vocabulary() string { + kinds := Vocabulary() + names := make([]string, 0, len(kinds)) + for _, t := range kinds { + names = append(names, string(t)) + } + sort.Strings(names) + return strings.Join(names, ", ") +} + +// ParseFileTrusted reads a declaration from a file somebody handed this host. +// +// The same as ParseTrusted, and it allows whole-line `//` comments first. A pinned, hand-authored +// artefact that nobody can annotate is one nobody can review — the foundation bundle is mostly +// explanation of why each digest is what it is. +// +// **Only for a file, never for the link.** Over the link the format stays exactly JSON, because +// a wire format with a second thing to strip is a wire format with a second thing to disagree +// about. +// +// It exists because there were two readers for one file: the bundle stripped comments and `apply` +// did not, so the example bundle in this repository could be built into a binary and not applied +// from disk. The failure was `invalid character '/'`, which names the symptom and not the cause. +func ParseFileTrusted(raw []byte) (*Declaration, error) { + return ParseTrusted(stripComments(raw)) +} + +// stripComments removes whole lines beginning with `//`. +// +// Only whole lines: anything cleverer would need to know where strings begin and end, and a +// parser that half-understands its input is worse than one that does not try. A `//` inside a +// value — every image reference has one — is untouched. +func stripComments(raw []byte) []byte { + var kept []string + for _, line := range strings.Split(string(raw), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "//") { + continue + } + kept = append(kept, line) + } + return []byte(strings.Join(kept, "\n")) +} + +// checkReplaces refuses a `replaces` naming something this same declaration still declares, or +// named by two processes (novox/hq issue 213). What is replaced is what the declaration no longer +// says — a resource still declared is applied, not handed over, and one handed to two replacements +// would go when the first of them came up, whatever became of the second. +func checkReplaces(resources []Resource) []string { + declared := map[string]bool{} + for _, r := range resources { + declared[r.Identity()] = true + } + var problems []string + by := map[string]string{} + for _, r := range resources { + p, ok := r.(*Process) + if !ok { + continue + } + for _, id := range p.Replaces { + if declared[id] { + problems = append(problems, fmt.Sprintf( + "resource %q: replaces %q, which this declaration still declares — a process "+ + "replaces what the declaration no longer says", p.ID, id)) + } + if other, taken := by[id]; taken && other != p.ID { + problems = append(problems, fmt.Sprintf( + "resource %q: replaces %q, which %q replaces too — one thing has one replacement", + p.ID, id, other)) + } + by[id] = p.ID + } + } + return problems +} diff --git a/go/declaration/declaration_test.go b/go/declaration/declaration_test.go new file mode 100644 index 0000000..36063d4 --- /dev/null +++ b/go/declaration/declaration_test.go @@ -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) + } + }) + } +} diff --git a/go/declaration/emitted_check_test.go b/go/declaration/emitted_check_test.go new file mode 100644 index 0000000..948dca5 --- /dev/null +++ b/go/declaration/emitted_check_test.go @@ -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()) + } +} diff --git a/go/declaration/health.go b/go/declaration/health.go new file mode 100644 index 0000000..d6395bf --- /dev/null +++ b/go/declaration/health.go @@ -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 +} diff --git a/go/declaration/health_test.go b/go/declaration/health_test.go new file mode 100644 index 0000000..000d53a --- /dev/null +++ b/go/declaration/health_test.go @@ -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) + } + } +} diff --git a/go/declaration/identity_test.go b/go/declaration/identity_test.go new file mode 100644 index 0000000..e97225f --- /dev/null +++ b/go/declaration/identity_test.go @@ -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-": `"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) + } + } +} diff --git a/go/declaration/into_test.go b/go/declaration/into_test.go new file mode 100644 index 0000000..531e015 --- /dev/null +++ b/go/declaration/into_test.go @@ -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) + } +} diff --git a/go/declaration/module_test.go b/go/declaration/module_test.go new file mode 100644 index 0000000..cdd4b02 --- /dev/null +++ b/go/declaration/module_test.go @@ -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) + } + } +} diff --git a/go/declaration/order_test.go b/go/declaration/order_test.go new file mode 100644 index 0000000..4d2c93d --- /dev/null +++ b/go/declaration/order_test.go @@ -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) + } +} diff --git a/go/declaration/ownership_test.go b/go/declaration/ownership_test.go new file mode 100644 index 0000000..76b0674 --- /dev/null +++ b/go/declaration/ownership_test.go @@ -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) + } + } +} diff --git a/go/declaration/process_test.go b/go/declaration/process_test.go new file mode 100644 index 0000000..9a7c7e9 --- /dev/null +++ b/go/declaration/process_test.go @@ -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) + } +} diff --git a/go/declaration/provisioner_test.go b/go/declaration/provisioner_test.go new file mode 100644 index 0000000..233aa40 --- /dev/null +++ b/go/declaration/provisioner_test.go @@ -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) + } + } +} diff --git a/go/declaration/replaces_test.go b/go/declaration/replaces_test.go new file mode 100644 index 0000000..c470505 --- /dev/null +++ b/go/declaration/replaces_test.go @@ -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") + } +} diff --git a/go/declaration/root_test.go b/go/declaration/root_test.go new file mode 100644 index 0000000..cd2908c --- /dev/null +++ b/go/declaration/root_test.go @@ -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]) + } + } +} diff --git a/go/declaration/schedule_declaration_test.go b/go/declaration/schedule_declaration_test.go new file mode 100644 index 0000000..d87d1fb --- /dev/null +++ b/go/declaration/schedule_declaration_test.go @@ -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) + } +} diff --git a/go/declaration/stateless_test.go b/go/declaration/stateless_test.go new file mode 100644 index 0000000..3338908 --- /dev/null +++ b/go/declaration/stateless_test.go @@ -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") + } +} diff --git a/go/declaration/trusted_test.go b/go/declaration/trusted_test.go new file mode 100644 index 0000000..c533334 --- /dev/null +++ b/go/declaration/trusted_test.go @@ -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") + } +} diff --git a/go/declaration/userscope_test.go b/go/declaration/userscope_test.go new file mode 100644 index 0000000..fdce635 --- /dev/null +++ b/go/declaration/userscope_test.go @@ -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") + } +}