Files
mesh-host/internal/apply/resources.go
T
jschoubben 4a80cc1002 apply: tier 0 consumes a declaration and converges this machine
Stage 2 begins. The host stops only reporting and starts doing its one
job (ADR 0037): take an ordered list of typed resources and make the
machine match it, from a local file, with no mesh present.

What lands in this slice — the network-free vocabulary ADR 0043 names
first:
- Parse: JSON, refused WHOLE on an unknown version, type, field, a
  missing id/type/path, or a duplicate id. An older host cannot be
  handed a newer vocabulary and do half of it.
- directory and file appliers, each reading back after it writes —
  mode and owner asserted against the machine, content compared byte
  for byte. A value that did not take is a failed apply, not a success.
- store: the applied-state record, authoritative while disconnected,
  written atomically. It is what makes removal possible.
- Convergence: apply in the stated order (the host never reorders),
  record each success AFTER it works (ADR 0035), and remove what was
  applied before and is no longer declared — in reverse order, so a
  file goes before the directory that held it.
- The data-loss guard: the host removes ONLY what it created, never
  what it adopted, and a created directory that now holds data is
  refused (os.Remove, never RemoveAll) rather than deleted (ADR 0018,
  0030). created is sticky across re-applies — caught by running the
  real binary, not just the unit tests: recomputing it from disk made
  a re-applied resource look adopted and leak on the next drop.
- Addressing: a declaration for another node is refused; a host with
  no identity yet applies its bundle (the first-node path).

Not yet: sealed secrets, and the types that need the network or a
runtime (container, package, network, service, archive, user, action)
— they follow, and until then the host refuses them rather than doing
part of a declaration.

CLI: mesh-host apply [--store P] FILE.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-02 22:34:44 +02:00

234 lines
7.1 KiB
Go

