Unify trunk on main: initialization → main #3
+38
-35
@@ -93,7 +93,7 @@ func Apply(
|
||||
|
||||
declared := map[string]bool{}
|
||||
for _, r := range d.Resources {
|
||||
declared[r.ID] = true
|
||||
declared[r.Identity()] = true
|
||||
}
|
||||
|
||||
for _, orphan := range known.Orphans(declared) {
|
||||
@@ -112,12 +112,12 @@ func Apply(
|
||||
for _, resource := range d.Resources {
|
||||
outcome, err := applyOne(ctx, resource, run)
|
||||
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.
|
||||
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(),
|
||||
})
|
||||
report.Outcomes = append(report.Outcomes, outcome)
|
||||
@@ -129,26 +129,32 @@ func Apply(
|
||||
}
|
||||
|
||||
func applyOne(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) {
|
||||
switch r.Type {
|
||||
case declaration.TypeDirectory:
|
||||
return applyDirectory(r)
|
||||
case declaration.TypeFile:
|
||||
return applyFile(r)
|
||||
case declaration.TypeService:
|
||||
return applyService(ctx, r, run)
|
||||
case declaration.TypePackage:
|
||||
return applyPackage(ctx, r, run)
|
||||
case declaration.TypeContainer:
|
||||
return applyContainer(ctx, r, run)
|
||||
case declaration.TypeAction:
|
||||
return applyAction(ctx, r, run)
|
||||
switch res := r.(type) {
|
||||
case *declaration.Directory:
|
||||
return applyDirectory(res)
|
||||
case *declaration.File:
|
||||
return applyFile(res)
|
||||
case *declaration.Service:
|
||||
return applyService(ctx, res, run)
|
||||
case *declaration.Package:
|
||||
return applyPackage(ctx, res, run)
|
||||
case *declaration.Container:
|
||||
return applyContainer(ctx, res, run)
|
||||
case *declaration.Action:
|
||||
return applyAction(ctx, res, run)
|
||||
default:
|
||||
// Unreachable: the declaration refused this already. Present because "unreachable"
|
||||
// stops being true the moment someone adds a type and forgets this switch.
|
||||
return Outcome{}, fmt.Errorf("no applier for type %q", r.Type)
|
||||
// stops being true the moment someone adds a kind and forgets this switch.
|
||||
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) {
|
||||
if spec == "" {
|
||||
return fallback, nil
|
||||
@@ -160,8 +166,8 @@ func modeOf(spec string, fallback os.FileMode) (os.FileMode, error) {
|
||||
return os.FileMode(parsed), nil
|
||||
}
|
||||
|
||||
func applyDirectory(r declaration.Resource) (Outcome, error) {
|
||||
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Path}
|
||||
func applyDirectory(r *declaration.Directory) (Outcome, error) {
|
||||
out := begin(r)
|
||||
mode, err := modeOf(r.Mode, 0o755)
|
||||
if err != nil {
|
||||
return out, err
|
||||
@@ -210,8 +216,8 @@ func applyDirectory(r declaration.Resource) (Outcome, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func applyFile(r declaration.Resource) (Outcome, error) {
|
||||
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Path}
|
||||
func applyFile(r *declaration.File) (Outcome, error) {
|
||||
out := begin(r)
|
||||
mode, err := modeOf(r.Mode, 0o644)
|
||||
if err != nil {
|
||||
return out, err
|
||||
@@ -308,8 +314,8 @@ func writeAtomically(path string, content []byte, mode os.FileMode) error {
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
|
||||
func applyService(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) {
|
||||
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Unit}
|
||||
func applyService(ctx context.Context, r *declaration.Service, run Runner) (Outcome, error) {
|
||||
out := begin(r)
|
||||
|
||||
before, err := serviceState(ctx, r.Unit, run)
|
||||
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
|
||||
// second opinion about it (novox/hq ADR 0041 — the host depends on nothing, and that includes
|
||||
// not becoming a second package manager).
|
||||
func applyPackage(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) {
|
||||
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Package}
|
||||
func applyPackage(ctx context.Context, r *declaration.Package, run Runner) (Outcome, error) {
|
||||
out := begin(r)
|
||||
|
||||
installed, err := packageInstalled(ctx, r.Package, run)
|
||||
if err != nil {
|
||||
@@ -558,7 +564,7 @@ const (
|
||||
|
||||
// containerSpec is the identity of a declared container: everything that, if changed, means
|
||||
// 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))
|
||||
for k := range r.Env {
|
||||
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
|
||||
// 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.
|
||||
func applyContainer(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) {
|
||||
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Name}
|
||||
func applyContainer(ctx context.Context, r *declaration.Container, run Runner) (Outcome, error) {
|
||||
out := begin(r)
|
||||
want := containerSpec(r)
|
||||
|
||||
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
|
||||
// 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).
|
||||
func applyAction(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) {
|
||||
out := Outcome{ID: r.ID, Type: string(r.Type), Target: strings.Join(r.Command, " ")}
|
||||
if r.In != "" {
|
||||
out.Target = "in " + r.In + ": " + out.Target
|
||||
}
|
||||
func applyAction(ctx context.Context, r *declaration.Action, run Runner) (Outcome, error) {
|
||||
out := begin(r)
|
||||
|
||||
if _, err := runAction(ctx, r, r.Verify, run); err == nil {
|
||||
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.
|
||||
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 {
|
||||
return "", errors.New("no command")
|
||||
}
|
||||
|
||||
@@ -622,7 +622,7 @@ func TestAContainerWhoseDeclarationChangedIsReplaced(t *testing.T) {
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"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
|
||||
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":[
|
||||
{"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
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
|
||||
+313
-195
@@ -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)
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestAValidDeclarationParsesInOrder(t *testing.T) {
|
||||
t.Fatalf("unexpected refusal: %v", err)
|
||||
}
|
||||
// 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"}
|
||||
for i := range want {
|
||||
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.
|
||||
// Asserted so that removing one is a failing test rather than a discovery during a
|
||||
// first-node install.
|
||||
speaks := map[Type]bool{}
|
||||
for _, t := range Vocabulary() {
|
||||
speaks[t] = true
|
||||
}
|
||||
for _, want := range []Type{
|
||||
TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction,
|
||||
} {
|
||||
if _, ok := uses[want]; !ok {
|
||||
if !speaks[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 "+
|
||||
"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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user