325 lines
12 KiB
Go
325 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 opens a source fingerprint of the form mesh-catalog's client defines (meshcatalogclient.SourcePrefix2,
|
|
// KeyPart); a client imports no other seat's client (ADR 0625 §4), so a composition's field is checked here for that form
|
|
// only, and a caller makes the key part with meshcatalogclient.KeyPart.
|
|
const sourcePrefix = "src2:"
|
|
|
|
// checkFingerprintForm refuses a fingerprint that is not `src2:` and 64 lowercase hex digits, naming it.
|
|
func checkFingerprintForm(fp 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 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 {
|
|
if err := checkFingerprintForm(c.FP); err != nil {
|
|
return err
|
|
}
|
|
if _, err := ComposedKey(c.Node, c.Module, strings.TrimPrefix(c.FP, sourcePrefix)); 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)
|
|
}
|