Stage 2 — the host applies a declaration
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.
This commit is contained in:
@@ -22,13 +22,17 @@ the host never queries the mesh database. It receives declarations and applies t
|
||||
|
||||
## What exists today
|
||||
|
||||
**Stage 1 only: it reports.** It applies nothing, connects to nothing, and listens on nothing.
|
||||
**Stages 1 and 2.** It reports what a machine is, and it applies a declaration to one. It
|
||||
connects to nothing and listens on nothing — what it applies comes from a file.
|
||||
|
||||
```
|
||||
mesh-host profile what this machine can be asked to do
|
||||
mesh-host inventory what this machine is, and what it holds
|
||||
--json machine-readable
|
||||
--timeout how long any single probe may take (default 10s)
|
||||
mesh-host profile what this machine can be asked to do
|
||||
mesh-host inventory what this machine is, and what it holds
|
||||
mesh-host apply FILE make this machine match a declaration
|
||||
mesh-host owned what this host has applied and still owns
|
||||
--json machine-readable
|
||||
--state where this node keeps what it knows
|
||||
--dry-run read and check the declaration, change nothing
|
||||
```
|
||||
|
||||
```
|
||||
@@ -46,8 +50,35 @@ linux/amd64
|
||||
cannot be asked to: [firewall privileged]
|
||||
```
|
||||
|
||||
Stages 2 to 4 — applying from a pinned bundle, the link and the local store, and enrolment —
|
||||
are designed and not built.
|
||||
## Applying
|
||||
|
||||
A declaration is JSON, versioned, and an **ordered list** of resources — the order is stated
|
||||
rather than derived, because deriving it would be the host deciding
|
||||
([`novox/hq` ADR 0043](https://git.novox.be/novox/hq)). The vocabulary is `directory`, `file`
|
||||
and `service`, and **anything outside it refuses the whole declaration**: a host that skipped
|
||||
what it did not understand would apply most of a declaration and report success.
|
||||
|
||||
```json
|
||||
{"declaration":1,"resources":[
|
||||
{"id":"mesh-etc","type":"directory","path":"/etc/mesh","mode":"0755"},
|
||||
{"id":"node-conf","type":"file","path":"/etc/mesh/node.conf","content":"role = anchor\n","mode":"0640"},
|
||||
{"id":"journal","type":"service","unit":"systemd-journald.service","state":"running"}
|
||||
]}
|
||||
```
|
||||
|
||||
**It converges rather than executes.** Applying twice changes nothing the second time; applying
|
||||
to a drifted machine returns it. A mode is *maintained*, not merely set — a permission applied
|
||||
at creation is not a permission held.
|
||||
|
||||
**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. It knows which is which because it recorded what it
|
||||
did, after each thing worked.
|
||||
|
||||
**A failed step fails the apply.** No step runs after a failure, and the error carries what had
|
||||
already been done — the machine is in whatever state that left it, and pretending otherwise is
|
||||
the fault this exists to prevent.
|
||||
|
||||
Stages 3 and 4 — the link, and enrolment — are designed and not built.
|
||||
|
||||
## A capability is detected, never assumed
|
||||
|
||||
@@ -63,6 +94,12 @@ firewall is asked to list a ruleset, which needs the privilege as well as the to
|
||||
nobody can act on. The reason is what a person reads when a node will not take work they
|
||||
expected it to take.
|
||||
|
||||
**A unit that does not exist is not a unit that is stopped.** `systemctl is-active` says
|
||||
`inactive` for both, so declaring a unit stopped reported success for a unit the host cannot
|
||||
manage at all. `LoadState` separates them. Found by applying inside a raised machine, not by
|
||||
reasoning — and its sibling: removing an orphaned service whose unit has since been uninstalled
|
||||
used to fail the whole apply, which left a node able to apply *nothing*, ever.
|
||||
|
||||
**Exit codes are not the whole answer.** Found by running against a real machine rather than by
|
||||
reasoning: `systemctl is-system-running` exits non-zero for every state except `running` —
|
||||
including `degraded`, which means some units failed and the init is emphatically there. Reading
|
||||
|
||||
+122
-8
@@ -9,6 +9,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -17,8 +18,11 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/inventory"
|
||||
"github.com/novox/mesh-host/internal/profile"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// version is stamped at build time. Unset in a development build, and said so rather than
|
||||
@@ -29,12 +33,16 @@ const usage = `mesh-host — the node host
|
||||
|
||||
profile what this machine can be asked to do
|
||||
inventory what this machine is, and what it holds
|
||||
apply FILE make this machine match a declaration
|
||||
owned what this host has applied and still owns
|
||||
version
|
||||
|
||||
--json machine-readable output
|
||||
--timeout how long any single probe may take (default 10s)
|
||||
--state where this node keeps what it knows (default /var/lib/mesh-host/state.json)
|
||||
--dry-run read the declaration and refuse it if wrong, but change nothing
|
||||
|
||||
Stage 1: reports only. It applies nothing, connects to nothing, listens on nothing.
|
||||
It connects to nothing and listens on nothing. What it applies comes from a file.
|
||||
`
|
||||
|
||||
func main() {
|
||||
@@ -45,7 +53,7 @@ func main() {
|
||||
|
||||
command, opts, err := parseArgs(os.Args[1:])
|
||||
if err == nil {
|
||||
err = run(ctx, command, opts.json, opts.timeout)
|
||||
err = run(ctx, command, opts)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "mesh-host: %v\n", err)
|
||||
@@ -56,6 +64,9 @@ func main() {
|
||||
type options struct {
|
||||
json bool
|
||||
timeout time.Duration
|
||||
state string
|
||||
dryRun bool
|
||||
file string
|
||||
}
|
||||
|
||||
// parseArgs takes the subcommand first, then its flags.
|
||||
@@ -65,7 +76,7 @@ type options struct {
|
||||
// passed, silently ignored, with a successful exit. That is the fault this whole project keeps
|
||||
// naming, so the parser takes the subcommand off the front and parses what follows.
|
||||
func parseArgs(args []string) (string, options, error) {
|
||||
opts := options{timeout: 10 * time.Second}
|
||||
opts := options{timeout: 10 * time.Second, state: store.DefaultPath}
|
||||
|
||||
command := ""
|
||||
if len(args) > 0 {
|
||||
@@ -78,19 +89,45 @@ func parseArgs(args []string) (string, options, error) {
|
||||
set.Usage = func() { fmt.Fprint(os.Stderr, usage) }
|
||||
set.BoolVar(&opts.json, "json", false, "machine-readable output")
|
||||
set.DurationVar(&opts.timeout, "timeout", opts.timeout, "how long any single probe may take")
|
||||
set.StringVar(&opts.state, "state", opts.state, "where this node keeps what it knows")
|
||||
set.BoolVar(&opts.dryRun, "dry-run", false, "read and check the declaration, change nothing")
|
||||
|
||||
if err := set.Parse(args); err != nil {
|
||||
return "", opts, err
|
||||
// Parsed in a loop, because the standard library stops at the FIRST non-flag argument.
|
||||
// `mesh-host inventory --json` hit that once, and taking the subcommand off the front
|
||||
// fixed only half of it: `mesh-host apply decl.json --dry-run` left --dry-run unread in
|
||||
// exactly the same way. A flag may sit before, after or between positionals, and one that
|
||||
// is silently dropped is the fault this whole project keeps naming.
|
||||
var positionals []string
|
||||
rest := args
|
||||
for {
|
||||
if err := set.Parse(rest); err != nil {
|
||||
return "", opts, err
|
||||
}
|
||||
rest = set.Args()
|
||||
if len(rest) == 0 {
|
||||
break
|
||||
}
|
||||
positionals = append(positionals, rest[0])
|
||||
rest = rest[1:]
|
||||
}
|
||||
|
||||
if command == "apply" {
|
||||
if len(positionals) != 1 {
|
||||
return "", opts, errors.New("apply needs exactly one declaration file")
|
||||
}
|
||||
opts.file = positionals[0]
|
||||
return command, opts, nil
|
||||
}
|
||||
// Anything left over was neither the command nor a flag. Refused rather than ignored: a
|
||||
// mistyped argument that changes nothing and reports success is worse than an error.
|
||||
if rest := set.Args(); len(rest) > 0 {
|
||||
return "", opts, fmt.Errorf("unexpected argument %q — try `mesh-host help`", rest[0])
|
||||
if len(positionals) > 0 {
|
||||
return "", opts, fmt.Errorf("unexpected argument %q — try `mesh-host help`", positionals[0])
|
||||
}
|
||||
return command, opts, nil
|
||||
}
|
||||
|
||||
func run(ctx context.Context, command string, jsonOut bool, timeout time.Duration) error {
|
||||
func run(ctx context.Context, command string, opts options) error {
|
||||
jsonOut, timeout := opts.json, opts.timeout
|
||||
switch command {
|
||||
case "profile":
|
||||
p := profile.Detect(ctx, profile.Default(nil), timeout)
|
||||
@@ -108,6 +145,27 @@ func run(ctx context.Context, command string, jsonOut bool, timeout time.Duratio
|
||||
writeInventory(inv)
|
||||
return nil
|
||||
|
||||
case "apply":
|
||||
return runApply(ctx, opts)
|
||||
|
||||
case "owned":
|
||||
known, err := store.Load(opts.state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOut {
|
||||
return writeJSON(known)
|
||||
}
|
||||
if len(known.Resources) == 0 {
|
||||
fmt.Println("this host has applied nothing on this machine")
|
||||
return nil
|
||||
}
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
for _, r := range known.Resources {
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\n", r.Type, r.ID, r.Target)
|
||||
}
|
||||
return w.Flush()
|
||||
|
||||
case "version":
|
||||
fmt.Println(version)
|
||||
return nil
|
||||
@@ -178,3 +236,59 @@ func writeInventory(inv inventory.Inventory) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runApply reads a declaration and makes the machine match it.
|
||||
//
|
||||
// The state is loaded before anything is touched and saved after, including when the apply
|
||||
// fails part-way: what was applied before the failure is on the machine, and a host that did
|
||||
// not record it would believe it owns less than it does and leave that behind forever.
|
||||
func runApply(ctx context.Context, opts options) error {
|
||||
raw, err := os.ReadFile(opts.file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading the declaration: %w", err)
|
||||
}
|
||||
|
||||
d, err := declaration.Parse(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
known, err := store.Load(opts.state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.dryRun {
|
||||
fmt.Printf("%s: %d resource(s), version %d — accepted, nothing applied\n",
|
||||
opts.file, len(d.Resources), d.Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
report, updated, applyErr := apply.Apply(ctx, d, known, apply.ExecRunner, func(line string) {
|
||||
if !opts.json {
|
||||
fmt.Println(line)
|
||||
}
|
||||
})
|
||||
|
||||
// Saved whichever way it went. Recording only on success would lose the footprint of a
|
||||
// failed apply, and that footprint is on the machine either way.
|
||||
if saveErr := store.Save(opts.state, updated); saveErr != nil {
|
||||
if applyErr != nil {
|
||||
return fmt.Errorf("%w\n\nand the node's state could not be saved: %v", applyErr, saveErr)
|
||||
}
|
||||
return saveErr
|
||||
}
|
||||
if applyErr != nil {
|
||||
return applyErr
|
||||
}
|
||||
|
||||
if opts.json {
|
||||
return writeJSON(report)
|
||||
}
|
||||
if !report.Changed() {
|
||||
fmt.Printf("%s: already matches — %d resource(s) checked\n", opts.file, len(report.Outcomes))
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%s: applied — %d resource(s)\n", opts.file, len(report.Outcomes))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -81,3 +82,56 @@ func TestNoCommandIsNotAnError(t *testing.T) {
|
||||
t.Errorf("command = %q, want empty", command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyNeedsExactlyOneDeclaration(t *testing.T) {
|
||||
// `apply` takes a file where every other command takes nothing, so the leftover-argument
|
||||
// rule has an exception — and an exception is where a parser stops refusing things it
|
||||
// should. Both directions are checked.
|
||||
if _, _, err := parseArgs([]string{"apply"}); err == nil {
|
||||
t.Error("apply with no file was accepted")
|
||||
}
|
||||
if _, _, err := parseArgs([]string{"apply", "a.json", "b.json"}); err == nil {
|
||||
t.Error("apply with two files was accepted")
|
||||
}
|
||||
|
||||
command, opts, err := parseArgs([]string{"apply", "decl.json", "--dry-run"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if command != "apply" || opts.file != "decl.json" || !opts.dryRun {
|
||||
t.Errorf("parsed as command=%q file=%q dry-run=%v", command, opts.file, opts.dryRun)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheStateHasADocumentedDefault(t *testing.T) {
|
||||
// A host that wrote its state somewhere unexpected would forget what it owns on the next
|
||||
// run, and then leave everything it had applied behind forever.
|
||||
_, opts, err := parseArgs([]string{"owned"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if opts.state != store.DefaultPath {
|
||||
t.Errorf("default state path is %q, not the documented %q", opts.state, store.DefaultPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFlagAfterAPositionalIsRead(t *testing.T) {
|
||||
// The same fault as TestAFlagAfterTheCommandIsRead, one level down. Taking the subcommand
|
||||
// off the front fixed the flag after the COMMAND and not the flag after its ARGUMENT: the
|
||||
// standard library stops at the first non-flag argument wherever that argument is.
|
||||
for _, args := range [][]string{
|
||||
{"apply", "decl.json", "--dry-run", "--json"},
|
||||
{"apply", "--dry-run", "decl.json", "--json"},
|
||||
{"apply", "--dry-run", "--json", "decl.json"},
|
||||
} {
|
||||
command, opts, err := parseArgs(args)
|
||||
if err != nil {
|
||||
t.Errorf("%v: unexpected error: %v", args, err)
|
||||
continue
|
||||
}
|
||||
if command != "apply" || opts.file != "decl.json" || !opts.dryRun || !opts.json {
|
||||
t.Errorf("%v parsed as file=%q dry-run=%v json=%v",
|
||||
args, opts.file, opts.dryRun, opts.json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
// Package apply makes a machine match a declaration.
|
||||
//
|
||||
// Three properties, each following a recorded decision, and each of them the difference
|
||||
// between this and a script that writes files:
|
||||
//
|
||||
// - A failed step fails the apply (novox/hq ADR 0008). Not "logs and continues": a partial
|
||||
// apply that reports success is the mesh's most expensive shape.
|
||||
// - Every applier READS BACK. Setting a value is not evidence the value took.
|
||||
// - What was applied is recorded after it works, never before (ADR 0035). A failed apply
|
||||
// leaves the machine in whatever state it reached, and nothing must claim otherwise.
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Runner executes a command. The real one is used everywhere outside unit tests; behaviour
|
||||
// against a real system is tested alongside rather than mocked (novox/hq ADR 0034).
|
||||
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
||||
|
||||
// Outcome is what happened to one resource.
|
||||
type Outcome struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Target string `json:"target"`
|
||||
Action string `json:"action"` // created · updated · unchanged · removed
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// Report is what an apply did, in the order it did it.
|
||||
type Report struct {
|
||||
Outcomes []Outcome `json:"outcomes"`
|
||||
}
|
||||
|
||||
// Changed reports whether anything about the machine actually moved. An apply that changed
|
||||
// nothing is the ordinary steady state, and saying so is not the same as saying it failed.
|
||||
func (r Report) Changed() bool {
|
||||
for _, o := range r.Outcomes {
|
||||
if o.Action != "unchanged" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Error is a failure part-way through, carrying what had already been done.
|
||||
//
|
||||
// The outcomes matter as much as the message: the machine is in whatever state the apply
|
||||
// reached, and the only honest thing to hand back is the list of what did happen.
|
||||
type Error struct {
|
||||
Resource string
|
||||
Err error
|
||||
Done Report
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
return fmt.Sprintf("applying %q: %v\n\n%d resource(s) were applied before this and remain; "+
|
||||
"the machine is in whatever state that left it.", e.Resource, e.Err, len(e.Done.Outcomes))
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error { return e.Err }
|
||||
|
||||
// Apply makes the machine match the declaration, and returns what it did.
|
||||
//
|
||||
// Removal happens FIRST, and the order is not arbitrary. A resource that leaves a declaration
|
||||
// while another arrives at the same path is an ordinary rename: removing afterwards would
|
||||
// delete the file that had just been written. Removing first risks losing the old state if the
|
||||
// apply then fails — a recovery concern, where the other is a correctness one.
|
||||
func Apply(
|
||||
ctx context.Context,
|
||||
d *declaration.Declaration,
|
||||
known store.State,
|
||||
run Runner,
|
||||
log func(string),
|
||||
) (Report, store.State, error) {
|
||||
if log == nil {
|
||||
log = func(string) {}
|
||||
}
|
||||
report := Report{}
|
||||
|
||||
declared := map[string]bool{}
|
||||
for _, r := range d.Resources {
|
||||
declared[r.ID] = true
|
||||
}
|
||||
|
||||
for _, orphan := range known.Orphans(declared) {
|
||||
if err := remove(ctx, orphan, run); err != nil {
|
||||
return report, known, &Error{Resource: orphan.ID, Err: err, Done: report}
|
||||
}
|
||||
known.Forget(orphan.ID)
|
||||
report.Outcomes = append(report.Outcomes, Outcome{
|
||||
ID: orphan.ID, Type: orphan.Type, Target: orphan.Target, Action: "removed",
|
||||
Detail: "no longer declared",
|
||||
})
|
||||
log(fmt.Sprintf(" removed %s (%s)", orphan.ID, orphan.Target))
|
||||
}
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
// Only now. The record follows the fact, never leads it.
|
||||
known.Record(store.Applied{
|
||||
ID: resource.ID, Type: string(resource.Type),
|
||||
Target: outcome.Target, AppliedAt: time.Now().UTC(),
|
||||
})
|
||||
report.Outcomes = append(report.Outcomes, outcome)
|
||||
if outcome.Action != "unchanged" {
|
||||
log(fmt.Sprintf(" %s %s (%s)", outcome.Action, outcome.ID, outcome.Target))
|
||||
}
|
||||
}
|
||||
return report, known, nil
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func modeOf(spec string, fallback os.FileMode) (os.FileMode, error) {
|
||||
if spec == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := strconv.ParseUint(spec, 8, 32)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("mode %q: %w", spec, err)
|
||||
}
|
||||
return os.FileMode(parsed), nil
|
||||
}
|
||||
|
||||
func applyDirectory(r declaration.Resource) (Outcome, error) {
|
||||
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Path}
|
||||
mode, err := modeOf(r.Mode, 0o755)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
before, err := os.Stat(r.Path)
|
||||
existed := err == nil
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return out, err
|
||||
}
|
||||
if existed && !before.IsDir() {
|
||||
return out, fmt.Errorf("%s exists and is not a directory", r.Path)
|
||||
}
|
||||
|
||||
if !existed {
|
||||
if err := os.MkdirAll(r.Path, mode); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
// Set explicitly even when it existed: MkdirAll applies the mode only on creation, and a
|
||||
// permission set at creation is not a permission maintained — a lesson this repository
|
||||
// already paid for once, with world-readable environment files.
|
||||
if err := os.Chmod(r.Path, mode); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Read back.
|
||||
after, err := os.Stat(r.Path)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("made %s and cannot stat it: %w", r.Path, err)
|
||||
}
|
||||
if !after.IsDir() {
|
||||
return out, fmt.Errorf("%s is not a directory after applying", r.Path)
|
||||
}
|
||||
if after.Mode().Perm() != mode.Perm() {
|
||||
return out, fmt.Errorf("%s is mode %o after setting %o", r.Path, after.Mode().Perm(), mode.Perm())
|
||||
}
|
||||
|
||||
out.Action = "unchanged"
|
||||
if !existed {
|
||||
out.Action = "created"
|
||||
} else if before.Mode().Perm() != mode.Perm() {
|
||||
out.Action = "updated"
|
||||
out.Detail = fmt.Sprintf("mode %o to %o", before.Mode().Perm(), mode.Perm())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func applyFile(r declaration.Resource) (Outcome, error) {
|
||||
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Path}
|
||||
mode, err := modeOf(r.Mode, 0o644)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
existing, readErr := os.ReadFile(r.Path)
|
||||
existed := readErr == nil
|
||||
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
||||
return out, readErr
|
||||
}
|
||||
|
||||
var beforeMode os.FileMode
|
||||
if existed {
|
||||
if info, err := os.Stat(r.Path); err == nil {
|
||||
beforeMode = info.Mode().Perm()
|
||||
}
|
||||
}
|
||||
|
||||
contentSame := existed && string(existing) == r.Content
|
||||
modeSame := existed && beforeMode == mode.Perm()
|
||||
|
||||
if !contentSame {
|
||||
if err := os.MkdirAll(filepath.Dir(r.Path), 0o755); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := writeAtomically(r.Path, []byte(r.Content), mode); err != nil {
|
||||
return out, err
|
||||
}
|
||||
} else if !modeSame {
|
||||
if err := os.Chmod(r.Path, mode); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
|
||||
// Read back — the file, not the call that wrote it.
|
||||
written, err := os.ReadFile(r.Path)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("wrote %s and cannot read it back: %w", r.Path, err)
|
||||
}
|
||||
if string(written) != r.Content {
|
||||
return out, fmt.Errorf("%s does not contain what was declared after writing it", r.Path)
|
||||
}
|
||||
info, err := os.Stat(r.Path)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if info.Mode().Perm() != mode.Perm() {
|
||||
return out, fmt.Errorf("%s is mode %o after setting %o", r.Path, info.Mode().Perm(), mode.Perm())
|
||||
}
|
||||
|
||||
switch {
|
||||
case !existed:
|
||||
out.Action = "created"
|
||||
case !contentSame && !modeSame:
|
||||
out.Action = "updated"
|
||||
out.Detail = "content and mode"
|
||||
case !contentSame:
|
||||
out.Action = "updated"
|
||||
out.Detail = "content"
|
||||
case !modeSame:
|
||||
out.Action = "updated"
|
||||
out.Detail = fmt.Sprintf("mode %o to %o", beforeMode, mode.Perm())
|
||||
default:
|
||||
out.Action = "unchanged"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// writeAtomically writes through a temporary file in the same directory.
|
||||
//
|
||||
// A reader of a managed file must never see half of one. The mesh's own configuration is read
|
||||
// by daemons that reload on change, so a torn write is a service reading a truncated config.
|
||||
func writeAtomically(path string, content []byte, mode os.FileMode) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".mesh-host-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
|
||||
if _, err := tmp.Write(content); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp.Name(), mode); err != nil {
|
||||
return err
|
||||
}
|
||||
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}
|
||||
|
||||
before, err := serviceState(ctx, r.Unit, run)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if before == r.State {
|
||||
out.Action = "unchanged"
|
||||
out.Detail = before
|
||||
return out, nil
|
||||
}
|
||||
|
||||
verb := "start"
|
||||
if r.State == "stopped" {
|
||||
verb = "stop"
|
||||
}
|
||||
if _, err := run(ctx, "systemctl", verb, r.Unit); err != nil {
|
||||
return out, fmt.Errorf("%s %s: %w", verb, r.Unit, err)
|
||||
}
|
||||
|
||||
// Read back. `systemctl start` returning zero says the transaction was accepted, not that
|
||||
// the unit is running — a unit that starts and immediately dies satisfies the command.
|
||||
after, err := serviceState(ctx, r.Unit, run)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if after != r.State {
|
||||
return out, fmt.Errorf("%s was asked to be %s and is %s", r.Unit, r.State, after)
|
||||
}
|
||||
|
||||
out.Action = "updated"
|
||||
out.Detail = before + " to " + after
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// serviceState reads what the service manager says about a unit.
|
||||
//
|
||||
// Two traps here, and both were hit before this read what it now reads.
|
||||
//
|
||||
// The exit code is not the answer: `is-active` exits non-zero for every state except active —
|
||||
// the same shape as the capability detector reading a degraded init as no init at all.
|
||||
//
|
||||
// And "inactive" does not mean stopped. `systemctl is-active` says "inactive" for a unit that
|
||||
// DOES NOT EXIST exactly as it does for one that is installed and stopped. Declaring a unit
|
||||
// stopped therefore 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 is what separates
|
||||
// them, so LoadState is what is read.
|
||||
func serviceState(ctx context.Context, unit string, run Runner) (string, error) {
|
||||
out, _ := run(ctx, "systemctl", "show", unit,
|
||||
"--property=LoadState", "--property=ActiveState")
|
||||
|
||||
var load, active string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
key, value, found := strings.Cut(strings.TrimSpace(line), "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "LoadState":
|
||||
load = value
|
||||
case "ActiveState":
|
||||
active = value
|
||||
}
|
||||
}
|
||||
|
||||
switch load {
|
||||
case "":
|
||||
return "", fmt.Errorf("the service manager said nothing about %s", unit)
|
||||
case "not-found":
|
||||
return "", fmt.Errorf(
|
||||
"%s does not exist on this machine. A declaration naming a unit that is not "+
|
||||
"installed cannot be satisfied, and reporting it stopped would be reporting "+
|
||||
"absence as success", unit)
|
||||
case "masked":
|
||||
return "", fmt.Errorf("%s is masked, so its state cannot be declared", unit)
|
||||
case "error", "bad-setting":
|
||||
return "", fmt.Errorf("%s is installed but its unit file cannot be loaded (%s)", unit, load)
|
||||
}
|
||||
|
||||
switch active {
|
||||
case "active", "activating", "reloading":
|
||||
return "running", nil
|
||||
case "inactive", "failed", "deactivating":
|
||||
return "stopped", nil
|
||||
default:
|
||||
return "", fmt.Errorf(
|
||||
"the service manager reports %s as %q, which is neither running nor stopped", unit, active)
|
||||
}
|
||||
}
|
||||
|
||||
// remove undoes one resource the host applied and the declaration no longer names.
|
||||
//
|
||||
// Only ever called for something in the store, which is what bounds it: the host is
|
||||
// authoritative over its own footprint and inert everywhere else (novox/hq ADR 0043).
|
||||
func remove(ctx context.Context, a store.Applied, run Runner) error {
|
||||
switch declaration.Type(a.Type) {
|
||||
case declaration.TypeFile, declaration.TypeDirectory:
|
||||
if err := os.RemoveAll(a.Target); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(a.Target); !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("%s is still there after removing it", a.Target)
|
||||
}
|
||||
return nil
|
||||
|
||||
case declaration.TypeService:
|
||||
// A unit that is no longer declared is stopped, not deleted. The host did not install
|
||||
// it and does not own the unit file — only the state it put the unit into.
|
||||
//
|
||||
// A unit that no longer EXISTS is already in the state removal is trying to reach, and
|
||||
// saying so matters: stopping it fails, and a failure here fails the whole apply. A
|
||||
// host holding a record of an uninstalled unit would then be unable to apply anything,
|
||||
// ever, with no way out but editing its state by hand. Removal is idempotent for the
|
||||
// same reason `os.RemoveAll` is.
|
||||
if _, err := serviceState(ctx, a.Target, run); err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist on this machine") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if _, err := run(ctx, "systemctl", "stop", a.Target); err != nil {
|
||||
return fmt.Errorf("stopping %s: %w", a.Target, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("no way to remove a %q", a.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// ExecRunner runs a real command, with stdin closed and output captured.
|
||||
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Stdin = nil
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
return string(out), fmt.Errorf("%s exited %d: %s",
|
||||
name, exit.ExitCode(), strings.TrimSpace(string(exit.Stderr)))
|
||||
}
|
||||
return string(out), fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Each test names the decision it defends (novox/hq ADR 0034).
|
||||
|
||||
func parse(t *testing.T, raw string) *declaration.Declaration {
|
||||
t.Helper()
|
||||
d, err := declaration.Parse([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("fixture is not a valid declaration: %v", err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// noServices refuses to run anything. Used where a test declares no services, so that a test
|
||||
// which accidentally reaches the service manager fails loudly instead of passing quietly.
|
||||
func noServices(context.Context, string, ...string) (string, error) {
|
||||
return "", errors.New("this test declares no services and should not have run a command")
|
||||
}
|
||||
|
||||
func TestApplyingTwiceChangesNothingTheSecondTime(t *testing.T) {
|
||||
// Idempotence is what makes an apply safe to run on a schedule. Without it, a host that
|
||||
// reconciles every few minutes rewrites files forever and every reader sees churn.
|
||||
dir := t.TempDir()
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"d","type":"directory","path":"`+dir+`/etc","mode":"0755"},
|
||||
{"id":"f","type":"file","path":"`+dir+`/etc/a.conf","content":"hello\n","mode":"0640"}
|
||||
]}`)
|
||||
|
||||
first, state, err := Apply(context.Background(), d, store.State{}, noServices, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !first.Changed() {
|
||||
t.Fatal("the first apply on an empty machine changed nothing")
|
||||
}
|
||||
|
||||
second, _, err := Apply(context.Background(), d, state, noServices, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Changed() {
|
||||
t.Errorf("the second apply changed something: %+v", second.Outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADriftedMachineIsReturned(t *testing.T) {
|
||||
// The other half of idempotence, and the half that matters: converging is not "do nothing
|
||||
// if the state file says it was done". The machine is read, not the record.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "a.conf")
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"f","type":"file","path":"`+path+`","content":"correct\n","mode":"0644"}
|
||||
]}`)
|
||||
|
||||
_, state, err := Apply(context.Background(), d, store.State{}, noServices, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("someone edited this\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, _, err := Apply(context.Background(), d, state, noServices, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.Changed() {
|
||||
t.Fatal("a drifted file was left drifted")
|
||||
}
|
||||
got, _ := os.ReadFile(path)
|
||||
if string(got) != "correct\n" {
|
||||
t.Errorf("the file was not returned: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADroppedResourceIsRemoved(t *testing.T) {
|
||||
// novox/hq ADR 0043: the host removes what it previously applied and is no longer
|
||||
// declared. Removing a line from a declaration is an act with an effect.
|
||||
dir := t.TempDir()
|
||||
keep := filepath.Join(dir, "keep.conf")
|
||||
drop := filepath.Join(dir, "drop.conf")
|
||||
|
||||
both := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"keep","type":"file","path":"`+keep+`","content":"a\n"},
|
||||
{"id":"drop","type":"file","path":"`+drop+`","content":"b\n"}
|
||||
]}`)
|
||||
_, state, err := Apply(context.Background(), both, store.State{}, noServices, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
one := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"keep","type":"file","path":"`+keep+`","content":"a\n"}
|
||||
]}`)
|
||||
report, state, err := Apply(context.Background(), one, state, noServices, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(drop); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Error("a resource dropped from the declaration was left on the machine")
|
||||
}
|
||||
if _, err := os.Stat(keep); err != nil {
|
||||
t.Error("a declared resource was removed")
|
||||
}
|
||||
if _, still := state.Find("drop"); still {
|
||||
t.Error("the host still believes it owns what it removed")
|
||||
}
|
||||
if report.Outcomes[0].Action != "removed" {
|
||||
t.Errorf("removal is not reported first: %+v", report.Outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingTheHostDidNotCreateIsTouched(t *testing.T) {
|
||||
// The boundary the whole removal rule turns on. A machine has things on it the mesh did
|
||||
// not put there, and a converger that treats "not declared" as "must not exist" deletes
|
||||
// them. Authoritative over its own footprint; inert everywhere else.
|
||||
dir := t.TempDir()
|
||||
stranger := filepath.Join(dir, "not-ours.conf")
|
||||
if err := os.WriteFile(stranger, []byte("someone else's\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"ours","type":"file","path":"`+filepath.Join(dir, "ours.conf")+`","content":"a\n"}
|
||||
]}`)
|
||||
if _, _, err := Apply(context.Background(), d, store.State{}, noServices, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(stranger)
|
||||
if err != nil || string(got) != "someone else's\n" {
|
||||
t.Error("a file the host did not create was removed or changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARenameToTheSamePathDoesNotDeleteTheNewFile(t *testing.T) {
|
||||
// Why removal happens FIRST. A resource leaving a declaration while another arrives at the
|
||||
// same path is an ordinary rename; removing afterwards would delete the file just written.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "shared.conf")
|
||||
|
||||
before := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"old","type":"file","path":"`+path+`","content":"old\n"}
|
||||
]}`)
|
||||
_, state, err := Apply(context.Background(), before, store.State{}, noServices, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
after := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"new","type":"file","path":"`+path+`","content":"new\n"}
|
||||
]}`)
|
||||
if _, _, err := Apply(context.Background(), after, state, noServices, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("the renamed resource is gone: %v", err)
|
||||
}
|
||||
if string(got) != "new\n" {
|
||||
t.Errorf("content is %q, want the new one", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedStepFailsTheApply(t *testing.T) {
|
||||
// novox/hq ADR 0008. And the error carries what HAD been done, because the machine is in
|
||||
// whatever state the apply reached and the only honest thing to hand back is that list.
|
||||
dir := t.TempDir()
|
||||
blocker := filepath.Join(dir, "blocker")
|
||||
if err := os.WriteFile(blocker, []byte("i am a file\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"fine","type":"file","path":"`+filepath.Join(dir, "fine.conf")+`","content":"a\n"},
|
||||
{"id":"doomed","type":"directory","path":"`+blocker+`"},
|
||||
{"id":"never","type":"file","path":"`+filepath.Join(dir, "never.conf")+`","content":"b\n"}
|
||||
]}`)
|
||||
|
||||
_, _, err := Apply(context.Background(), d, store.State{}, noServices, nil)
|
||||
if err == nil {
|
||||
t.Fatal("an impossible resource did not fail the apply")
|
||||
}
|
||||
|
||||
var applyErr *Error
|
||||
if !errors.As(err, &applyErr) {
|
||||
t.Fatalf("expected an apply error, got %T", err)
|
||||
}
|
||||
if applyErr.Resource != "doomed" {
|
||||
t.Errorf("the failure names %q, not the resource that failed", applyErr.Resource)
|
||||
}
|
||||
if len(applyErr.Done.Outcomes) != 1 {
|
||||
t.Errorf("the error does not carry what was already applied: %+v", applyErr.Done.Outcomes)
|
||||
}
|
||||
// And nothing after the failure ran.
|
||||
if _, err := os.Stat(filepath.Join(dir, "never.conf")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Error("the apply continued past a failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingIsRecordedUntilItWorked(t *testing.T) {
|
||||
// novox/hq ADR 0035. A record written before the fact restates the request in a new place
|
||||
// and inherits none of the authority of having happened.
|
||||
dir := t.TempDir()
|
||||
blocker := filepath.Join(dir, "blocker")
|
||||
if err := os.WriteFile(blocker, []byte("x\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"doomed","type":"directory","path":"`+blocker+`"}
|
||||
]}`)
|
||||
|
||||
_, state, err := Apply(context.Background(), d, store.State{}, noServices, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a failure")
|
||||
}
|
||||
if _, claimed := state.Find("doomed"); claimed {
|
||||
t.Error("the host recorded owning something it failed to apply")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModeIsMaintainedNotJustSet(t *testing.T) {
|
||||
// A permission set at creation is not a permission maintained — this repository has
|
||||
// already paid for that once, with generated files left world-readable because the mode
|
||||
// applied only when the file was first written.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "secret.conf")
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"f","type":"file","path":"`+path+`","content":"s\n","mode":"0600"}
|
||||
]}`)
|
||||
|
||||
_, state, err := Apply(context.Background(), d, store.State{}, noServices, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o666); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, _, err := Apply(context.Background(), d, state, noServices, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, _ := os.Stat(path)
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("mode is %o after reconciling, want 0600", info.Mode().Perm())
|
||||
}
|
||||
if !report.Changed() {
|
||||
t.Error("a mode that had drifted was reported as unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAServiceIsReadBackNotAssumed(t *testing.T) {
|
||||
// `systemctl start` returning zero says the transaction was accepted, not that the unit is
|
||||
// running. A unit that starts and immediately dies satisfies the command.
|
||||
started := false
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
if args[0] == "show" {
|
||||
if started {
|
||||
return "LoadState=loaded\nActiveState=failed\n", nil // started, then died
|
||||
}
|
||||
return "LoadState=loaded\nActiveState=inactive\n", nil
|
||||
}
|
||||
started = true
|
||||
return "", nil // `systemctl start` succeeds
|
||||
}
|
||||
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"s","type":"service","unit":"doomed.service","state":"running"}
|
||||
]}`)
|
||||
_, _, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a service that died immediately was reported as running")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "asked to be running and is stopped") {
|
||||
t.Errorf("the failure does not say what was observed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnknownServiceStateIsRefusedNotGuessed(t *testing.T) {
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
return "LoadState=loaded\nActiveState=reticent\n", nil
|
||||
}
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"s","type":"service","unit":"odd.service","state":"running"}
|
||||
]}`)
|
||||
_, _, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "neither running nor stopped") {
|
||||
t.Errorf("an unrecognised service state was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADroppedServiceIsStoppedNotDeleted(t *testing.T) {
|
||||
// The host did not install the unit and does not own the unit file — only the state it put
|
||||
// the unit into.
|
||||
var commands []string
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
commands = append(commands, strings.Join(args, " "))
|
||||
if args[0] == "show" {
|
||||
return "LoadState=loaded\nActiveState=active\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
state := store.State{Resources: []store.Applied{
|
||||
{ID: "s", Type: "service", Target: "gone.service"},
|
||||
}}
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"other","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
|
||||
]}`)
|
||||
|
||||
if _, _, err := Apply(context.Background(), d, state, run, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := strings.Join(commands, "; ")
|
||||
if !strings.Contains(joined, "stop gone.service") {
|
||||
t.Errorf("the dropped service was not stopped: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "disable") || strings.Contains(joined, "mask") {
|
||||
t.Errorf("the host did more than stop a unit it does not own: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAUnitThatDoesNotExistIsNotStopped(t *testing.T) {
|
||||
// Found by applying inside a raised machine. `systemctl is-active` says "inactive" for a
|
||||
// unit that DOES NOT EXIST exactly as it does for one that is installed and stopped, so
|
||||
// declaring a unit stopped reported success for a unit the host cannot manage at all.
|
||||
//
|
||||
// Absence read as satisfaction — 04-ISSUES/007 wearing a different hat, and the mirror of
|
||||
// the degraded-init bug the capability detector had.
|
||||
absent := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
return "LoadState=not-found\nActiveState=inactive\n", nil
|
||||
}
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"s","type":"service","unit":"never-installed.service","state":"stopped"}
|
||||
]}`)
|
||||
|
||||
_, state, err := Apply(context.Background(), d, store.State{}, absent, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a unit that does not exist was reported as satisfactorily stopped")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not exist on this machine") {
|
||||
t.Errorf("the failure does not say the unit is absent: %v", err)
|
||||
}
|
||||
if _, claimed := state.Find("s"); claimed {
|
||||
t.Error("the host recorded owning a unit that is not installed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMaskedUnitIsRefused(t *testing.T) {
|
||||
// Masked means someone deliberately made it unstartable. Applying over that would undo a
|
||||
// decision the host did not make and cannot see the reason for.
|
||||
masked := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
return "LoadState=masked\nActiveState=inactive\n", nil
|
||||
}
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"s","type":"service","unit":"masked.service","state":"running"}
|
||||
]}`)
|
||||
if _, _, err := Apply(context.Background(), d, store.State{}, masked, nil); err == nil {
|
||||
t.Fatal("a masked unit was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgettingAUnitThatIsGoneDoesNotStrandTheNode(t *testing.T) {
|
||||
// Found on a real machine. Removing an orphaned service runs `systemctl stop`, which fails
|
||||
// when the unit no longer exists — and a failure there fails the whole apply. A host
|
||||
// holding a record of an uninstalled unit could then apply NOTHING, ever, with no way out
|
||||
// but editing its state by hand.
|
||||
//
|
||||
// Removal is idempotent for the same reason os.RemoveAll is: the desired end state is
|
||||
// already true.
|
||||
var stopped bool
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
if args[0] == "show" {
|
||||
return "LoadState=not-found\nActiveState=inactive\n", nil
|
||||
}
|
||||
stopped = true
|
||||
return "", errors.New("systemctl exited 5: Unit not loaded")
|
||||
}
|
||||
known := store.State{Resources: []store.Applied{
|
||||
{ID: "gone", Type: "service", Target: "uninstalled.service"},
|
||||
}}
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"f","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
|
||||
]}`)
|
||||
|
||||
report, state, err := Apply(context.Background(), d, known, run, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("a vanished unit stranded the apply: %v", err)
|
||||
}
|
||||
if stopped {
|
||||
t.Error("the host tried to stop a unit that does not exist")
|
||||
}
|
||||
if _, still := state.Find("gone"); still {
|
||||
t.Error("the host still believes it owns a unit that is gone")
|
||||
}
|
||||
if report.Outcomes[0].Action != "removed" {
|
||||
t.Errorf("the vanished unit was not reported as removed: %+v", report.Outcomes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// 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, ", ")
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package declaration
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Each test names the decision it defends (novox/hq ADR 0034). The decision here is ADR 0043,
|
||||
// and the property it turns on is that unknown is REFUSED, never skipped.
|
||||
|
||||
func valid() string {
|
||||
return `{"declaration":1,"resources":[
|
||||
{"id":"etc","type":"directory","path":"/etc/mesh","mode":"0755"},
|
||||
{"id":"conf","type":"file","path":"/etc/mesh/host.conf","content":"a\n","mode":"0640"},
|
||||
{"id":"svc","type":"service","unit":"mesh-host.service","state":"running"}
|
||||
]}`
|
||||
}
|
||||
|
||||
func refusalFor(t *testing.T, raw string) *RefusalError {
|
||||
t.Helper()
|
||||
_, err := Parse([]byte(raw))
|
||||
if err == nil {
|
||||
t.Fatal("expected a refusal")
|
||||
}
|
||||
var refusal *RefusalError
|
||||
if !errors.As(err, &refusal) {
|
||||
t.Fatalf("expected a RefusalError, got %T: %v", err, err)
|
||||
}
|
||||
return refusal
|
||||
}
|
||||
|
||||
func TestAValidDeclarationParsesInOrder(t *testing.T) {
|
||||
d, err := Parse([]byte(valid()))
|
||||
if err != nil {
|
||||
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}
|
||||
want := []string{"etc", "conf", "svc"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("resources reordered: %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnknownTypeRefusesTheWholeDeclaration(t *testing.T) {
|
||||
// The property everything else rests on. A host that skipped what it did not understand
|
||||
// would apply most of a declaration and report success — a node that looks configured and
|
||||
// is not, which is 04-ISSUES/003 with the declaration on the other side of the wire.
|
||||
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
||||
{"id":"ok","type":"directory","path":"/etc/mesh"},
|
||||
{"id":"what","type":"blockchain","path":"/etc/mesh"}
|
||||
]}`)
|
||||
|
||||
joined := strings.Join(refusal.Problems, "\n")
|
||||
if !strings.Contains(joined, "blockchain") {
|
||||
t.Errorf("the unknown type was not named: %v", refusal.Problems)
|
||||
}
|
||||
// And it must say what IS understood, or the reader goes to the source to find out.
|
||||
if !strings.Contains(joined, "directory") || !strings.Contains(joined, "service") {
|
||||
t.Errorf("the refusal does not say what this host understands: %v", refusal.Problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnknownFieldIsRefused(t *testing.T) {
|
||||
// A field the host does not know is a thing the control plane believes it asked for.
|
||||
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
||||
{"id":"conf","type":"file","path":"/etc/x","content":"a","owner":"root"}
|
||||
]}`)
|
||||
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "owner") {
|
||||
t.Errorf("the unknown field was not named: %v", refusal.Problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFieldTheTypeDoesNotUseIsRefusedNotIgnored(t *testing.T) {
|
||||
// The same fault in miniature: set and ignored means the control plane believes it asked
|
||||
// for something the host will never do.
|
||||
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
||||
{"id":"svc","type":"service","unit":"a.service","state":"running","path":"/etc/x"}
|
||||
]}`)
|
||||
joined := strings.Join(refusal.Problems, "\n")
|
||||
if !strings.Contains(joined, "path") || !strings.Contains(joined, "Refused rather than ignored") {
|
||||
t.Errorf("a field a service does not use was accepted: %v", refusal.Problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnknownVersionIsRefusedWhole(t *testing.T) {
|
||||
// An older host handed a newer vocabulary must not quietly do half of it.
|
||||
refusal := refusalFor(t, `{"declaration":99,"resources":[
|
||||
{"id":"a","type":"directory","path":"/etc/mesh"}
|
||||
]}`)
|
||||
joined := strings.Join(refusal.Problems, "\n")
|
||||
if !strings.Contains(joined, "99") || !strings.Contains(joined, "version 1") {
|
||||
t.Errorf("the version mismatch was not stated plainly: %v", refusal.Problems)
|
||||
}
|
||||
// Nothing else is reported, because everything else assumes a vocabulary this host does
|
||||
// not have — a list of complaints derived from the wrong grammar is noise.
|
||||
if len(refusal.Problems) != 1 {
|
||||
t.Errorf("expected only the version problem, got: %v", refusal.Problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryProblemIsReportedAtOnce(t *testing.T) {
|
||||
// A declaration is generated, so a person reading a refusal is debugging the generator.
|
||||
// Fixing one problem at a time and re-running to find the next wastes their afternoon.
|
||||
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
||||
{"id":"","type":"directory","path":"/a"},
|
||||
{"id":"b","type":"file"},
|
||||
{"id":"c","type":"service","unit":"x.service","state":"dancing"}
|
||||
]}`)
|
||||
if len(refusal.Problems) < 3 {
|
||||
t.Errorf("expected every problem at once, got: %v", refusal.Problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityIsRequiredAndUnique(t *testing.T) {
|
||||
// Identity is what lets the store know this is the same resource it applied last time,
|
||||
// which is what makes removal possible at all.
|
||||
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
||||
{"id":"same","type":"directory","path":"/a"},
|
||||
{"id":"same","type":"directory","path":"/b"}
|
||||
]}`)
|
||||
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "already used") {
|
||||
t.Errorf("a duplicate identity was accepted: %v", refusal.Problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeIsRefusedUnlessItMeansWhatItLooksLike(t *testing.T) {
|
||||
// "644" and "0644" differ, and the one that looks right in a manifest is the four-digit
|
||||
// form. Accepting both would make a mode mean two things.
|
||||
for _, mode := range []string{"644", "0999", "rwxr-xr-x", "07777777"} {
|
||||
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
||||
{"id":"f","type":"file","path":"/a","mode":"`+mode+`"}
|
||||
]}`)
|
||||
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "mode") {
|
||||
t.Errorf("mode %q was accepted: %v", mode, refusal.Problems)
|
||||
}
|
||||
}
|
||||
if _, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"f","type":"file","path":"/a","mode":"0644"}
|
||||
]}`)); err != nil {
|
||||
t.Errorf("a well-formed mode was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARefusalSaysNothingWasApplied(t *testing.T) {
|
||||
// The reader's first question is whether the machine was left half-changed.
|
||||
refusal := refusalFor(t, `{"declaration":1,"resources":[{"id":"x","type":"nope"}]}`)
|
||||
if !strings.Contains(refusal.Error(), "none of it was applied") {
|
||||
t.Errorf("the refusal does not say the machine is untouched: %s", refusal.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyDeclarationIsAMistake(t *testing.T) {
|
||||
refusalFor(t, `{"declaration":1,"resources":[]}`)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Package store is what this node knows about itself, and it is authoritative while
|
||||
// disconnected.
|
||||
//
|
||||
// Not a cache of the control plane. novox/hq ADR 0036 makes disconnection an ordinary
|
||||
// situation rather than an exception, and this is what makes it ordinary: a machine shut for a
|
||||
// week comes back and reconciles, it does not come back and ask what it is.
|
||||
//
|
||||
// Its first job arrives with the first apply rather than with the link (ADR 0043): the host
|
||||
// removes what it previously applied and is no longer declared, and it can only know that
|
||||
// because it wrote it down.
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultPath is where a node keeps what it knows. Under /var/lib because it survives a
|
||||
// reboot and is not configuration — nothing generates this, the host writes it.
|
||||
const DefaultPath = "/var/lib/mesh-host/state.json"
|
||||
|
||||
// Applied is one resource the host put on this machine, and what it did.
|
||||
//
|
||||
// Recorded AFTER the resource was applied and read back, never before (novox/hq ADR 0035).
|
||||
// A record written up front restates the request in a new place and inherits none of the
|
||||
// authority of having happened.
|
||||
type Applied struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
// Target is what was changed — a path, a unit — so removal knows what to undo without
|
||||
// re-reading a declaration that may no longer exist.
|
||||
Target string `json:"target"`
|
||||
AppliedAt time.Time `json:"applied_at"`
|
||||
}
|
||||
|
||||
// State is the whole of what a node knows about what it has done.
|
||||
type State struct {
|
||||
// Resources, keyed by identity, in the order they were applied. Order matters for removal:
|
||||
// undoing in reverse is the only ordering the host can derive without deciding anything.
|
||||
Resources []Applied `json:"resources"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Find returns what was applied under an identity.
|
||||
func (s State) Find(id string) (Applied, bool) {
|
||||
for _, r := range s.Resources {
|
||||
if r.ID == id {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return Applied{}, false
|
||||
}
|
||||
|
||||
// IDs returns every identity the host has applied, sorted.
|
||||
func (s State) IDs() []string {
|
||||
out := make([]string, 0, len(s.Resources))
|
||||
for _, r := range s.Resources {
|
||||
out = append(out, r.ID)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Load reads the state. A node that has never applied anything has an empty state, which is a
|
||||
// fact rather than an error — the first apply on a fresh machine is the ordinary case.
|
||||
//
|
||||
// A state file that exists and cannot be read IS an error, and a loud one: continuing with an
|
||||
// empty state would make the host believe it owns nothing, and it would then remove nothing it
|
||||
// should and re-apply everything it need not.
|
||||
func Load(path string) (State, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return State{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return State{}, fmt.Errorf("reading what this node knows about itself (%s): %w", path, err)
|
||||
}
|
||||
|
||||
var s State
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return State{}, fmt.Errorf(
|
||||
"what this node knows about itself is unreadable (%s): %w\n"+
|
||||
"Refusing rather than starting empty: an empty state would mean the host "+
|
||||
"believes it owns nothing, so it would remove nothing it should and re-apply "+
|
||||
"everything it need not", path, err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Save writes the state, atomically.
|
||||
//
|
||||
// Atomic because the alternative has a failure mode with no floor: a host interrupted while
|
||||
// writing loses the record of everything it owns, and then owns nothing it can clean up.
|
||||
func Save(path string, s State) error {
|
||||
s.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("making room for the node's state: %w", err)
|
||||
}
|
||||
|
||||
raw, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding the node's state: %w", err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".state-*.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing the node's state: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
|
||||
if _, err := tmp.Write(raw); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("writing the node's state: %w", err)
|
||||
}
|
||||
// Flushed before the rename: a rename is atomic, and a rename of a file whose contents are
|
||||
// still in the page cache is atomically the wrong thing.
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("flushing the node's state: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("closing the node's state: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tmp.Name(), 0o600); err != nil {
|
||||
return fmt.Errorf("securing the node's state: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), path); err != nil {
|
||||
return fmt.Errorf("replacing the node's state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Record adds or replaces what is known about one resource, preserving order.
|
||||
func (s *State) Record(a Applied) {
|
||||
for i, existing := range s.Resources {
|
||||
if existing.ID == a.ID {
|
||||
s.Resources[i] = a
|
||||
return
|
||||
}
|
||||
}
|
||||
s.Resources = append(s.Resources, a)
|
||||
}
|
||||
|
||||
// Forget drops a resource from what the node owns.
|
||||
func (s *State) Forget(id string) {
|
||||
kept := s.Resources[:0]
|
||||
for _, r := range s.Resources {
|
||||
if r.ID != id {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
s.Resources = kept
|
||||
}
|
||||
|
||||
// Orphans returns what the host applied and the declaration no longer names, newest first.
|
||||
//
|
||||
// Reverse order because undoing in the order things were made undoes a directory before the
|
||||
// file inside it. Reversing is the only ordering the host can derive without deciding
|
||||
// anything, which is the line novox/hq ADR 0037 draws.
|
||||
func (s State) Orphans(declared map[string]bool) []Applied {
|
||||
var out []Applied
|
||||
for i := len(s.Resources) - 1; i >= 0; i-- {
|
||||
if !declared[s.Resources[i].ID] {
|
||||
out = append(out, s.Resources[i])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAFreshMachineHasAnEmptyStateNotAnError(t *testing.T) {
|
||||
// The first apply on a machine that has never been touched is the ordinary case, not a
|
||||
// failure. A host that errored here could never bootstrap anything.
|
||||
s, err := Load(filepath.Join(t.TempDir(), "nothing-here.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("a fresh machine produced an error: %v", err)
|
||||
}
|
||||
if len(s.Resources) != 0 {
|
||||
t.Errorf("a fresh machine claims to own %d resources", len(s.Resources))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnreadableStateIsRefusedNotIgnored(t *testing.T) {
|
||||
// The dangerous one. Starting empty would make the host believe it owns nothing, so it
|
||||
// would remove nothing it should and re-apply everything it need not — silently.
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
if err := os.WriteFile(path, []byte("{this is not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("a corrupt state was read as an empty one")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "believes it owns nothing") {
|
||||
t.Errorf("the error does not say why this matters: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatIsSavedIsWhatIsLoaded(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
want := State{Resources: []Applied{
|
||||
{ID: "etc", Type: "directory", Target: "/etc/mesh", AppliedAt: time.Now().UTC().Truncate(time.Second)},
|
||||
{ID: "conf", Type: "file", Target: "/etc/mesh/host.conf", AppliedAt: time.Now().UTC().Truncate(time.Second)},
|
||||
}}
|
||||
if err := Save(path, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Resources) != 2 || got.Resources[0].ID != "etc" || got.Resources[1].ID != "conf" {
|
||||
t.Fatalf("order or content was lost: %+v", got.Resources)
|
||||
}
|
||||
if got.UpdatedAt.IsZero() {
|
||||
t.Error("the state does not say when it was written")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheStateIsNotWorldReadable(t *testing.T) {
|
||||
// It records what is on the machine and where. Not secret, and not everyone's business.
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
if err := Save(path, State{Resources: []Applied{{ID: "a", Type: "file", Target: "/a"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode := info.Mode().Perm(); mode&0o077 != 0 {
|
||||
t.Errorf("the state is readable by others: %o", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavingLeavesNoDebrisBehind(t *testing.T) {
|
||||
// The write is atomic through a temporary file. A run that left those behind would fill a
|
||||
// directory with near-copies of the truth, and the next reader would have to guess.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "state.json")
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := Save(path, State{Resources: []Applied{{ID: "a", Type: "file", Target: "/a"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
names := []string{}
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
t.Errorf("expected only the state file, found: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordReplacesRatherThanDuplicating(t *testing.T) {
|
||||
s := State{}
|
||||
s.Record(Applied{ID: "a", Type: "file", Target: "/old"})
|
||||
s.Record(Applied{ID: "a", Type: "file", Target: "/new"})
|
||||
|
||||
if len(s.Resources) != 1 {
|
||||
t.Fatalf("one identity produced %d records", len(s.Resources))
|
||||
}
|
||||
if s.Resources[0].Target != "/new" {
|
||||
t.Errorf("the record was not updated: %+v", s.Resources[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrphansAreWhatWasAppliedAndIsNoLongerDeclared(t *testing.T) {
|
||||
// The whole reason the store arrives at stage 2 rather than stage 3: removal is impossible
|
||||
// without knowing what was applied.
|
||||
s := State{Resources: []Applied{
|
||||
{ID: "dir", Type: "directory", Target: "/etc/mesh"},
|
||||
{ID: "file", Type: "file", Target: "/etc/mesh/a.conf"},
|
||||
{ID: "kept", Type: "file", Target: "/etc/mesh/b.conf"},
|
||||
}}
|
||||
orphans := s.Orphans(map[string]bool{"kept": true})
|
||||
|
||||
if len(orphans) != 2 {
|
||||
t.Fatalf("expected two orphans, got %d: %+v", len(orphans), orphans)
|
||||
}
|
||||
// Reverse order: undoing in the order things were made would remove a directory before the
|
||||
// file inside it.
|
||||
if orphans[0].ID != "file" || orphans[1].ID != "dir" {
|
||||
t.Errorf("orphans are not in reverse order: %s then %s", orphans[0].ID, orphans[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingIsAnOrphanWhenEverythingIsDeclared(t *testing.T) {
|
||||
s := State{Resources: []Applied{{ID: "a", Type: "file", Target: "/a"}}}
|
||||
if got := s.Orphans(map[string]bool{"a": true}); len(got) != 0 {
|
||||
t.Errorf("a declared resource was treated as an orphan: %+v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user