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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user