The three shapes the substrate bootstrap needs and the host did not have. Until now tier 1 could not be raised at all -- step 0 is a package, step 1 a container, steps 2 and 3 actions -- so every line of the tier 1 and 2 designs was unbuildable. package -- present, never upgraded, never uninstalled. Removal is "forgotten", not "removed": the host cannot know what else needs the package, uninstalling a container runtime because a declaration changed would stop every container on the node, and the machine may have had it before the mesh saw it. Reporting it removed would claim an effect the host declined to have. container -- identified by a label carrying a digest of the declaration that made it. Comparing every field the runtime reports cannot be done reliably: a runtime normalises, defaults and reorders what it is given, and that is indistinguishable from real drift. There is no in-place update; a container's configuration is fixed at creation, so any change is a replacement, and saying so beats a partial update that leaves the running thing half-declared. This is the one shape the host removes, because it is the one the host created. action -- bundle-only, per ADR 0047. Verify is mandatory and does double duty: it is the idempotency check as well as the read-back. The host does not know what a database is, so "is it already there" is a question only the declaration can ask. `in` runs the action inside a named container, which steps 2 and 3 need. Parse now refuses actions; ParseTrusted permits them. The safe path is the default and the permissive one has to be named. The bundle and a local file handed to a root process use ParseTrusted; the link will use Parse. Also replaced the per-type "fields this type ignores" check with a field-set diff stated as what each type USES. The negative form needs every type revisited whenever a field is added, and the one nobody revisits silently accepts a field it will never read. Images must be pinned by digest (ADR 0046). A bundle naming a tag pins nothing. Verified against a real machine, not only fakes: an action ran and was idempotent on the second apply; an action that exits zero and satisfies nothing fails the apply; a real container was created, labelled, replaced when its declaration changed, exec'd into, and removed; a real package query round- tripped. Each new test was also confirmed to fail on an injected fault -- five injections, each breaking exactly its own test. One existing test changed: a vanished unit is now reported "forgotten" rather than "removed", which is what actually happened.
348 lines
12 KiB
Go
348 lines
12 KiB
Go
// Package declaration is what the host is told a machine should be.
|
|
//
|
|
// Data, never instructions. The vocabulary is finite, versioned, and anything outside it
|
|
// refuses the whole declaration rather than being skipped — a host that applied most of what
|
|
// it was sent and reported success is a node that looks configured and is not
|
|
// (novox/hq ADR 0043).
|
|
package declaration
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Version is the vocabulary this host speaks. A declaration naming any other version is
|
|
// refused: an older host handed a newer vocabulary must not quietly do half of it.
|
|
const Version = 1
|
|
|
|
// Type names a kind of resource. Every addition widens what a compromised control plane can
|
|
// express, so the list is a security artefact and grows deliberately.
|
|
type Type string
|
|
|
|
const (
|
|
TypeDirectory Type = "directory"
|
|
TypeFile Type = "file"
|
|
TypeService Type = "service"
|
|
TypePackage Type = "package"
|
|
TypeContainer Type = "container"
|
|
TypeAction Type = "action"
|
|
)
|
|
|
|
// uses names the fields each type consumes. A field set on a type that is not listed here as
|
|
// using it is refused.
|
|
//
|
|
// Stated as what each type USES rather than as what it ignores. The negative form needs every
|
|
// type revisited whenever a field is added, and the one nobody revisits is the one that
|
|
// silently accepts a field it will never read — which is the whole fault this package exists
|
|
// to prevent.
|
|
var uses = map[Type]map[string]bool{
|
|
TypeDirectory: {"path": true, "mode": true},
|
|
TypeFile: {"path": true, "content": true, "mode": true},
|
|
TypeService: {"unit": true, "state": true},
|
|
TypePackage: {"package": true},
|
|
TypeContainer: {"image": true, "name": true, "env": true, "ports": true, "volumes": true, "args": true},
|
|
TypeAction: {"command": true, "verify": true, "in": true},
|
|
}
|
|
|
|
// Resource is one thing that should be true of the machine.
|
|
//
|
|
// Identity is a name the control plane keeps stable across declarations, not a position and
|
|
// not a hash of the content. It is what lets the store say *this is the same resource I
|
|
// applied last time*, which is what makes removal possible at all.
|
|
type Resource struct {
|
|
ID string `json:"id"`
|
|
Type Type `json:"type"`
|
|
|
|
// Path, for a file or directory.
|
|
Path string `json:"path,omitempty"`
|
|
// Content, for a file. Literal; the host renders nothing.
|
|
Content string `json:"content,omitempty"`
|
|
// Mode, for a file or directory, as an octal string such as "0644".
|
|
Mode string `json:"mode,omitempty"`
|
|
|
|
// Unit and State, for a service. State is "running" or "stopped".
|
|
Unit string `json:"unit,omitempty"`
|
|
State string `json:"state,omitempty"`
|
|
|
|
// Package, for a package: the name this machine's own package manager knows it by.
|
|
Package string `json:"package,omitempty"`
|
|
|
|
// Image and Name, for a container. Image is pinned by digest (novox/hq ADR 0046) — a tag
|
|
// moves and a digest does not, and a bundle that pinned a tag would not be pinned.
|
|
Image string `json:"image,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
// Env, Ports, Volumes and Args, for a container. Literal; the host renders nothing.
|
|
Env map[string]string `json:"env,omitempty"`
|
|
Ports []string `json:"ports,omitempty"`
|
|
Volumes []string `json:"volumes,omitempty"`
|
|
Args []string `json:"args,omitempty"`
|
|
|
|
// Command, Verify and In, for an action.
|
|
//
|
|
// Verify is not optional and is not a courtesy. An action that runs and reports success
|
|
// without reading anything back is the fault this repository exists to name, and an action
|
|
// is the easiest place in the vocabulary to reintroduce it (novox/hq ADR 0047).
|
|
Command []string `json:"command,omitempty"`
|
|
Verify []string `json:"verify,omitempty"`
|
|
// In names a container to run the action inside, when the thing being acted on lives
|
|
// there. Empty means the machine itself.
|
|
In string `json:"in,omitempty"`
|
|
}
|
|
|
|
// Declaration is what a machine should be, in the order it should be made so.
|
|
type Declaration struct {
|
|
Version int `json:"declaration"`
|
|
// 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 `json:"for,omitempty"`
|
|
// 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 0037).
|
|
Resources []Resource `json:"resources"`
|
|
}
|
|
|
|
// 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 0047).
|
|
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 0047: 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) }
|
|
|
|
func parse(raw []byte, allowActions bool) (*Declaration, 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()
|
|
|
|
var d Declaration
|
|
if err := dec.Decode(&d); err != nil {
|
|
return nil, &RefusalError{Problems: []string{"not a declaration: " + err.Error()}}
|
|
}
|
|
|
|
if problems := validate(&d, allowActions); len(problems) > 0 {
|
|
return nil, &RefusalError{Problems: problems}
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
func validate(d *Declaration, allowActions bool) []string {
|
|
var problems []string
|
|
|
|
if d.Version != Version {
|
|
problems = append(problems, 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",
|
|
d.Version, Version))
|
|
// Everything below assumes the vocabulary, so there is nothing further to say.
|
|
return problems
|
|
}
|
|
|
|
if len(d.Resources) == 0 {
|
|
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, r := range d.Resources {
|
|
where := fmt.Sprintf("resource %d", i)
|
|
if r.ID != "" {
|
|
where = fmt.Sprintf("resource %q", r.ID)
|
|
}
|
|
|
|
if r.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[r.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[r.ID] = i
|
|
}
|
|
|
|
if _, ok := uses[r.Type]; !ok {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: unknown type %q. This host understands %s", where, r.Type, vocabulary()))
|
|
continue
|
|
}
|
|
problems = append(problems, validateResource(where, r, allowActions)...)
|
|
}
|
|
return problems
|
|
}
|
|
|
|
func validateResource(where string, r Resource, allowActions bool) []string {
|
|
problems := unusedBy(where, r)
|
|
|
|
switch r.Type {
|
|
case TypeDirectory:
|
|
if r.Path == "" {
|
|
problems = append(problems, where+": a directory needs a path")
|
|
}
|
|
problems = append(problems, checkMode(where, r.Mode)...)
|
|
|
|
case TypeFile:
|
|
if r.Path == "" {
|
|
problems = append(problems, where+": a file needs a path")
|
|
}
|
|
problems = append(problems, checkMode(where, r.Mode)...)
|
|
|
|
case TypeService:
|
|
if r.Unit == "" {
|
|
problems = append(problems, where+": a service needs a unit")
|
|
}
|
|
if r.State != "running" && r.State != "stopped" {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: state %q; a service is \"running\" or \"stopped\"", where, r.State))
|
|
}
|
|
|
|
case TypePackage:
|
|
if r.Package == "" {
|
|
problems = append(problems, where+": a package needs a package name")
|
|
}
|
|
|
|
case TypeContainer:
|
|
if r.Name == "" {
|
|
problems = append(problems, where+": a container needs a name")
|
|
}
|
|
problems = append(problems, checkImage(where, r.Image)...)
|
|
|
|
case TypeAction:
|
|
// The whole reason an action is bounded rather than forbidden (novox/hq ADR 0047).
|
|
if !allowActions {
|
|
problems = append(problems, 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")
|
|
break
|
|
}
|
|
if len(r.Command) == 0 {
|
|
problems = append(problems, where+": an action needs a command")
|
|
}
|
|
if len(r.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
|
|
}
|
|
|
|
// checkImage insists on a digest.
|
|
//
|
|
// A tag moves and a digest does not. The bundle's whole claim is that what it names is exact
|
|
// (novox/hq ADR 0046), and a bundle pinning `postgres:17` pins nothing — it names whatever
|
|
// that tag points at on the day the host happens to run.
|
|
func checkImage(where, image string) []string {
|
|
if image == "" {
|
|
return []string{where + ": a container needs an image"}
|
|
}
|
|
name, digest, found := strings.Cut(image, "@")
|
|
if !found || name == "" {
|
|
return []string{fmt.Sprintf(
|
|
"%s: image %q is not pinned. Write it as name@sha256:... — 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
|
|
}
|
|
|
|
// setFields names every field carried on this resource, other than its identity and type.
|
|
func setFields(r Resource) []string {
|
|
var set []string
|
|
add := func(name string, populated bool) {
|
|
if populated {
|
|
set = append(set, name)
|
|
}
|
|
}
|
|
add("path", r.Path != "")
|
|
add("content", r.Content != "")
|
|
add("mode", r.Mode != "")
|
|
add("unit", r.Unit != "")
|
|
add("state", r.State != "")
|
|
add("package", r.Package != "")
|
|
add("image", r.Image != "")
|
|
add("name", r.Name != "")
|
|
add("env", len(r.Env) > 0)
|
|
add("ports", len(r.Ports) > 0)
|
|
add("volumes", len(r.Volumes) > 0)
|
|
add("args", len(r.Args) > 0)
|
|
add("command", len(r.Command) > 0)
|
|
add("verify", len(r.Verify) > 0)
|
|
add("in", r.In != "")
|
|
sort.Strings(set)
|
|
return set
|
|
}
|
|
|
|
// unusedBy refuses a field this type does not use.
|
|
//
|
|
// A field set and ignored is the fault this package exists to prevent, in miniature: the
|
|
// control plane believes it asked for something the host will never do.
|
|
func unusedBy(where string, r Resource) []string {
|
|
var problems []string
|
|
for _, name := range setFields(r) {
|
|
if !uses[r.Type][name] {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: a %s does not use %q, and it is set. Refused rather than ignored",
|
|
where, r.Type, name))
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func vocabulary() string {
|
|
var names []string
|
|
for t := range uses {
|
|
names = append(names, string(t))
|
|
}
|
|
sort.Strings(names)
|
|
return strings.Join(names, ", ")
|
|
}
|