A declaration is JSON, versioned, and an ordered list of resources with stable identities (novox/hq ADR 0043). The vocabulary is directory, file and service, and anything outside it — an unknown version, type or field — refuses the WHOLE declaration. A host that skipped what it did not understand would apply most of what it was sent and report success. It converges rather than executes: applying twice changes nothing the second time, and applying to a drifted machine returns it. A mode is maintained rather than set, because a permission applied at creation is not a permission held — this repository has paid for that once already. It owns a footprint and only that. What it applied and is no longer declared is removed; what it did not create is never touched. Removal runs FIRST, because a resource leaving a declaration while another arrives at the same path is an ordinary rename, and removing afterwards would delete the file just written. The store arrives here rather than at stage 3, as ADR 0043 predicted: nothing can be removed without knowing what was applied. It is written atomically, refuses to start empty when it exists and cannot be read — believing it owns nothing would leave everything behind forever — and is saved even when an apply fails, because what was applied before the failure is on the machine either way. Three faults found by running inside a raised machine rather than by reasoning: A unit that DOES NOT EXIST reads as `inactive` from `systemctl is-active`, exactly as a stopped one does. So declaring a unit stopped reported success for a unit the host cannot manage at all — absence read as satisfaction, which is 04-ISSUES/007 wearing a different hat. LoadState separates them. Removing an orphaned service whose unit has since been uninstalled failed the whole apply, and a host holding such a record could then apply NOTHING, ever, with no way out but editing its state by hand. Removal is now idempotent for the same reason os.RemoveAll is. And the flag parser was wrong in the same way twice: fixing `mesh-host inventory --json` by taking the subcommand off the front left `mesh-host apply decl.json --dry-run` broken identically, because the standard library stops at the first non-flag argument wherever that argument is. Parsed in a loop now. 30 new tests, 55 in total.
222 lines
7.3 KiB
Go
222 lines
7.3 KiB
Go
// Package declaration is what the host is told a machine should be.
|
|
//
|
|
// Data, never instructions. The vocabulary is finite, versioned, and anything outside it
|
|
// refuses the whole declaration rather than being skipped — a host that applied most of what
|
|
// it was sent and reported success is a node that looks configured and is not
|
|
// (novox/hq ADR 0043).
|
|
package declaration
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Version is the vocabulary this host speaks. A declaration naming any other version is
|
|
// refused: an older host handed a newer vocabulary must not quietly do half of it.
|
|
const Version = 1
|
|
|
|
// Type names a kind of resource. Every addition widens what a compromised control plane can
|
|
// express, so the list is a security artefact and grows deliberately.
|
|
type Type string
|
|
|
|
const (
|
|
TypeDirectory Type = "directory"
|
|
TypeFile Type = "file"
|
|
TypeService Type = "service"
|
|
)
|
|
|
|
// known is the whole vocabulary. Anything else is refused.
|
|
var known = map[Type]bool{
|
|
TypeDirectory: true,
|
|
TypeFile: true,
|
|
TypeService: 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 {
|
|
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".
|
|
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"`
|
|
}
|
|
|
|
// Declaration is what a machine should be, in the order it should be made so.
|
|
type Declaration struct {
|
|
Version int `json:"declaration"`
|
|
// 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"`
|
|
// 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"`
|
|
}
|
|
|
|
// RefusalError refuses a whole declaration, naming every problem at once.
|
|
//
|
|
// Every problem rather than the first: a caller fixing one at a time learns the next only by
|
|
// running again, and a declaration is generated, so a person reading this is debugging the
|
|
// generator.
|
|
type RefusalError struct {
|
|
Problems []string
|
|
}
|
|
|
|
func (e *RefusalError) Error() string {
|
|
return fmt.Sprintf(
|
|
"this declaration is refused, and none of it was applied:\n - %s\n\n"+
|
|
"A host that applied the parts it understood would leave a machine that looks "+
|
|
"configured and is not.",
|
|
strings.Join(e.Problems, "\n - "))
|
|
}
|
|
|
|
// Parse reads a declaration and refuses anything it does not fully understand.
|
|
func Parse(raw []byte) (*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()
|
|
|
|
var d Declaration
|
|
if err := dec.Decode(&d); err != nil {
|
|
return nil, &RefusalError{Problems: []string{"not a declaration: " + err.Error()}}
|
|
}
|
|
|
|
if problems := validate(&d); len(problems) > 0 {
|
|
return nil, &RefusalError{Problems: problems}
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
func validate(d *Declaration) []string {
|
|
var problems []string
|
|
|
|
if d.Version != Version {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"declaration version %d; this host speaks version %d. Refused whole rather than "+
|
|
"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
|
|
}
|
|
|
|
if len(d.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 {
|
|
where := fmt.Sprintf("resource %d", i)
|
|
if r.ID != "" {
|
|
where = fmt.Sprintf("resource %q", r.ID)
|
|
}
|
|
|
|
if r.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 {
|
|
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
|
|
}
|
|
|
|
if !known[r.Type] {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: unknown type %q. This host understands %s", where, r.Type, vocabulary()))
|
|
continue
|
|
}
|
|
problems = append(problems, validateResource(where, r)...)
|
|
}
|
|
return problems
|
|
}
|
|
|
|
func validateResource(where string, r Resource) []string {
|
|
var problems []string
|
|
switch r.Type {
|
|
case TypeDirectory:
|
|
if r.Path == "" {
|
|
problems = append(problems, where+": a directory needs a path")
|
|
}
|
|
problems = append(problems, checkMode(where, r.Mode)...)
|
|
problems = append(problems, unusedBy(where, r, "unit", r.Unit, "state", r.State, "content", r.Content)...)
|
|
|
|
case TypeFile:
|
|
if r.Path == "" {
|
|
problems = append(problems, where+": a file needs a path")
|
|
}
|
|
problems = append(problems, checkMode(where, r.Mode)...)
|
|
problems = append(problems, unusedBy(where, r, "unit", r.Unit, "state", r.State)...)
|
|
|
|
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))
|
|
}
|
|
problems = append(problems, unusedBy(where, r, "path", r.Path, "content", r.Content, "mode", r.Mode)...)
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// 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, pairs ...string) []string {
|
|
var problems []string
|
|
for i := 0; i+1 < len(pairs); i += 2 {
|
|
if pairs[i+1] != "" {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: a %s does not use %q, and it is set. Refused rather than ignored",
|
|
where, r.Type, pairs[i]))
|
|
}
|
|
}
|
|
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 known {
|
|
names = append(names, string(t))
|
|
}
|
|
sort.Strings(names)
|
|
return strings.Join(names, ", ")
|
|
}
|