Files
mesh-host/internal/apply/resources.go
T
jschoubben c80f671203 apply: container and network resources, over the container runtime
Extends the applier past the filesystem to the two types the workloads
need: a container and the private network it joins. The workloads are
the bulk of what a cutover re-declares (research 009), so this is what
makes a workload manifest actually appliable.

- container: run/reconcile/remove over the runtime. Up to date means a
  container that is ours (a spec-hash label matches this exact
  declaration) AND running; anything else — a changed spec, a stopped
  container, or a foreign one the old control plane left by that name —
  is recreated into ours. Safe because a container carries no state:
  its data is in bind-mounted directories declared separately, and
  recreating it never touches them. Read-back asks the runtime whether
  it is actually running on the declared spec, because 'started' only
  means the runtime returned.
- network: create if absent, adopt if present, remove only what it
  created.
- The runtime is driven through a Runner, faked in unit tests and
  exercised for real in a smoke test that stands a container up, proves
  idempotency, and tears it down — skipped, never failed, where the
  runtime is absent.

The store's per-resource reference generalises from a path to a ref:
a path for files and directories, a name for containers and networks.

Verified end to end through the binary: a container on a bind mount,
then dropped from the declaration — the container is removed and the
data directory survives, which is the migration property itself.

Still deferred: sealed secrets, and package/service/archive/user/action
— refused whole until built, never half-applied.

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

244 lines
7.5 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, using the real container
// runtime.
func Appliers() map[string]Applier {
return appliersWith(execRunner)
}
// appliersWith builds the applier set against a given runtime runner. The filesystem appliers
// ignore it; the container and network ones drive the runtime through it, which is where a test
// substitutes a fake.
func appliersWith(run Runner) map[string]Applier {
return map[string]Applier{
"directory": directoryApplier{},
"file": fileApplier{},
"network": networkApplier{run: run},
"container": containerApplier{run: run},
}
}
// --- 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.Ref)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("removing directory %s (left in place): %w", rec.Ref, 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.Ref)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("removing file %s: %w", rec.Ref, 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
}