node-engine client: the codec, the digest and the comparison of a composition, in the field order its first writer used (ADR 0634 §1, §4; issue 1966)

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-05 14:51:46 +01:00
parent f00aac3847
commit ce9d39d3f0
3 changed files with 516 additions and 0 deletions
+323
View File
@@ -0,0 +1,323 @@
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)
}
@@ -0,0 +1,130 @@
package nodeengineclient
import (
"encoding/json"
"fmt"
"os"
"strings"
"testing"
seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
)
// captured is resources of ace's declaration of 2026-10-24 (the controller's `plan` verb answered it; testdata/captured-ace.json
// keeps three modules of it, never typed by hand), composed here by module under a stand-in fingerprint.
func captured(t *testing.T) (account string, byModule map[string][]json.RawMessage) {
t.Helper()
raw, err := os.ReadFile("testdata/captured-ace.json")
if err != nil {
t.Fatal(err)
}
var c struct {
Account string `json:"provisioner_account"`
Resources []json.RawMessage `json:"resources"`
}
if err := json.Unmarshal(raw, &c); err != nil {
t.Fatal(err)
}
byModule = map[string][]json.RawMessage{}
for _, r := range c.Resources {
var h struct {
Module string `json:"module"`
}
_ = json.Unmarshal(r, &h)
byModule[h.Module] = append(byModule[h.Module], r)
}
return c.Account, byModule
}
func fp(n int) string { return SourcePrefix + fmt.Sprintf("%064x", n) }
// TestCompositionDigests: a composition's digest is the one the first writer computed (the values are the digests
// mesh-sdk's delivery package gave for the same compositions when it moved here), whatever order the contract's generated
// types sort their fields in, and the plan is no part of it.
func TestCompositionDigests(t *testing.T) {
account, by := captured(t)
own := seat.NodeOwn{
Adoption: &seat.Adoption{Taken: []string{"avahi"}, Untaken: map[string][]string{"plex": {"plex.bundle-code"}}},
Identities: []seat.DeclaredIdentity{{Module: "plex", Name: "plex", Uid: 1001, Gid: 1001, Groups: []int64{1002}, Runner: "plex"}},
Groups: []seat.DeclaredGroup{{Name: "mesh-media", Gid: 1002}},
Libraries: []seat.DeclaredLibrary{{Library: "movies", Root: "/srv/media/movies", Group: "mesh-media", Gid: 1002, Taken: true, Paths: []string{"/srv/media/movies"}}},
ProvisionerAccount: "mesh-provisioner-client",
}
cases := []struct {
name string
c Composition
digest string
}{
{"a node's own, the account only", Composition{Node: "ace", Own: seat.NodeOwn{ProvisionerAccount: account}}, "sha256:17fde3a215d522619c18bc80b8c236975e25b2d2980a984aebd7c18376b095a2"},
{"a node's own, every part", Composition{Node: "ace", Own: own}, "sha256:b8ac7ace490a3c483c3ae18a9cda58e607aa6b69167fa274c062949c7b34fdbf"},
{"one package", Composition{Node: "ace", Module: "buildah", FP: fp(7), Resources: by["buildah"]}, "sha256:84e480a7a62333960b3e4be21a7548bbaa09572ef73ca8fb9dc6dfd1228aeb6b"},
{"a service and a file", Composition{Node: "ace", Module: "mesh-wireguard", FP: fp(14), Resources: by["mesh-wireguard"]}, "sha256:d6edec2474f606503e55d4ee2e0b4f5d1ffc25d016e4bc595e86ea67fe205b8c"},
{"a plan beside the resources", Composition{Node: "ace", Module: "hostname", FP: fp(8), Resources: by["hostname"], Plan: json.RawMessage(`{"files":[{"path":"/etc/hostname"}]}`)}, "sha256:49e17d6c471ee65cdef745ca3ac5cea4d37b6a100e11d8434dc64a6e2949e57e"},
}
for _, c := range cases {
got, err := ComposedDigest(c.c)
if err != nil || got != c.digest {
t.Errorf("%s: digest %s, %v; want %s", c.name, got, err, c.digest)
}
raw, err := EncodeComposition(c.c)
if err != nil {
t.Errorf("%s: %v", c.name, err)
continue
}
back, err := DecodeComposition(raw)
if err != nil {
t.Errorf("%s: %v", c.name, err)
continue
}
if !SameComposition(c.c, back) {
t.Errorf("%s: reads back as another composition", c.name)
}
again, _ := ComposedDigest(back)
if again != c.digest {
t.Errorf("%s: read back, its digest is %s", c.name, again)
}
}
a := Composition{Node: "ace", Module: "hostname", FP: fp(8), Resources: by["hostname"], Plan: json.RawMessage(`{"a":1}`)}
b := a
b.Plan = json.RawMessage(`{"a":2}`)
da, _ := ComposedDigest(a)
db, _ := ComposedDigest(b)
if da != db || SameComposition(a, b) {
t.Error("a plan changes the composition's encoding and not its digest")
}
}
// TestCompositionRefusals: what the codec refuses.
func TestCompositionRefusals(t *testing.T) {
_, by := captured(t)
ok := Composition{Node: "ace", Module: "buildah", FP: fp(7), Resources: by["buildah"]}
cases := []struct {
name string
c Composition
want string
}{
{"an old-form fingerprint", Composition{Node: "ace", Module: "buildah", FP: "src1:abc", Resources: ok.Resources}, "new form"},
{"a resource of another module", Composition{Node: "ace", Module: "hostname", FP: fp(8), Resources: ok.Resources}, "is of"},
{"the node's own parts on a module", Composition{Node: "ace", Module: "buildah", FP: fp(7), Own: seat.NodeOwn{ProvisionerAccount: "x"}}, "node's own parts"},
{"a refusal that holds resources", Composition{Node: "ace", Module: "buildah", FP: fp(7), Resources: ok.Resources, Refused: "no secret"}, "refused and holds"},
{"the node's own with a fingerprint", Composition{Node: "ace", FP: fp(7)}, "names no fingerprint"},
{"a node name with a dot", Composition{Node: "a.ce"}, "node"},
}
for _, c := range cases {
if _, err := EncodeComposition(c.c); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: %v, want an error saying %q", c.name, err, c.want)
}
}
if _, err := ComposedDigest(Composition{Node: "ace", Module: "buildah", FP: fp(7), Refused: "no secret"}); err == nil {
t.Error("a refused composition has a digest")
}
if _, err := DecodeComposition([]byte(`{"v":2,"node":"ace","resources":[]}`)); err == nil {
t.Error("a composition of version 2 is read")
}
if _, err := DecodeComposition([]byte(`{"v":1,"node":"ace","resources":[],"extra":1}`)); err == nil {
t.Error("a composition with a field it does not know is read")
}
if _, err := DecodeComposition([]byte(`{"v":1,"node":"ace","resources":[],"signature":"x"}`)); err != nil {
t.Errorf("a signed composition of the ended controller is refused: %v", err)
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"provisioner_account": "mesh-provisioner-client",
"resources": [
{
"id": "buildah.buildah",
"module": "buildah",
"package": "buildah",
"type": "package"
},
{
"id": "mesh-wireguard.overlay-tools",
"module": "mesh-wireguard",
"package": "wireguard-tools",
"type": "package"
},
{
"content": "# Generated by the mesh. Do not edit \u2014 this file is replaced whenever the\n# peer graph changes, and an edit would survive until the next change and\n# then vanish, which is worse than not being applied at all.\n#\n# node ace, at home, not dialable \u2014 it opens every path itself\n\n[Interface]\nAddress = 10.10.0.2/32\nPostUp = wg set %i private-key /var/lib/mesh-host/overlay.key\n\n# novox \u2014 the hub \u2014 everything not at this site\n[Peer]\nPublicKey = xW0ETQwyR9rHi2XSfOJrKkairE+Fp70FPNNcirqMixY=\nAllowedIPs = 10.10.0.0/24\nEndpoint = 142.132.152.141:51820\nPersistentKeepalive = 25\n",
"id": "mesh-wireguard.overlay-config",
"mode": "0600",
"module": "mesh-wireguard",
"path": "/etc/wireguard/mesh0.conf",
"type": "file"
},
{
"boot": "enabled",
"id": "mesh-wireguard.overlay-up",
"module": "mesh-wireguard",
"restart-on": [
"mesh-wireguard.overlay-config"
],
"state": "running",
"type": "service",
"unit": "wg-quick@mesh0"
},
{
"digest": "sha256:71ed8f5b4e7ac73ab82b7b559b08e5951a82553197d153651cc8be36ff35bcda",
"id": "hostname.bundle-tools",
"module": "hostname",
"path": "/var/lib/mesh/bundles/hostname/tools",
"source": "http://novox.internal:5100/v2/hostname/tools/blobs/sha256:71ed8f5b4e7ac73ab82b7b559b08e5951a82553197d153651cc8be36ff35bcda",
"type": "archive"
},
{
"at": "start",
"content": "# The machine's own names (module hostname, novox/hq ADR 0199, ADR 0223). Every line outside this\n# block is the operator's: kept across every push, changed through the node-hostname verbs add and\n# remove, and given back when this module goes. The mesh's names are not here: the mesh's resolver\n# answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n127.0.1.1\tace\n",
"id": "hostname.own",
"into": "block",
"mode": "0644",
"module": "hostname",
"path": "/etc/hosts",
"type": "file"
},
{
"content": "Ace\n",
"id": "hostname.name",
"mode": "0644",
"module": "hostname",
"path": "/etc/hostname",
"type": "file"
}
],
"filled_files": []
}