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
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
// 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 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.
|
||||
func (r Resource) Path() string {
|
||||
return r.stringField("path")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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"},
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
if _, hasPath := extra["path"]; !hasPath {
|
||||
return Resource{}, fmt.Errorf("%q is a %s and names no path", id, typ)
|
||||
}
|
||||
|
||||
return Resource{ID: id, Type: typ, Fields: extra}, 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
|
||||
}
|
||||
Reference in New Issue
Block a user