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
244 lines
7.5 KiB
Go
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
|
|
}
|