Files
mesh-host/internal/apply/store.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

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 }