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
This commit is contained in:
2026-09-02 22:48:27 +02:00
parent 7414da96ed
commit c80f671203
7 changed files with 524 additions and 20 deletions
+42 -6
View File
@@ -53,12 +53,21 @@ type Resource struct {
Fields map[string]json.RawMessage
}
// Path is the host-owned filesystem path this resource lives at. Every type this host applies
// so far is addressed by a path, and the store needs it to remove the resource later.
// 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 {
@@ -71,6 +80,29 @@ func (r Resource) stringField(key string) string {
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
@@ -84,6 +116,8 @@ func (r Resource) stringField(key string) string {
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.
@@ -167,11 +201,13 @@ func parseResource(raw json.RawMessage) (Resource, error) {
}
}
if _, hasPath := extra["path"]; !hasPath {
return Resource{}, fmt.Errorf("%q is a %s and names no path", id, typ)
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 Resource{ID: id, Type: typ, Fields: extra}, nil
return res, nil
}
func decodeString(raw json.RawMessage) string {