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 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:", 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) }