diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 60649aa..c0d0d7e 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -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") } diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go index 0e5b5fb..3b40a66 100644 --- a/internal/apply/apply_test.go +++ b/internal/apply/apply_test.go @@ -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) { diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 06ddd0d..f500777 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -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) diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go index 0b2391c..dc755ef 100644 --- a/internal/declaration/declaration_test.go +++ b/internal/declaration/declaration_test.go @@ -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()) } }