Files
mesh-host/internal/apply/store.go
T
jschoubben 4a80cc1002 apply: tier 0 consumes a declaration and converges this machine
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
2026-09-02 22:34:44 +02:00

86 lines
2.9 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"`
Path string `json:"path"`
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 }