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
234 lines
7.1 KiB
Go
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
|
|
}
|