package delivery import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "git.novox.be/novox/mesh-sdk/go/declaration" ) // NodeOwn is what a node's own composition (`.node`) carries beside its resources: what the declaration says of // the node as a whole (novox/hq ADR 0539 clause 2: adoption, accounts, identities and groups). A module's composition // carries none of it. type NodeOwn struct { // Adoption says the node is adopted and which of its modules are taken (ADR 0100, ADR 0103); nil for a converged node. Adoption *declaration.Adoption `json:"adoption,omitempty"` // Identities is the identity of every module assigned to the node (ADR 0328 §4), Groups and Libraries every library // it is reached by (ADR 0328 §3). Identities []declaration.Identity `json:"identities,omitempty"` Groups []declaration.Group `json:"groups,omitempty"` Libraries []declaration.Library `json:"libraries,omitempty"` // Provisioner is the account the provisioner's client runs as on the node (ADR 0349 §4). Provisioner string `json:"provisioner_account,omitempty"` } func (o NodeOwn) empty() bool { return o.Adoption == nil && len(o.Identities) == 0 && len(o.Groups) == 0 && len(o.Libraries) == 0 && o.Provisioner == "" } // Composed is a composition (ADR 0539 clause 1): one module's resources composed for one node at one fingerprint, or, // with Module and FP empty, the node's own resources and parts (Own). Each resource is a declaration resource as the // node-engine reads it, saying its module; it is kept as JSON and written canonically (its keys sorted, its spacing // dropped), so its digest (ComposedDigestOf) is a function of its content alone. Data says the module declares data, // so the node agent backs it up before it moves (ADR 0429 §14). Filled names the files of the module the provisioner // fills (ADR 0349 §5). A file over 64 KiB travels by digest and size (ByDigest), its content in the node's object // bucket (ADR 0539 clause 4). type Composed struct { V int Node string Module string FP string Data bool Resources []json.RawMessage Filled []string Own NodeOwn // Signature is the controller's or, made by hand, the operator's (ADR 0539 clause 3, SignComposed); it signs the // digest and is never part of it. Signature *Signature } // composedWire is a composition as it is written. type composedWire struct { V int `json:"v"` Node string `json:"node"` Module string `json:"module,omitempty"` FP string `json:"fp,omitempty"` Data bool `json:"data,omitempty"` Resources []json.RawMessage `json:"resources"` Filled []string `json:"filled_files,omitempty"` Own *NodeOwn `json:"own,omitempty"` Signature *Signature `json:"signature,omitempty"` } // canonicalJSON is raw with its object keys sorted and its spacing dropped, its numbers kept as written. func canonicalJSON(raw json.RawMessage) (json.RawMessage, error) { var v any d := json.NewDecoder(bytes.NewReader(raw)) d.UseNumber() if err := d.Decode(&v); err != nil { return nil, err } if _, err := d.Token(); err != io.EOF { return nil, fmt.Errorf("more after the value") } return json.Marshal(v) } func (c Composed) check() error { if _, err := HaveNodeKey(c.Node); err != nil { return err } if c.Module == "" { if c.FP != "" { return fmt.Errorf("delivery: the node's own composition names no fingerprint, and this one names %s", c.FP) } // It carries the filled paths of its own files, those of a module with no fingerprint of the new form moved // into it (issue 1945), and never a module's data. if c.Data { return fmt.Errorf("delivery: the node's own composition holds no module's data") } } else { if _, err := ComposedKey(c.Node, c.Module, c.FP); err != nil { return err } if !c.Own.empty() { return fmt.Errorf("delivery: the composition of %s carries the node's own parts, which only .node carries", c.Module) } } seen := map[string]bool{} files := map[string][]*declaration.File{} for _, raw := range c.Resources { r, err := declaration.ParseResource(raw) if err != nil { return fmt.Errorf("delivery: the composition of %q on %s: %w", c.Module, c.Node, err) } if seen[r.Identity()] { return fmt.Errorf("delivery: the composition of %q on %s holds the resource %q twice", c.Module, c.Node, r.Identity()) } seen[r.Identity()] = true if r.DeclaredModule() != c.Module { return fmt.Errorf("delivery: the resource %q is of %q, and this is the composition of %q on %s", r.Identity(), r.DeclaredModule(), c.Module, c.Node) } if f, ok := r.(*declaration.File); ok { if f.TravelsByDigest() { return fmt.Errorf("delivery: the file %q is over %d bytes and travels by digest (ByDigest), not whole", f.ID, declaration.FileReferenceThreshold) } files[f.Path] = append(files[f.Path], f) } } // A file the provisioner fills says no content, so a composition that does not say it is filled would have the // node-engine write it empty (issue 1945, live on 2026-10-25): refused, naming it. filledHere := make(map[string]bool, len(c.Filled)) for _, path := range c.Filled { filledHere[path] = true } for path, fs := range files { for _, f := range fs { if f.OwnerAtRuntime == declaration.RuntimeOwned && !filledHere[path] && !saysContent(f) { return fmt.Errorf("delivery: the file %q says no content and the composition of %q on %s does not say "+ "the provisioner fills %s: the node-engine would write it empty", f.ID, c.Module, c.Node, path) } } } for _, path := range c.Filled { if len(files[path]) == 0 { return fmt.Errorf("delivery: the composition of %q fills %q, and declares no file there", c.Module, path) } for _, f := range files[path] { if f.OwnerAtRuntime == "" { return fmt.Errorf("delivery: the file %q is filled by the provisioner and says no owner-at-runtime", f.ID) } } } if c.Signature != nil { if err := c.Signature.check(); err != nil { return err } } return nil } // saysContent says a file names what to write: content, bytes, a digest or a sealed value. A file the provisioner fills // names none of them. func saysContent(f *declaration.File) bool { return f.Content != "" || f.Bytes != "" || f.Digest != "" || f.Sealed != "" || f.VaultSealed != nil } // wire is c checked and made canonical, ready to marshal. func (c Composed) wire() (composedWire, error) { if err := c.check(); err != nil { return composedWire{}, err } w := composedWire{V: Version, Node: c.Node, Module: c.Module, FP: c.FP, Data: c.Data, Filled: c.Filled, Signature: c.Signature, Resources: make([]json.RawMessage, len(c.Resources))} for i, r := range c.Resources { canon, err := canonicalJSON(r) if err != nil { return composedWire{}, fmt.Errorf("delivery: a resource of %q is not JSON: %w", c.Module, err) } w.Resources[i] = canon } if !c.Own.empty() { own := c.Own w.Own = &own } return w, nil } // EncodeComposed is c as JSON at Version, each resource canonical. It refuses a composition whose node or fingerprint // a key refuses, a resource its kind refuses (an action among them), a resource of another module, a module's // composition carrying the node's own parts, a file over 64 KiB carried whole, and a filled path no file declares. func EncodeComposed(c Composed) ([]byte, error) { w, err := c.wire() if err != nil { return nil, err } return json.Marshal(w) } // DecodeComposed reads a composition; it refuses an unknown version (ErrUnknownVersion), a field it does not know, and // what EncodeComposed refuses. func DecodeComposed(data []byte) (Composed, error) { if err := checkVersion(data); err != nil { return Composed{}, err } var w composedWire d := json.NewDecoder(bytes.NewReader(data)) d.DisallowUnknownFields() if err := d.Decode(&w); err != nil { return Composed{}, fmt.Errorf("delivery: a composition does not fit its shape: %w", err) } c := Composed{V: w.V, Node: w.Node, Module: w.Module, FP: w.FP, Data: w.Data, Resources: w.Resources, Filled: w.Filled, Signature: w.Signature} if w.Own != nil { c.Own = *w.Own } if err := c.check(); err != nil { return Composed{}, err } return c, nil } // ComposedDigestOf is a composition's digest, "sha256:", of its encoding without its signature. A want names it // (ADR 0539 clause 1), the controller writes it beside the composition, and the signature signs it. func ComposedDigestOf(c Composed) (string, error) { c.Signature = nil raw, err := EncodeComposed(c) if err != nil { return "", err } sum := sha256.Sum256(raw) return "sha256:" + hex.EncodeToString(sum[:]), nil } // SameComposed says whether two compositions are the same value, their signatures included, so the controller writes // only when they differ. func SameComposed(a, b Composed) bool { ra, errA := EncodeComposed(a) rb, errB := EncodeComposed(b) return errA == nil && errB == nil && bytes.Equal(ra, rb) } // ByDigest is c with every file over 64 KiB given by its digest and size, and the content of each by digest, for the // writer to put in the node's object bucket (declaration.FileBucketFor) under declaration.ObjectName before it writes // the composition (ADR 0539 clause 4, ADR 0488 §3). A file that is sealed, filled with secrets or written into another // stays as it is (declaration.File.TravelsByDigest). func (c Composed) ByDigest() (Composed, map[string][]byte, error) { out := c out.Resources = make([]json.RawMessage, len(c.Resources)) objects := map[string][]byte{} for i, raw := range c.Resources { out.Resources[i] = raw r, err := declaration.ParseResource(raw) if err != nil { return Composed{}, nil, err } f, ok := r.(*declaration.File) if !ok || !f.TravelsByDigest() { continue } ref, body, err := f.AsReference() if err != nil { return Composed{}, nil, err } var fields map[string]json.RawMessage if err := json.Unmarshal(raw, &fields); err != nil { return Composed{}, nil, err } delete(fields, "content") delete(fields, "bytes") fields["digest"], _ = json.Marshal(ref.Digest) fields["size"], _ = json.Marshal(ref.Size) rewritten, err := json.Marshal(fields) if err != nil { return Composed{}, nil, err } out.Resources[i] = rewritten objects[ref.Digest] = body } return out, objects, nil } // SplitComposed splits a node's composed resources into its compositions, by key (ComposedKey, ComposedNodeKey): each // resource into the composition of the module it says, under that module's fingerprint in fps, and a resource that // says no module into the node's own, with own. Each filled path goes with the module whose file declares it. The order // of the resources is kept within each composition. A resource of a module fps names no fingerprint for is refused, // naming it. Data is the caller's to set, from the module's manifest. func SplitComposed(node string, resources []json.RawMessage, fps map[string]string, filled []string, own NodeOwn) (map[string]Composed, error) { nodeKey, err := ComposedNodeKey(node) if err != nil { return nil, err } out := map[string]Composed{nodeKey: {Node: node, Own: own, Resources: []json.RawMessage{}}} pathOf := map[string]string{} for _, raw := range resources { var head struct { ID string `json:"id"` Type string `json:"type"` Module string `json:"module"` Path string `json:"path"` } if err := json.Unmarshal(raw, &head); err != nil { return nil, fmt.Errorf("delivery: a resource of %s is not JSON: %w", node, err) } key := nodeKey if head.Module != "" { fp, ok := fps[head.Module] if !ok { return nil, fmt.Errorf("delivery: the resource %q is of %q, whose fingerprint on %s is not given", head.ID, head.Module, node) } if key, err = ComposedKey(node, head.Module, fp); err != nil { return nil, err } if _, ok := out[key]; !ok { out[key] = Composed{Node: node, Module: head.Module, FP: fp} } } if head.Type == string(declaration.TypeFile) && head.Module != "" { pathOf[head.Path] = key } c := out[key] c.Resources = append(c.Resources, raw) out[key] = c } for _, path := range filled { key, ok := pathOf[path] if !ok { return nil, fmt.Errorf("delivery: the provisioner fills %q on %s, and no module's file declares it", path, node) } c := out[key] c.Filled = append(c.Filled, path) out[key] = c } return out, nil }