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
88 lines
3.1 KiB
Go
88 lines
3.1 KiB
Go
package apply
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// Store is the host's record of what it has applied to this machine, and it is authoritative
|
|
// while disconnected (novox/hq 03-DESIGN/01-to-be/05-the-node-host.md). It is not a cache of
|
|
// the control plane: it is what makes removal possible — the host removes what it previously
|
|
// applied and is no longer declared, and it knows what it applied because this recorded it
|
|
// (ADR 0043).
|
|
//
|
|
// The records are kept in application order, so removal can run in reverse — a file goes before
|
|
// the directory that holds it.
|
|
type Store struct {
|
|
path string
|
|
records []Record
|
|
}
|
|
|
|
// Record is one applied resource, holding just enough to remove it.
|
|
//
|
|
// Created is the whole of the data-loss guard. The host is authoritative over its own footprint
|
|
// and inert everywhere else (ADR 0043), so it removes only what it created. A directory it
|
|
// merely adopted — one that already held workload data — is recorded Created:false and is never
|
|
// removed, which is the same rule that ADR 0018 exists to enforce: never act on a path you did
|
|
// not create.
|
|
type Record struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
// Ref is how the resource is found again for removal: a filesystem path for files and
|
|
// directories, a container or network name for those.
|
|
Ref string `json:"ref"`
|
|
Created bool `json:"created"`
|
|
}
|
|
|
|
// LoadStore reads the store at path. A missing file is an empty store, not an error: a machine
|
|
// the host has never applied to has applied nothing, which is a fact with a true empty answer.
|
|
func LoadStore(path string) (*Store, error) {
|
|
s := &Store{path: path}
|
|
raw, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
return s, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading the applied-state store %s: %w", path, err)
|
|
}
|
|
var records []Record
|
|
if err := json.Unmarshal(raw, &records); err != nil {
|
|
return nil, fmt.Errorf(
|
|
"the applied-state store %s is not readable — refusing rather than treating a machine "+
|
|
"as blank when it is not: %w", path, err)
|
|
}
|
|
s.records = records
|
|
return s, nil
|
|
}
|
|
|
|
// Records returns the applied resources in application order.
|
|
func (s *Store) Records() []Record { return s.records }
|
|
|
|
// Save writes the store atomically: a torn store is a machine that has forgotten what it holds,
|
|
// so the write goes to a sibling temp file and is renamed into place.
|
|
func (s *Store) Save() error {
|
|
if s.path == "" {
|
|
return nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
|
return fmt.Errorf("preparing the store directory: %w", err)
|
|
}
|
|
raw, err := json.MarshalIndent(s.records, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encoding the store: %w", err)
|
|
}
|
|
tmp := s.path + ".tmp"
|
|
if err := os.WriteFile(tmp, append(raw, '\n'), 0o600); err != nil {
|
|
return fmt.Errorf("writing the store: %w", err)
|
|
}
|
|
if err := os.Rename(tmp, s.path); err != nil {
|
|
return fmt.Errorf("committing the store: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// replace sets the records to exactly what was just applied, in order.
|
|
func (s *Store) replace(records []Record) { s.records = records }
|