// 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, ", ") }