96 comments across the two repos named records that no longer exist. Each now points at the consolidated record that holds its reasoning -- ADR 0034 (a test defends a decision) is 0017, the eight host records are 0005, the four lab records are 0016. Worth noting for next time: these are references from outside HQ, so renumbering there is not free. It cost 38 files here.
481 lines
17 KiB
Go
481 lines
17 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 0005).
|
|
package declaration
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"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"
|
|
)
|
|
|
|
// Resource is one thing that should be true of the machine.
|
|
//
|
|
// A struct per kind rather than one struct carrying every field, because the decoder is then
|
|
// what rejects a field the kind does not have: a `file` carrying an `image` is refused because
|
|
// File has no such field, not because a list somewhere remembered to say so. The one-struct
|
|
// form needs every kind revisited whenever a field is added, and the kind nobody revisits
|
|
// silently accepts a field the host will never read.
|
|
type Resource interface {
|
|
// Identity is the 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.
|
|
Identity() string
|
|
// Kind is the resource's type, for the store and for reporting.
|
|
Kind() Type
|
|
// Target is what the resource acts on, for a person reading a report.
|
|
Target() string
|
|
|
|
validate(where string, allowActions bool) []string
|
|
}
|
|
|
|
// The `Type` field on each kind below exists only to absorb the JSON `"type"` key, which the
|
|
// strict decoder would otherwise refuse. `Kind()` returns the constant and is what anything
|
|
// else should read.
|
|
|
|
// Directory is a directory that should exist, with a mode.
|
|
type Directory struct {
|
|
ID string `json:"id"`
|
|
Type Type `json:"type"`
|
|
Path string `json:"path"`
|
|
Mode string `json:"mode,omitempty"`
|
|
}
|
|
|
|
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")
|
|
}
|
|
return append(problems, checkMode(where, d.Mode)...)
|
|
}
|
|
|
|
// File is a file with literal content. The host renders nothing.
|
|
type File struct {
|
|
ID string `json:"id"`
|
|
Type Type `json:"type"`
|
|
Path string `json:"path"`
|
|
Content string `json:"content"`
|
|
Mode string `json:"mode,omitempty"`
|
|
}
|
|
|
|
func (f *File) Identity() string { return f.ID }
|
|
func (f *File) Kind() Type { return TypeFile }
|
|
func (f *File) Target() string { return f.Path }
|
|
|
|
func (f *File) validate(where string, _ bool) []string {
|
|
var problems []string
|
|
if f.Path == "" {
|
|
problems = append(problems, where+": a file needs a path")
|
|
}
|
|
return append(problems, checkMode(where, f.Mode)...)
|
|
}
|
|
|
|
// 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"`
|
|
Unit string `json:"unit"`
|
|
State string `json:"state"`
|
|
// 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"`
|
|
}
|
|
|
|
func (s *Service) Identity() string { return s.ID }
|
|
func (s *Service) Kind() Type { return TypeService }
|
|
func (s *Service) Target() string { return s.Unit }
|
|
|
|
func (s *Service) validate(where string, _ bool) []string {
|
|
var problems []string
|
|
if s.Unit == "" {
|
|
problems = append(problems, where+": a service needs a unit")
|
|
}
|
|
if s.State != "running" && s.State != "stopped" {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: state %q; a service is \"running\" or \"stopped\"", where, s.State))
|
|
}
|
|
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))
|
|
}
|
|
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"`
|
|
Package string `json:"package"`
|
|
}
|
|
|
|
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"`
|
|
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"`
|
|
Ports []string `json:"ports,omitempty"`
|
|
Volumes []string `json:"volumes,omitempty"`
|
|
Args []string `json:"args,omitempty"`
|
|
}
|
|
|
|
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 {
|
|
var problems []string
|
|
if c.Name == "" {
|
|
problems = append(problems, where+": a container needs a name")
|
|
}
|
|
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"`
|
|
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{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Vocabulary is every kind this host speaks.
|
|
func Vocabulary() []Type {
|
|
return []Type{
|
|
TypeAction, TypeContainer, TypeDirectory, TypeFile, TypePackage, TypeService,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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"`
|
|
Resources []json.RawMessage `json:"resources"`
|
|
}
|
|
|
|
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}
|
|
var problems []string
|
|
|
|
if len(env.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, 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)
|
|
}
|
|
|
|
if len(problems) > 0 {
|
|
return nil, &RefusalError{Problems: problems}
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
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()
|
|
return dec.Decode(into)
|
|
}
|
|
|
|
// 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 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 0006), 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
|
|
}
|
|
|
|
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, ", ")
|
|
}
|