// 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 }