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
+38 -35
View File
@@ -93,7 +93,7 @@ func Apply(
declared := map[string]bool{} declared := map[string]bool{}
for _, r := range d.Resources { for _, r := range d.Resources {
declared[r.ID] = true declared[r.Identity()] = true
} }
for _, orphan := range known.Orphans(declared) { for _, orphan := range known.Orphans(declared) {
@@ -112,12 +112,12 @@ func Apply(
for _, resource := range d.Resources { for _, resource := range d.Resources {
outcome, err := applyOne(ctx, resource, run) outcome, err := applyOne(ctx, resource, run)
if err != nil { if err != nil {
return report, known, &Error{Resource: resource.ID, Err: err, Done: report} return report, known, &Error{Resource: resource.Identity(), Err: err, Done: report}
} }
// Only now. The record follows the fact, never leads it. // Only now. The record follows the fact, never leads it.
known.Record(store.Applied{ known.Record(store.Applied{
ID: resource.ID, Type: string(resource.Type), ID: resource.Identity(), Type: string(resource.Kind()),
Target: outcome.Target, AppliedAt: time.Now().UTC(), Target: outcome.Target, AppliedAt: time.Now().UTC(),
}) })
report.Outcomes = append(report.Outcomes, outcome) report.Outcomes = append(report.Outcomes, outcome)
@@ -129,26 +129,32 @@ func Apply(
} }
func applyOne(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) { func applyOne(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) {
switch r.Type { switch res := r.(type) {
case declaration.TypeDirectory: case *declaration.Directory:
return applyDirectory(r) return applyDirectory(res)
case declaration.TypeFile: case *declaration.File:
return applyFile(r) return applyFile(res)
case declaration.TypeService: case *declaration.Service:
return applyService(ctx, r, run) return applyService(ctx, res, run)
case declaration.TypePackage: case *declaration.Package:
return applyPackage(ctx, r, run) return applyPackage(ctx, res, run)
case declaration.TypeContainer: case *declaration.Container:
return applyContainer(ctx, r, run) return applyContainer(ctx, res, run)
case declaration.TypeAction: case *declaration.Action:
return applyAction(ctx, r, run) return applyAction(ctx, res, run)
default: default:
// Unreachable: the declaration refused this already. Present because "unreachable" // Unreachable: the declaration refused this already. Present because "unreachable"
// stops being true the moment someone adds a type and forgets this switch. // stops being true the moment someone adds a kind and forgets this switch.
return Outcome{}, fmt.Errorf("no applier for type %q", r.Type) return Outcome{}, fmt.Errorf("no applier for type %q", r.Kind())
} }
} }
// begin starts an outcome from any resource, so the three facts a report needs are read from
// the resource itself rather than restated by each applier.
func begin(r declaration.Resource) Outcome {
return Outcome{ID: r.Identity(), Type: string(r.Kind()), Target: r.Target()}
}
func modeOf(spec string, fallback os.FileMode) (os.FileMode, error) { func modeOf(spec string, fallback os.FileMode) (os.FileMode, error) {
if spec == "" { if spec == "" {
return fallback, nil return fallback, nil
@@ -160,8 +166,8 @@ func modeOf(spec string, fallback os.FileMode) (os.FileMode, error) {
return os.FileMode(parsed), nil return os.FileMode(parsed), nil
} }
func applyDirectory(r declaration.Resource) (Outcome, error) { func applyDirectory(r *declaration.Directory) (Outcome, error) {
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Path} out := begin(r)
mode, err := modeOf(r.Mode, 0o755) mode, err := modeOf(r.Mode, 0o755)
if err != nil { if err != nil {
return out, err return out, err
@@ -210,8 +216,8 @@ func applyDirectory(r declaration.Resource) (Outcome, error) {
return out, nil return out, nil
} }
func applyFile(r declaration.Resource) (Outcome, error) { func applyFile(r *declaration.File) (Outcome, error) {
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Path} out := begin(r)
mode, err := modeOf(r.Mode, 0o644) mode, err := modeOf(r.Mode, 0o644)
if err != nil { if err != nil {
return out, err return out, err
@@ -308,8 +314,8 @@ func writeAtomically(path string, content []byte, mode os.FileMode) error {
return os.Rename(tmp.Name(), path) return os.Rename(tmp.Name(), path)
} }
func applyService(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) { func applyService(ctx context.Context, r *declaration.Service, run Runner) (Outcome, error) {
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Unit} out := begin(r)
before, err := serviceState(ctx, r.Unit, run) before, err := serviceState(ctx, r.Unit, run)
if err != nil { if err != nil {
@@ -495,8 +501,8 @@ func ExecRunner(ctx context.Context, name string, args ...string) (string, error
// asserts, because version is the package manager's business and the mesh does not have a // asserts, because version is the package manager's business and the mesh does not have a
// second opinion about it (novox/hq ADR 0041 — the host depends on nothing, and that includes // second opinion about it (novox/hq ADR 0041 — the host depends on nothing, and that includes
// not becoming a second package manager). // not becoming a second package manager).
func applyPackage(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) { func applyPackage(ctx context.Context, r *declaration.Package, run Runner) (Outcome, error) {
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Package} out := begin(r)
installed, err := packageInstalled(ctx, r.Package, run) installed, err := packageInstalled(ctx, r.Package, run)
if err != nil { if err != nil {
@@ -558,7 +564,7 @@ const (
// containerSpec is the identity of a declared container: everything that, if changed, means // containerSpec is the identity of a declared container: everything that, if changed, means
// the running container is no longer what was asked for. // the running container is no longer what was asked for.
func containerSpec(r declaration.Resource) string { func containerSpec(r *declaration.Container) string {
keys := make([]string, 0, len(r.Env)) keys := make([]string, 0, len(r.Env))
for k := range r.Env { for k := range r.Env {
keys = append(keys, k) keys = append(keys, k)
@@ -604,8 +610,8 @@ func containerState(ctx context.Context, name string, run Runner) (state struct
// There is no "update" for a container: a container's configuration is fixed when it is // There is no "update" for a container: a container's configuration is fixed when it is
// created, so any change is a replacement. Saying that plainly is better than a partial // created, so any change is a replacement. Saying that plainly is better than a partial
// in-place update that leaves the running thing half-declared. // in-place update that leaves the running thing half-declared.
func applyContainer(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) { func applyContainer(ctx context.Context, r *declaration.Container, run Runner) (Outcome, error) {
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Name} out := begin(r)
want := containerSpec(r) want := containerSpec(r)
if _, err := run(ctx, "docker", "version", "--format", "{{.Server.Version}}"); err != nil { if _, err := run(ctx, "docker", "version", "--format", "{{.Server.Version}}"); err != nil {
@@ -685,11 +691,8 @@ func sortedKeys(m map[string]string) []string {
// anything to do — it does not know what a database is, so "is the database there" is a // anything to do — it does not know what a database is, so "is the database there" is a
// question only the declaration can ask. Running it again afterwards is how the host knows the // question only the declaration can ask. Running it again afterwards is how the host knows the
// command had the effect it claimed (novox/hq ADR 0047). // command had the effect it claimed (novox/hq ADR 0047).
func applyAction(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) { func applyAction(ctx context.Context, r *declaration.Action, run Runner) (Outcome, error) {
out := Outcome{ID: r.ID, Type: string(r.Type), Target: strings.Join(r.Command, " ")} out := begin(r)
if r.In != "" {
out.Target = "in " + r.In + ": " + out.Target
}
if _, err := runAction(ctx, r, r.Verify, run); err == nil { if _, err := runAction(ctx, r, r.Verify, run); err == nil {
out.Action = "unchanged" out.Action = "unchanged"
@@ -714,7 +717,7 @@ func applyAction(ctx context.Context, r declaration.Resource, run Runner) (Outco
} }
// runAction runs one of an action's command lines, on the machine or inside a container. // runAction runs one of an action's command lines, on the machine or inside a container.
func runAction(ctx context.Context, r declaration.Resource, argv []string, run Runner) (string, error) { func runAction(ctx context.Context, r *declaration.Action, argv []string, run Runner) (string, error) {
if len(argv) == 0 { if len(argv) == 0 {
return "", errors.New("no command") return "", errors.New("no command")
} }
+2 -2
View File
@@ -622,7 +622,7 @@ func TestAContainerWhoseDeclarationChangedIsReplaced(t *testing.T) {
d := parseTrusted(t, `{"declaration":1,"resources":[ d := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}} {"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}}
]}`) ]}`)
want := containerSpec(d.Resources[0]) want := containerSpec(d.Resources[0].(*declaration.Container))
var removed, created bool var removed, created bool
run := func(ctx context.Context, name string, args ...string) (string, error) { run := func(ctx context.Context, name string, args ...string) (string, error) {
@@ -660,7 +660,7 @@ func TestAContainerThatMatchesIsLeftAlone(t *testing.T) {
d := parseTrusted(t, `{"declaration":1,"resources":[ d := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}} {"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}}
]}`) ]}`)
spec := containerSpec(d.Resources[0]) spec := containerSpec(d.Resources[0].(*declaration.Container))
var touched bool var touched bool
run := func(ctx context.Context, name string, args ...string) (string, error) { run := func(ctx context.Context, name string, args ...string) (string, error) {
+313 -195
View File
@@ -10,6 +10,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"reflect"
"sort" "sort"
"strings" "strings"
) )
@@ -31,77 +32,225 @@ const (
TypeAction Type = "action" 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. // 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 // A struct per kind rather than one struct carrying every field, because the decoder is then
// not a hash of the content. It is what lets the store say *this is the same resource I // what rejects a field the kind does not have: a `file` carrying an `image` is refused because
// applied last time*, which is what makes removal possible at all. // File has no such field, not because a list somewhere remembered to say so. The one-struct
type Resource 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"` ID string `json:"id"`
Type Type `json:"type"` Type Type `json:"type"`
Path string `json:"path"`
// 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"` Mode string `json:"mode,omitempty"`
}
// Unit and State, for a service. State is "running" or "stopped". func (d *Directory) Identity() string { return d.ID }
Unit string `json:"unit,omitempty"` func (d *Directory) Kind() Type { return TypeDirectory }
State string `json:"state,omitempty"` func (d *Directory) Target() string { return d.Path }
// Package, for a package: the name this machine's own package manager knows it by. func (d *Directory) validate(where string, _ bool) []string {
Package string `json:"package,omitempty"` 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 // File is a file with literal content. The host renders nothing.
// moves and a digest does not, and a bundle that pinned a tag would not be pinned. type File struct {
Image string `json:"image,omitempty"` ID string `json:"id"`
Name string `json:"name,omitempty"` Type Type `json:"type"`
// Env, Ports, Volumes and Args, for a container. Literal; the host renders nothing. 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"` Env map[string]string `json:"env,omitempty"`
Ports []string `json:"ports,omitempty"` Ports []string `json:"ports,omitempty"`
Volumes []string `json:"volumes,omitempty"` Volumes []string `json:"volumes,omitempty"`
Args []string `json:"args,omitempty"` Args []string `json:"args,omitempty"`
}
// Command, Verify and In, for an action. func (c *Container) Identity() string { return c.ID }
// func (c *Container) Kind() Type { return TypeContainer }
// Verify is not optional and is not a courtesy. An action that runs and reports success func (c *Container) Target() string { return c.Name }
// 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). func (c *Container) validate(where string, _ bool) []string {
Command []string `json:"command,omitempty"` var problems []string
Verify []string `json:"verify,omitempty"` if c.Name == "" {
// In names a container to run the action inside, when the thing being acted on lives problems = append(problems, where+": a container needs a name")
// there. Empty means the machine itself. }
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"` 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. // Declaration is what a machine should be, in the order it should be made so.
type Declaration struct { 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 // 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 // elsewhere; a host without one — the first node, applying the bundle it carries — has
// nothing to check against. // 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 // 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). // 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. // 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. // control plane can express.
func ParseTrusted(raw []byte) (*Declaration, error) { return parse(raw, true) } func ParseTrusted(raw []byte) (*Declaration, error) { return parse(raw, true) }
func parse(raw []byte, allowActions bool) (*Declaration, error) { // envelope is the declaration with its resources still unread.
// 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. // Two passes, because which fields are legal depends on the "type" inside each resource. The
dec := json.NewDecoder(bytes.NewReader(raw)) // first pass takes the envelope and each resource's bytes; the second decodes each one into
dec.DisallowUnknownFields() // 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 func parse(raw []byte, allowActions bool) (*Declaration, error) {
if err := dec.Decode(&d); err != nil { var env envelope
if err := strictDecode(raw, &env); err != nil {
return nil, &RefusalError{Problems: []string{"not a declaration: " + err.Error()}} return nil, &RefusalError{Problems: []string{"not a declaration: " + err.Error()}}
} }
if problems := validate(&d, allowActions); len(problems) > 0 { if env.Version != Version {
return nil, &RefusalError{Problems: problems} // Everything below assumes the vocabulary, so there is nothing further to say.
} return nil, &RefusalError{Problems: []string{fmt.Sprintf(
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 "+ "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", "partly, so a newer vocabulary is never half-applied by an older host",
d.Version, Version)) env.Version, Version)}}
// Everything below assumes the vocabulary, so there is nothing further to say.
return problems
} }
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 "+ 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") "machine with nothing on it — say so with an explicit empty list if that is meant")
} }
seen := map[string]int{} 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) where := fmt.Sprintf("resource %d", i)
if r.ID != "" { if head.ID != "" {
where = fmt.Sprintf("resource %q", r.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 "+ problems = append(problems, where+": no id. Identity is what lets the host know "+
"this is the same resource it applied last time") "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( problems = append(problems, fmt.Sprintf(
"%s: id already used by resource %d. Two resources with one identity cannot "+ "%s: id already used by resource %d. Two resources with one identity cannot "+
"both be tracked", where, first)) "both be tracked", where, first))
} else { } 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( 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 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 { func strictDecode(raw []byte, into any) error {
problems := unusedBy(where, r) // 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 { // unknownFields names every JSON key the kind's struct has no field for.
case TypeDirectory: //
if r.Path == "" { // The struct's own tags are the list of what is legal, so adding a field to a kind is the
problems = append(problems, where+": a directory needs a path") // whole of adding it — there is nowhere else that has to agree.
} func unknownFields(raw []byte, into Resource) []string {
problems = append(problems, checkMode(where, r.Mode)...) 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: known := map[string]bool{}
if r.Path == "" { t := reflect.TypeOf(into).Elem()
problems = append(problems, where+": a file needs a path") for i := 0; i < t.NumField(); i++ {
} name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",")
problems = append(problems, checkMode(where, r.Mode)...) if name != "" && name != "-" {
known[name] = true
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
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. // checkImage insists on a digest.
@@ -277,69 +454,10 @@ func checkImage(where, image string) []string {
return nil 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 { func vocabulary() string {
var names []string kinds := Vocabulary()
for t := range uses { names := make([]string, 0, len(kinds))
for _, t := range kinds {
names = append(names, string(t)) names = append(names, string(t))
} }
sort.Strings(names) sort.Strings(names)
+12 -4
View File
@@ -36,7 +36,7 @@ func TestAValidDeclarationParsesInOrder(t *testing.T) {
t.Fatalf("unexpected refusal: %v", err) t.Fatalf("unexpected refusal: %v", err)
} }
// Order is stated, not derived. The host must not sort. // Order is stated, not derived. The host must not sort.
got := []string{d.Resources[0].ID, d.Resources[1].ID, d.Resources[2].ID} got := []string{d.Resources[0].Identity(), d.Resources[1].Identity(), d.Resources[2].Identity()}
want := []string{"etc", "conf", "svc"} want := []string{"etc", "conf", "svc"}
for i := range want { for i := range want {
if got[i] != want[i] { if got[i] != want[i] {
@@ -244,15 +244,23 @@ func TestTheVocabularyIsTheSixShapesTheBootstrapNeeds(t *testing.T) {
// novox/hq 07-the-substrate.md names six shapes and the bootstrap uses all of them. // novox/hq 07-the-substrate.md names six shapes and the bootstrap uses all of them.
// Asserted so that removing one is a failing test rather than a discovery during a // Asserted so that removing one is a failing test rather than a discovery during a
// first-node install. // first-node install.
speaks := map[Type]bool{}
for _, t := range Vocabulary() {
speaks[t] = true
}
for _, want := range []Type{ for _, want := range []Type{
TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction, TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction,
} { } {
if _, ok := uses[want]; !ok { if !speaks[want] {
t.Errorf("the host no longer speaks %q", want) t.Errorf("the host no longer speaks %q", want)
} }
if newOf(want) == nil {
t.Errorf("%q is in the vocabulary and cannot be constructed", want)
}
} }
if len(uses) != 6 { if len(speaks) != 6 {
t.Errorf("the vocabulary is %d shapes; every addition widens what a compromised "+ t.Errorf("the vocabulary is %d shapes; every addition widens what a compromised "+
"control plane can express, so a change here is a decision: %s", len(uses), vocabulary()) "control plane can express, so a change here is a decision: %s",
len(speaks), vocabulary())
} }
} }