Files
mesh-sdk/go/delivery/composed_value.go
T
jschoubben d8da8f1d36 Sign a composition with a trusted key and compose one by hand with mesh-compose (issue 1943, S5)
The node-engine applies only a composition signed by the controller or the operator, and when the controller cannot
compose its own fix the operator composes, signs and writes one at the terminal (ADR 0539 clauses 3 and 10).
2026-10-24 16:36:14 +02:00

309 lines
11 KiB
Go

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>.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)
}
if c.Data || len(c.Filled) > 0 {
return fmt.Errorf("delivery: the node's own composition holds no module's data or filled files")
}
} 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>.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)
}
}
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
}
// 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:<hex>", 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
}