Two gaps found by testing podman rather than reasoning about it.
The service shape could not say "starts at boot". It ran `systemctl start`, so
`service: docker.service, running` started docker now and it would not come
back after a reboot unless something else had enabled it. A declaration that
reports success and stops being true at the next power cut.
`boot: enabled|disabled` is now a separate field, not a fourth value of
`state`, because the two are orthogonal: a unit can be enabled and stopped (it
returns at boot) or disabled and running (started by hand, gone after one).
Absent means the host asserts nothing, so a machine whose operator enabled
something is not silently disabled by a declaration that never mentioned it.
Boot state is made true BEFORE the unit is started. When an apply fails part
way, enabled-and-stopped comes back at the next boot and running-and-disabled
does not, so the more durable half goes first.
`is-enabled` has the same trap as `is-active` had. Its exit code is non-zero
for nearly everything, and `static` is neither enabled nor disabled -- the unit
has no install section and CANNOT be enabled. Reading it as "disabled" would
have the host try, fail, and blame the wrong thing, which is the same shape as
reading a missing unit as "stopped".
The container applier no longer calls `docker` literally. Verified on this
machine against podman 6.1.0:
docker info --format '{{.ServerVersion}}' -> 29.7.2
podman info --format '{{.ServerVersion}}' -> Error: can't evaluate field
ServerVersion
podman info --format '{{.Version.Version}}' -> 6.1.0
So one probe cannot find both, and a host using docker's would report a machine
running podman as having no container runtime at all. Everything else IS
compatible -- run, rm -f, and docker's own Go template syntax for reading state
and labels all work unchanged on podman, confirmed by running them. That is why
this is a two-entry lookup rather than an interface: only the probe differs.
Detected rather than declared, because adoption keeps what the machine already
has (research 012), which hardcoding one runtime contradicts.
A machine with neither now says so, naming both: "docker: command not found" on
a machine deliberately running podman sends the reader after the wrong thing.
Verified end to end against real docker (container created, running, labelled)
and against an empty PATH (refused, naming both runtimes).
Two injections per behaviour, all confirmed to bite. One injection produced a
build failure that my check read as "no bite" for the third time, so the check
now distinguishes them.
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 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, ", ")
|
|
}
|