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

232 lines
8.4 KiB
Go

// Package apply is tier 0's one job: take a declaration and make this machine match it.
//
// A declaration is data, not instructions — an ordered list of typed resources the host owns,
// settled by novox/hq ADR 0043. This package is the *consumer* side of that record: what the
// host accepts, and what it does with it. What produces a declaration (the control plane, or a
// hand-authored substrate.lock) is deliberately not here.
//
// The cardinal rule of the whole project appears twice in this file, because a declaration is
// exactly where it bites: an unknown version, an unknown resource type, or an unknown field is
// a refusal of the WHOLE declaration — never a skip, never best-effort. A host that applied the
// parts it understood would leave a machine that looks configured and is not, which is
// novox/hq 04-ISSUES/003 with the declaration on the other side of the wire.
package apply
import (
"bytes"
"encoding/json"
"fmt"
"sort"
)
// Version is the one declaration vocabulary this host understands. A declaration naming any
// other version is refused whole — an older host cannot be handed a newer vocabulary and
// quietly do half of it (ADR 0043).
const Version = 1
// Declaration is what crosses the link, or what substrate.lock carries: an ordered list of
// resources, addressed to one node.
type Declaration struct {
// Version of the vocabulary. Refused whole if it is not exactly Version.
Version int `json:"version"`
// For names the node this is meant for. A host with an identity refuses a declaration
// addressed elsewhere; a host with no identity yet — the first node — has nothing to check
// against and applies it (ADR 0043). Empty means unaddressed, which any host applies.
For string `json:"for"`
// Resources, in the order they are to be applied. The host does not sort them and does not
// resolve dependencies: ordering is the control plane's decision, stated rather than derived
// (ADR 0037).
Resources []Resource `json:"resources"`
}
// Resource is one thing the host owns on this machine. Its identity is a name the control plane
// keeps stable across declarations — not a position, not a hash of its content — because that
// stable name is what lets the store say "this is the same resource I applied last time", which
// is what makes convergence and removal possible at all (ADR 0043).
//
// Beyond id and type, a resource's fields are type-specific and validated against the shape the
// type declares. They are kept as raw JSON so an unknown field can be refused rather than
// silently dropped by struct decoding.
type Resource struct {
ID string
Type string
Fields map[string]json.RawMessage
}
// Path is the host-owned filesystem path a file or directory resource lives at.
func (r Resource) Path() string {
return r.stringField("path")
}
// ref is what the store records to find this resource again for removal: a filesystem path for
// files and directories, a name for containers and networks. Every resource is addressed by one
// or the other, and the parser refuses a resource that has neither.
func (r Resource) ref() string {
if p := r.stringField("path"); p != "" {
return p
}
return r.stringField("name")
}
func (r Resource) stringField(key string) string {
raw, ok := r.Fields[key]
if !ok {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return ""
}
return s
}
// stringSlice reads a field that is a JSON array of strings — ports, volumes, args, hosts.
func (r Resource) stringSlice(key string) []string {
raw, ok := r.Fields[key]
if !ok {
return nil
}
var out []string
_ = json.Unmarshal(raw, &out)
return out
}
// stringMap reads a field that is a JSON object of string→string — a container's env. Keys are
// returned sorted by the caller when order matters, so a container's spec hash is stable.
func (r Resource) stringMap(key string) map[string]string {
raw, ok := r.Fields[key]
if !ok {
return nil
}
var out map[string]string
_ = json.Unmarshal(raw, &out)
return out
}
// shape is the set of field keys a resource type may carry, beyond the common id and type.
// This is the host's half of a wire contract whose other half is the control plane's catalogue
// (novox/mesh-control examples/modules/modules_test.go). Duplicated deliberately, because the
// host shares no code with any other tier (ADR 0041) — and checked on both sides, because a
// contract with two copies and no check is a contract only until someone edits one.
//
// A type absent from this table is unknown TO THIS HOST, and unknown is refused. That is not a
// gap to apologise for: the network-free types come first (ADR 0043), and a host refusing a
// container resource it cannot yet apply is the same protection as refusing an unknown one —
// it never does half a declaration.
var shapes = map[string][]string{
"directory": {"path", "mode", "owner"},
"file": {"path", "content", "mode", "owner"},
"network": {"name"},
"container": {"name", "image", "env", "env-file", "ports", "volumes", "args", "hosts", "network"},
}
// Parse reads a declaration and refuses anything it does not fully understand.
//
// The refusal is whole and it is specific: the error names what it could not accept, because a
// boundary that refuses without saying why is worse than the thing it guards (ADR 0039).
func Parse(raw []byte) (Declaration, error) {
// The envelope is decoded strictly: an unknown top-level field is refused like any other.
var envelope struct {
Version int `json:"version"`
For string `json:"for"`
Resources []json.RawMessage `json:"resources"`
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&envelope); err != nil {
return Declaration{}, fmt.Errorf("not a declaration this host accepts: %w", err)
}
if envelope.Version != Version {
return Declaration{}, fmt.Errorf(
"declaration version %d, and this host speaks version %d — refused whole rather than "+
"applying a vocabulary it does not know", envelope.Version, Version)
}
d := Declaration{Version: envelope.Version, For: envelope.For}
seen := map[string]bool{}
for i, rawRes := range envelope.Resources {
r, err := parseResource(rawRes)
if err != nil {
return Declaration{}, fmt.Errorf("resource %d: %w", i, err)
}
if seen[r.ID] {
return Declaration{}, fmt.Errorf(
"resource %d: id %q appears twice — an id is how the store tells one resource "+
"from another, so two cannot share one", i, r.ID)
}
seen[r.ID] = true
d.Resources = append(d.Resources, r)
}
return d, nil
}
func parseResource(raw json.RawMessage) (Resource, error) {
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return Resource{}, fmt.Errorf("not an object: %w", err)
}
id := decodeString(fields["id"])
typ := decodeString(fields["type"])
if id == "" {
return Resource{}, fmt.Errorf("has no id, and every resource must have one")
}
if typ == "" {
return Resource{}, fmt.Errorf("%q has no type", id)
}
allowed, known := shapes[typ]
if !known {
return Resource{}, fmt.Errorf(
"%q is a %q, which this host cannot apply — refused whole, because applying the rest "+
"would leave a machine that looks configured and is not", id, typ)
}
// Every field beyond the common two must belong to the type's shape. An unknown one is
// refused: a firewall-scope key read by nothing is exactly the fault this prevents
// (04-ISSUES/003).
ok := map[string]bool{"id": true, "type": true}
for _, k := range allowed {
ok[k] = true
}
extra := make(map[string]json.RawMessage, len(fields))
for k, v := range fields {
if !ok[k] {
return Resource{}, fmt.Errorf(
"%q is a %s and carries %q, which that shape does not have", id, typ, k)
}
if k != "id" && k != "type" {
extra[k] = v
}
}
res := Resource{ID: id, Type: typ, Fields: extra}
if res.ref() == "" {
return Resource{}, fmt.Errorf(
"%q is a %s and names neither a path nor a name — the store would have no way to find "+
"it again", id, typ)
}
return res, nil
}
func decodeString(raw json.RawMessage) string {
if raw == nil {
return ""
}
var s string
_ = json.Unmarshal(raw, &s)
return s
}
// KnownTypes lists the resource types this host can apply, in stable order. Exists so the CLI
// and tests can state the host's reach rather than restating the shape table.
func KnownTypes() []string {
out := make([]string, 0, len(shapes))
for t := range shapes {
out = append(out, t)
}
sort.Strings(out)
return out
}