package apply
import (
"fmt"
"os"
"strconv"
"strings"
"syscall"
)
// Applier makes this machine match one kind of resource, and reads back to prove it took.
//
// Read-back is not optional and it is not this package's habit alone: setting a value is not
// evidence the value took (novox/hq how-we-build §5, and 05-the-node-host.md as a component
// requirement). So Apply writes AND confirms, and returns an error if the machine does not then
// match — a firewall is asked whether the rule loaded, and a file is read back byte for byte.
type Applier interface {
// Type is the resource type this handles — the key in the shape table.
Type() string
// Apply makes the machine match r and reads back to confirm. created reports whether the
// host brought the resource into being (as opposed to adopting one already present), which
// is what the store needs so removal never deletes what the host did not create.
Apply(r Resource) (created bool, err error)
// Remove undoes a resource the host created. Only ever called for a store Record whose
// Created is true, and written to never destroy data it did not put there.
Remove(rec Record) error
}
// Appliers is the set of types this host can apply, keyed by type name.
func Appliers() map[string]Applier {
return map[string]Applier{
"directory": directoryApplier{},
"file": fileApplier{},
}
}
// --- directory ---
type directoryApplier struct{}
func (directoryApplier) Type() string { return "directory" }
func (directoryApplier) Apply(r Resource) (bool, error) {
path := r.Path()
mode, err := parseMode(r.stringField("mode"), 0o755)
if err != nil {
return false, err
}
created := false
info, statErr := os.Lstat(path)
switch {
case statErr == nil:
if !info.IsDir() {
return false, fmt.Errorf("%s exists and is not a directory", path)
}
case os.IsNotExist(statErr):
if err := os.Mkdir(path, mode); err != nil {
return false, fmt.Errorf("creating %s: %w", path, err)
}
created = true
default:
return false, fmt.Errorf("inspecting %s: %w", path, statErr)
}
if err := os.Chmod(path, mode); err != nil {
return created, fmt.Errorf("setting mode on %s: %w", path, err)
}
if err := applyOwner(path, r.stringField("owner")); err != nil {
return created, err
}
// Read back: the directory must now exist, be a directory, and hold the mode and owner
// asked for. Anything else is a value that did not take.
if err := verifyPathState(path, true, mode, r.stringField("owner")); err != nil {
return created, fmt.Errorf("%s did not take: %w", path, err)
}
return created, nil
}
func (directoryApplier) Remove(rec Record) error {
// os.Remove, never RemoveAll: it fails on a non-empty directory, and that failure is the
// point. A directory the host created but that now holds something is not the host's to
// delete — data outlives the mesh that declared it (ADR 0030).
err := os.Remove(rec.Path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("removing directory %s (left in place): %w", rec.Path, err)
}
return nil
}
// --- file ---
type fileApplier struct{}
func (fileApplier) Type() string { return "file" }
func (fileApplier) Apply(r Resource) (bool, error) {
path := r.Path()
mode, err := parseMode(r.stringField("mode"), 0o644)
if err != nil {
return false, err
}
content := []byte(r.stringField("content"))
_, statErr := os.Lstat(path)
created := os.IsNotExist(statErr)
if statErr != nil && !created {
return false, fmt.Errorf("inspecting %s: %w", path, statErr)
}
// Idempotent: the file is rewritten only when the bytes differ, so applying the same
// declaration twice changes nothing the second time. Mode and owner are still reconciled
// below, because those can drift without the content doing so.
needsWrite := created
if !created {
existing, readErr := os.ReadFile(path)
needsWrite = readErr != nil || string(existing) != string(content)
}
if needsWrite {
if err := os.WriteFile(path, content, mode); err != nil {
return created, fmt.Errorf("writing %s: %w", path, err)
}
}
if err := os.Chmod(path, mode); err != nil {
return created, fmt.Errorf("setting mode on %s: %w", path, err)
}
if err := applyOwner(path, r.stringField("owner")); err != nil {
return created, err
}
// Read back: the file must now hold exactly these bytes and this mode. A file whose content
// was composed on the machine, or whose write was short, is a value that did not take.
got, err := os.ReadFile(path)
if err != nil {
return created, fmt.Errorf("%s did not take: reading it back: %w", path, err)
}
if string(got) != string(content) {
return created, fmt.Errorf("%s did not take: content read back does not match", path)
}
if err := verifyPathState(path, false, mode, r.stringField("owner")); err != nil {
return created, fmt.Errorf("%s did not take: %w", path, err)
}
return created, nil
}
func (fileApplier) Remove(rec Record) error {
err := os.Remove(rec.Path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("removing file %s: %w", rec.Path, err)
}
return nil
}
// --- shared ---
func parseMode(s string, fallback os.FileMode) (os.FileMode, error) {
if s == "" {
return fallback, nil
}
n, err := strconv.ParseUint(s, 8, 32)
if err != nil {
return 0, fmt.Errorf("mode %q is not an octal number like \"0700\": %w", s, err)
}
return os.FileMode(n), nil
}
// applyOwner sets uid:gid when an owner is named. Owners are numeric because a container's user
// has no name on the machine (novox/mesh-control: "an owner may be numeric"). An empty owner is
// left untouched — not every resource asserts one.
func applyOwner(path, owner string) error {
if owner == "" {
return nil
}
uid, gid, err := parseOwner(owner)
if err != nil {
return err
}
if err := os.Chown(path, uid, gid); err != nil {
return fmt.Errorf("setting owner %s on %s: %w", owner, path, err)
}
return nil
}
func parseOwner(owner string) (int, int, error) {
parts := strings.SplitN(owner, ":", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("owner %q is not \"uid:gid\"", owner)
}
uid, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, fmt.Errorf("owner %q: uid is not a number", owner)
}
gid, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, fmt.Errorf("owner %q: gid is not a number", owner)
}
return uid, gid, nil
}
// verifyPathState reads back mode and (when asserted) owner, and reports the first mismatch.
func verifyPathState(path string, wantDir bool, mode os.FileMode, owner string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if info.IsDir() != wantDir {
return fmt.Errorf("expected directory=%v, found directory=%v", wantDir, info.IsDir())
}
if info.Mode().Perm() != mode.Perm() {
return fmt.Errorf("expected mode %04o, found %04o", mode.Perm(), info.Mode().Perm())
}
if owner != "" {
wantUID, wantGID, err := parseOwner(owner)
if err != nil {
return err
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Errorf("cannot read owner back on this platform")
}
if int(st.Uid) != wantUID || int(st.Gid) != wantGID {
return fmt.Errorf("expected owner %d:%d, found %d:%d", wantUID, wantGID, st.Uid, st.Gid)
}
}
return nil
}