Files
mesh-contracts/go/clients/nodeengineclient/composed.go
T

324 lines
12 KiB
Go

package nodeengineclient
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"strings"
seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// The compositions (novox/hq ADR 0539 clauses 1 to 4), the node-engine's state composed: its layout is the seat's contract
// (seat.ComposedByNodeModuleFingerprintValue, seat.ComposedByNodeNodeValue); this file is the codec and the digest the
// engine, the assigner, the grants part and the provisioner share. A composition's digest is a function of its content
// alone, so the encoding keeps the field order its first writer used, which the contract's generated type (fields sorted)
// does not: the wire structs below hold it.
// SourcePrefix is the start of a fingerprint of the new form, `src2:` and 64 lowercase hex digits.
const SourcePrefix = "src2:"
// FingerprintKeyPart is a new-form fingerprint as the key part of ComposedKey: its 64 hex digits without the prefix.
func FingerprintKeyPart(fp string) (string, error) {
part, ok := strings.CutPrefix(fp, SourcePrefix)
if !ok || !keys.IsHex64(part) {
return "", fmt.Errorf("node-engine: %q is not a fingerprint of the new form, %s and 64 lowercase hex digits", fp, SourcePrefix)
}
return part, nil
}
// Composition is 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, kept as JSON and written canonically (keys
// sorted, spacing dropped). Plan is the module's whole provisioning plan, kept as JSON too: part of the encoding, not of
// the digest. Refused says the module cannot be composed at this fingerprint; such a composition holds nothing else.
type Composition struct {
V int
Node string
Module string
FP string
Data bool
Resources []json.RawMessage
Filled []string
Own seat.NodeOwn
Plan json.RawMessage
Refused string
}
type adoptionWire struct {
Taken []string `json:"taken"`
Untaken map[string][]string `json:"untaken,omitempty"`
}
type identityWire struct {
Module string `json:"module"`
Name string `json:"name"`
UID int64 `json:"uid"`
GID int64 `json:"gid"`
Groups []int64 `json:"groups,omitempty"`
Runner string `json:"runner,omitempty"`
}
type groupWire struct {
Name string `json:"name"`
GID int64 `json:"gid"`
}
type libraryWire struct {
Library string `json:"library"`
Root string `json:"root"`
Group string `json:"group"`
GID int64 `json:"gid"`
Home bool `json:"home"`
Taken bool `json:"taken,omitempty"`
Paths []string `json:"paths,omitempty"`
Runner string `json:"runner,omitempty"`
}
type ownWire struct {
Adoption *adoptionWire `json:"adoption,omitempty"`
Identities []identityWire `json:"identities,omitempty"`
Groups []groupWire `json:"groups,omitempty"`
Libraries []libraryWire `json:"libraries,omitempty"`
Provisioner string `json:"provisioner_account,omitempty"`
}
func ownIsEmpty(o seat.NodeOwn) bool {
return o.Adoption == nil && len(o.Identities) == 0 && len(o.Groups) == 0 && len(o.Libraries) == 0 && o.ProvisionerAccount == ""
}
func ownToWire(o seat.NodeOwn) *ownWire {
w := ownWire{Provisioner: o.ProvisionerAccount}
if o.Adoption != nil {
w.Adoption = &adoptionWire{Taken: o.Adoption.Taken, Untaken: o.Adoption.Untaken}
}
for _, i := range o.Identities {
w.Identities = append(w.Identities, identityWire{Module: i.Module, Name: i.Name, UID: i.Uid, GID: i.Gid, Groups: i.Groups, Runner: i.Runner})
}
for _, g := range o.Groups {
w.Groups = append(w.Groups, groupWire{Name: g.Name, GID: g.Gid})
}
for _, l := range o.Libraries {
w.Libraries = append(w.Libraries, libraryWire{Library: l.Library, Root: l.Root, Group: l.Group, GID: l.Gid, Home: l.Home, Taken: l.Taken, Paths: l.Paths, Runner: l.Runner})
}
return &w
}
func ownFromWire(w ownWire) seat.NodeOwn {
o := seat.NodeOwn{ProvisionerAccount: w.Provisioner}
if w.Adoption != nil {
o.Adoption = &seat.Adoption{Taken: w.Adoption.Taken, Untaken: w.Adoption.Untaken}
}
for _, i := range w.Identities {
o.Identities = append(o.Identities, seat.DeclaredIdentity{Module: i.Module, Name: i.Name, Uid: i.UID, Gid: i.GID, Groups: i.Groups, Runner: i.Runner})
}
for _, g := range w.Groups {
o.Groups = append(o.Groups, seat.DeclaredGroup{Name: g.Name, Gid: g.GID})
}
for _, l := range w.Libraries {
o.Libraries = append(o.Libraries, seat.DeclaredLibrary{Library: l.Library, Root: l.Root, Group: l.Group, Gid: l.GID, Home: l.Home, Taken: l.Taken, Paths: l.Paths, Runner: l.Runner})
}
return o
}
// 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 *ownWire `json:"own,omitempty"`
Plan json.RawMessage `json:"plan,omitempty"`
Refused string `json:"refused,omitempty"`
// Signature is read and dropped: a composition is unsigned (ADR 0593 §6), and a value the ended controller signed
// still decodes. Nothing writes it.
Signature json.RawMessage `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 Composition) check() error {
if err := keys.CheckName("node", c.Node); err != nil {
return err
}
if c.Module == "" {
if c.FP != "" {
return fmt.Errorf("node-engine: the node's own composition names no fingerprint, and this one names %s", c.FP)
}
if c.Data {
return fmt.Errorf("node-engine: the node's own composition holds no module's data")
}
if len(c.Plan) > 0 || c.Refused != "" {
return fmt.Errorf("node-engine: the node's own composition carries no plan and no refusal: only a module's does")
}
} else {
part, err := FingerprintKeyPart(c.FP)
if err != nil {
return err
}
if _, err := ComposedKey(c.Node, c.Module, part); err != nil {
return err
}
if !ownIsEmpty(c.Own) {
return fmt.Errorf("node-engine: the composition of %s carries the node's own parts, which only <node>.node carries", c.Module)
}
}
if c.Refused != "" && (len(c.Resources) > 0 || len(c.Filled) > 0 || c.Data || len(c.Plan) > 0) {
return fmt.Errorf("node-engine: the composition of %s on %s is refused and holds resources, filled files, data or a plan", c.Module, c.Node)
}
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("node-engine: the composition of %q on %s: %w", c.Module, c.Node, err)
}
if seen[r.Identity()] {
return fmt.Errorf("node-engine: 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("node-engine: 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("node-engine: the file %q is over %d bytes and travels by digest, 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
// engine write it empty (issue 1945): 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("node-engine: the file %q says no content and the composition of %q on %s does not say "+
"the provisioner fills %s: the 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("node-engine: 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("node-engine: the file %q is filled by the provisioner and says no owner-at-runtime", f.ID)
}
}
}
return nil
}
func saysContent(f *declaration.File) bool {
return f.Content != "" || f.Bytes != "" || f.Digest != "" || f.Sealed != "" || f.VaultSealed != nil
}
func (c Composition) wire() (composedWire, error) {
if err := c.check(); err != nil {
return composedWire{}, err
}
w := composedWire{V: keys.Version, Node: c.Node, Module: c.Module, FP: c.FP, Data: c.Data, Filled: c.Filled,
Refused: c.Refused, Resources: make([]json.RawMessage, len(c.Resources))}
for i, r := range c.Resources {
canon, err := canonicalJSON(r)
if err != nil {
return composedWire{}, fmt.Errorf("node-engine: a resource of %q is not JSON: %w", c.Module, err)
}
w.Resources[i] = canon
}
if len(c.Plan) > 0 {
canon, err := canonicalJSON(c.Plan)
if err != nil {
return composedWire{}, fmt.Errorf("node-engine: the plan of %q is not JSON: %w", c.Module, err)
}
w.Plan = canon
}
if !ownIsEmpty(c.Own) {
w.Own = ownToWire(c.Own)
}
return w, nil
}
// EncodeComposition is c as JSON at the version, each resource canonical. It refuses a composition whose node or
// fingerprint a key refuses, a resource its kind refuses, 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 EncodeComposition(c Composition) ([]byte, error) {
w, err := c.wire()
if err != nil {
return nil, err
}
return json.Marshal(w)
}
// DecodeComposition reads a composition; it refuses an unknown version (keys.ErrUnknownVersion), a field it does not
// know, and what EncodeComposition refuses.
func DecodeComposition(data []byte) (Composition, error) {
if err := keys.CheckVersion(data); err != nil {
return Composition{}, err
}
var w composedWire
d := json.NewDecoder(bytes.NewReader(data))
d.DisallowUnknownFields()
if err := d.Decode(&w); err != nil {
return Composition{}, fmt.Errorf("node-engine: a composition does not fit its shape: %w", err)
}
c := Composition{V: w.V, Node: w.Node, Module: w.Module, FP: w.FP, Data: w.Data, Resources: w.Resources,
Filled: w.Filled, Plan: w.Plan, Refused: w.Refused}
if w.Own != nil {
c.Own = ownFromWire(*w.Own)
}
if err := c.check(); err != nil {
return Composition{}, err
}
return c, nil
}
// ComposedDigest is a composition's digest, "sha256:<hex>", of its encoding without its plan. A want names it (ADR 0539
// clause 1), and the node's engine writes it beside the composition. A refused composition has no digest.
func ComposedDigest(c Composition) (string, error) {
if c.Refused != "" {
return "", fmt.Errorf("node-engine: the composition of %s on %s is refused (%s) and has no digest", c.Module, c.Node, c.Refused)
}
c.Plan = nil
raw, err := EncodeComposition(c)
if err != nil {
return "", err
}
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
// SameComposition says whether two compositions are the same value, their plans included, so the engine writes only
// when they differ.
func SameComposition(a, b Composition) bool {
ra, errA := EncodeComposition(a)
rb, errB := EncodeComposition(b)
return errA == nil && errB == nil && bytes.Equal(ra, rb)
}