// 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" "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 0046) — 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 0047). 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 0047). 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 0037). 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 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) } // 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 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 } 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, ", ") }