Unify trunk on main: initialization → main #3
@@ -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