Unify trunk on main: initialization → main #3

Merged
jschoubben merged 58 commits from initialization into main 2026-09-05 01:13:33 +00:00
4 changed files with 365 additions and 236 deletions
Showing only changes of commit 9a9937b7e6 - Show all commits
+38 -35
View File
@@ -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")
}
+2 -2
View File
@@ -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
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)
+12 -4
View File
@@ -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())
}
}