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).
309 lines
11 KiB
Go
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
|
|
}
|