A struct per resource kind, instead of one struct with every field

Jochen asked why we don't simply have dedicated structs. We should, and the
flat struct was me extending an existing pattern rather than questioning it.

Before: one Resource struct carrying path, content, mode, unit, state, package,
image, name, env, ports, volumes, args, command, verify and in. Because a file
and a container shared it, nothing stopped {"type":"file","image":"postgres"},
so a `uses` map listed which fields each kind was allowed to carry -- a second
place to keep current, and the kind nobody updates is the one that silently
accepts a field the host will never read.

Now: Directory, File, Service, Package, Container and Action are separate
structs behind a Resource interface. File has no Image field, so the mistake is
not detected -- it is unrepresentable. Adding a field to a kind is the whole of
adding it; there is nowhere else that has to agree.

Parsing is two passes: read the envelope and each resource's raw bytes, peek at
"type" to choose the struct, then decode into it. Peeking is lenient on purpose
-- reading strictly there would report an unknown field before knowing which
fields are known.

Unknown fields are found by comparing the JSON keys against the struct's own
json tags rather than by catching the decoder's error. The decoder stops at the
first unknown field, and RefusalError promises every problem at once: a caller
fixing one field at a time learns the next only by running again. Caught by
testing the refactor against a real declaration -- a container carrying both
`unit` and `mode` reported only one of them.

apply.go switches on the concrete type instead of a string, so a new kind that
has no applier is a compile error rather than a runtime default branch.

No behaviour change otherwise. All existing tests pass unmodified except two
that reached for fields the interface no longer exposes.
This commit is contained in:
2026-08-27 21:03:59 +02:00
parent 337126603e
commit 9a9937b7e6
4 changed files with 365 additions and 236 deletions
+313 -195
View File
@@ -10,6 +10,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
)
@@ -31,77 +32,225 @@ const (
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 {
// 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, 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".
Path string `json:"path"`
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"`
func (d *Directory) Identity() string { return d.ID }
func (d *Directory) Kind() Type { return TypeDirectory }
func (d *Directory) Target() string { return d.Path }
// Package, for a package: the name this machine's own package manager knows it by.
Package string `json:"package,omitempty"`
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)...)
}
// 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.
// 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.
type Service struct {
ID string `json:"id"`
Type Type `json:"type"`
Unit string `json:"unit"`
State string `json:"state"`
}
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))
}
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"`
}
// 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.
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 `json:"declaration"`
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 `json:"for,omitempty"`
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 `json:"resources"`
Resources []Resource
}
// RefusalError refuses a whole declaration, naming every problem at once.
@@ -134,125 +283,153 @@ func Parse(raw []byte) (*Declaration, error) { return parse(raw, false) }
// 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()
// 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"`
}
var d Declaration
if err := dec.Decode(&d); err != nil {
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 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(
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",
d.Version, Version))
// Everything below assumes the vocabulary, so there is nothing further to say.
return problems
env.Version, Version)}}
}
if len(d.Resources) == 0 {
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, r := range d.Resources {
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 r.ID != "" {
where = fmt.Sprintf("resource %q", r.ID)
if head.ID != "" {
where = fmt.Sprintf("resource %q", head.ID)
}
if r.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[r.ID]; ok {
} 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[r.ID] = i
seen[head.ID] = i
}
if _, ok := uses[r.Type]; !ok {
resource := newOf(head.Type)
if resource == nil {
problems = append(problems, fmt.Sprintf(
"%s: unknown type %q. This host understands %s", where, r.Type, vocabulary()))
"%s: unknown type %q. This host understands %s", where, head.Type, vocabulary()))
continue
}
problems = append(problems, validateResource(where, r, allowActions)...)
// 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)
}
return problems
if len(problems) > 0 {
return nil, &RefusalError{Problems: problems}
}
return d, nil
}
func validateResource(where string, r Resource, allowActions bool) []string {
problems := unusedBy(where, r)
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)
}
switch r.Type {
case TypeDirectory:
if r.Path == "" {
problems = append(problems, where+": a directory needs a path")
}
problems = append(problems, checkMode(where, r.Mode)...)
// 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
}
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")
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
}
}
return problems
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.
@@ -277,69 +454,10 @@ func checkImage(where, image string) []string {
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 {
kinds := Vocabulary()
names := make([]string, 0, len(kinds))
for _, t := range kinds {
names = append(names, string(t))
}
sort.Strings(names)