From 326d729c0b88aba67973fca6f907234b253768b0 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:09:45 +0100 Subject: [PATCH 1/3] =?UTF-8?q?Placement=20and=20Identity-and-access=20con?= =?UTF-8?q?tracts:=20mesh-module-assigner=20and=20mesh-wants=20whole=20wit?= =?UTF-8?q?h=20states,=20mesh-vault=20moves,=20mesh-node-enroller=20whole,?= =?UTF-8?q?=20node-secret-service=20moves;=20the=20two=20seats=20renamed?= =?UTF-8?q?=20(ADR=200634=20=C2=A77,=20=C2=A79;=20C-5,=20C-10;=20issue=201?= =?UTF-8?q?966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/seats/meshvault/go.mod | 3 - .../go/seats/meshnodeenroller/contract.go | 260 +++++ .../seats/meshnodeenroller/contract_test.go | 60 ++ .../go/seats/meshnodeenroller/go.mod | 3 + .../go}/seats/meshvault/contract.go | 145 ++- .../go}/seats/meshvault/contract_test.go | 6 +- identity-and-access/go/seats/meshvault/go.mod | 3 + identity-and-access/module.json | 67 ++ identity-and-access/seats/index.json | 8 + .../mesh-node-enroller/fixtures/events.json | 12 + .../mesh-node-enroller/fixtures/states.json | 20 + .../seats/mesh-node-enroller}/seat.json | 156 ++- .../seats}/mesh-vault/fixtures/events.json | 2 +- .../seats/mesh-vault/fixtures/states.json | 22 + .../seats}/mesh-vault/fixtures/verbs.json | 0 .../seats}/mesh-vault/seat.json | 137 ++- .../seats}/node-secret-service/seat.json | 0 .../seats/meshnodeenroller/package-lock.json | 12 + .../ts/seats/meshnodeenroller/package.json | 13 + .../ts/seats/meshnodeenroller/src/index.ts | 191 ++++ .../ts/seats/meshnodeenroller}/tsconfig.json | 4 +- .../ts/seats/meshvault/package-lock.json | 12 + .../ts/seats/meshvault/package.json | 13 + .../ts}/seats/meshvault/src/index.ts | 90 +- .../ts/seats/meshvault/tsconfig.json | 12 + module.json | 20 - .../go/seats/meshmoduleassigner/contract.go | 665 +++++++++++++ .../seats/meshmoduleassigner/contract_test.go | 86 ++ placement/go/seats/meshmoduleassigner/go.mod | 3 + placement/go/seats/meshwants/contract.go | 331 ++++++ placement/go/seats/meshwants/contract_test.go | 76 ++ placement/go/seats/meshwants/go.mod | 3 + placement/module.json | 58 ++ placement/seats/index.json | 7 + .../mesh-module-assigner/fixtures/events.json | 29 + .../mesh-module-assigner/fixtures/states.json | 109 ++ .../seats/mesh-module-assigner/seat.json | 941 ++++++++++++++++++ .../seats/mesh-wants/fixtures/states.json | 47 + placement/seats/mesh-wants/seat.json | 279 ++++++ .../meshmoduleassigner/package-lock.json | 12 + .../ts/seats/meshmoduleassigner/package.json | 13 + .../ts/seats/meshmoduleassigner/src/index.ts | 488 +++++++++ .../ts/seats/meshmoduleassigner/tsconfig.json | 12 + .../ts/seats/meshwants/package-lock.json | 12 + placement/ts/seats/meshwants/package.json | 13 + placement/ts/seats/meshwants/src/index.ts | 211 ++++ placement/ts/seats/meshwants/tsconfig.json | 12 + seats/index.json | 7 +- seats/mesh-placement/seat.json | 137 --- seats/mesh-wants/seat.json | 51 - ts/seats/meshvault/package.json | 19 - 51 files changed, 4626 insertions(+), 266 deletions(-) delete mode 100644 go/seats/meshvault/go.mod create mode 100644 identity-and-access/go/seats/meshnodeenroller/contract.go create mode 100644 identity-and-access/go/seats/meshnodeenroller/contract_test.go create mode 100644 identity-and-access/go/seats/meshnodeenroller/go.mod rename {go => identity-and-access/go}/seats/meshvault/contract.go (75%) rename {go => identity-and-access/go}/seats/meshvault/contract_test.go (85%) create mode 100644 identity-and-access/go/seats/meshvault/go.mod create mode 100644 identity-and-access/module.json create mode 100644 identity-and-access/seats/index.json create mode 100644 identity-and-access/seats/mesh-node-enroller/fixtures/events.json create mode 100644 identity-and-access/seats/mesh-node-enroller/fixtures/states.json rename {seats/mesh-enrolment => identity-and-access/seats/mesh-node-enroller}/seat.json (56%) rename {seats => identity-and-access/seats}/mesh-vault/fixtures/events.json (98%) create mode 100644 identity-and-access/seats/mesh-vault/fixtures/states.json rename {seats => identity-and-access/seats}/mesh-vault/fixtures/verbs.json (100%) rename {seats => identity-and-access/seats}/mesh-vault/seat.json (83%) rename {seats => identity-and-access/seats}/node-secret-service/seat.json (100%) create mode 100644 identity-and-access/ts/seats/meshnodeenroller/package-lock.json create mode 100644 identity-and-access/ts/seats/meshnodeenroller/package.json create mode 100644 identity-and-access/ts/seats/meshnodeenroller/src/index.ts rename {ts/seats/meshvault => identity-and-access/ts/seats/meshnodeenroller}/tsconfig.json (77%) create mode 100644 identity-and-access/ts/seats/meshvault/package-lock.json create mode 100644 identity-and-access/ts/seats/meshvault/package.json rename {ts => identity-and-access/ts}/seats/meshvault/src/index.ts (79%) create mode 100644 identity-and-access/ts/seats/meshvault/tsconfig.json create mode 100644 placement/go/seats/meshmoduleassigner/contract.go create mode 100644 placement/go/seats/meshmoduleassigner/contract_test.go create mode 100644 placement/go/seats/meshmoduleassigner/go.mod create mode 100644 placement/go/seats/meshwants/contract.go create mode 100644 placement/go/seats/meshwants/contract_test.go create mode 100644 placement/go/seats/meshwants/go.mod create mode 100644 placement/module.json create mode 100644 placement/seats/index.json create mode 100644 placement/seats/mesh-module-assigner/fixtures/events.json create mode 100644 placement/seats/mesh-module-assigner/fixtures/states.json create mode 100644 placement/seats/mesh-module-assigner/seat.json create mode 100644 placement/seats/mesh-wants/fixtures/states.json create mode 100644 placement/seats/mesh-wants/seat.json create mode 100644 placement/ts/seats/meshmoduleassigner/package-lock.json create mode 100644 placement/ts/seats/meshmoduleassigner/package.json create mode 100644 placement/ts/seats/meshmoduleassigner/src/index.ts create mode 100644 placement/ts/seats/meshmoduleassigner/tsconfig.json create mode 100644 placement/ts/seats/meshwants/package-lock.json create mode 100644 placement/ts/seats/meshwants/package.json create mode 100644 placement/ts/seats/meshwants/src/index.ts create mode 100644 placement/ts/seats/meshwants/tsconfig.json delete mode 100644 seats/mesh-placement/seat.json delete mode 100644 seats/mesh-wants/seat.json delete mode 100644 ts/seats/meshvault/package.json diff --git a/go/seats/meshvault/go.mod b/go/seats/meshvault/go.mod deleted file mode 100644 index 10f7a40..0000000 --- a/go/seats/meshvault/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module git.novox.be/novox/mesh-contracts/go/seats/meshvault - -go 1.26.0 diff --git a/identity-and-access/go/seats/meshnodeenroller/contract.go b/identity-and-access/go/seats/meshnodeenroller/contract.go new file mode 100644 index 0000000..9f1cc08 --- /dev/null +++ b/identity-and-access/go/seats/meshnodeenroller/contract.go @@ -0,0 +1,260 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package meshnodeenroller is the contract of the seat "mesh-node-enroller", defined whole (seats/mesh-node-enroller/seat.json): The seat that enrols a new machine on the operator's Approve and a one-time token (ADR 0587). Renamed from mesh-enrolment (ADR 0634 §9). +package meshnodeenroller + +import ( + "bytes" + "encoding/json" + "errors" + "strings" +) + +// Seat is the seat's name. +const Seat = "mesh-node-enroller" + +const ( + VerbIssue = "issue" + VerbRedeem = "redeem" + VerbConfirm = "confirm" + VerbRevoke = "revoke" + VerbTokens = "tokens" + VerbAgain = "again" +) + +const ( + EventEnrolmentIssued = "enrolment-issued" + EventNodeEnrolled = "node-enrolled" + EventEnrolmentRefused = "enrolment-refused" + EventEnrolmentRevoked = "enrolment-revoked" + EventEnrolmentExpired = "enrolment-expired" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbIssue, VerbRedeem, VerbConfirm, VerbRevoke, VerbTokens, VerbAgain} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventEnrolmentIssued, EventNodeEnrolled, EventEnrolmentRefused, EventEnrolmentRevoked, EventEnrolmentExpired} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "mesh-node-enroller.events" + FamilyMaxAgeDays = 7 +) + +// SubjectEnrolmentIssued is where enrolment-issued is published. +const SubjectEnrolmentIssued = "mesh.seat.mesh-node-enroller.event.enrolment-issued" + +// SubjectNodeEnrolled is where node-enrolled is published. +const SubjectNodeEnrolled = "mesh.seat.mesh-node-enroller.event.node-enrolled" + +// SubjectEnrolmentRefused is where enrolment-refused is published. +const SubjectEnrolmentRefused = "mesh.seat.mesh-node-enroller.event.enrolment-refused" + +// SubjectEnrolmentRevoked is where enrolment-revoked is published. +const SubjectEnrolmentRevoked = "mesh.seat.mesh-node-enroller.event.enrolment-revoked" + +// SubjectEnrolmentExpired is where enrolment-expired is published. +const SubjectEnrolmentExpired = "mesh.seat.mesh-node-enroller.event.enrolment-expired" + +const ( + StateEnrolments = "enrolments" +) + +// EnrolmentsByNodeNodeKey is the key of one value of the state enrolments under the form "node.". +func EnrolmentsByNodeNodeKey(node string) string { return "node." + node } + +// ParseEnrolmentsByNodeNodeKey reads the parts of a key of the form "node.", in the order of the form; the last result is false for a key of another form. +func ParseEnrolmentsByNodeNodeKey(key string) (string, bool) { + rest := key + if !strings.HasPrefix(rest, "node.") { + return "", false + } + rest = rest[5:] + if rest == "" { + return "", false + } + v1 := rest + rest = "" + if rest != "" { + return "", false + } + return v1, true +} + +// DecodeEnrolmentsByNodeNodeValue reads one value of the state enrolments under the form "node.", refusing a field EnrolmentsByNodeNodeValue does not know and any text after the value. +func DecodeEnrolmentsByNodeNodeValue(raw []byte) (EnrolmentsByNodeNodeValue, error) { + var v EnrolmentsByNodeNodeValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// EnrolmentsByAddressAddressKey is the key of one value of the state enrolments under the form "address.
". +func EnrolmentsByAddressAddressKey(address string) string { return "address." + address } + +// ParseEnrolmentsByAddressAddressKey reads the parts of a key of the form "address.
", in the order of the form; the last result is false for a key of another form. +func ParseEnrolmentsByAddressAddressKey(key string) (string, bool) { + rest := key + if !strings.HasPrefix(rest, "address.") { + return "", false + } + rest = rest[8:] + if rest == "" { + return "", false + } + v1 := rest + rest = "" + if rest != "" { + return "", false + } + return v1, true +} + +// DecodeEnrolmentsByAddressAddressValue reads one value of the state enrolments under the form "address.
", refusing a field EnrolmentsByAddressAddressValue does not know and any text after the value. +func DecodeEnrolmentsByAddressAddressValue(raw []byte) (EnrolmentsByAddressAddressValue, error) { + var v EnrolmentsByAddressAddressValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// Enrolment is one open enrolment. +type Enrolment struct { + Address string `json:"address"` + Ask string `json:"ask,omitempty"` + Caller string `json:"caller,omitempty"` + Expires string `json:"expires,omitempty"` + Issued string `json:"issued,omitempty"` + KeysDigest string `json:"keys-digest,omitempty"` + Node string `json:"node"` + OverlayKey string `json:"overlay-key,omitempty"` + // asked, live or redeeming + Stage string `json:"stage"` + V int64 `json:"v,omitempty"` + Why string `json:"why,omitempty"` +} + +// IssueInput is what issue takes. +type IssueInput struct { + // how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours + Expires string `json:"expires,omitempty"` + // the new machine's name in the mesh + Node string `json:"node"` + // the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it + OverlayKey string `json:"overlay_key"` + // why it joins, shown to the operator with the ask + Why string `json:"why"` +} + +// IssueAnswer is what issue answers. +type IssueAnswer = map[string]any + +// RedeemInput is what redeem takes. +type RedeemInput struct { + // the node the token was issued for + Node string `json:"node"` + // the private network key's public half + OverlayKey string `json:"overlay_key,omitempty"` + // what the machine is, as its record says it + Profile map[string]any `json:"profile,omitempty"` + // the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64 + Proof string `json:"proof"` + // the identity key's public half, base64 + PublicKey string `json:"public_key"` + // the sealing key's public half, which the account is sealed to + SealingKey string `json:"sealing_key"` + // the serving key's public half, which the certificate binds + ServingKey string `json:"serving_key,omitempty"` + // a tunnel found on the machine: everything but its private key + Tunnel map[string]any `json:"tunnel,omitempty"` +} + +// RedeemAnswer is what redeem answers. +type RedeemAnswer = map[string]any + +// ConfirmInput is what confirm takes. +type ConfirmInput struct { +} + +// ConfirmAnswer is what confirm answers. +type ConfirmAnswer = map[string]any + +// RevokeInput is what revoke takes. +type RevokeInput struct { + // the node + Node string `json:"node"` + // why, recorded with the hand act + Why string `json:"why"` +} + +// RevokeAnswer is what revoke answers. +type RevokeAnswer = map[string]any + +// TokensInput is what tokens takes. +type TokensInput struct { + // "true": the ended enrolments too + History string `json:"history,omitempty"` + // one node + Node string `json:"node,omitempty"` +} + +// The values of TokensInput's history. +const ( + TokensInputHistoryTrue = "true" + TokensInputHistoryFalse = "false" +) + +// TokensAnswer is what tokens answers. +type TokensAnswer = map[string]any + +// AgainInput is what again takes. +type AgainInput struct { + // the node whose ask is raised again + Node string `json:"node"` +} + +// AgainAnswer is what again answers. +type AgainAnswer = map[string]any + +// EnrolmentIssuedEvent is the body of the event enrolment-issued. +type EnrolmentIssuedEvent = Enrolment + +// NodeEnrolledEvent is the body of the event node-enrolled. +type NodeEnrolledEvent = Enrolment + +// EnrolmentRefusedEvent is the body of the event enrolment-refused. +type EnrolmentRefusedEvent = Enrolment + +// EnrolmentRevokedEvent is the body of the event enrolment-revoked. +type EnrolmentRevokedEvent = Enrolment + +// EnrolmentExpiredEvent is the body of the event enrolment-expired. +type EnrolmentExpiredEvent = Enrolment + +// EnrolmentsByNodeNodeValue is one value of the state enrolments under the key node.. +type EnrolmentsByNodeNodeValue = Enrolment + +// EnrolmentsByAddressAddressValue is one value of the state enrolments under the key address.
. +type EnrolmentsByAddressAddressValue struct { + Ask string `json:"ask,omitempty"` + Issued string `json:"issued,omitempty"` + Node string `json:"node"` + V int64 `json:"v,omitempty"` +} diff --git a/identity-and-access/go/seats/meshnodeenroller/contract_test.go b/identity-and-access/go/seats/meshnodeenroller/contract_test.go new file mode 100644 index 0000000..6e1d2bf --- /dev/null +++ b/identity-and-access/go/seats/meshnodeenroller/contract_test.go @@ -0,0 +1,60 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package meshnodeenroller + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/mesh-node-enroller/fixtures: each decodes into its type, a field the type does not +// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's. +func TestFixtures(t *testing.T) { + cases := []struct { + name, subject, given, wire string + decode func([]byte) (any, error) + subjectIs *regexp.Regexp + }{ + {name: "node-enrolled carries the enrolment", subject: "mesh.seat.mesh-node-enroller.event.node-enrolled", given: "{\"address\":\"10.0.0.9\",\"node\":\"newbox\",\"stage\":\"live\"}", wire: "{\"address\":\"10.0.0.9\",\"node\":\"newbox\",\"stage\":\"live\"}", + decode: func(b []byte) (any, error) { var v NodeEnrolledEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-node-enroller\\.event\\.node-enrolled$")}, + {name: "an open enrolment", subject: "", given: "{\"address\":\"10.0.0.9\",\"node\":\"newbox\",\"stage\":\"live\",\"v\":1}", wire: "{\"address\":\"10.0.0.9\",\"node\":\"newbox\",\"stage\":\"live\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v EnrolmentsByNodeNodeValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a reserved address", subject: "", given: "{\"node\":\"newbox\",\"v\":1}", wire: "{\"node\":\"newbox\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v EnrolmentsByAddressAddressValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + } + for _, c := range cases { + v, err := c.decode([]byte(c.given)) + if err != nil { + t.Errorf("%s: does not decode: %v", c.name, err) + continue + } + back, err := json.Marshal(v) + if err != nil { + t.Errorf("%s: does not encode: %v", c.name, err) + continue + } + var got, want any + if err := json.Unmarshal(back, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(c.wire), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) { + t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs) + } + } +} diff --git a/identity-and-access/go/seats/meshnodeenroller/go.mod b/identity-and-access/go/seats/meshnodeenroller/go.mod new file mode 100644 index 0000000..61f3627 --- /dev/null +++ b/identity-and-access/go/seats/meshnodeenroller/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller + +go 1.26.0 diff --git a/go/seats/meshvault/contract.go b/identity-and-access/go/seats/meshvault/contract.go similarity index 75% rename from go/seats/meshvault/contract.go rename to identity-and-access/go/seats/meshvault/contract.go index df00f7c..2f3fe11 100644 --- a/go/seats/meshvault/contract.go +++ b/identity-and-access/go/seats/meshvault/contract.go @@ -3,6 +3,13 @@ // Package meshvault is the contract of the seat "mesh-vault", defined whole (seats/mesh-vault/seat.json): The mesh's secrets. accept and move-asks, which no holder served and nothing called, are left out (ADR 0620 §3). The vault's own events stay under its own subjects (ADR 0620 §2). package meshvault +import ( + "bytes" + "encoding/json" + "errors" + "strings" +) + // Seat is the seat's name. const Seat = "mesh-vault" @@ -95,6 +102,102 @@ func HolderSubjectOwnRetired(holder string) string { return "mesh.mod." + holder + ".event.own-retired" } +const ( + StateSigners = "signers" + StateNodeLogins = "node-logins" +) + +// SignersByKeyKey is the key of one value of the state signers under the form "key". +func SignersByKeyKey() string { return "key" } + +// ParseSignersByKeyKey reads the parts of a key of the form "key", in the order of the form; the last result is false for a key of another form. +func ParseSignersByKeyKey(key string) bool { + rest := key + if !strings.HasPrefix(rest, "key") { + return false + } + rest = rest[3:] + if rest != "" { + return false + } + return true +} + +// DecodeSignersByKeyValue reads one value of the state signers under the form "key", refusing a field SignersByKeyValue does not know and any text after the value. +func DecodeSignersByKeyValue(raw []byte) (SignersByKeyValue, error) { + var v SignersByKeyValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// SignersByAuthorityKey is the key of one value of the state signers under the form "authority". +func SignersByAuthorityKey() string { return "authority" } + +// ParseSignersByAuthorityKey reads the parts of a key of the form "authority", in the order of the form; the last result is false for a key of another form. +func ParseSignersByAuthorityKey(key string) bool { + rest := key + if !strings.HasPrefix(rest, "authority") { + return false + } + rest = rest[9:] + if rest != "" { + return false + } + return true +} + +// DecodeSignersByAuthorityValue reads one value of the state signers under the form "authority", refusing a field SignersByAuthorityValue does not know and any text after the value. +func DecodeSignersByAuthorityValue(raw []byte) (SignersByAuthorityValue, error) { + var v SignersByAuthorityValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// NodeLoginsByNodeKey is the key of one value of the state node-logins under the form "". +func NodeLoginsByNodeKey(node string) string { return node } + +// ParseNodeLoginsByNodeKey reads the parts of a key of the form "", in the order of the form; the last result is false for a key of another form. +func ParseNodeLoginsByNodeKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeNodeLoginsByNodeValue reads one value of the state node-logins under the form "", refusing a field NodeLoginsByNodeValue does not know and any text after the value. +func DecodeNodeLoginsByNodeValue(raw []byte) (NodeLoginsByNodeValue, error) { + var v NodeLoginsByNodeValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + // GetInput is what get takes. type GetInput struct { // the secret's name @@ -241,11 +344,11 @@ type IssueAccountAnswer = map[string]any type SignInput struct { // node-certificate or enrolment Kind string `json:"kind"` - // the machine, for a node certificate: the caller's own, or the node mesh-enrolment enrols + // the machine, for a node certificate: the caller's own, or the node mesh-node-enroller enrols Node string `json:"node,omitempty"` // the redeem answer's bytes in base64, for an enrolment Reply string `json:"reply,omitempty"` - // from mesh-enrolment: the serving key's public half the enrolling node presented + // from mesh-node-enroller: the serving key's public half the enrolling node presented ServingKey string `json:"serving_key,omitempty"` } @@ -267,7 +370,7 @@ type SignAnswer struct { Signer string `json:"signer"` } -// EnrolmentTokenInputTunnel is the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range. +// EnrolmentTokenInputTunnel is the node's first tunnel, built by mesh-node-enroller at issue and carried in the token: the hub's public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range. type EnrolmentTokenInputTunnel struct { // the joining node's tunnel address Address string `json:"address"` @@ -291,7 +394,7 @@ type EnrolmentTokenInput struct { Node string `json:"node"` // "true": answer the token sealed to the operator key Sealed string `json:"sealed,omitempty"` - // the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range + // the node's first tunnel, built by mesh-node-enroller at issue and carried in the token: the hub's public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range Tunnel *EnrolmentTokenInputTunnel `json:"tunnel,omitempty"` } @@ -431,3 +534,37 @@ type OwnRetiredHolderEvent struct { // the node Node string `json:"node"` } + +// SignersByKeyValue is one value of the state signers under the key key. +type SignersByKeyValue struct { + Fingerprint string `json:"fingerprint"` + // the signing key's public half, standard base64 + Key string `json:"key"` + Made string `json:"made"` + V int64 `json:"v,omitempty"` +} + +// SignersByAuthorityValue is one value of the state signers under the key authority. +type SignersByAuthorityValue struct { + // the authority certificate, PEM + Certificate string `json:"certificate"` + Fingerprint string `json:"fingerprint"` + Made string `json:"made"` + V int64 `json:"v,omitempty"` +} + +// NodeLoginsByNodeValue is one value of the state node-logins under the key . +type NodeLoginsByNodeValue struct { + Acts []string `json:"acts,omitempty"` + // the user list's revision that held both logins, from ready on + ListRevision int64 `json:"list-revision,omitempty"` + // the login in use + Login string `json:"login"` + Node string `json:"node"` + // the login rotating to, empty when none + Rotating string `json:"rotating,omitempty"` + Since string `json:"since"` + // begun, ready or confirmed; empty when no rotation + Stage string `json:"stage,omitempty"` + V int64 `json:"v,omitempty"` +} diff --git a/go/seats/meshvault/contract_test.go b/identity-and-access/go/seats/meshvault/contract_test.go similarity index 85% rename from go/seats/meshvault/contract_test.go rename to identity-and-access/go/seats/meshvault/contract_test.go index a358263..8b8c520 100644 --- a/go/seats/meshvault/contract_test.go +++ b/identity-and-access/go/seats/meshvault/contract_test.go @@ -29,7 +29,7 @@ func TestFixtures(t *testing.T) { decode: func(b []byte) (any, error) { var v map[string]any; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.mod\\.[a-z0-9-]+\\.event\\.own-replaced$")}, {name: "account-issued under the vault's own subjects", subject: "mesh.mod.mesh-vault.event.account-issued", given: "{}", wire: "{}", decode: func(b []byte) (any, error) { var v map[string]any; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.mod\\.[a-z0-9-]+\\.event\\.account-issued$")}, - {name: "signed under the vault's own subjects", subject: "mesh.mod.mesh-vault.event.signed", given: "{\"at\":\"2026-11-04T13:00:00Z\",\"caller\":\"mesh-enrolment\",\"digest\":\"sha256:0000000000000000000000000000000000000000000000000000000000000000\",\"kind\":\"enrolment\",\"signer\":\"sha256:1111111111111111111111111111111111111111111111111111111111111111\"}", wire: "{\"at\":\"2026-11-04T13:00:00Z\",\"caller\":\"mesh-enrolment\",\"digest\":\"sha256:0000000000000000000000000000000000000000000000000000000000000000\",\"kind\":\"enrolment\",\"signer\":\"sha256:1111111111111111111111111111111111111111111111111111111111111111\"}", + {name: "signed under the vault's own subjects", subject: "mesh.mod.mesh-vault.event.signed", given: "{\"at\":\"2026-11-04T13:00:00Z\",\"caller\":\"mesh-node-enroller\",\"digest\":\"sha256:0000000000000000000000000000000000000000000000000000000000000000\",\"kind\":\"enrolment\",\"signer\":\"sha256:1111111111111111111111111111111111111111111111111111111111111111\"}", wire: "{\"at\":\"2026-11-04T13:00:00Z\",\"caller\":\"mesh-node-enroller\",\"digest\":\"sha256:0000000000000000000000000000000000000000000000000000000000000000\",\"kind\":\"enrolment\",\"signer\":\"sha256:1111111111111111111111111111111111111111111111111111111111111111\"}", decode: func(b []byte) (any, error) { var v SignedHolderEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.mod\\.[a-z0-9-]+\\.event\\.signed$")}, {name: "rotation-begun under the vault's own subjects", subject: "mesh.mod.mesh-vault.event.rotation-begun", given: "{}", wire: "{}", decode: func(b []byte) (any, error) { var v map[string]any; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.mod\\.[a-z0-9-]+\\.event\\.rotation-begun$")}, @@ -39,6 +39,10 @@ func TestFixtures(t *testing.T) { decode: func(b []byte) (any, error) { var v map[string]any; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.mod\\.[a-z0-9-]+\\.event\\.rotation-done$")}, {name: "own-retired", subject: "mesh.mod.mesh-vault.event.own-retired", given: "{\"ask\":\"a1\",\"caller\":\"mesh-config\",\"module\":\"messenger\",\"name\":\"token\",\"node\":\"ace\"}", wire: "{\"ask\":\"a1\",\"caller\":\"mesh-config\",\"module\":\"messenger\",\"name\":\"token\",\"node\":\"ace\"}", decode: func(b []byte) (any, error) { var v OwnRetiredHolderEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.mod\\.[a-z0-9-]+\\.event\\.own-retired$")}, + {name: "the signing key", subject: "", given: "{\"fingerprint\":\"sha256:aa\",\"key\":\"AAAA\",\"made\":\"2026-11-05T12:00:00Z\",\"v\":1}", wire: "{\"fingerprint\":\"sha256:aa\",\"key\":\"AAAA\",\"made\":\"2026-11-05T12:00:00Z\",\"v\":1}", + decode: func(b []byte) (any, error) { var v SignersByKeyValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a node's login", subject: "", given: "{\"login\":\"node.ace\",\"node\":\"ace\",\"since\":\"2026-11-05T12:00:00Z\",\"v\":1}", wire: "{\"login\":\"node.ace\",\"node\":\"ace\",\"since\":\"2026-11-05T12:00:00Z\",\"v\":1}", + decode: func(b []byte) (any, error) { var v NodeLoginsByNodeValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, {name: "retire on a warrant", subject: "", given: "{\"ask\":\"a1\",\"module\":\"messenger\",\"name\":\"token\",\"node\":\"ace\",\"warrant\":\"w-1\"}", wire: "{\"ask\":\"a1\",\"module\":\"messenger\",\"name\":\"token\",\"node\":\"ace\",\"warrant\":\"w-1\"}", decode: func(b []byte) (any, error) { var v RetireInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, {name: "a node certificate signed", subject: "", given: "{\"authority\":\"-----BEGIN CERTIFICATE-----\",\"certificate\":\"-----BEGIN CERTIFICATE-----\",\"digest\":\"sha256:0000000000000000000000000000000000000000000000000000000000000000\",\"kind\":\"node-certificate\",\"node\":\"ace\",\"signer\":\"sha256:1111111111111111111111111111111111111111111111111111111111111111\"}", wire: "{\"authority\":\"-----BEGIN CERTIFICATE-----\",\"certificate\":\"-----BEGIN CERTIFICATE-----\",\"digest\":\"sha256:0000000000000000000000000000000000000000000000000000000000000000\",\"kind\":\"node-certificate\",\"node\":\"ace\",\"signer\":\"sha256:1111111111111111111111111111111111111111111111111111111111111111\"}", diff --git a/identity-and-access/go/seats/meshvault/go.mod b/identity-and-access/go/seats/meshvault/go.mod new file mode 100644 index 0000000..a6cf36c --- /dev/null +++ b/identity-and-access/go/seats/meshvault/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault + +go 1.26.0 diff --git a/identity-and-access/module.json b/identity-and-access/module.json new file mode 100644 index 0000000..20e2d2c --- /dev/null +++ b/identity-and-access/module.json @@ -0,0 +1,67 @@ +{ + "module": "mesh-identity-and-access-contracts", + "version": "1", + "build": { + "artifacts": [ + { + "name": "go-meshvault", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshvault" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshnodeenroller", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshnodeenroller" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshvaultclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/meshvaultclient" + ], + "series": "0.1" + }, + { + "name": "go-meshnodeenrollerclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/meshnodeenrollerclient" + ], + "series": "0.1" + }, + { + "name": "ts-meshvault", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshvault" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-meshnodeenroller", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshnodeenroller" + ], + "series": "0.1", + "generated-from": "." + } + ] + }, + "resources": [] +} diff --git a/identity-and-access/seats/index.json b/identity-and-access/seats/index.json new file mode 100644 index 0000000..72e28e3 --- /dev/null +++ b/identity-and-access/seats/index.json @@ -0,0 +1,8 @@ +{ + "format": 1, + "seats": [ + "mesh-node-enroller", + "mesh-vault", + "node-secret-service" + ] +} diff --git a/identity-and-access/seats/mesh-node-enroller/fixtures/events.json b/identity-and-access/seats/mesh-node-enroller/fixtures/events.json new file mode 100644 index 0000000..22c07c1 --- /dev/null +++ b/identity-and-access/seats/mesh-node-enroller/fixtures/events.json @@ -0,0 +1,12 @@ +[ + { + "name": "node-enrolled carries the enrolment", + "of": "event:node-enrolled", + "subject": "mesh.seat.mesh-node-enroller.event.node-enrolled", + "given": { + "node": "newbox", + "address": "10.0.0.9", + "stage": "live" + } + } +] diff --git a/identity-and-access/seats/mesh-node-enroller/fixtures/states.json b/identity-and-access/seats/mesh-node-enroller/fixtures/states.json new file mode 100644 index 0000000..e0e547f --- /dev/null +++ b/identity-and-access/seats/mesh-node-enroller/fixtures/states.json @@ -0,0 +1,20 @@ +[ + { + "name": "an open enrolment", + "of": "state:enrolments/node.", + "given": { + "v": 1, + "node": "newbox", + "address": "10.0.0.9", + "stage": "live" + } + }, + { + "name": "a reserved address", + "of": "state:enrolments/address.
", + "given": { + "v": 1, + "node": "newbox" + } + } +] diff --git a/seats/mesh-enrolment/seat.json b/identity-and-access/seats/mesh-node-enroller/seat.json similarity index 56% rename from seats/mesh-enrolment/seat.json rename to identity-and-access/seats/mesh-node-enroller/seat.json index b611558..3bbe3be 100644 --- a/seats/mesh-enrolment/seat.json +++ b/identity-and-access/seats/mesh-node-enroller/seat.json @@ -1,9 +1,10 @@ { "format": 1, - "seat": "mesh-enrolment", + "seat": "mesh-node-enroller", "scope": "mesh", - "grain": "seat-list", - "decision": "novox/hq ADR 0587", + "grain": "whole", + "decision": "novox/hq ADR 0587, ADR 0634, ADR 0635", + "description": "The seat that enrols a new machine on the operator's Approve and a one-time token (ADR 0587). Renamed from mesh-enrolment (ADR 0634 §9).", "verbs": [ { "name": "issue", @@ -33,11 +34,15 @@ "overlay_key" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "the enrolment's stage, address and ask, or every finding refused at once" } }, { "name": "redeem", - "description": "Spend a token: asked by a joining node-engine dialled as enrol.\u003cnode\u003e alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the vault for the node's account and certificate, writes the node into placement, and answers the account sealed to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys it finishes what is not done and answers the same; with other keys it is refused.", + "description": "Spend a token: asked by a joining node-engine dialled as enrol. alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the vault for the node's account and certificate, writes the node into placement, and answers the account sealed to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys it finishes what is not done and answers the same; with other keys it is refused.", "input": { "additionalProperties": false, "properties": { @@ -81,6 +86,10 @@ "proof" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "the account sealed to the node, its certificate and the vault's public half, signed by the vault" } }, { @@ -89,6 +98,10 @@ "input": { "properties": {}, "type": "object" + }, + "answer": { + "type": "object", + "description": "the enrolment ended" } }, { @@ -110,6 +123,10 @@ "why" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "the enrolment ended" } }, { @@ -131,6 +148,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "every open enrolment, or one" } }, { @@ -147,24 +168,139 @@ "node" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "the ask raised again" } } ], "events": [ { - "name": "enrolment-issued" + "name": "enrolment-issued", + "body": { + "$ref": "#/defs/Enrolment" + } }, { - "name": "node-enrolled" + "name": "node-enrolled", + "body": { + "$ref": "#/defs/Enrolment" + } }, { - "name": "enrolment-refused" + "name": "enrolment-refused", + "body": { + "$ref": "#/defs/Enrolment" + } }, { - "name": "enrolment-revoked" + "name": "enrolment-revoked", + "body": { + "$ref": "#/defs/Enrolment" + } }, { - "name": "enrolment-expired" + "name": "enrolment-expired", + "body": { + "$ref": "#/defs/Enrolment" + } } - ] + ], + "family": { + "name": "mesh-node-enroller.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "enrolments", + "template": "state", + "key-forms": [ + { + "key": "node.", + "value": { + "$ref": "#/defs/Enrolment" + }, + "description": "a node's enrolment, living until the token expires while live" + }, + { + "key": "address.
", + "value": { + "type": "object", + "description": "the private-network address reserved at issue", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "ask": { + "type": "string" + }, + "issued": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "node" + ] + }, + "description": "an address reserved for a node" + } + ], + "history": 5, + "description": "open enrolments and the addresses reserved for them" + } + ], + "defs": { + "Enrolment": { + "type": "object", + "description": "one open enrolment", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "address": { + "type": "string" + }, + "why": { + "type": "string" + }, + "caller": { + "type": "string" + }, + "ask": { + "type": "string" + }, + "stage": { + "type": "string", + "description": "asked, live or redeeming" + }, + "issued": { + "type": "string", + "format": "date-time" + }, + "expires": { + "type": "string", + "format": "date-time" + }, + "keys-digest": { + "type": "string" + }, + "overlay-key": { + "type": "string" + } + }, + "required": [ + "node", + "address", + "stage" + ] + } + } } diff --git a/seats/mesh-vault/fixtures/events.json b/identity-and-access/seats/mesh-vault/fixtures/events.json similarity index 98% rename from seats/mesh-vault/fixtures/events.json rename to identity-and-access/seats/mesh-vault/fixtures/events.json index ef46519..4a64125 100644 --- a/seats/mesh-vault/fixtures/events.json +++ b/identity-and-access/seats/mesh-vault/fixtures/events.json @@ -40,7 +40,7 @@ "of": "holder-event:signed", "subject": "mesh.mod.mesh-vault.event.signed", "given": { - "caller": "mesh-enrolment", + "caller": "mesh-node-enroller", "kind": "enrolment", "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "signer": "sha256:1111111111111111111111111111111111111111111111111111111111111111", diff --git a/identity-and-access/seats/mesh-vault/fixtures/states.json b/identity-and-access/seats/mesh-vault/fixtures/states.json new file mode 100644 index 0000000..f0b72c0 --- /dev/null +++ b/identity-and-access/seats/mesh-vault/fixtures/states.json @@ -0,0 +1,22 @@ +[ + { + "name": "the signing key", + "of": "state:signers/key", + "given": { + "v": 1, + "key": "AAAA", + "fingerprint": "sha256:aa", + "made": "2026-11-05T12:00:00Z" + } + }, + { + "name": "a node's login", + "of": "state:node-logins/", + "given": { + "v": 1, + "node": "ace", + "login": "node.ace", + "since": "2026-11-05T12:00:00Z" + } + } +] diff --git a/seats/mesh-vault/fixtures/verbs.json b/identity-and-access/seats/mesh-vault/fixtures/verbs.json similarity index 100% rename from seats/mesh-vault/fixtures/verbs.json rename to identity-and-access/seats/mesh-vault/fixtures/verbs.json diff --git a/seats/mesh-vault/seat.json b/identity-and-access/seats/mesh-vault/seat.json similarity index 83% rename from seats/mesh-vault/seat.json rename to identity-and-access/seats/mesh-vault/seat.json index 5a115a6..177adcc 100644 --- a/seats/mesh-vault/seat.json +++ b/identity-and-access/seats/mesh-vault/seat.json @@ -3,7 +3,7 @@ "seat": "mesh-vault", "scope": "mesh", "grain": "whole", - "decision": "novox/hq ADR 0161", + "decision": "novox/hq ADR 0161, ADR 0634, ADR 0635", "delivers": "secret", "verbs": [ { @@ -299,7 +299,7 @@ }, { "name": "sign", - "description": "Sign with the vault's key or authority (novox/hq ADR 0550 §2): kind node-certificate signs the certificate of a machine's internal name, asked by that machine's node-engine for the serving key its record states, built from bus state alone, or by mesh-enrolment for a node it enrols, for the serving key the node presented at redeem (ADR 0587 §3); kind enrolment signs a redeem answer under its own context, for mesh-enrolment alone. Answers the certificate or the signature and the signer's fingerprint, and says signed; never a key.", + "description": "Sign with the vault's key or authority (novox/hq ADR 0550 §2): kind node-certificate signs the certificate of a machine's internal name, asked by that machine's node-engine for the serving key its record states, built from bus state alone, or by mesh-node-enroller for a node it enrols, for the serving key the node presented at redeem (ADR 0587 §3); kind enrolment signs a redeem answer under its own context, for mesh-node-enroller alone. Answers the certificate or the signature and the signer's fingerprint, and says signed; never a key.", "optional": true, "input": { "properties": { @@ -308,7 +308,7 @@ "type": "string" }, "node": { - "description": "the machine, for a node certificate: the caller's own, or the node mesh-enrolment enrols", + "description": "the machine, for a node certificate: the caller's own, or the node mesh-node-enroller enrols", "type": "string" }, "reply": { @@ -316,7 +316,7 @@ "type": "string" }, "serving_key": { - "description": "from mesh-enrolment: the serving key's public half the enrolling node presented", + "description": "from mesh-node-enroller: the serving key's public half the enrolling node presented", "type": "string" } }, @@ -367,7 +367,7 @@ }, { "name": "enrolment-token", - "description": "Make a one-time token for a node mesh-enrolment issues on the operator's warrant: its secret kept with its copy sealed to the operator key and its hash written as the bus user enrol.. Answers only what the joining machine must trust (the bus's address, the bus certificate's fingerprint, the vault's public signing key), never the token; with sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash dropped, at the enrolment's end. Asked by mesh-enrolment alone, only from the lease write. The token carries the first tunnel mesh-enrolment built at issue (ADR 0587 §3, insight of 2026-11-02).", + "description": "Make a one-time token for a node mesh-node-enroller issues on the operator's warrant: its secret kept with its copy sealed to the operator key and its hash written as the bus user enrol.. Answers only what the joining machine must trust (the bus's address, the bus certificate's fingerprint, the vault's public signing key), never the token; with sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash dropped, at the enrolment's end. Asked by mesh-node-enroller alone, only from the lease write. The token carries the first tunnel mesh-node-enroller built at issue (ADR 0587 §3, insight of 2026-11-02).", "optional": true, "input": { "properties": { @@ -397,7 +397,7 @@ }, "tunnel": { "additionalProperties": false, - "description": "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range", + "description": "the node's first tunnel, built by mesh-node-enroller at issue and carried in the token: the hub's public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range", "properties": { "address": { "description": "the joining node's tunnel address", @@ -471,7 +471,7 @@ }, { "name": "node-account", - "description": "Mint a joining node's bus account node., sealed to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the node's node-logins key naming node.. Asked by mesh-enrolment alone. Never the value in the clear.", + "description": "Mint a joining node's bus account node., sealed to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the node's node-logins key naming node.. Asked by mesh-node-enroller alone. Never the value in the clear.", "optional": true, "input": { "properties": { @@ -757,5 +757,128 @@ ] } } + ], + "states": [ + { + "name": "signers", + "template": "state", + "key-forms": [ + { + "key": "key", + "value": { + "type": "object", + "description": "the vault's signing key's public half", + "properties": { + "v": { + "type": "integer" + }, + "key": { + "type": "string", + "description": "the signing key's public half, standard base64" + }, + "fingerprint": { + "type": "string" + }, + "made": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "key", + "fingerprint", + "made" + ] + }, + "description": "the signing key's public half" + }, + { + "key": "authority", + "value": { + "type": "object", + "description": "the vault's authority certificate", + "properties": { + "v": { + "type": "integer" + }, + "certificate": { + "type": "string", + "description": "the authority certificate, PEM" + }, + "fingerprint": { + "type": "string" + }, + "made": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "certificate", + "fingerprint", + "made" + ] + }, + "description": "the authority certificate" + } + ], + "history": 5, + "description": "the vault's public signing halves; written by the vault alone" + }, + { + "name": "node-logins", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "a node's two logins and the rotation's stage", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "login": { + "type": "string", + "description": "the login in use" + }, + "rotating": { + "type": "string", + "description": "the login rotating to, empty when none" + }, + "stage": { + "type": "string", + "description": "begun, ready or confirmed; empty when no rotation" + }, + "since": { + "type": "string", + "format": "date-time" + }, + "list-revision": { + "type": "integer", + "description": "the user list's revision that held both logins, from ready on" + }, + "acts": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "node", + "login", + "since" + ] + }, + "description": "a node" + } + ], + "history": 5, + "description": "which login a node uses and the stage of its rotation; the key lives while the node does" + } ] } diff --git a/seats/node-secret-service/seat.json b/identity-and-access/seats/node-secret-service/seat.json similarity index 100% rename from seats/node-secret-service/seat.json rename to identity-and-access/seats/node-secret-service/seat.json diff --git a/identity-and-access/ts/seats/meshnodeenroller/package-lock.json b/identity-and-access/ts/seats/meshnodeenroller/package-lock.json new file mode 100644 index 0000000..8e7f57d --- /dev/null +++ b/identity-and-access/ts/seats/meshnodeenroller/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-identity-and-access-contracts-mesh-node-enroller", + "packages": { + "": { + "name": "@novox/mesh-identity-and-access-contracts-mesh-node-enroller", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/identity-and-access/ts/seats/meshnodeenroller/package.json b/identity-and-access/ts/seats/meshnodeenroller/package.json new file mode 100644 index 0000000..88192fc --- /dev/null +++ b/identity-and-access/ts/seats/meshnodeenroller/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-node-enroller, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-identity-and-access-contracts-mesh-node-enroller", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/identity-and-access/ts/seats/meshnodeenroller/src/index.ts b/identity-and-access/ts/seats/meshnodeenroller/src/index.ts new file mode 100644 index 0000000..4068a1d --- /dev/null +++ b/identity-and-access/ts/seats/meshnodeenroller/src/index.ts @@ -0,0 +1,191 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "mesh-node-enroller", defined whole (seats/mesh-node-enroller/seat.json): The seat that enrols a new machine on the operator's Approve and a one-time token (ADR 0587). Renamed from mesh-enrolment (ADR 0634 §9). + +export const seat = "mesh-node-enroller"; + +export const verbs = { + issue: "issue", + redeem: "redeem", + confirm: "confirm", + revoke: "revoke", + tokens: "tokens", + again: "again", +} as const; + +export const events = { + enrolmentIssued: "enrolment-issued", + nodeEnrolled: "node-enrolled", + enrolmentRefused: "enrolment-refused", + enrolmentRevoked: "enrolment-revoked", + enrolmentExpired: "enrolment-expired", +} as const; + +export const family = { name: "mesh-node-enroller.events", maxAgeDays: 7 } as const; + +export const enrolmentIssuedSubject = "mesh.seat.mesh-node-enroller.event.enrolment-issued"; + +export const nodeEnrolledSubject = "mesh.seat.mesh-node-enroller.event.node-enrolled"; + +export const enrolmentRefusedSubject = "mesh.seat.mesh-node-enroller.event.enrolment-refused"; + +export const enrolmentRevokedSubject = "mesh.seat.mesh-node-enroller.event.enrolment-revoked"; + +export const enrolmentExpiredSubject = "mesh.seat.mesh-node-enroller.event.enrolment-expired"; + +export const states = { + enrolments: "enrolments", +} as const; + +/** The key of one value of the state enrolments under the form "node.". */ +export function enrolmentsByNodeNodeKey(node: string): string { + return "node." + node; +} + +/** The parts of a key of the form "node.", or undefined for a key of another form. */ +export function parseEnrolmentsByNodeNodeKey(key: string): { node: string } | undefined { + let rest = key; + if (!rest.startsWith("node.")) return undefined; + rest = rest.slice(5); + if (rest === "") return undefined; + const node = rest; + rest = ""; + if (rest !== "") return undefined; + return { node }; +} + +/** The key of one value of the state enrolments under the form "address.
". */ +export function enrolmentsByAddressAddressKey(address: string): string { + return "address." + address; +} + +/** The parts of a key of the form "address.
", or undefined for a key of another form. */ +export function parseEnrolmentsByAddressAddressKey(key: string): { address: string } | undefined { + let rest = key; + if (!rest.startsWith("address.")) return undefined; + rest = rest.slice(8); + if (rest === "") return undefined; + const address = rest; + rest = ""; + if (rest !== "") return undefined; + return { address }; +} + +/** one open enrolment */ +export interface Enrolment { + "address": string; + "ask"?: string; + "caller"?: string; + "expires"?: string; + "issued"?: string; + "keys-digest"?: string; + "node": string; + "overlay-key"?: string; + /** asked, live or redeeming */ + "stage": string; + "v"?: number; + "why"?: string; +} + +/** is what issue takes. */ +export interface IssueInput { + /** how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours */ + "expires"?: string; + /** the new machine's name in the mesh */ + "node": string; + /** the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it */ + "overlay_key": string; + /** why it joins, shown to the operator with the ask */ + "why": string; +} + +/** is what issue answers. */ +export type IssueAnswer = Record; + +/** is what redeem takes. */ +export interface RedeemInput { + /** the node the token was issued for */ + "node": string; + /** the private network key's public half */ + "overlay_key"?: string; + /** what the machine is, as its record says it */ + "profile"?: Record; + /** the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64 */ + "proof": string; + /** the identity key's public half, base64 */ + "public_key": string; + /** the sealing key's public half, which the account is sealed to */ + "sealing_key": string; + /** the serving key's public half, which the certificate binds */ + "serving_key"?: string; + /** a tunnel found on the machine: everything but its private key */ + "tunnel"?: Record; +} + +/** is what redeem answers. */ +export type RedeemAnswer = Record; + +/** is what confirm takes. */ +export interface ConfirmInput { +} + +/** is what confirm answers. */ +export type ConfirmAnswer = Record; + +/** is what revoke takes. */ +export interface RevokeInput { + /** the node */ + "node": string; + /** why, recorded with the hand act */ + "why": string; +} + +/** is what revoke answers. */ +export type RevokeAnswer = Record; + +/** is what tokens takes. */ +export interface TokensInput { + /** "true": the ended enrolments too */ + "history"?: "true" | "false"; + /** one node */ + "node"?: string; +} + +/** is what tokens answers. */ +export type TokensAnswer = Record; + +/** is what again takes. */ +export interface AgainInput { + /** the node whose ask is raised again */ + "node": string; +} + +/** is what again answers. */ +export type AgainAnswer = Record; + +/** is the body of the event enrolment-issued. */ +export type EnrolmentIssuedEvent = Enrolment; + +/** is the body of the event node-enrolled. */ +export type NodeEnrolledEvent = Enrolment; + +/** is the body of the event enrolment-refused. */ +export type EnrolmentRefusedEvent = Enrolment; + +/** is the body of the event enrolment-revoked. */ +export type EnrolmentRevokedEvent = Enrolment; + +/** is the body of the event enrolment-expired. */ +export type EnrolmentExpiredEvent = Enrolment; + +/** is one value of the state enrolments under the key node.. */ +export type EnrolmentsByNodeNodeValue = Enrolment; + +/** is one value of the state enrolments under the key address.
. */ +export interface EnrolmentsByAddressAddressValue { + "ask"?: string; + "issued"?: string; + "node": string; + "v"?: number; +} + diff --git a/ts/seats/meshvault/tsconfig.json b/identity-and-access/ts/seats/meshnodeenroller/tsconfig.json similarity index 77% rename from ts/seats/meshvault/tsconfig.json rename to identity-and-access/ts/seats/meshnodeenroller/tsconfig.json index c610421..60274c6 100644 --- a/ts/seats/meshvault/tsconfig.json +++ b/identity-and-access/ts/seats/meshnodeenroller/tsconfig.json @@ -3,9 +3,7 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "declaration": true, - "outDir": "dist", - "rootDir": "src", + "noEmit": true, "strict": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true diff --git a/identity-and-access/ts/seats/meshvault/package-lock.json b/identity-and-access/ts/seats/meshvault/package-lock.json new file mode 100644 index 0000000..c7aa797 --- /dev/null +++ b/identity-and-access/ts/seats/meshvault/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-identity-and-access-contracts-mesh-vault", + "packages": { + "": { + "name": "@novox/mesh-identity-and-access-contracts-mesh-vault", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/identity-and-access/ts/seats/meshvault/package.json b/identity-and-access/ts/seats/meshvault/package.json new file mode 100644 index 0000000..39944e5 --- /dev/null +++ b/identity-and-access/ts/seats/meshvault/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-vault, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-identity-and-access-contracts-mesh-vault", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/ts/seats/meshvault/src/index.ts b/identity-and-access/ts/seats/meshvault/src/index.ts similarity index 79% rename from ts/seats/meshvault/src/index.ts rename to identity-and-access/ts/seats/meshvault/src/index.ts index c78145a..112701a 100644 --- a/ts/seats/meshvault/src/index.ts +++ b/identity-and-access/ts/seats/meshvault/src/index.ts @@ -80,6 +80,54 @@ export function ownRetiredHolderSubject(holder: string): string { return "mesh.mod." + holder + ".event.own-retired"; } +export const states = { + signers: "signers", + nodeLogins: "node-logins", +} as const; + +/** The key of one value of the state signers under the form "key". */ +export function signersByKeyKey(): string { + return "key"; +} + +/** The parts of a key of the form "key", or undefined for a key of another form. */ +export function parseSignersByKeyKey(key: string): { } | undefined { + let rest = key; + if (!rest.startsWith("key")) return undefined; + rest = rest.slice(3); + if (rest !== "") return undefined; + return { }; +} + +/** The key of one value of the state signers under the form "authority". */ +export function signersByAuthorityKey(): string { + return "authority"; +} + +/** The parts of a key of the form "authority", or undefined for a key of another form. */ +export function parseSignersByAuthorityKey(key: string): { } | undefined { + let rest = key; + if (!rest.startsWith("authority")) return undefined; + rest = rest.slice(9); + if (rest !== "") return undefined; + return { }; +} + +/** The key of one value of the state node-logins under the form "". */ +export function nodeLoginsByNodeKey(node: string): string { + return node; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseNodeLoginsByNodeKey(key: string): { node: string } | undefined { + let rest = key; + if (rest === "") return undefined; + const node = rest; + rest = ""; + if (rest !== "") return undefined; + return { node }; +} + /** is what get takes. */ export interface GetInput { /** the secret's name */ @@ -225,11 +273,11 @@ export type IssueAccountAnswer = Record; export interface SignInput { /** node-certificate or enrolment */ "kind": string; - /** the machine, for a node certificate: the caller's own, or the node mesh-enrolment enrols */ + /** the machine, for a node certificate: the caller's own, or the node mesh-node-enroller enrols */ "node"?: string; /** the redeem answer's bytes in base64, for an enrolment */ "reply"?: string; - /** from mesh-enrolment: the serving key's public half the enrolling node presented */ + /** from mesh-node-enroller: the serving key's public half the enrolling node presented */ "serving_key"?: string; } @@ -251,7 +299,7 @@ export interface SignAnswer { "signer": string; } -/** the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range */ +/** the node's first tunnel, built by mesh-node-enroller at issue and carried in the token: the hub's public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range */ export interface EnrolmentTokenInputTunnel { /** the joining node's tunnel address */ "address": string; @@ -275,7 +323,7 @@ export interface EnrolmentTokenInput { "node": string; /** "true": answer the token sealed to the operator key */ "sealed"?: "true" | "false"; - /** the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range */ + /** the node's first tunnel, built by mesh-node-enroller at issue and carried in the token: the hub's public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range */ "tunnel"?: EnrolmentTokenInputTunnel; } @@ -398,3 +446,37 @@ export interface OwnRetiredHolderEvent { "node": string; } +/** is one value of the state signers under the key key. */ +export interface SignersByKeyValue { + "fingerprint": string; + /** the signing key's public half, standard base64 */ + "key": string; + "made": string; + "v"?: number; +} + +/** is one value of the state signers under the key authority. */ +export interface SignersByAuthorityValue { + /** the authority certificate, PEM */ + "certificate": string; + "fingerprint": string; + "made": string; + "v"?: number; +} + +/** is one value of the state node-logins under the key . */ +export interface NodeLoginsByNodeValue { + "acts"?: string[]; + /** the user list's revision that held both logins, from ready on */ + "list-revision"?: number; + /** the login in use */ + "login": string; + "node": string; + /** the login rotating to, empty when none */ + "rotating"?: string; + "since": string; + /** begun, ready or confirmed; empty when no rotation */ + "stage"?: string; + "v"?: number; +} + diff --git a/identity-and-access/ts/seats/meshvault/tsconfig.json b/identity-and-access/ts/seats/meshvault/tsconfig.json new file mode 100644 index 0000000..60274c6 --- /dev/null +++ b/identity-and-access/ts/seats/meshvault/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/module.json b/module.json index c850c06..ca42cfb 100644 --- a/module.json +++ b/module.json @@ -99,16 +99,6 @@ "series": "0.1", "generated-from": "." }, - { - "name": "go-meshvault", - "kind": "package", - "language": "go", - "paths": [ - "go/seats/meshvault" - ], - "series": "0.1", - "generated-from": "." - }, { "name": "go-nodeengine", "kind": "package", @@ -189,16 +179,6 @@ "series": "0.1", "generated-from": "." }, - { - "name": "ts-meshvault", - "kind": "package", - "language": "typescript", - "paths": [ - "ts/seats/meshvault" - ], - "series": "0.1", - "generated-from": "." - }, { "name": "ts-nodeengine", "kind": "package", diff --git a/placement/go/seats/meshmoduleassigner/contract.go b/placement/go/seats/meshmoduleassigner/contract.go new file mode 100644 index 0000000..843340f --- /dev/null +++ b/placement/go/seats/meshmoduleassigner/contract.go @@ -0,0 +1,665 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package meshmoduleassigner is the contract of the seat "mesh-module-assigner", defined whole (seats/mesh-module-assigner/seat.json): The seat that places modules on nodes (ADR 0567): the state of placement and the proposals that change it. Renamed from mesh-placement (ADR 0634 §9). +package meshmoduleassigner + +import ( + "bytes" + "encoding/json" + "errors" + "strings" +) + +// Seat is the seat's name. +const Seat = "mesh-module-assigner" + +const ( + VerbPlacement = "placement" + VerbProposals = "proposals" + VerbPropose = "propose" + VerbWithdraw = "withdraw" + VerbAgain = "again" +) + +const ( + EventPlacementProposed = "placement-proposed" + EventPlacementWithdrawn = "placement-withdrawn" + EventPlacementJudged = "placement-judged" + EventPlacementChanged = "placement-changed" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbPlacement, VerbProposals, VerbPropose, VerbWithdraw, VerbAgain} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventPlacementProposed, EventPlacementWithdrawn, EventPlacementJudged, EventPlacementChanged} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "mesh-module-assigner.events" + FamilyMaxAgeDays = 7 +) + +// SubjectPlacementProposed is where placement-proposed is published. +const SubjectPlacementProposed = "mesh.seat.mesh-module-assigner.event.placement-proposed" + +// SubjectPlacementWithdrawn is where placement-withdrawn is published. +const SubjectPlacementWithdrawn = "mesh.seat.mesh-module-assigner.event.placement-withdrawn" + +// SubjectPlacementJudged is where placement-judged is published. +const SubjectPlacementJudged = "mesh.seat.mesh-module-assigner.event.placement-judged" + +// SubjectPlacementChanged is where placement-changed is published. +const SubjectPlacementChanged = "mesh.seat.mesh-module-assigner.event.placement-changed" + +const ( + StatePlacement = "placement" + StateProposals = "proposals" + StateCandidates = "candidates" + StatePulses = "pulses" + StateSeatHolders = "seat-holders" +) + +// PlacementByNodeNodeKey is the key of one value of the state placement under the form ".node". +func PlacementByNodeNodeKey(node string) string { return node + ".node" } + +// ParsePlacementByNodeNodeKey reads the parts of a key of the form ".node", in the order of the form; the last result is false for a key of another form. +func ParsePlacementByNodeNodeKey(key string) (string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".node") + if !found0 || v0 == "" { + return "", false + } + rest = after0 + if rest != "" { + return "", false + } + return v0, true +} + +// DecodePlacementByNodeNodeValue reads one value of the state placement under the form ".node", refusing a field PlacementByNodeNodeValue does not know and any text after the value. +func DecodePlacementByNodeNodeValue(raw []byte) (PlacementByNodeNodeValue, error) { + var v PlacementByNodeNodeValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// PlacementByNodeModuleKey is the key of one value of the state placement under the form ".". +func PlacementByNodeModuleKey(node, module string) string { return node + "." + module } + +// ParsePlacementByNodeModuleKey reads the parts of a key of the form ".", in the order of the form; the last result is false for a key of another form. +func ParsePlacementByNodeModuleKey(key string) (string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", false + } + rest = after0 + if rest == "" { + return "", "", false + } + v2 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v0, v2, true +} + +// DecodePlacementByNodeModuleValue reads one value of the state placement under the form ".", refusing a field PlacementByNodeModuleValue does not know and any text after the value. +func DecodePlacementByNodeModuleValue(raw []byte) (PlacementByNodeModuleValue, error) { + var v PlacementByNodeModuleValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// ProposalsByProposalKey is the key of one value of the state proposals under the form "". +func ProposalsByProposalKey(proposal string) string { return proposal } + +// ParseProposalsByProposalKey reads the parts of a key of the form "", in the order of the form; the last result is false for a key of another form. +func ParseProposalsByProposalKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeProposalsByProposalValue reads one value of the state proposals under the form "", refusing a field ProposalsByProposalValue does not know and any text after the value. +func DecodeProposalsByProposalValue(raw []byte) (ProposalsByProposalValue, error) { + var v ProposalsByProposalValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// CandidatesByProposalNodeKey is the key of one value of the state candidates under the form ".". +func CandidatesByProposalNodeKey(proposal, node string) string { return proposal + "." + node } + +// ParseCandidatesByProposalNodeKey reads the parts of a key of the form ".", in the order of the form; the last result is false for a key of another form. +func ParseCandidatesByProposalNodeKey(key string) (string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", false + } + rest = after0 + if rest == "" { + return "", "", false + } + v2 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v0, v2, true +} + +// DecodeCandidatesByProposalNodeValue reads one value of the state candidates under the form ".", refusing a field CandidatesByProposalNodeValue does not know and any text after the value. +func DecodeCandidatesByProposalNodeValue(raw []byte) (CandidatesByProposalNodeValue, error) { + var v CandidatesByProposalNodeValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// PulsesByAnswerProposalNodeKey is the key of one value of the state pulses under the form "answer..". +func PulsesByAnswerProposalNodeKey(proposal, node string) string { + return "answer." + proposal + "." + node +} + +// ParsePulsesByAnswerProposalNodeKey reads the parts of a key of the form "answer..", in the order of the form; the last result is false for a key of another form. +func ParsePulsesByAnswerProposalNodeKey(key string) (string, string, bool) { + rest := key + if !strings.HasPrefix(rest, "answer.") { + return "", "", false + } + rest = rest[7:] + v1, after1, found1 := strings.Cut(rest, ".") + if !found1 || v1 == "" { + return "", "", false + } + rest = after1 + if rest == "" { + return "", "", false + } + v3 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v1, v3, true +} + +// DecodePulsesByAnswerProposalNodeValue reads one value of the state pulses under the form "answer..", refusing a field PulsesByAnswerProposalNodeValue does not know and any text after the value. +func DecodePulsesByAnswerProposalNodeValue(raw []byte) (PulsesByAnswerProposalNodeValue, error) { + var v PulsesByAnswerProposalNodeValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// PulsesByAskProposalKey is the key of one value of the state pulses under the form "ask.". +func PulsesByAskProposalKey(proposal string) string { return "ask." + proposal } + +// ParsePulsesByAskProposalKey reads the parts of a key of the form "ask.", in the order of the form; the last result is false for a key of another form. +func ParsePulsesByAskProposalKey(key string) (string, bool) { + rest := key + if !strings.HasPrefix(rest, "ask.") { + return "", false + } + rest = rest[4:] + if rest == "" { + return "", false + } + v1 := rest + rest = "" + if rest != "" { + return "", false + } + return v1, true +} + +// DecodePulsesByAskProposalValue reads one value of the state pulses under the form "ask.", refusing a field PulsesByAskProposalValue does not know and any text after the value. +func DecodePulsesByAskProposalValue(raw []byte) (PulsesByAskProposalValue, error) { + var v PulsesByAskProposalValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// SeatHoldersBySeatKey is the key of one value of the state seat-holders under the form "". +func SeatHoldersBySeatKey(seat string) string { return seat } + +// ParseSeatHoldersBySeatKey reads the parts of a key of the form "", in the order of the form; the last result is false for a key of another form. +func ParseSeatHoldersBySeatKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeSeatHoldersBySeatValue reads one value of the state seat-holders under the form "", refusing a field SeatHoldersBySeatValue does not know and any text after the value. +func DecodeSeatHoldersBySeatValue(raw []byte) (SeatHoldersBySeatValue, error) { + var v SeatHoldersBySeatValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// SeatHoldersBySeatNodeKey is the key of one value of the state seat-holders under the form ".". +func SeatHoldersBySeatNodeKey(seat, node string) string { return seat + "." + node } + +// ParseSeatHoldersBySeatNodeKey reads the parts of a key of the form ".", in the order of the form; the last result is false for a key of another form. +func ParseSeatHoldersBySeatNodeKey(key string) (string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", false + } + rest = after0 + if rest == "" { + return "", "", false + } + v2 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v0, v2, true +} + +// DecodeSeatHoldersBySeatNodeValue reads one value of the state seat-holders under the form ".", refusing a field SeatHoldersBySeatNodeValue does not know and any text after the value. +func DecodeSeatHoldersBySeatNodeValue(raw []byte) (SeatHoldersBySeatNodeValue, error) { + var v SeatHoldersBySeatNodeValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// Holding is one seat a module placed on the node holds. +type Holding struct { + Claim string `json:"claim"` + Kind string `json:"kind,omitempty"` + Module string `json:"module"` + Scope string `json:"scope,omitempty"` + Site string `json:"site,omitempty"` +} + +// NodeDecided is what a person decides of a node; no secret and no key material. +type NodeDecided struct { + Account string `json:"account,omitempty"` + AccountHome string `json:"account-home,omitempty"` + Address string `json:"address,omitempty"` + Adopted bool `json:"adopted,omitempty"` + AgentAccount string `json:"agent-account,omitempty"` + AgentAccountHome string `json:"agent-account-home,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Hub bool `json:"hub,omitempty"` + // on the hub, the name a person gave a peer of that tunnel, by its public key + PeerNames map[string]string `json:"peer-names,omitempty"` + PublicDomain string `json:"public-domain,omitempty"` + Site string `json:"site,omitempty"` + Taken []string `json:"taken,omitempty"` + // the tunnel found on the node when it was adopted + Tunnel map[string]any `json:"tunnel,omitempty"` +} + +// PlacedModule is one module placed on a node. +type PlacedModule struct { + // by provision, the provider each requirement that keeps data is bound to + Bindings map[string]Provider `json:"bindings,omitempty"` + HasLayer bool `json:"has-layer,omitempty"` + HasMeshWide bool `json:"has-mesh-wide,omitempty"` + // this node's settings layer of the module's placement + Layer map[string]any `json:"layer,omitempty"` + // the whole mesh's settings layer of the module's placement + MeshWide map[string]any `json:"mesh-wide,omitempty"` + // config values a person set in the whole mesh's layer, for a module in the settings form + MeshWideValues map[string]any `json:"mesh-wide-values,omitempty"` + Module string `json:"module"` + // the host port each declared port is published on, by the port the software listens on + Ports map[string]int64 `json:"ports,omitempty"` + // config values a person set in this node's layer, for a module in the settings form + Values map[string]any `json:"values,omitempty"` +} + +// Placement is one node's placement assembled. +type Placement struct { + Decided NodeDecided `json:"decided"` + Holds []Holding `json:"holds,omitempty"` + Modules []PlacedModule `json:"modules"` + Node string `json:"node"` + // by provision, the provider chosen for the node + Pins map[string]Provider `json:"pins,omitempty"` + Revision string `json:"revision,omitempty"` + V int64 `json:"v,omitempty"` +} + +// PlacementEventWarrant is who approved it, through which channel, when. +type PlacementEventWarrant struct { + At string `json:"at"` + Warrant string `json:"warrant"` + Words string `json:"words"` +} + +// PlacementEvent is what each placement event carries. +type PlacementEvent struct { + // for placement-withdrawn, why it ended + Ended string `json:"ended,omitempty"` + // for placement-changed, by node the revisions before and after and the modules added, removed or changed + Nodes []map[string]any `json:"nodes,omitempty"` + Proposal Proposal `json:"proposal"` + // who approved it, through which channel, when + Warrant *PlacementEventWarrant `json:"warrant,omitempty"` +} + +// Proposal is an open proposal. +type Proposal struct { + Again string `json:"again,omitempty"` + Ask string `json:"ask,omitempty"` + At string `json:"at"` + // by node whose key it changes, the revision of the key it was laid over (0: none) + Base map[string]int64 `json:"base"` + By string `json:"by"` + Changes []ProposalChange `json:"changes"` + ExportDigest string `json:"export-digest,omitempty"` + HandBack bool `json:"hand-back,omitempty"` + ID string `json:"id"` + Must []string `json:"must,omitempty"` + Seed bool `json:"seed,omitempty"` + // answering, failed or asked + State string `json:"state"` + V int64 `json:"v,omitempty"` + Verdict *Verdict `json:"verdict,omitempty"` + Warrant *Warrant `json:"warrant,omitempty"` + Why string `json:"why"` + Withdrawn string `json:"withdrawn,omitempty"` + Words string `json:"words,omitempty"` + Written string `json:"written,omitempty"` +} + +// ProposalChangeKeys is the public halves of a joining node's keys. +type ProposalChangeKeys struct { + Identity string `json:"identity"` + Overlay string `json:"overlay,omitempty"` + Sealing string `json:"sealing"` + Serving string `json:"serving,omitempty"` +} + +// ProposalChange is one change of a proposal. +type ProposalChange struct { + Bindings map[string]Provider `json:"bindings,omitempty"` + Claim string `json:"claim,omitempty"` + Decided *NodeDecided `json:"decided,omitempty"` + Field string `json:"field,omitempty"` + // the public halves of a joining node's keys + Keys *ProposalChangeKeys `json:"keys,omitempty"` + Kind string `json:"kind,omitempty"` + Layer map[string]any `json:"layer,omitempty"` + MeshWide map[string]any `json:"mesh-wide,omitempty"` + MeshWideValues map[string]any `json:"mesh-wide-values,omitempty"` + Module string `json:"module,omitempty"` + Node string `json:"node"` + // place, take-off, pin, unpin, hold, unhold, decide, config, seed, add-node or remove-node + Op string `json:"op"` + Ports map[string]int64 `json:"ports,omitempty"` + Provider *Provider `json:"provider,omitempty"` + Provision string `json:"provision,omitempty"` + Scope string `json:"scope,omitempty"` + Site string `json:"site,omitempty"` + // a decided field's value + Value any `json:"value,omitempty"` + Values map[string]any `json:"values,omitempty"` + // the node's whole placement, for a seed + Whole *Placement `json:"whole,omitempty"` +} + +// Provider is the module on a node that answers a requirement or holds a seat. +type Provider struct { + Module string `json:"module"` + Node string `json:"node"` +} + +// SeatHoldersHoldersItem is a type of the contract. +type SeatHoldersHoldersItem struct { + Module string `json:"module"` + Node string `json:"node"` +} + +// SeatHolders is one key of seat-holders. +type SeatHolders struct { + Holders []SeatHoldersHoldersItem `json:"holders"` + Node string `json:"node,omitempty"` + // the claim names no seat of the seat list + Outside bool `json:"outside,omitempty"` + Scope string `json:"scope"` + Seat string `json:"seat"` + V int64 `json:"v,omitempty"` +} + +// Verdict is the holder's judgement of a proposal's answers. +type Verdict struct { + // every node's answer to its candidate, by node + Answers map[string]map[string]any `json:"answers,omitempty"` + At string `json:"at"` + Carried map[string][]string `json:"carried,omitempty"` + Fails []string `json:"fails,omitempty"` + Intended map[string][]string `json:"intended,omitempty"` + Notes []string `json:"notes,omitempty"` + Silent []string `json:"silent,omitempty"` + // waits, pass or fail + State string `json:"state"` + Unintended map[string][]string `json:"unintended,omitempty"` + Until string `json:"until,omitempty"` +} + +// Warrant is the enrolment's warrant an add-node carries. +type Warrant struct { + Address string `json:"address"` + Ask string `json:"ask"` + Node string `json:"node"` + Warrant string `json:"warrant"` +} + +// PlacementInput is what placement takes. +type PlacementInput struct { + // "true": the changes in order, from the stream and the state's revisions + History string `json:"history,omitempty"` + // one module: the nodes it is placed on + Module string `json:"module,omitempty"` + // one node + Node string `json:"node,omitempty"` + // "true": who holds each seat, and on which node (the state seat-holders); with node, that node's + Seats string `json:"seats,omitempty"` +} + +// The values of PlacementInput's history. +const ( + PlacementInputHistoryTrue = "true" + PlacementInputHistoryFalse = "false" +) + +// The values of PlacementInput's seats. +const ( + PlacementInputSeatsTrue = "true" + PlacementInputSeatsFalse = "false" +) + +// PlacementAnswer is what placement answers. +type PlacementAnswer = map[string]any + +// ProposalsInput is what proposals takes. +type ProposalsInput struct { + // one proposal's id + ID string `json:"id,omitempty"` +} + +// ProposalsAnswer is what proposals answers. +type ProposalsAnswer = map[string]any + +// ProposeInput is what propose takes. +type ProposeInput struct { + // the changes, each an object with `op`: place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide (node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, on the same warrant) + Changes []map[string]any `json:"changes"` + // for an add-node alone: the enrolment's warrant {node, address, ask, warrant}, naming the change's node and address + Warrant map[string]any `json:"warrant,omitempty"` + // why, shown to the operator with the proposal + Why string `json:"why"` +} + +// ProposeAnswer is what propose answers. +type ProposeAnswer = map[string]any + +// WithdrawInput is what withdraw takes. +type WithdrawInput struct { + // the proposal's id + ID string `json:"id"` + // why, kept in the event + Why string `json:"why"` +} + +// WithdrawAnswer is what withdraw answers. +type WithdrawAnswer = map[string]any + +// AgainInput is what again takes. +type AgainInput struct { + // the proposal's id + ID string `json:"id"` +} + +// AgainAnswer is what again answers. +type AgainAnswer = map[string]any + +// PlacementProposedEvent is the body of the event placement-proposed. +type PlacementProposedEvent = PlacementEvent + +// PlacementWithdrawnEvent is the body of the event placement-withdrawn. +type PlacementWithdrawnEvent = PlacementEvent + +// PlacementJudgedEvent is the body of the event placement-judged. +type PlacementJudgedEvent = PlacementEvent + +// PlacementChangedEvent is the body of the event placement-changed. +type PlacementChangedEvent = PlacementEvent + +// PlacementByNodeNodeValueModulesItem is a type of the contract. +type PlacementByNodeNodeValueModulesItem struct { + // the digest of the module key's value it stands for + Digest string `json:"digest"` + Module string `json:"module"` +} + +// PlacementByNodeNodeValue is one value of the state placement under the key .node. +type PlacementByNodeNodeValue struct { + Decided NodeDecided `json:"decided"` + Holds []Holding `json:"holds,omitempty"` + // the modules placed, in the order the composition reads them + Modules []PlacementByNodeNodeValueModulesItem `json:"modules"` + Node string `json:"node"` + Pins map[string]Provider `json:"pins,omitempty"` + V int64 `json:"v,omitempty"` +} + +// PlacementByNodeModuleValue is one value of the state placement under the key .. +type PlacementByNodeModuleValue struct { + Node string `json:"node"` + Placed PlacedModule `json:"placed"` + // the value it replaces while a write is under way + Previous *PlacedModule `json:"previous,omitempty"` + V int64 `json:"v,omitempty"` +} + +// ProposalsByProposalValue is one value of the state proposals under the key . +type ProposalsByProposalValue = Proposal + +// CandidatesByProposalNodeValue is one value of the state candidates under the key .. +type CandidatesByProposalNodeValue struct { + // the node's placement with the proposal laid over it; absent when the proposal takes its key away + Placement *Placement `json:"placement,omitempty"` + Proposal string `json:"proposal"` + // the proposal changes this node's composition through another's key + Reached bool `json:"reached,omitempty"` + V int64 `json:"v,omitempty"` +} + +// PulsesByAnswerProposalNodeValue is one value of the state pulses under the key answer... +type PulsesByAnswerProposalNodeValue struct { + V int64 `json:"v,omitempty"` +} + +// PulsesByAskProposalValue is one value of the state pulses under the key ask.. +type PulsesByAskProposalValue struct { + V int64 `json:"v,omitempty"` +} + +// SeatHoldersBySeatValue is one value of the state seat-holders under the key . +type SeatHoldersBySeatValue = SeatHolders + +// SeatHoldersBySeatNodeValue is one value of the state seat-holders under the key .. +type SeatHoldersBySeatNodeValue = SeatHolders diff --git a/placement/go/seats/meshmoduleassigner/contract_test.go b/placement/go/seats/meshmoduleassigner/contract_test.go new file mode 100644 index 0000000..178ad88 --- /dev/null +++ b/placement/go/seats/meshmoduleassigner/contract_test.go @@ -0,0 +1,86 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package meshmoduleassigner + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/mesh-module-assigner/fixtures: each decodes into its type, a field the type does not +// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's. +func TestFixtures(t *testing.T) { + cases := []struct { + name, subject, given, wire string + decode func([]byte) (any, error) + subjectIs *regexp.Regexp + }{ + {name: "a proposal says placement-proposed", subject: "mesh.seat.mesh-module-assigner.event.placement-proposed", given: "{\"proposal\":{\"at\":\"2026-11-05T12:00:00Z\",\"base\":{\"ace\":3},\"by\":\"operator\",\"changes\":[{\"module\":\"plex\",\"node\":\"ace\",\"op\":\"place\",\"ports\":{\"32400\":32400}}],\"id\":\"p-20261105t120000-aabbccdd\",\"state\":\"answering\",\"why\":\"move plex\"}}", wire: "{\"proposal\":{\"at\":\"2026-11-05T12:00:00Z\",\"base\":{\"ace\":3},\"by\":\"operator\",\"changes\":[{\"module\":\"plex\",\"node\":\"ace\",\"op\":\"place\",\"ports\":{\"32400\":32400}}],\"id\":\"p-20261105t120000-aabbccdd\",\"state\":\"answering\",\"why\":\"move plex\"}}", + decode: func(b []byte) (any, error) { var v PlacementProposedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-module-assigner\\.event\\.placement-proposed$")}, + {name: "a node's key", subject: "", given: "{\"decided\":{\"address\":\"10.0.0.2\"},\"modules\":[{\"digest\":\"0123456789abcdef\",\"module\":\"plex\"}],\"node\":\"ace\",\"v\":1}", wire: "{\"decided\":{\"address\":\"10.0.0.2\"},\"modules\":[{\"digest\":\"0123456789abcdef\",\"module\":\"plex\"}],\"node\":\"ace\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v PlacementByNodeNodeValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a module's key", subject: "", given: "{\"node\":\"ace\",\"placed\":{\"bindings\":{\"database\":{\"module\":\"postgres\",\"node\":\"ace\"}},\"module\":\"plex\",\"ports\":{\"32400\":32400}},\"v\":1}", wire: "{\"node\":\"ace\",\"placed\":{\"bindings\":{\"database\":{\"module\":\"postgres\",\"node\":\"ace\"}},\"module\":\"plex\",\"ports\":{\"32400\":32400}},\"v\":1}", + decode: func(b []byte) (any, error) { + var v PlacementByNodeModuleValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "an open proposal", subject: "", given: "{\"at\":\"2026-11-05T12:00:00Z\",\"base\":{\"ace\":3},\"by\":\"operator\",\"changes\":[{\"module\":\"plex\",\"node\":\"ace\",\"op\":\"place\",\"ports\":{\"32400\":32400}}],\"id\":\"p-20261105t120000-aabbccdd\",\"state\":\"answering\",\"why\":\"move plex\"}", wire: "{\"at\":\"2026-11-05T12:00:00Z\",\"base\":{\"ace\":3},\"by\":\"operator\",\"changes\":[{\"module\":\"plex\",\"node\":\"ace\",\"op\":\"place\",\"ports\":{\"32400\":32400}}],\"id\":\"p-20261105t120000-aabbccdd\",\"state\":\"answering\",\"why\":\"move plex\"}", + decode: func(b []byte) (any, error) { + var v ProposalsByProposalValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a candidate", subject: "", given: "{\"proposal\":\"p-20261105t120000-aabbccdd\",\"v\":1}", wire: "{\"proposal\":\"p-20261105t120000-aabbccdd\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v CandidatesByProposalNodeValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "the bound on an answer", subject: "", given: "{\"v\":1}", wire: "{\"v\":1}", + decode: func(b []byte) (any, error) { + var v PulsesByAnswerProposalNodeValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a seat held once", subject: "", given: "{\"holders\":[{\"module\":\"mesh-delivery-assigner\",\"node\":\"novox\"}],\"scope\":\"mesh\",\"seat\":\"mesh-module-assigner\",\"v\":1}", wire: "{\"holders\":[{\"module\":\"mesh-delivery-assigner\",\"node\":\"novox\"}],\"scope\":\"mesh\",\"seat\":\"mesh-module-assigner\",\"v\":1}", + decode: func(b []byte) (any, error) { var v SeatHoldersBySeatValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a node seat on a node", subject: "", given: "{\"holders\":[{\"module\":\"node-files\",\"node\":\"ace\"}],\"node\":\"ace\",\"scope\":\"node\",\"seat\":\"node-files\",\"v\":1}", wire: "{\"holders\":[{\"module\":\"node-files\",\"node\":\"ace\"}],\"node\":\"ace\",\"scope\":\"node\",\"seat\":\"node-files\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v SeatHoldersBySeatNodeValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + } + for _, c := range cases { + v, err := c.decode([]byte(c.given)) + if err != nil { + t.Errorf("%s: does not decode: %v", c.name, err) + continue + } + back, err := json.Marshal(v) + if err != nil { + t.Errorf("%s: does not encode: %v", c.name, err) + continue + } + var got, want any + if err := json.Unmarshal(back, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(c.wire), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) { + t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs) + } + } +} diff --git a/placement/go/seats/meshmoduleassigner/go.mod b/placement/go/seats/meshmoduleassigner/go.mod new file mode 100644 index 0000000..cda234d --- /dev/null +++ b/placement/go/seats/meshmoduleassigner/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner + +go 1.26.0 diff --git a/placement/go/seats/meshwants/contract.go b/placement/go/seats/meshwants/contract.go new file mode 100644 index 0000000..fc93351 --- /dev/null +++ b/placement/go/seats/meshwants/contract.go @@ -0,0 +1,331 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package meshwants is the contract of the seat "mesh-wants", defined whole (seats/mesh-wants/seat.json): The seat that says what each node should run and whether each module's new builds wait for a person (ADR 0565, ADR 0539). +package meshwants + +import ( + "bytes" + "encoding/json" + "errors" + "strings" +) + +// Seat is the seat's name. +const Seat = "mesh-wants" + +const ( + VerbUpgrade = "upgrade" + VerbBus = "bus" +) + +const ( + EventPolicyChanged = "policy-changed" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbUpgrade, VerbBus} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventPolicyChanged} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "mesh-wants.events" + FamilyMaxAgeDays = 7 +) + +// SubjectPolicyChanged is where policy-changed is published. +const SubjectPolicyChanged = "mesh.seat.mesh-wants.event.policy-changed" + +const ( + StateWants = "wants" + StateDifferences = "differences" + StateCompositions = "compositions" +) + +// WantsByWantNodeModuleKey is the key of one value of the state wants under the form "want..". +func WantsByWantNodeModuleKey(node, module string) string { return "want." + node + "." + module } + +// ParseWantsByWantNodeModuleKey reads the parts of a key of the form "want..", in the order of the form; the last result is false for a key of another form. +func ParseWantsByWantNodeModuleKey(key string) (string, string, bool) { + rest := key + if !strings.HasPrefix(rest, "want.") { + return "", "", false + } + rest = rest[5:] + v1, after1, found1 := strings.Cut(rest, ".") + if !found1 || v1 == "" { + return "", "", false + } + rest = after1 + if rest == "" { + return "", "", false + } + v3 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v1, v3, true +} + +// DecodeWantsByWantNodeModuleValue reads one value of the state wants under the form "want..", refusing a field WantsByWantNodeModuleValue does not know and any text after the value. +func DecodeWantsByWantNodeModuleValue(raw []byte) (WantsByWantNodeModuleValue, error) { + var v WantsByWantNodeModuleValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// WantsByWantNodeNodeKey is the key of one value of the state wants under the form "want..node". +func WantsByWantNodeNodeKey(node string) string { return "want." + node + ".node" } + +// ParseWantsByWantNodeNodeKey reads the parts of a key of the form "want..node", in the order of the form; the last result is false for a key of another form. +func ParseWantsByWantNodeNodeKey(key string) (string, bool) { + rest := key + if !strings.HasPrefix(rest, "want.") { + return "", false + } + rest = rest[5:] + v1, after1, found1 := strings.Cut(rest, ".node") + if !found1 || v1 == "" { + return "", false + } + rest = after1 + if rest != "" { + return "", false + } + return v1, true +} + +// DecodeWantsByWantNodeNodeValue reads one value of the state wants under the form "want..node", refusing a field WantsByWantNodeNodeValue does not know and any text after the value. +func DecodeWantsByWantNodeNodeValue(raw []byte) (WantsByWantNodeNodeValue, error) { + var v WantsByWantNodeNodeValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// WantsByHeldModuleFingerprintKey is the key of one value of the state wants under the form "held..". +func WantsByHeldModuleFingerprintKey(module, fingerprint string) string { + return "held." + module + "." + fingerprint +} + +// ParseWantsByHeldModuleFingerprintKey reads the parts of a key of the form "held..", in the order of the form; the last result is false for a key of another form. +func ParseWantsByHeldModuleFingerprintKey(key string) (string, string, bool) { + rest := key + if !strings.HasPrefix(rest, "held.") { + return "", "", false + } + rest = rest[5:] + v1, after1, found1 := strings.Cut(rest, ".") + if !found1 || v1 == "" { + return "", "", false + } + rest = after1 + if rest == "" { + return "", "", false + } + v3 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v1, v3, true +} + +// DecodeWantsByHeldModuleFingerprintValue reads one value of the state wants under the form "held..", refusing a field WantsByHeldModuleFingerprintValue does not know and any text after the value. +func DecodeWantsByHeldModuleFingerprintValue(raw []byte) (WantsByHeldModuleFingerprintValue, error) { + var v WantsByHeldModuleFingerprintValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// DifferencesByNodeModuleKey is the key of one value of the state differences under the form ".". +func DifferencesByNodeModuleKey(node, module string) string { return node + "." + module } + +// ParseDifferencesByNodeModuleKey reads the parts of a key of the form ".", in the order of the form; the last result is false for a key of another form. +func ParseDifferencesByNodeModuleKey(key string) (string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", false + } + rest = after0 + if rest == "" { + return "", "", false + } + v2 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v0, v2, true +} + +// DecodeDifferencesByNodeModuleValue reads one value of the state differences under the form ".", refusing a field DifferencesByNodeModuleValue does not know and any text after the value. +func DecodeDifferencesByNodeModuleValue(raw []byte) (DifferencesByNodeModuleValue, error) { + var v DifferencesByNodeModuleValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// CompositionsByGroupHeadsKey is the key of one value of the state compositions under the form ".". +func CompositionsByGroupHeadsKey(group, heads string) string { return group + "." + heads } + +// ParseCompositionsByGroupHeadsKey reads the parts of a key of the form ".", in the order of the form; the last result is false for a key of another form. +func ParseCompositionsByGroupHeadsKey(key string) (string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", false + } + rest = after0 + if rest == "" { + return "", "", false + } + v2 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v0, v2, true +} + +// DecodeCompositionsByGroupHeadsValue reads one value of the state compositions under the form ".", refusing a field CompositionsByGroupHeadsValue does not know and any text after the value. +func DecodeCompositionsByGroupHeadsValue(raw []byte) (CompositionsByGroupHeadsValue, error) { + var v CompositionsByGroupHeadsValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// UpgradeInput is what upgrade takes. +type UpgradeInput struct { + // "true": each node's own composition, by its digest, and the assigner's findings + Compare string `json:"compare,omitempty"` + // one module + Module string `json:"module,omitempty"` + // with module: roll, together, record, or default to take a person's choice back + Policy string `json:"policy,omitempty"` + // with policy: why, required and kept + Why string `json:"why,omitempty"` +} + +// The values of UpgradeInput's compare. +const ( + UpgradeInputCompareTrue = "true" + UpgradeInputCompareFalse = "false" +) + +// UpgradeAnswer is what upgrade answers. +type UpgradeAnswer = map[string]any + +// BusInput is what bus takes. +type BusInput struct { +} + +// BusAnswer is what bus answers. +type BusAnswer = map[string]any + +// PolicyChangedEvent is the body of the event policy-changed. +type PolicyChangedEvent = map[string]any + +// WantsByWantNodeModuleValue is one value of the state wants under the key want... +type WantsByWantNodeModuleValue struct { + // again or skip + Backup string `json:"backup,omitempty"` + Composed string `json:"composed,omitempty"` + Fp string `json:"fp,omitempty"` + Hold bool `json:"hold,omitempty"` + Newest string `json:"newest,omitempty"` + Put bool `json:"put,omitempty"` + Release string `json:"release,omitempty"` + Removed bool `json:"removed,omitempty"` + // the rule that holds the node back, 1 to 10 + Rule int64 `json:"rule,omitempty"` + Self bool `json:"self,omitempty"` + V int64 `json:"v,omitempty"` + Words string `json:"words,omitempty"` + Yes string `json:"yes,omitempty"` +} + +// WantsByWantNodeNodeValue is one value of the state wants under the key want..node. +type WantsByWantNodeNodeValue struct { + Composed string `json:"composed"` + Self bool `json:"self,omitempty"` + V int64 `json:"v,omitempty"` + Words string `json:"words,omitempty"` +} + +// WantsByHeldModuleFingerprintValue is one value of the state wants under the key held... +type WantsByHeldModuleFingerprintValue struct { + Node string `json:"node"` + V int64 `json:"v,omitempty"` + Words string `json:"words,omitempty"` +} + +// DifferencesByNodeModuleValue is one value of the state differences under the key .. +type DifferencesByNodeModuleValue struct { + Rule int64 `json:"rule,omitempty"` + Sent string `json:"sent,omitempty"` + SentBuild string `json:"sent-build,omitempty"` + Unexplained bool `json:"unexplained,omitempty"` + V int64 `json:"v,omitempty"` + Want string `json:"want,omitempty"` + WantRemoved bool `json:"want-removed,omitempty"` + Words string `json:"words,omitempty"` +} + +// CompositionsByGroupHeadsValue is one value of the state compositions under the key .. +type CompositionsByGroupHeadsValue struct { + Group string `json:"group"` + // 12 lowercase hex digits + Heads string `json:"heads"` + Machine string `json:"machine,omitempty"` + Machines []string `json:"machines,omitempty"` + Sdk string `json:"sdk,omitempty"` + // pass, fail or waits + State string `json:"state"` + // milliseconds + Took int64 `json:"took,omitempty"` + V int64 `json:"v,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Words string `json:"words,omitempty"` +} diff --git a/placement/go/seats/meshwants/contract_test.go b/placement/go/seats/meshwants/contract_test.go new file mode 100644 index 0000000..1355a9b --- /dev/null +++ b/placement/go/seats/meshwants/contract_test.go @@ -0,0 +1,76 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package meshwants + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/mesh-wants/fixtures: each decodes into its type, a field the type does not +// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's. +func TestFixtures(t *testing.T) { + cases := []struct { + name, subject, given, wire string + decode func([]byte) (any, error) + subjectIs *regexp.Regexp + }{ + {name: "a want", subject: "", given: "{\"fp\":\"ab12\",\"rule\":3,\"v\":1}", wire: "{\"fp\":\"ab12\",\"rule\":3,\"v\":1}", + decode: func(b []byte) (any, error) { + var v WantsByWantNodeModuleValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a node's own want", subject: "", given: "{\"composed\":\"sha256:aa\",\"self\":true,\"v\":1}", wire: "{\"composed\":\"sha256:aa\",\"self\":true,\"v\":1}", + decode: func(b []byte) (any, error) { + var v WantsByWantNodeNodeValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a held fingerprint", subject: "", given: "{\"node\":\"ace\",\"v\":1}", wire: "{\"node\":\"ace\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v WantsByHeldModuleFingerprintValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a difference", subject: "", given: "{\"unexplained\":true,\"v\":1,\"want\":\"ab12\"}", wire: "{\"unexplained\":true,\"v\":1,\"want\":\"ab12\"}", + decode: func(b []byte) (any, error) { + var v DifferencesByNodeModuleValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a composition", subject: "", given: "{\"group\":\"g1\",\"heads\":\"0123456789ab\",\"state\":\"pass\",\"v\":1}", wire: "{\"group\":\"g1\",\"heads\":\"0123456789ab\",\"state\":\"pass\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v CompositionsByGroupHeadsValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + } + for _, c := range cases { + v, err := c.decode([]byte(c.given)) + if err != nil { + t.Errorf("%s: does not decode: %v", c.name, err) + continue + } + back, err := json.Marshal(v) + if err != nil { + t.Errorf("%s: does not encode: %v", c.name, err) + continue + } + var got, want any + if err := json.Unmarshal(back, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(c.wire), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) { + t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs) + } + } +} diff --git a/placement/go/seats/meshwants/go.mod b/placement/go/seats/meshwants/go.mod new file mode 100644 index 0000000..a075868 --- /dev/null +++ b/placement/go/seats/meshwants/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants + +go 1.26.0 diff --git a/placement/module.json b/placement/module.json new file mode 100644 index 0000000..9be9cf6 --- /dev/null +++ b/placement/module.json @@ -0,0 +1,58 @@ +{ + "module": "mesh-placement-contracts", + "version": "1", + "build": { + "artifacts": [ + { + "name": "go-meshmoduleassigner", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshmoduleassigner" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshwants", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshwants" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshmoduleassignerclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/meshmoduleassignerclient" + ], + "series": "0.1" + }, + { + "name": "ts-meshmoduleassigner", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshmoduleassigner" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-meshwants", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshwants" + ], + "series": "0.1", + "generated-from": "." + } + ] + }, + "resources": [] +} diff --git a/placement/seats/index.json b/placement/seats/index.json new file mode 100644 index 0000000..e7d0756 --- /dev/null +++ b/placement/seats/index.json @@ -0,0 +1,7 @@ +{ + "format": 1, + "seats": [ + "mesh-module-assigner", + "mesh-wants" + ] +} diff --git a/placement/seats/mesh-module-assigner/fixtures/events.json b/placement/seats/mesh-module-assigner/fixtures/events.json new file mode 100644 index 0000000..1886c81 --- /dev/null +++ b/placement/seats/mesh-module-assigner/fixtures/events.json @@ -0,0 +1,29 @@ +[ + { + "name": "a proposal says placement-proposed", + "of": "event:placement-proposed", + "subject": "mesh.seat.mesh-module-assigner.event.placement-proposed", + "given": { + "proposal": { + "id": "p-20261105t120000-aabbccdd", + "by": "operator", + "why": "move plex", + "at": "2026-11-05T12:00:00Z", + "changes": [ + { + "op": "place", + "node": "ace", + "module": "plex", + "ports": { + "32400": 32400 + } + } + ], + "base": { + "ace": 3 + }, + "state": "answering" + } + } + } +] diff --git a/placement/seats/mesh-module-assigner/fixtures/states.json b/placement/seats/mesh-module-assigner/fixtures/states.json new file mode 100644 index 0000000..2ccef79 --- /dev/null +++ b/placement/seats/mesh-module-assigner/fixtures/states.json @@ -0,0 +1,109 @@ +[ + { + "name": "a node's key", + "of": "state:placement/.node", + "given": { + "v": 1, + "node": "ace", + "decided": { + "address": "10.0.0.2" + }, + "modules": [ + { + "module": "plex", + "digest": "0123456789abcdef" + } + ] + } + }, + { + "name": "a module's key", + "of": "state:placement/.", + "given": { + "v": 1, + "node": "ace", + "placed": { + "module": "plex", + "ports": { + "32400": 32400 + }, + "bindings": { + "database": { + "node": "ace", + "module": "postgres" + } + } + } + } + }, + { + "name": "an open proposal", + "of": "state:proposals/", + "given": { + "id": "p-20261105t120000-aabbccdd", + "by": "operator", + "why": "move plex", + "at": "2026-11-05T12:00:00Z", + "changes": [ + { + "op": "place", + "node": "ace", + "module": "plex", + "ports": { + "32400": 32400 + } + } + ], + "base": { + "ace": 3 + }, + "state": "answering" + } + }, + { + "name": "a candidate", + "of": "state:candidates/.", + "given": { + "v": 1, + "proposal": "p-20261105t120000-aabbccdd" + } + }, + { + "name": "the bound on an answer", + "of": "state:pulses/answer..", + "given": { + "v": 1 + } + }, + { + "name": "a seat held once", + "of": "state:seat-holders/", + "given": { + "v": 1, + "seat": "mesh-module-assigner", + "scope": "mesh", + "holders": [ + { + "module": "mesh-delivery-assigner", + "node": "novox" + } + ] + } + }, + { + "name": "a node seat on a node", + "of": "state:seat-holders/.", + "given": { + "v": 1, + "seat": "node-files", + "scope": "node", + "node": "ace", + "holders": [ + { + "module": "node-files", + "node": "ace" + } + ] + } + } +] diff --git a/placement/seats/mesh-module-assigner/seat.json b/placement/seats/mesh-module-assigner/seat.json new file mode 100644 index 0000000..8d987c9 --- /dev/null +++ b/placement/seats/mesh-module-assigner/seat.json @@ -0,0 +1,941 @@ +{ + "format": 1, + "seat": "mesh-module-assigner", + "scope": "mesh", + "grain": "whole", + "decision": "novox/hq ADR 0567, ADR 0634, ADR 0635", + "description": "The seat that places modules on nodes (ADR 0567): the state of placement and the proposals that change it. Renamed from mesh-placement (ADR 0634 §9).", + "verbs": [ + { + "name": "placement", + "description": "Read placement: one node's key, the nodes one module is placed on, or every key, each with its revision, and how full the state is against its cap. With history, one node's or one module's changes in order: a revision the state keeps whole, an older one as the proposal's changes from its placement-changed event.", + "optional": true, + "input": { + "properties": { + "history": { + "description": "\"true\": the changes in order, from the stream and the state's revisions", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "module": { + "description": "one module: the nodes it is placed on", + "type": "string" + }, + "node": { + "description": "one node", + "type": "string" + }, + "seats": { + "description": "\"true\": who holds each seat, and on which node (the state seat-holders); with node, that node's", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "placement as read: nodes, keys with their revisions, history, seat holders, the state's fill" + } + }, + { + "name": "proposals", + "description": "Every open proposal of placement: its changes, the revision of each key it was laid over, its candidates, every node's answer, the differences computed and answered, its verdict and its ask. Narrowed to one by id.", + "optional": true, + "input": { + "properties": { + "id": { + "description": "one proposal's id", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the open proposals" + } + }, + { + "name": "propose", + "description": "Propose a change of placement, with why: laid over the current keys and refused at once, naming every finding, when the result cannot be placed (two modules on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).", + "optional": true, + "input": { + "additionalProperties": false, + "properties": { + "changes": { + "description": "the changes, each an object with `op`: place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide (node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, on the same warrant)", + "items": { + "required": [ + "op", + "node" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "warrant": { + "description": "for an add-node alone: the enrolment's warrant {node, address, ask, warrant}, naming the change's node and address", + "required": [ + "node", + "address", + "ask", + "warrant" + ], + "type": "object" + }, + "why": { + "description": "why, shown to the operator with the proposal", + "type": "string" + } + }, + "required": [ + "why", + "changes" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the proposal's id and its verdict, or every finding refused at once" + } + }, + { + "name": "withdraw", + "description": "Withdraw an open proposal: its key and its candidates are deleted and placement-withdrawn carries it whole. With why.", + "optional": true, + "input": { + "properties": { + "id": { + "description": "the proposal's id", + "type": "string" + }, + "why": { + "description": "why, kept in the event", + "type": "string" + } + }, + "required": [ + "id", + "why" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the proposal withdrawn" + } + }, + { + "name": "again", + "description": "Judge an open proposal now with the answers that stand, and raise its ask again when its verdict passes and the operator's channel does not hold the ask. Nothing more: the recovery of a proposal whose judgement or ask was lost.", + "optional": true, + "input": { + "properties": { + "id": { + "description": "the proposal's id", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "what was judged or asked again" + } + } + ], + "events": [ + { + "name": "placement-proposed", + "body": { + "$ref": "#/defs/PlacementEvent" + } + }, + { + "name": "placement-withdrawn", + "body": { + "$ref": "#/defs/PlacementEvent" + } + }, + { + "name": "placement-judged", + "body": { + "$ref": "#/defs/PlacementEvent" + } + }, + { + "name": "placement-changed", + "body": { + "$ref": "#/defs/PlacementEvent" + } + } + ], + "family": { + "name": "mesh-module-assigner.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "placement", + "template": "state", + "key-forms": [ + { + "key": ".node", + "value": { + "type": "object", + "description": "a node's key: what a person decided of it, its pins and holdings, and the modules placed on it; the commit of a write", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "decided": { + "$ref": "#/defs/NodeDecided" + }, + "modules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "digest": { + "type": "string", + "description": "the digest of the module key's value it stands for" + } + }, + "required": [ + "module", + "digest" + ] + }, + "description": "the modules placed, in the order the composition reads them" + }, + "pins": { + "type": "object", + "additionalProperties": { + "$ref": "#/defs/Provider" + } + }, + "holds": { + "type": "array", + "items": { + "$ref": "#/defs/Holding" + } + } + }, + "required": [ + "node", + "decided", + "modules" + ] + }, + "description": "a node's key" + }, + { + "key": ".", + "value": { + "type": "object", + "description": "a module's key: the module as placed", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "placed": { + "$ref": "#/defs/PlacedModule" + }, + "previous": { + "$ref": "#/defs/PlacedModule", + "description": "the value it replaces while a write is under way" + } + }, + "required": [ + "node", + "placed" + ] + }, + "description": "one module on a node; no module is named node" + } + ], + "history": 64, + "description": "placements, one key per node and one per module placed on it, written only on the operator's Approve of a proposal by compare-and-set at the revision it was laid over" + }, + { + "name": "proposals", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "$ref": "#/defs/Proposal" + }, + "description": "an open proposal, by its id" + } + ], + "history": 5, + "description": "open proposals; a key leaves when the proposal is applied, withdrawn, declined or expired" + }, + { + "name": "candidates", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "one node's candidate in one proposal", + "properties": { + "v": { + "type": "integer" + }, + "proposal": { + "type": "string" + }, + "reached": { + "type": "boolean", + "description": "the proposal changes this node's composition through another's key" + }, + "placement": { + "$ref": "#/defs/Placement", + "description": "the node's placement with the proposal laid over it; absent when the proposal takes its key away" + } + }, + "required": [ + "proposal" + ] + }, + "description": "a node's candidate" + } + ], + "history": 5, + "description": "the candidates a proposal needs answers on; a node watches its own" + }, + { + "name": "pulses", + "template": "state", + "key-forms": [ + { + "key": "answer..", + "value": { + "type": "object", + "description": "the bound on one node's answer", + "properties": { + "v": { + "type": "integer" + } + } + }, + "description": "the bound on a node's answer" + }, + { + "key": "ask.", + "value": { + "type": "object", + "description": "the bound on a raised ask", + "properties": { + "v": { + "type": "integer" + } + } + }, + "description": "the bound on a proposal's raised ask" + } + ], + "description": "bounds, each a key that lives for the bound; the server's delete marker when one ends is the event that fails a silent node or an unanswered ask" + }, + { + "name": "seat-holders", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "$ref": "#/defs/SeatHolders" + }, + "description": "a seat held once for the mesh" + }, + { + "key": ".", + "value": { + "$ref": "#/defs/SeatHolders" + }, + "description": "a node seat on one node" + } + ], + "description": "who holds which seat on which node, composed from every node's placement" + } + ], + "defs": { + "Provider": { + "type": "object", + "description": "the module on a node that answers a requirement or holds a seat", + "properties": { + "node": { + "type": "string" + }, + "module": { + "type": "string" + } + }, + "required": [ + "node", + "module" + ] + }, + "PlacedModule": { + "type": "object", + "description": "one module placed on a node", + "properties": { + "module": { + "type": "string" + }, + "ports": { + "type": "object", + "additionalProperties": { + "type": "integer" + }, + "description": "the host port each declared port is published on, by the port the software listens on" + }, + "bindings": { + "type": "object", + "additionalProperties": { + "$ref": "#/defs/Provider" + }, + "description": "by provision, the provider each requirement that keeps data is bound to" + }, + "mesh-wide": { + "type": "object", + "description": "the whole mesh's settings layer of the module's placement" + }, + "has-mesh-wide": { + "type": "boolean" + }, + "layer": { + "type": "object", + "description": "this node's settings layer of the module's placement" + }, + "has-layer": { + "type": "boolean" + }, + "mesh-wide-values": { + "type": "object", + "description": "config values a person set in the whole mesh's layer, for a module in the settings form" + }, + "values": { + "type": "object", + "description": "config values a person set in this node's layer, for a module in the settings form" + } + }, + "required": [ + "module" + ] + }, + "Holding": { + "type": "object", + "description": "one seat a module placed on the node holds", + "properties": { + "claim": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "module": { + "type": "string" + }, + "site": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "required": [ + "claim", + "module" + ] + }, + "NodeDecided": { + "type": "object", + "description": "what a person decides of a node; no secret and no key material", + "properties": { + "endpoint": { + "type": "string" + }, + "site": { + "type": "string" + }, + "hub": { + "type": "boolean" + }, + "address": { + "type": "string" + }, + "public-domain": { + "type": "string" + }, + "account": { + "type": "string" + }, + "account-home": { + "type": "string" + }, + "agent-account": { + "type": "string" + }, + "agent-account-home": { + "type": "string" + }, + "adopted": { + "type": "boolean" + }, + "taken": { + "type": "array", + "items": { + "type": "string" + } + }, + "tunnel": { + "type": "object", + "description": "the tunnel found on the node when it was adopted" + }, + "peer-names": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "on the hub, the name a person gave a peer of that tunnel, by its public key" + } + } + }, + "Placement": { + "type": "object", + "description": "one node's placement assembled", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "decided": { + "$ref": "#/defs/NodeDecided" + }, + "modules": { + "type": "array", + "items": { + "$ref": "#/defs/PlacedModule" + } + }, + "pins": { + "type": "object", + "additionalProperties": { + "$ref": "#/defs/Provider" + }, + "description": "by provision, the provider chosen for the node" + }, + "holds": { + "type": "array", + "items": { + "$ref": "#/defs/Holding" + } + } + }, + "required": [ + "node", + "decided", + "modules" + ] + }, + "ProposalChange": { + "type": "object", + "description": "one change of a proposal", + "properties": { + "op": { + "type": "string", + "description": "place, take-off, pin, unpin, hold, unhold, decide, config, seed, add-node or remove-node" + }, + "node": { + "type": "string" + }, + "module": { + "type": "string" + }, + "ports": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "bindings": { + "type": "object", + "additionalProperties": { + "$ref": "#/defs/Provider" + } + }, + "provision": { + "type": "string" + }, + "provider": { + "$ref": "#/defs/Provider" + }, + "claim": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "site": { + "type": "string" + }, + "field": { + "type": "string" + }, + "value": { + "description": "a decided field's value" + }, + "mesh-wide": { + "type": "object" + }, + "layer": { + "type": "object" + }, + "mesh-wide-values": { + "type": "object" + }, + "values": { + "type": "object" + }, + "whole": { + "$ref": "#/defs/Placement", + "description": "the node's whole placement, for a seed" + }, + "decided": { + "$ref": "#/defs/NodeDecided" + }, + "keys": { + "type": "object", + "properties": { + "identity": { + "type": "string" + }, + "overlay": { + "type": "string" + }, + "sealing": { + "type": "string" + }, + "serving": { + "type": "string" + } + }, + "required": [ + "identity", + "sealing" + ], + "description": "the public halves of a joining node's keys" + } + }, + "required": [ + "op", + "node" + ] + }, + "Warrant": { + "type": "object", + "description": "the enrolment's warrant an add-node carries", + "properties": { + "node": { + "type": "string" + }, + "address": { + "type": "string" + }, + "ask": { + "type": "string" + }, + "warrant": { + "type": "string" + } + }, + "required": [ + "node", + "address", + "ask", + "warrant" + ] + }, + "Verdict": { + "type": "object", + "description": "the holder's judgement of a proposal's answers", + "properties": { + "state": { + "type": "string", + "description": "waits, pass or fail" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "fails": { + "type": "array", + "items": { + "type": "string" + } + }, + "silent": { + "type": "array", + "items": { + "type": "string" + } + }, + "until": { + "type": "string", + "format": "date-time" + }, + "intended": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "unintended": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "carried": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "answers": { + "type": "object", + "additionalProperties": { + "type": "object" + }, + "description": "every node's answer to its candidate, by node" + }, + "notes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "state", + "at" + ] + }, + "Proposal": { + "type": "object", + "description": "an open proposal", + "properties": { + "v": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "by": { + "type": "string" + }, + "why": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "written": { + "type": "string", + "format": "date-time" + }, + "changes": { + "type": "array", + "items": { + "$ref": "#/defs/ProposalChange" + } + }, + "seed": { + "type": "boolean" + }, + "export-digest": { + "type": "string" + }, + "base": { + "type": "object", + "additionalProperties": { + "type": "integer" + }, + "description": "by node whose key it changes, the revision of the key it was laid over (0: none)" + }, + "must": { + "type": "array", + "items": { + "type": "string" + } + }, + "hand-back": { + "type": "boolean" + }, + "state": { + "type": "string", + "description": "answering, failed or asked" + }, + "verdict": { + "$ref": "#/defs/Verdict" + }, + "ask": { + "type": "string" + }, + "words": { + "type": "string" + }, + "again": { + "type": "string", + "format": "date-time" + }, + "warrant": { + "$ref": "#/defs/Warrant" + }, + "withdrawn": { + "type": "string" + } + }, + "required": [ + "id", + "by", + "why", + "at", + "changes", + "base", + "state" + ] + }, + "PlacementEvent": { + "type": "object", + "description": "what each placement event carries", + "properties": { + "proposal": { + "$ref": "#/defs/Proposal" + }, + "nodes": { + "type": "array", + "items": { + "type": "object" + }, + "description": "for placement-changed, by node the revisions before and after and the modules added, removed or changed" + }, + "warrant": { + "type": "object", + "description": "who approved it, through which channel, when", + "properties": { + "warrant": { + "type": "string" + }, + "words": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "warrant", + "words", + "at" + ] + }, + "ended": { + "type": "string", + "description": "for placement-withdrawn, why it ended" + } + }, + "required": [ + "proposal" + ] + }, + "SeatHolders": { + "type": "object", + "description": "one key of seat-holders", + "properties": { + "v": { + "type": "integer" + }, + "seat": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "node": { + "type": "string" + }, + "outside": { + "type": "boolean", + "description": "the claim names no seat of the seat list" + }, + "holders": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "node": { + "type": "string" + } + }, + "required": [ + "module", + "node" + ] + } + } + }, + "required": [ + "seat", + "scope", + "holders" + ] + } + } +} diff --git a/placement/seats/mesh-wants/fixtures/states.json b/placement/seats/mesh-wants/fixtures/states.json new file mode 100644 index 0000000..cb38a39 --- /dev/null +++ b/placement/seats/mesh-wants/fixtures/states.json @@ -0,0 +1,47 @@ +[ + { + "name": "a want", + "of": "state:wants/want..", + "given": { + "v": 1, + "fp": "ab12", + "rule": 3 + } + }, + { + "name": "a node's own want", + "of": "state:wants/want..node", + "given": { + "v": 1, + "composed": "sha256:aa", + "self": true + } + }, + { + "name": "a held fingerprint", + "of": "state:wants/held..", + "given": { + "v": 1, + "node": "ace" + } + }, + { + "name": "a difference", + "of": "state:differences/.", + "given": { + "v": 1, + "want": "ab12", + "unexplained": true + } + }, + { + "name": "a composition", + "of": "state:compositions/.", + "given": { + "v": 1, + "group": "g1", + "heads": "0123456789ab", + "state": "pass" + } + } +] diff --git a/placement/seats/mesh-wants/seat.json b/placement/seats/mesh-wants/seat.json new file mode 100644 index 0000000..141b7d0 --- /dev/null +++ b/placement/seats/mesh-wants/seat.json @@ -0,0 +1,279 @@ +{ + "format": 1, + "seat": "mesh-wants", + "scope": "mesh", + "grain": "whole", + "decision": "novox/hq ADR 0565, ADR 0634, ADR 0635", + "description": "The seat that says what each node should run and whether each module's new builds wait for a person (ADR 0565, ADR 0539).", + "verbs": [ + { + "name": "upgrade", + "description": "Whether each module's new builds wait for a person: its policy (roll, together or record) and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.", + "input": { + "properties": { + "compare": { + "description": "\"true\": each node's own composition, by its digest, and the assigner's findings", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "module": { + "description": "one module", + "type": "string" + }, + "policy": { + "description": "with module: roll, together, record, or default to take a person's choice back", + "type": "string" + }, + "why": { + "description": "with policy: why, required and kept", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "each module's policy, or the one chosen, or the comparison" + } + }, + { + "name": "bus", + "description": "What a bus upgrade would do: the fingerprint of the bus module each node runs against the indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: the step itself is the put on the operator's warrant.", + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what a bus upgrade would do, read only" + } + } + ], + "events": [ + { + "name": "policy-changed", + "body": { + "type": "object" + } + } + ], + "family": { + "name": "mesh-wants.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "wants", + "template": "state", + "key-forms": [ + { + "key": "want..", + "value": { + "type": "object", + "description": "a node's want for a module", + "properties": { + "v": { + "type": "integer" + }, + "fp": { + "type": "string" + }, + "composed": { + "type": "string" + }, + "removed": { + "type": "boolean" + }, + "newest": { + "type": "string" + }, + "rule": { + "type": "integer", + "description": "the rule that holds the node back, 1 to 10" + }, + "backup": { + "type": "string", + "description": "again or skip" + }, + "words": { + "type": "string" + }, + "put": { + "type": "boolean" + }, + "hold": { + "type": "boolean" + }, + "yes": { + "type": "string" + }, + "release": { + "type": "string" + }, + "self": { + "type": "boolean" + } + } + }, + "description": "a node's want for a module" + }, + { + "key": "want..node", + "value": { + "type": "object", + "description": "a node's own want", + "properties": { + "v": { + "type": "integer" + }, + "composed": { + "type": "string" + }, + "words": { + "type": "string" + }, + "self": { + "type": "boolean" + } + }, + "required": [ + "composed" + ] + }, + "description": "a node's own want, naming the digest of its own composition" + }, + { + "key": "held..", + "value": { + "type": "object", + "description": "a fingerprint the assigner holds", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "words": { + "type": "string" + } + }, + "required": [ + "node" + ] + }, + "description": "a module's fingerprint the assigner holds" + } + ], + "history": 20, + "description": "what each node should run, and the fingerprints held back" + }, + { + "name": "differences", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "the difference between a want and what is sent", + "properties": { + "v": { + "type": "integer" + }, + "want": { + "type": "string" + }, + "want-removed": { + "type": "boolean" + }, + "sent": { + "type": "string" + }, + "sent-build": { + "type": "string" + }, + "rule": { + "type": "integer" + }, + "unexplained": { + "type": "boolean" + }, + "words": { + "type": "string" + } + } + }, + "description": "a module on a node" + } + ], + "history": 64, + "description": "differences between a want and what the node-engine is sent" + }, + { + "name": "compositions", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "the composition verdict of a group's check at its heads", + "properties": { + "v": { + "type": "integer" + }, + "group": { + "type": "string" + }, + "heads": { + "type": "string", + "description": "12 lowercase hex digits" + }, + "state": { + "type": "string", + "description": "pass, fail or waits" + }, + "words": { + "type": "string" + }, + "sdk": { + "type": "string" + }, + "machines": { + "type": "array", + "items": { + "type": "string" + } + }, + "machine": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "took": { + "type": "integer", + "description": "milliseconds" + } + }, + "required": [ + "group", + "heads", + "state" + ] + }, + "description": "a group's check at its heads" + } + ], + "history": 5, + "description": "composition verdicts of a group's check; the checker reads them" + } + ] +} diff --git a/placement/ts/seats/meshmoduleassigner/package-lock.json b/placement/ts/seats/meshmoduleassigner/package-lock.json new file mode 100644 index 0000000..b346586 --- /dev/null +++ b/placement/ts/seats/meshmoduleassigner/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-placement-contracts-mesh-module-assigner", + "packages": { + "": { + "name": "@novox/mesh-placement-contracts-mesh-module-assigner", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/placement/ts/seats/meshmoduleassigner/package.json b/placement/ts/seats/meshmoduleassigner/package.json new file mode 100644 index 0000000..5c25c3f --- /dev/null +++ b/placement/ts/seats/meshmoduleassigner/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-module-assigner, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-placement-contracts-mesh-module-assigner", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/placement/ts/seats/meshmoduleassigner/src/index.ts b/placement/ts/seats/meshmoduleassigner/src/index.ts new file mode 100644 index 0000000..8f958a4 --- /dev/null +++ b/placement/ts/seats/meshmoduleassigner/src/index.ts @@ -0,0 +1,488 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "mesh-module-assigner", defined whole (seats/mesh-module-assigner/seat.json): The seat that places modules on nodes (ADR 0567): the state of placement and the proposals that change it. Renamed from mesh-placement (ADR 0634 §9). + +export const seat = "mesh-module-assigner"; + +export const verbs = { + placement: "placement", + proposals: "proposals", + propose: "propose", + withdraw: "withdraw", + again: "again", +} as const; + +export const events = { + placementProposed: "placement-proposed", + placementWithdrawn: "placement-withdrawn", + placementJudged: "placement-judged", + placementChanged: "placement-changed", +} as const; + +export const family = { name: "mesh-module-assigner.events", maxAgeDays: 7 } as const; + +export const placementProposedSubject = "mesh.seat.mesh-module-assigner.event.placement-proposed"; + +export const placementWithdrawnSubject = "mesh.seat.mesh-module-assigner.event.placement-withdrawn"; + +export const placementJudgedSubject = "mesh.seat.mesh-module-assigner.event.placement-judged"; + +export const placementChangedSubject = "mesh.seat.mesh-module-assigner.event.placement-changed"; + +export const states = { + placement: "placement", + proposals: "proposals", + candidates: "candidates", + pulses: "pulses", + seatHolders: "seat-holders", +} as const; + +/** The key of one value of the state placement under the form ".node". */ +export function placementByNodeNodeKey(node: string): string { + return node + ".node"; +} + +/** The parts of a key of the form ".node", or undefined for a key of another form. */ +export function parsePlacementByNodeNodeKey(key: string): { node: string } | undefined { + let rest = key; + const at0 = rest.indexOf(".node"); + if (at0 <= 0) return undefined; + const node = rest.slice(0, at0); + rest = rest.slice(at0 + 5); + if (rest !== "") return undefined; + return { node }; +} + +/** The key of one value of the state placement under the form ".". */ +export function placementByNodeModuleKey(node: string, module: string): string { + return node + "." + module; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parsePlacementByNodeModuleKey(key: string): { node: string; module: string } | undefined { + let rest = key; + const at0 = rest.indexOf("."); + if (at0 <= 0) return undefined; + const node = rest.slice(0, at0); + rest = rest.slice(at0 + 1); + if (rest === "") return undefined; + const module = rest; + rest = ""; + if (rest !== "") return undefined; + return { node, module }; +} + +/** The key of one value of the state proposals under the form "". */ +export function proposalsByProposalKey(proposal: string): string { + return proposal; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseProposalsByProposalKey(key: string): { proposal: string } | undefined { + let rest = key; + if (rest === "") return undefined; + const proposal = rest; + rest = ""; + if (rest !== "") return undefined; + return { proposal }; +} + +/** The key of one value of the state candidates under the form ".". */ +export function candidatesByProposalNodeKey(proposal: string, node: string): string { + return proposal + "." + node; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseCandidatesByProposalNodeKey(key: string): { proposal: string; node: string } | undefined { + let rest = key; + const at0 = rest.indexOf("."); + if (at0 <= 0) return undefined; + const proposal = rest.slice(0, at0); + rest = rest.slice(at0 + 1); + if (rest === "") return undefined; + const node = rest; + rest = ""; + if (rest !== "") return undefined; + return { proposal, node }; +} + +/** The key of one value of the state pulses under the form "answer..". */ +export function pulsesByAnswerProposalNodeKey(proposal: string, node: string): string { + return "answer." + proposal + "." + node; +} + +/** The parts of a key of the form "answer..", or undefined for a key of another form. */ +export function parsePulsesByAnswerProposalNodeKey(key: string): { proposal: string; node: string } | undefined { + let rest = key; + if (!rest.startsWith("answer.")) return undefined; + rest = rest.slice(7); + const at1 = rest.indexOf("."); + if (at1 <= 0) return undefined; + const proposal = rest.slice(0, at1); + rest = rest.slice(at1 + 1); + if (rest === "") return undefined; + const node = rest; + rest = ""; + if (rest !== "") return undefined; + return { proposal, node }; +} + +/** The key of one value of the state pulses under the form "ask.". */ +export function pulsesByAskProposalKey(proposal: string): string { + return "ask." + proposal; +} + +/** The parts of a key of the form "ask.", or undefined for a key of another form. */ +export function parsePulsesByAskProposalKey(key: string): { proposal: string } | undefined { + let rest = key; + if (!rest.startsWith("ask.")) return undefined; + rest = rest.slice(4); + if (rest === "") return undefined; + const proposal = rest; + rest = ""; + if (rest !== "") return undefined; + return { proposal }; +} + +/** The key of one value of the state seat-holders under the form "". */ +export function seatHoldersBySeatKey(seat: string): string { + return seat; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseSeatHoldersBySeatKey(key: string): { seat: string } | undefined { + let rest = key; + if (rest === "") return undefined; + const seat = rest; + rest = ""; + if (rest !== "") return undefined; + return { seat }; +} + +/** The key of one value of the state seat-holders under the form ".". */ +export function seatHoldersBySeatNodeKey(seat: string, node: string): string { + return seat + "." + node; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseSeatHoldersBySeatNodeKey(key: string): { seat: string; node: string } | undefined { + let rest = key; + const at0 = rest.indexOf("."); + if (at0 <= 0) return undefined; + const seat = rest.slice(0, at0); + rest = rest.slice(at0 + 1); + if (rest === "") return undefined; + const node = rest; + rest = ""; + if (rest !== "") return undefined; + return { seat, node }; +} + +/** one seat a module placed on the node holds */ +export interface Holding { + "claim": string; + "kind"?: string; + "module": string; + "scope"?: string; + "site"?: string; +} + +/** what a person decides of a node; no secret and no key material */ +export interface NodeDecided { + "account"?: string; + "account-home"?: string; + "address"?: string; + "adopted"?: boolean; + "agent-account"?: string; + "agent-account-home"?: string; + "endpoint"?: string; + "hub"?: boolean; + /** on the hub, the name a person gave a peer of that tunnel, by its public key */ + "peer-names"?: Record; + "public-domain"?: string; + "site"?: string; + "taken"?: string[]; + /** the tunnel found on the node when it was adopted */ + "tunnel"?: Record; +} + +/** one module placed on a node */ +export interface PlacedModule { + /** by provision, the provider each requirement that keeps data is bound to */ + "bindings"?: Record; + "has-layer"?: boolean; + "has-mesh-wide"?: boolean; + /** this node's settings layer of the module's placement */ + "layer"?: Record; + /** the whole mesh's settings layer of the module's placement */ + "mesh-wide"?: Record; + /** config values a person set in the whole mesh's layer, for a module in the settings form */ + "mesh-wide-values"?: Record; + "module": string; + /** the host port each declared port is published on, by the port the software listens on */ + "ports"?: Record; + /** config values a person set in this node's layer, for a module in the settings form */ + "values"?: Record; +} + +/** one node's placement assembled */ +export interface Placement { + "decided": NodeDecided; + "holds"?: Holding[]; + "modules": PlacedModule[]; + "node": string; + /** by provision, the provider chosen for the node */ + "pins"?: Record; + "revision"?: string; + "v"?: number; +} + +/** who approved it, through which channel, when */ +export interface PlacementEventWarrant { + "at": string; + "warrant": string; + "words": string; +} + +/** what each placement event carries */ +export interface PlacementEvent { + /** for placement-withdrawn, why it ended */ + "ended"?: string; + /** for placement-changed, by node the revisions before and after and the modules added, removed or changed */ + "nodes"?: Record[]; + "proposal": Proposal; + /** who approved it, through which channel, when */ + "warrant"?: PlacementEventWarrant; +} + +/** an open proposal */ +export interface Proposal { + "again"?: string; + "ask"?: string; + "at": string; + /** by node whose key it changes, the revision of the key it was laid over (0: none) */ + "base": Record; + "by": string; + "changes": ProposalChange[]; + "export-digest"?: string; + "hand-back"?: boolean; + "id": string; + "must"?: string[]; + "seed"?: boolean; + /** answering, failed or asked */ + "state": string; + "v"?: number; + "verdict"?: Verdict; + "warrant"?: Warrant; + "why": string; + "withdrawn"?: string; + "words"?: string; + "written"?: string; +} + +/** the public halves of a joining node's keys */ +export interface ProposalChangeKeys { + "identity": string; + "overlay"?: string; + "sealing": string; + "serving"?: string; +} + +/** one change of a proposal */ +export interface ProposalChange { + "bindings"?: Record; + "claim"?: string; + "decided"?: NodeDecided; + "field"?: string; + /** the public halves of a joining node's keys */ + "keys"?: ProposalChangeKeys; + "kind"?: string; + "layer"?: Record; + "mesh-wide"?: Record; + "mesh-wide-values"?: Record; + "module"?: string; + "node": string; + /** place, take-off, pin, unpin, hold, unhold, decide, config, seed, add-node or remove-node */ + "op": string; + "ports"?: Record; + "provider"?: Provider; + "provision"?: string; + "scope"?: string; + "site"?: string; + /** a decided field's value */ + "value"?: unknown; + "values"?: Record; + /** the node's whole placement, for a seed */ + "whole"?: Placement; +} + +/** the module on a node that answers a requirement or holds a seat */ +export interface Provider { + "module": string; + "node": string; +} + +export interface SeatHoldersHoldersItem { + "module": string; + "node": string; +} + +/** one key of seat-holders */ +export interface SeatHolders { + "holders": SeatHoldersHoldersItem[]; + "node"?: string; + /** the claim names no seat of the seat list */ + "outside"?: boolean; + "scope": string; + "seat": string; + "v"?: number; +} + +/** the holder's judgement of a proposal's answers */ +export interface Verdict { + /** every node's answer to its candidate, by node */ + "answers"?: Record>; + "at": string; + "carried"?: Record; + "fails"?: string[]; + "intended"?: Record; + "notes"?: string[]; + "silent"?: string[]; + /** waits, pass or fail */ + "state": string; + "unintended"?: Record; + "until"?: string; +} + +/** the enrolment's warrant an add-node carries */ +export interface Warrant { + "address": string; + "ask": string; + "node": string; + "warrant": string; +} + +/** is what placement takes. */ +export interface PlacementInput { + /** "true": the changes in order, from the stream and the state's revisions */ + "history"?: "true" | "false"; + /** one module: the nodes it is placed on */ + "module"?: string; + /** one node */ + "node"?: string; + /** "true": who holds each seat, and on which node (the state seat-holders); with node, that node's */ + "seats"?: "true" | "false"; +} + +/** is what placement answers. */ +export type PlacementAnswer = Record; + +/** is what proposals takes. */ +export interface ProposalsInput { + /** one proposal's id */ + "id"?: string; +} + +/** is what proposals answers. */ +export type ProposalsAnswer = Record; + +/** is what propose takes. */ +export interface ProposeInput { + /** the changes, each an object with `op`: place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide (node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, on the same warrant) */ + "changes": Record[]; + /** for an add-node alone: the enrolment's warrant {node, address, ask, warrant}, naming the change's node and address */ + "warrant"?: Record; + /** why, shown to the operator with the proposal */ + "why": string; +} + +/** is what propose answers. */ +export type ProposeAnswer = Record; + +/** is what withdraw takes. */ +export interface WithdrawInput { + /** the proposal's id */ + "id": string; + /** why, kept in the event */ + "why": string; +} + +/** is what withdraw answers. */ +export type WithdrawAnswer = Record; + +/** is what again takes. */ +export interface AgainInput { + /** the proposal's id */ + "id": string; +} + +/** is what again answers. */ +export type AgainAnswer = Record; + +/** is the body of the event placement-proposed. */ +export type PlacementProposedEvent = PlacementEvent; + +/** is the body of the event placement-withdrawn. */ +export type PlacementWithdrawnEvent = PlacementEvent; + +/** is the body of the event placement-judged. */ +export type PlacementJudgedEvent = PlacementEvent; + +/** is the body of the event placement-changed. */ +export type PlacementChangedEvent = PlacementEvent; + +export interface PlacementByNodeNodeValueModulesItem { + /** the digest of the module key's value it stands for */ + "digest": string; + "module": string; +} + +/** is one value of the state placement under the key .node. */ +export interface PlacementByNodeNodeValue { + "decided": NodeDecided; + "holds"?: Holding[]; + /** the modules placed, in the order the composition reads them */ + "modules": PlacementByNodeNodeValueModulesItem[]; + "node": string; + "pins"?: Record; + "v"?: number; +} + +/** is one value of the state placement under the key .. */ +export interface PlacementByNodeModuleValue { + "node": string; + "placed": PlacedModule; + /** the value it replaces while a write is under way */ + "previous"?: PlacedModule; + "v"?: number; +} + +/** is one value of the state proposals under the key . */ +export type ProposalsByProposalValue = Proposal; + +/** is one value of the state candidates under the key .. */ +export interface CandidatesByProposalNodeValue { + /** the node's placement with the proposal laid over it; absent when the proposal takes its key away */ + "placement"?: Placement; + "proposal": string; + /** the proposal changes this node's composition through another's key */ + "reached"?: boolean; + "v"?: number; +} + +/** is one value of the state pulses under the key answer... */ +export interface PulsesByAnswerProposalNodeValue { + "v"?: number; +} + +/** is one value of the state pulses under the key ask.. */ +export interface PulsesByAskProposalValue { + "v"?: number; +} + +/** is one value of the state seat-holders under the key . */ +export type SeatHoldersBySeatValue = SeatHolders; + +/** is one value of the state seat-holders under the key .. */ +export type SeatHoldersBySeatNodeValue = SeatHolders; + diff --git a/placement/ts/seats/meshmoduleassigner/tsconfig.json b/placement/ts/seats/meshmoduleassigner/tsconfig.json new file mode 100644 index 0000000..60274c6 --- /dev/null +++ b/placement/ts/seats/meshmoduleassigner/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/placement/ts/seats/meshwants/package-lock.json b/placement/ts/seats/meshwants/package-lock.json new file mode 100644 index 0000000..e274634 --- /dev/null +++ b/placement/ts/seats/meshwants/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-placement-contracts-mesh-wants", + "packages": { + "": { + "name": "@novox/mesh-placement-contracts-mesh-wants", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/placement/ts/seats/meshwants/package.json b/placement/ts/seats/meshwants/package.json new file mode 100644 index 0000000..d4a29ee --- /dev/null +++ b/placement/ts/seats/meshwants/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-wants, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-placement-contracts-mesh-wants", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/placement/ts/seats/meshwants/src/index.ts b/placement/ts/seats/meshwants/src/index.ts new file mode 100644 index 0000000..ad3dab8 --- /dev/null +++ b/placement/ts/seats/meshwants/src/index.ts @@ -0,0 +1,211 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "mesh-wants", defined whole (seats/mesh-wants/seat.json): The seat that says what each node should run and whether each module's new builds wait for a person (ADR 0565, ADR 0539). + +export const seat = "mesh-wants"; + +export const verbs = { + upgrade: "upgrade", + bus: "bus", +} as const; + +export const events = { + policyChanged: "policy-changed", +} as const; + +export const family = { name: "mesh-wants.events", maxAgeDays: 7 } as const; + +export const policyChangedSubject = "mesh.seat.mesh-wants.event.policy-changed"; + +export const states = { + wants: "wants", + differences: "differences", + compositions: "compositions", +} as const; + +/** The key of one value of the state wants under the form "want..". */ +export function wantsByWantNodeModuleKey(node: string, module: string): string { + return "want." + node + "." + module; +} + +/** The parts of a key of the form "want..", or undefined for a key of another form. */ +export function parseWantsByWantNodeModuleKey(key: string): { node: string; module: string } | undefined { + let rest = key; + if (!rest.startsWith("want.")) return undefined; + rest = rest.slice(5); + const at1 = rest.indexOf("."); + if (at1 <= 0) return undefined; + const node = rest.slice(0, at1); + rest = rest.slice(at1 + 1); + if (rest === "") return undefined; + const module = rest; + rest = ""; + if (rest !== "") return undefined; + return { node, module }; +} + +/** The key of one value of the state wants under the form "want..node". */ +export function wantsByWantNodeNodeKey(node: string): string { + return "want." + node + ".node"; +} + +/** The parts of a key of the form "want..node", or undefined for a key of another form. */ +export function parseWantsByWantNodeNodeKey(key: string): { node: string } | undefined { + let rest = key; + if (!rest.startsWith("want.")) return undefined; + rest = rest.slice(5); + const at1 = rest.indexOf(".node"); + if (at1 <= 0) return undefined; + const node = rest.slice(0, at1); + rest = rest.slice(at1 + 5); + if (rest !== "") return undefined; + return { node }; +} + +/** The key of one value of the state wants under the form "held..". */ +export function wantsByHeldModuleFingerprintKey(module: string, fingerprint: string): string { + return "held." + module + "." + fingerprint; +} + +/** The parts of a key of the form "held..", or undefined for a key of another form. */ +export function parseWantsByHeldModuleFingerprintKey(key: string): { module: string; fingerprint: string } | undefined { + let rest = key; + if (!rest.startsWith("held.")) return undefined; + rest = rest.slice(5); + const at1 = rest.indexOf("."); + if (at1 <= 0) return undefined; + const module = rest.slice(0, at1); + rest = rest.slice(at1 + 1); + if (rest === "") return undefined; + const fingerprint = rest; + rest = ""; + if (rest !== "") return undefined; + return { module, fingerprint }; +} + +/** The key of one value of the state differences under the form ".". */ +export function differencesByNodeModuleKey(node: string, module: string): string { + return node + "." + module; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseDifferencesByNodeModuleKey(key: string): { node: string; module: string } | undefined { + let rest = key; + const at0 = rest.indexOf("."); + if (at0 <= 0) return undefined; + const node = rest.slice(0, at0); + rest = rest.slice(at0 + 1); + if (rest === "") return undefined; + const module = rest; + rest = ""; + if (rest !== "") return undefined; + return { node, module }; +} + +/** The key of one value of the state compositions under the form ".". */ +export function compositionsByGroupHeadsKey(group: string, heads: string): string { + return group + "." + heads; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseCompositionsByGroupHeadsKey(key: string): { group: string; heads: string } | undefined { + let rest = key; + const at0 = rest.indexOf("."); + if (at0 <= 0) return undefined; + const group = rest.slice(0, at0); + rest = rest.slice(at0 + 1); + if (rest === "") return undefined; + const heads = rest; + rest = ""; + if (rest !== "") return undefined; + return { group, heads }; +} + +/** is what upgrade takes. */ +export interface UpgradeInput { + /** "true": each node's own composition, by its digest, and the assigner's findings */ + "compare"?: "true" | "false"; + /** one module */ + "module"?: string; + /** with module: roll, together, record, or default to take a person's choice back */ + "policy"?: string; + /** with policy: why, required and kept */ + "why"?: string; +} + +/** is what upgrade answers. */ +export type UpgradeAnswer = Record; + +/** is what bus takes. */ +export interface BusInput { +} + +/** is what bus answers. */ +export type BusAnswer = Record; + +/** is the body of the event policy-changed. */ +export type PolicyChangedEvent = Record; + +/** is one value of the state wants under the key want... */ +export interface WantsByWantNodeModuleValue { + /** again or skip */ + "backup"?: string; + "composed"?: string; + "fp"?: string; + "hold"?: boolean; + "newest"?: string; + "put"?: boolean; + "release"?: string; + "removed"?: boolean; + /** the rule that holds the node back, 1 to 10 */ + "rule"?: number; + "self"?: boolean; + "v"?: number; + "words"?: string; + "yes"?: string; +} + +/** is one value of the state wants under the key want..node. */ +export interface WantsByWantNodeNodeValue { + "composed": string; + "self"?: boolean; + "v"?: number; + "words"?: string; +} + +/** is one value of the state wants under the key held... */ +export interface WantsByHeldModuleFingerprintValue { + "node": string; + "v"?: number; + "words"?: string; +} + +/** is one value of the state differences under the key .. */ +export interface DifferencesByNodeModuleValue { + "rule"?: number; + "sent"?: string; + "sent-build"?: string; + "unexplained"?: boolean; + "v"?: number; + "want"?: string; + "want-removed"?: boolean; + "words"?: string; +} + +/** is one value of the state compositions under the key .. */ +export interface CompositionsByGroupHeadsValue { + "group": string; + /** 12 lowercase hex digits */ + "heads": string; + "machine"?: string; + "machines"?: string[]; + "sdk"?: string; + /** pass, fail or waits */ + "state": string; + /** milliseconds */ + "took"?: number; + "v"?: number; + "warnings"?: string[]; + "words"?: string; +} + diff --git a/placement/ts/seats/meshwants/tsconfig.json b/placement/ts/seats/meshwants/tsconfig.json new file mode 100644 index 0000000..60274c6 --- /dev/null +++ b/placement/ts/seats/meshwants/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/seats/index.json b/seats/index.json index eed4efb..00b0a20 100644 --- a/seats/index.json +++ b/seats/index.json @@ -3,15 +3,11 @@ "seats": [ "mesh-store", "mesh-broker", - "mesh-vault", "mesh-config", "mesh-artifact-store", "mesh-catalog", "mesh-delivery", - "mesh-placement", "mesh-delivery-checker", - "mesh-wants", - "mesh-enrolment", "mesh-artifact-sweep", "npm-package-registry", "go-package-registry", @@ -42,7 +38,6 @@ "node-lock-screen", "node-clipboard", "node-bar", - "node-compositor", - "node-secret-service" + "node-compositor" ] } diff --git a/seats/mesh-placement/seat.json b/seats/mesh-placement/seat.json deleted file mode 100644 index cc3d44c..0000000 --- a/seats/mesh-placement/seat.json +++ /dev/null @@ -1,137 +0,0 @@ -{ - "format": 1, - "seat": "mesh-placement", - "scope": "mesh", - "grain": "seat-list", - "decision": "novox/hq ADR 0567", - "verbs": [ - { - "name": "placement", - "description": "Read placement: one node's key, the nodes one module is placed on, or every key, each with its revision, and how full the state is against its cap. With history, one node's or one module's changes in order: a revision the state keeps whole, an older one as the proposal's changes from its placement-changed event.", - "optional": true, - "input": { - "properties": { - "history": { - "description": "\"true\": the changes in order, from the stream and the state's revisions", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "module": { - "description": "one module: the nodes it is placed on", - "type": "string" - }, - "node": { - "description": "one node", - "type": "string" - }, - "seats": { - "description": "\"true\": who holds each seat, and on which node (the state seat-holders); with node, that node's", - "enum": [ - "true", - "false" - ], - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "proposals", - "description": "Every open proposal of placement: its changes, the revision of each key it was laid over, its candidates, every node's answer, the differences computed and answered, its verdict and its ask. Narrowed to one by id.", - "optional": true, - "input": { - "properties": { - "id": { - "description": "one proposal's id", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "propose", - "description": "Propose a change of placement, with why: laid over the current keys and refused at once, naming every finding, when the result cannot be placed (two modules on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).", - "optional": true, - "input": { - "additionalProperties": false, - "properties": { - "changes": { - "description": "the changes, each an object with `op`: place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide (node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, on the same warrant)", - "items": { - "required": [ - "op", - "node" - ], - "type": "object" - }, - "minItems": 1, - "type": "array" - }, - "warrant": { - "description": "for an add-node alone: the enrolment's warrant {node, address, ask, warrant}, naming the change's node and address", - "required": [ - "node", - "address", - "ask", - "warrant" - ], - "type": "object" - }, - "why": { - "description": "why, shown to the operator with the proposal", - "type": "string" - } - }, - "required": [ - "why", - "changes" - ], - "type": "object" - } - }, - { - "name": "withdraw", - "description": "Withdraw an open proposal: its key and its candidates are deleted and placement-withdrawn carries it whole. With why.", - "optional": true, - "input": { - "properties": { - "id": { - "description": "the proposal's id", - "type": "string" - }, - "why": { - "description": "why, kept in the event", - "type": "string" - } - }, - "required": [ - "id", - "why" - ], - "type": "object" - } - }, - { - "name": "again", - "description": "Judge an open proposal now with the answers that stand, and raise its ask again when its verdict passes and the operator's channel does not hold the ask. Nothing more: the recovery of a proposal whose judgement or ask was lost.", - "optional": true, - "input": { - "properties": { - "id": { - "description": "the proposal's id", - "type": "string" - } - }, - "required": [ - "id" - ], - "type": "object" - } - } - ] -} diff --git a/seats/mesh-wants/seat.json b/seats/mesh-wants/seat.json deleted file mode 100644 index 8b50c1a..0000000 --- a/seats/mesh-wants/seat.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "format": 1, - "seat": "mesh-wants", - "scope": "mesh", - "grain": "seat-list", - "decision": "novox/hq ADR 0565", - "verbs": [ - { - "name": "upgrade", - "description": "Whether each module's new builds wait for a person: its policy (roll, together or record) and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.", - "input": { - "properties": { - "compare": { - "description": "\"true\": each node's own composition, by its digest, and the assigner's findings", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "module": { - "description": "one module", - "type": "string" - }, - "policy": { - "description": "with module: roll, together, record, or default to take a person's choice back", - "type": "string" - }, - "why": { - "description": "with policy: why, required and kept", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "bus", - "description": "What a bus upgrade would do: the fingerprint of the bus module each node runs against the indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: the step itself is the put on the operator's warrant.", - "input": { - "properties": {}, - "type": "object" - } - } - ], - "events": [ - { - "name": "policy-changed" - } - ] -} diff --git a/ts/seats/meshvault/package.json b/ts/seats/meshvault/package.json deleted file mode 100644 index b811690..0000000 --- a/ts/seats/meshvault/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "description": "The contract of the seat mesh-vault, generated from its definition.", - "devDependencies": { - "typescript": "^5.6.0" - }, - "exports": { - ".": "./dist/index.js" - }, - "files": [ - "dist" - ], - "name": "@novox/mesh-contracts-mesh-vault", - "scripts": { - "build": "tsc", - "prepare": "tsc" - }, - "type": "module", - "version": "0.1.0" -} From c5fc93644f7b9176758d2c204a395eef5485e514 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:14:01 +0100 Subject: [PATCH 2/3] =?UTF-8?q?mesh-module-assigner=20client:=20the=20plac?= =?UTF-8?q?ement=20keys=20assembled=20into=20a=20node's=20placement,=20who?= =?UTF-8?q?=20stands=20where=20since=20when,=20who=20holds=20a=20seat,=20w?= =?UTF-8?q?hat=20a=20placement=20says=20to=20the=20composition;=20the=20no?= =?UTF-8?q?de=20key's=20digest=20kept=20as=20the=20first=20writer=20made?= =?UTF-8?q?=20it=20(ADR=200634=20=C2=A74;=20C-6;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meshmoduleassignerclient/client_test.go | 257 +++++++++ .../clients/meshmoduleassignerclient/go.mod | 8 + .../meshmoduleassignerclient/holders.go | 53 ++ .../meshmoduleassignerclient/holders_test.go | 65 +++ .../clients/meshmoduleassignerclient/keys.go | 545 ++++++++++++++++++ .../meshmoduleassignerclient/placed.go | 159 +++++ .../meshmoduleassignerclient/placement.go | 295 ++++++++++ 7 files changed, 1382 insertions(+) create mode 100644 placement/go/clients/meshmoduleassignerclient/client_test.go create mode 100644 placement/go/clients/meshmoduleassignerclient/go.mod create mode 100644 placement/go/clients/meshmoduleassignerclient/holders.go create mode 100644 placement/go/clients/meshmoduleassignerclient/holders_test.go create mode 100644 placement/go/clients/meshmoduleassignerclient/keys.go create mode 100644 placement/go/clients/meshmoduleassignerclient/placed.go create mode 100644 placement/go/clients/meshmoduleassignerclient/placement.go diff --git a/placement/go/clients/meshmoduleassignerclient/client_test.go b/placement/go/clients/meshmoduleassignerclient/client_test.go new file mode 100644 index 0000000..09ec425 --- /dev/null +++ b/placement/go/clients/meshmoduleassignerclient/client_test.go @@ -0,0 +1,257 @@ +package meshmoduleassignerclient + +import ( + "errors" + "fmt" + "reflect" + "strings" + "testing" + + seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func TestPlacementClient(t *testing.T) { + t.Run("key and file", func(t *testing.T) { + // Research 072 row 37: one node key and one key per module, a legacy key read beside them, any other refused. + for key, want := range map[string]struct { + node, module string + kind KeyKind + }{"g14.node": {"g14", "", NodeKind}, "g14.app": {"g14", "app", ModuleKind}, + "placement.g14": {"g14", "", LegacyKind}} { + if n, m, k, err := ParseKey(key); err != nil || n != want.node || m != want.module || k != want.kind { + t.Errorf("ParseKey(%q) = %q, %q, %d, %v", key, n, m, k, err) + } + } + for _, bad := range []string{"want.g14.x", "placement.", "placement.a.b", "g14", "g14.a.b", ".node"} { + if _, _, _, err := ParseKey(bad); err == nil { + t.Errorf("ParseKey(%q) took it", bad) + } + } + if k, err := NodeKey("g14"); err != nil || k != "g14.node" { + t.Errorf("NodeKey = %q, %v", k, err) + } + if k, err := ModuleKey("g14", "app"); err != nil || k != "g14.app" { + t.Errorf("ModuleKey = %q, %v", k, err) + } + if k, err := NodeKeys("g14"); err != nil || k != "g14.>" { + t.Errorf("NodeKeys = %q, %v", k, err) + } + if k, err := LegacyKey("g14"); err != nil || k != "placement.g14" { + t.Errorf("LegacyKey = %q, %v", k, err) + } + for name, err := range map[string]error{"a dotted node": func() error { _, err := NodeKey("a.b"); return err }(), + "a node named placement": func() error { _, err := NodeKey("placement"); return err }(), + "a module named node": func() error { _, err := ModuleKey("g14", "node"); return err }()} { + if err == nil { + t.Errorf("%s was taken", name) + } + } + }) + + g14 := seat.Placement{Node: "g14", Revision: "abc", + Decided: seat.NodeDecided{Endpoint: "g14.example:51820", Site: "home", Address: "10.77.0.2", Account: "op", Taken: []string{"zsh"}}, + Modules: []seat.PlacedModule{ + {Module: "zsh"}, + {Module: "app", Ports: map[string]int64{"8080": 18080}, Bindings: map[string]seat.Provider{"postgres": {Node: "novox", Module: "postgres"}}}, + }, + Pins: map[string]seat.Provider{"shell": {Node: "g14", Module: "zsh"}}, + Holds: []seat.Holding{{Claim: "node-shell", Scope: "node", Module: "zsh"}}, + } + novox := seat.Placement{Node: "novox", Decided: seat.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []seat.PlacedModule{{Module: "postgres", Ports: map[string]int64{"5432": 5432}}}, + Holds: []seat.Holding{{Claim: "mesh-store", Scope: "mesh", Module: "postgres"}}} + + t.Run("value", func(t *testing.T) { + raw, err := EncodePlacement(g14) + if err != nil { + t.Fatal(err) + } + back, err := DecodePlacement(raw) + if err != nil || back.V != Version || !SamePlacement(back, g14) { + t.Fatalf("round trip: %+v, %v", back, err) + } + moved := g14 + moved.Revision = "def" + if !SamePlacement(g14, moved) { + t.Error("a new revision alone counts as a change") + } + if _, err := DecodePlacement([]byte(`{"v":9,"node":"g14","modules":[]}`)); !errors.Is(err, ErrUnknownVersion) { + t.Errorf("version 9: %v", err) + } + for name, bad := range map[string]string{ + "module twice": `{"v":1,"node":"g14","modules":[{"module":"a"},{"module":"a"}]}`, + "port not number": `{"v":1,"node":"g14","modules":[{"module":"a","ports":{"http":80}}]}`, + "host port zero": `{"v":1,"node":"g14","modules":[{"module":"a","ports":{"80":0}}]}`, + "binding no node": `{"v":1,"node":"g14","modules":[{"module":"a","bindings":{"pg":{"module":"pg"}}}]}`, + "pin no module": `{"v":1,"node":"g14","modules":[],"pins":{"pg":{"node":"x"}}}`, + "hold not placed": `{"v":1,"node":"g14","modules":[],"holds":[{"claim":"s","module":"a"}]}`, + "node not a name": `{"v":1,"node":"g.14","modules":[]}`, + "value not shaped": `{"v":1,"node":"g14","modules":{}}`, + } { + if _, err := DecodePlacement([]byte(bad)); err == nil { + t.Errorf("%s: taken", name) + } + } + }) + + t.Run("as the composition reads it", func(t *testing.T) { + if got := Assigned(g14); !reflect.DeepEqual(got, []string{"zsh", "app"}) { + t.Errorf("Assigned = %v, want the file's order", got) + } + if got := ChosenPins(g14); !reflect.DeepEqual(got, map[string]catalogue.Chosen{"shell": {Node: "g14", Module: "zsh"}}) { + t.Errorf("ChosenPins = %v", got) + } + if got := Bound(g14); !reflect.DeepEqual(got, map[string]map[string]catalogue.Chosen{"app": {"postgres": {Node: "novox", Module: "postgres"}}}) { + t.Errorf("Bound = %v", got) + } + if got := HostPorts(g14); !reflect.DeepEqual(got, map[string]map[int]int{"app": {8080: 18080}}) { + t.Errorf("HostPorts = %v", got) + } + if got := Held(g14); !reflect.DeepEqual(got, []catalogue.Held{{Claim: "node-shell", Scope: "node", Node: "g14", Module: "zsh"}}) { + t.Errorf("Held = %v", got) + } + layered := seat.PlacedModule{Module: "app", HasMeshWide: true, Layer: map[string]any{"expose": map[string]any{"80": "mesh"}}} + want := []catalogue.Layer{ + {From: catalogue.MeshWideLayer, Values: map[string]any{}}, + {From: "g14", Values: map[string]any{"mode": "x"}, Placement: map[string]any{"expose": map[string]any{"80": "mesh"}}}, + } + if got := Layers(layered, "g14", map[string]any{"mode": "x"}); !reflect.DeepEqual(got, want) { + t.Errorf("Layers = %#v, want %#v", got, want) + } + if got := Layers(seat.PlacedModule{Module: "a"}, "g14", map[string]any{"k": 1}); len(got) != 1 || got[0].From != "g14" { + t.Errorf("values alone make the node's layer: %#v", got) + } + if got := Layers(seat.PlacedModule{Module: "a", HasLayer: true}, "g14", nil); len(got) != 1 || got[0].Values == nil { + t.Errorf("an empty layer of the node's own is kept, with empty values: %#v", got) + } + if got := Layers(seat.PlacedModule{Module: "a"}, "g14", nil); got != nil { + t.Errorf("no layer and no value is no layer: %#v", got) + } + empty := seat.Placement{Node: "x"} + if ChosenPins(empty) != nil || Bound(empty) != nil || HostPorts(empty) != nil || len(Held(empty)) != 0 { + t.Error("an empty placement reads as something") + } + }) + + // Research 072 row 37: a node's placement as its node key and module keys, written so a reader never takes a part of + // a write, read beside the form before, and converted from it once. + t.Run("one key per module and node", func(t *testing.T) { + values, err := PlacementValues(g14) + if err != nil || len(values) != 4 || values["g14.node"] == nil || values["g14.zsh"] == nil || values["g14.app"] == nil || + values["placement.g14"] == nil { + t.Fatalf("PlacementValues = %v, %v", values, err) + } + got, err := PlacementsOf(values) + if err != nil || !SamePlacement(got["g14"], g14) || len(got) != 1 { + t.Fatalf("PlacementsOf = %+v, %v", got, err) + } + if got := InsideAddresses(map[string]seat.Placement{"g14": g14, "novox": novox, "x": {Node: "x"}, "y": {Node: "y", Decided: seat.NodeDecided{Address: " 10.77.0.1"}}}); strings.Join(got, ",") != "10.77.0.1,10.77.0.2" { + t.Errorf("InsideAddresses = %v", got) + } + legacy, _ := EncodePlacement(novox) + stale, _ := EncodePlacement(seat.Placement{Node: "g14"}) + both := map[string][]byte{"placement.novox": legacy, "placement.g14": stale} + for k, v := range values { + both[k] = v + } + if got, err := PlacementsOf(both); err != nil || !SamePlacement(got["g14"], g14) || !SamePlacement(got["novox"], novox) { + t.Errorf("a node key wins over a legacy key, and a legacy key alone places its node: %+v, %v", got, err) + } + + keys := NewKeys() + rev := uint64(0) + apply := func(writes []KeyWrite, upTo int) { + t.Helper() + for i, w := range writes { + if i == upTo { + return + } + rev++ + if _, err := keys.Put(w.Key, w.Value, rev, w.Value == nil); err != nil { + t.Fatalf("write %d (%s): %v", i, w.Key, err) + } + } + } + // The form before converts once: its node and module keys, the legacy key left for the readers not yet delivered. + rev++ + legacyG14, _ := EncodePlacement(g14) + if _, err := keys.Put("placement.g14", legacyG14, rev, false); err != nil || !keys.Legacy("g14") { + t.Fatalf("the legacy key: %v", err) + } + migrations, err := keys.Migrations() + if err != nil || len(migrations["g14"]) != 3 || !migrations["g14"][2].Commit { + // The legacy key holds the placement already: no mirror write. + t.Fatalf("Migrations = %+v, %v", migrations, err) + } + apply(migrations["g14"], -1) + if m, _ := keys.Migrations(); len(m) != 0 || keys.Legacy("g14") { + t.Fatalf("a converted node converts again: %+v", m) + } + if p, r, ok := keys.Of("g14"); !ok || !SamePlacement(p, g14) || r != rev { + t.Fatalf("Of after the conversion = %+v, %d, %v", p, r, ok) + } + + // A write under way is never read half: app's new port is not read before the node key names it. + after := clonePlacement(g14) + after.Modules = []seat.PlacedModule{{Module: "app", Ports: map[string]int64{"8080": 18081}}, {Module: "new"}} + after.Holds, after.Pins = nil, nil + writes, err := keys.Writes("g14", &after) + if err != nil { + t.Fatal(err) + } + var kinds []string + for _, w := range writes { + kinds = append(kinds, fmt.Sprintf("%s:%v:%d", w.Key, w.Value != nil, w.Base)) + } + if strings.Join(kinds, " ") != "g14.app:true:3 g14.new:true:0 g14.node:true:4 placement.g14:true:1 g14.zsh:false:2" || + !writes[3].Mirror { + t.Fatalf("Writes = %v", kinds) + } + apply(writes, 2) + if p, _, _ := keys.Of("g14"); !SamePlacement(p, g14) { + t.Errorf("the module keys written before the node key are read: %+v", p) + } + apply(writes[2:], -1) + if p, _, _ := keys.Of("g14"); !SamePlacement(p, after) { + t.Errorf("the write done is not read whole: %+v", p) + } + if legacy, _ := PlacementsOf(map[string][]byte{"placement.g14": writes[3].Value}); !SamePlacement(legacy["g14"], after) { + t.Errorf("the mirror is not the placement written: %+v", legacy) + } + // A module key that stays is not written again, nor a mirror that holds it. + if again, _ := keys.Writes("g14", &after); len(again) != 1 || !again[0].Commit { + t.Errorf("an unchanged placement writes %+v", again) + } + // Keys that do not agree keep what the node had, and say so. + stray, _ := EncodeModule(seat.PlacementByNodeModuleValue{Node: "g14", Placed: seat.PlacedModule{Module: "app"}}) + if _, err := keys.Put("g14.app", stray, 99, false); err == nil { + t.Error("a module key the node key does not name the value of was taken silently") + } + if p, _, _ := keys.Of("g14"); !SamePlacement(p, after) { + t.Errorf("a stray module key moved the placement: %+v", p) + } + // The holder's next write puts the module key right again. + repair, _ := keys.Writes("g14", &after) + if len(repair) != 2 || repair[0].Key != "g14.app" || repair[0].Base != 99 || !repair[1].Commit { + t.Fatalf("the repair writes %+v", repair) + } + apply(repair, -1) + // No placement: the legacy key first, then the node key, then the module keys; none is read after. + gone, _ := keys.Writes("g14", nil) + kinds = nil + for _, w := range gone { + kinds = append(kinds, w.Key) + } + if strings.Join(kinds, " ") != "placement.g14 g14.node g14.app g14.new" { + t.Errorf("Writes(nil) = %v", kinds) + } + apply(gone, 2) + if _, _, ok := keys.Of("g14"); ok { + t.Error("a node key deleted still places its node") + } + if _, err := DecodeNode([]byte(`{"v":1,"node":"g14","modules":[{"module":"a","digest":"x"},{"module":"a","digest":"x"}]}`)); err == nil { + t.Error("a node key naming a module twice reads") + } + }) + +} diff --git a/placement/go/clients/meshmoduleassignerclient/go.mod b/placement/go/clients/meshmoduleassignerclient/go.mod new file mode 100644 index 0000000..2ebb4aa --- /dev/null +++ b/placement/go/clients/meshmoduleassignerclient/go.mod @@ -0,0 +1,8 @@ +module git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-20261105000000-000000000000 +) diff --git a/placement/go/clients/meshmoduleassignerclient/holders.go b/placement/go/clients/meshmoduleassignerclient/holders.go new file mode 100644 index 0000000..763d2be --- /dev/null +++ b/placement/go/clients/meshmoduleassignerclient/holders.go @@ -0,0 +1,53 @@ +package meshmoduleassignerclient + +import ( + "encoding/json" + "fmt" + "strings" + + seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" +) + +// SeatHoldersKey is a seat's key in the state `seat-holders`: `` for a seat held once for the mesh (node empty), +// `.` for a node seat on one node. +func SeatHoldersKey(name, node string) (string, error) { + if err := checkName("seat", name); err != nil { + return "", err + } + if node == "" { + return seat.SeatHoldersBySeatKey(name), nil + } + if err := checkName("node", node); err != nil { + return "", err + } + return seat.SeatHoldersBySeatNodeKey(name, node), nil +} + +// HoldersOf reads who holds the seat named (on node, for a node seat) from the state's keys as a reader watched them: the +// node's key first, then the seat's. None when nobody holds it. +func HoldersOf(keys map[string][]byte, name, node string) ([]seat.SeatHoldersHoldersItem, error) { + for _, n := range []string{node, ""} { + key, err := SeatHoldersKey(name, n) + if err != nil { + if n == "" { + return nil, err + } + continue + } + raw, ok := keys[key] + if !ok { + continue + } + var sh seat.SeatHolders + if err := json.Unmarshal(raw, &sh); err != nil { + return nil, fmt.Errorf("meshmoduleassignerclient: the holders of %s are not readable: %w", key, err) + } + if sh.V != Version { + return nil, fmt.Errorf("meshmoduleassignerclient: the holders of %s are of version %d, and this reads %d", key, sh.V, Version) + } + return sh.Holders, nil + } + return nil, nil +} + +var _ = strings.Cut diff --git a/placement/go/clients/meshmoduleassignerclient/holders_test.go b/placement/go/clients/meshmoduleassignerclient/holders_test.go new file mode 100644 index 0000000..88d520c --- /dev/null +++ b/placement/go/clients/meshmoduleassignerclient/holders_test.go @@ -0,0 +1,65 @@ +package meshmoduleassignerclient + +import ( + "testing" + "time" + + seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" +) + +// The digest a node key names is kept as the first writer made it, so a node key written before this client reads. +func TestDigestIsTheFirstWritersAndHoldersAndStandingRead(t *testing.T) { + m := seat.PlacedModule{Module: "app", Ports: map[string]int64{"8080": 18080}, Bindings: map[string]seat.Provider{"postgres": {Node: "novox", Module: "postgres"}}, + HasMeshWide: true, MeshWide: map[string]any{"a": 1.0}, Layer: map[string]any{"expose": "x"}, HasLayer: true, + MeshWideValues: map[string]any{"k": "v"}, Values: map[string]any{"p": 2.0}} + if got := Digest(m); got != "62b3c2796d264c42" { + t.Errorf("Digest = %s, the first writer's is 62b3c2796d264c42", got) + } + + keys := map[string][]byte{ + "mesh-module-assigner": []byte(`{"v":1,"seat":"mesh-module-assigner","scope":"mesh","holders":[{"module":"mesh-delivery-assigner","node":"novox"}]}`), + "node-files.ace": []byte(`{"v":1,"seat":"node-files","scope":"node","node":"ace","holders":[{"module":"node-files","node":"ace"}]}`), + "mesh-vault": []byte(`{"v":2,"seat":"mesh-vault","scope":"mesh","holders":[]}`), + } + for _, c := range []struct { + seat, node string + want int + bad bool + }{{"mesh-module-assigner", "", 1, false}, {"node-files", "ace", 1, false}, {"node-files", "g14", 0, false}, {"mesh-vault", "", 0, true}} { + got, err := HoldersOf(keys, c.seat, c.node) + if (err != nil) != c.bad || len(got) != c.want { + t.Errorf("HoldersOf(%s, %q) = %v, %v", c.seat, c.node, got, err) + } + } + + // A module stands since the revision of its node's key that first named it; the first revision heard stands since + // the zero time. + p := NewPlaced() + t0 := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) + node := func(modules ...string) []byte { + n := seat.PlacementByNodeNodeValue{Node: "ace"} + for _, name := range modules { + n.Modules = append(n.Modules, seat.PlacementByNodeNodeValueModulesItem{Module: name, Digest: "d"}) + } + raw, err := EncodeNode(n) + if err != nil { + t.Fatal(err) + } + return raw + } + if _, changed, err := p.Put("ace.node", node("a"), false, t0); err != nil || len(changed) != 1 { + t.Fatalf("first: %v, %v", changed, err) + } + if _, changed, _ := p.Put("ace.node", node("a", "b"), false, t0.Add(time.Hour)); len(changed) != 1 || changed[0] != "b" { + t.Fatalf("second: %v", changed) + } + if since, ok := p.Since("ace", "a"); !ok || !since.IsZero() { + t.Errorf("a stands since the zero time: %v %v", since, ok) + } + if since, ok := p.Since("ace", "b"); !ok || !since.Equal(t0.Add(time.Hour)) { + t.Errorf("b stands since the second revision: %v %v", since, ok) + } + if got := p.PlacedNodes("b"); len(got) != 1 || got[0] != "ace" { + t.Errorf("PlacedNodes = %v", got) + } +} diff --git a/placement/go/clients/meshmoduleassignerclient/keys.go b/placement/go/clients/meshmoduleassignerclient/keys.go new file mode 100644 index 0000000..27822a8 --- /dev/null +++ b/placement/go/clients/meshmoduleassignerclient/keys.go @@ -0,0 +1,545 @@ +package meshmoduleassignerclient + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "sync" + + seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" +) + +// **One key per module and node** (research 072 row 37; issue 774). The seat's state `placement` keeps a node's placement +// under the node's own keys, so a node watches `.>` alone and a change of one module writes that module's key: +// `.node`, the node key (what a person decided of the node, its pins and holdings, and the modules placed on it, +// each with the digest of its module key's value), and `.`, a module key. The node key is the commit of a +// write; a reader takes for each module the node key names the value of its module key whose digest the node key names, +// the current one once the node key is written, the previous one while the write is under way, so a reader never +// composes a part of a write. The form before, one value `placement.` per node, is still read and mirrored until +// a later change deletes it. + +// KeyKind is the kind of a key of the state `placement`. +type KeyKind int + +// The kinds of key: a node key, a module key, and the form before (one value per node). +const ( + NodeKind KeyKind = iota + 1 + ModuleKind + LegacyKind +) + +// NodeKey is a node's node key, `.node`. A node named `placement` is refused: its keys would read as the +// legacy form's. +func NodeKey(node string) (string, error) { + if err := checkPlacementNode(node); err != nil { + return "", err + } + return node + "." + NodeWord, nil +} + +// ModuleKey is a module's key on a node, `.`; a module named `node` is refused. +func ModuleKey(node, module string) (string, error) { + if err := checkPlacementNode(node); err != nil { + return "", err + } + if err := checkPlacementModule(module); err != nil { + return "", err + } + return node + "." + module, nil +} + +// NodeKeys is the pattern of every key of one node, `.>`: what a node watches of its own placement. +func NodeKeys(node string) (string, error) { + if err := checkPlacementNode(node); err != nil { + return "", err + } + return node + ".>", nil +} + +// LegacyKey is a node's key in the form before, `placement.`, read while this change is delivered. +func LegacyKey(node string) (string, error) { + if err := checkName("node", node); err != nil { + return "", err + } + return legacyWord + "." + node, nil +} + +// ParseKey reads a key of the state `placement` in either form: its node, its module (empty for a node key and +// a legacy key) and its kind. Any other key is refused, naming it. +func ParseKey(key string) (node, module string, kind KeyKind, err error) { + first, rest, ok := strings.Cut(key, ".") + if !ok || strings.Contains(rest, ".") || checkName("node", first) != nil || checkName("module", rest) != nil { + return "", "", 0, fmt.Errorf("meshmoduleassignerclient: %q is not a placement key, .node or .", key) + } + switch { + case first == legacyWord: + return rest, "", LegacyKind, nil + case rest == NodeWord: + return first, "", NodeKind, nil + default: + return first, rest, ModuleKind, nil + } +} + +func checkPlacementNode(node string) error { + if err := checkName("node", node); err != nil { + return err + } + if node == legacyWord { + return fmt.Errorf("meshmoduleassignerclient: no node is named %q: its placement keys would read as the form before", legacyWord) + } + return nil +} + +func checkPlacementModule(module string) error { + if err := checkName("module", module); err != nil { + return err + } + if module == NodeWord { + return fmt.Errorf("meshmoduleassignerclient: no module is named %q: it is the word of a node's own key", NodeWord) + } + return nil +} + +// EncodeNode is n as JSON at Version. +func EncodeNode(n seat.PlacementByNodeNodeValue) ([]byte, error) { + if err := checkPlacementNode(n.Node); err != nil { + return nil, err + } + n.V = Version + return json.Marshal(n) +} + +// DecodeNode reads a node key's value; it refuses an unknown version, a node or module that is not a name, and a module +// named twice or without a digest. +func DecodeNode(data []byte) (seat.PlacementByNodeNodeValue, error) { + if err := checkVersion(data); err != nil { + return seat.PlacementByNodeNodeValue{}, err + } + n, err := seat.DecodePlacementByNodeNodeValue(data) + if err != nil { + return n, fmt.Errorf("meshmoduleassignerclient: value does not fit its shape: %w", err) + } + if err := checkPlacementNode(n.Node); err != nil { + return seat.PlacementByNodeNodeValue{}, err + } + seen := map[string]bool{} + for _, r := range n.Modules { + if err := checkPlacementModule(r.Module); err != nil { + return seat.PlacementByNodeNodeValue{}, err + } + if seen[r.Module] || r.Digest == "" { + return seat.PlacementByNodeNodeValue{}, fmt.Errorf("meshmoduleassignerclient: the node key of %s names %s twice or without a digest", n.Node, r.Module) + } + seen[r.Module] = true + } + return n, nil +} + +// EncodeModule is m as JSON at Version. +func EncodeModule(m seat.PlacementByNodeModuleValue) ([]byte, error) { + if err := checkPlacementNode(m.Node); err != nil { + return nil, err + } + if err := checkPlacementModule(m.Placed.Module); err != nil { + return nil, err + } + if m.Previous != nil && m.Previous.Module != m.Placed.Module { + return nil, fmt.Errorf("meshmoduleassignerclient: the key of %s on %s carries %s as its previous value", m.Placed.Module, m.Node, m.Previous.Module) + } + m.V = Version + return json.Marshal(m) +} + +// DecodeModule reads a module key's value. +func DecodeModule(data []byte) (seat.PlacementByNodeModuleValue, error) { + if err := checkVersion(data); err != nil { + return seat.PlacementByNodeModuleValue{}, err + } + m, err := seat.DecodePlacementByNodeModuleValue(data) + if err != nil { + return m, fmt.Errorf("meshmoduleassignerclient: value does not fit its shape: %w", err) + } + if err := checkPlacementNode(m.Node); err != nil { + return seat.PlacementByNodeModuleValue{}, err + } + if err := checkPlacementModule(m.Placed.Module); err != nil { + return seat.PlacementByNodeModuleValue{}, err + } + return m, nil +} + +// Keys is the state `placement` as a reader holds it, key by key in either form, and every node's placement +// assembled from it: the one reader of the state's keys for every part (issue 774), so no part parses them itself. A +// write of the holder's under way is never assembled half: a module key's value is taken only as far as the node key +// names its digest (see the package's comment above NodeWord), and a node whose keys do not agree keeps the +// placement last assembled for it, Put saying why. +// +// Safe for use from a watch and from a reader at once. +type Keys struct { + mu sync.Mutex + nodes map[string]*keysOf +} + +// keysOf is what is held of one node's keys. +type keysOf struct { + node *seat.PlacementByNodeNodeValue + nodeRev uint64 + modules map[string]seat.PlacementByNodeModuleValue + moduleRevs map[string]uint64 + legacy *seat.Placement + legacyRev uint64 + last *seat.Placement // the placement last assembled whole, nil when none +} + +// NewKeys holds no key yet. +func NewKeys() *Keys { return &Keys{nodes: map[string]*keysOf{}} } + +// PlacementsOf is every node's placement assembled from the state's keys as a reader holds them (key to value; a +// deleted key absent), and an error naming every key that did not read and every node whose keys do not agree (that +// node is then assembled from its module keys' values as they are). +func PlacementsOf(keys map[string][]byte) (map[string]seat.Placement, error) { + k := NewKeys() + var errs []error + names := make([]string, 0, len(keys)) + for key := range keys { + names = append(names, key) + } + sort.Strings(names) + for _, key := range names { + // A node key read before its module keys does not agree yet; whether the keys agree is asked once all are in. + if _, readErr, _ := k.put(key, keys[key], 0, false); readErr != nil { + errs = append(errs, readErr) + } + } + k.mu.Lock() + for _, node := range sortedKeys(k.nodes) { + if _, _, err := k.nodes[node].assemble(node); err != nil { + errs = append(errs, err) + } + } + k.mu.Unlock() + return k.All(), errors.Join(errs...) +} + +// Put takes one key's value in (revision: the bus's revision of it, 0 when the reader has none; deleted: a delete or a +// purge). It answers the node the key is of and an error for a key or a value that does not read, or for a node whose +// keys do not agree; the node then keeps what it had. +func (k *Keys) Put(key string, value []byte, revision uint64, deleted bool) (string, error) { + node, readErr, err := k.put(key, value, revision, deleted) + if readErr != nil { + return node, readErr + } + return node, err +} + +// put is Put telling an error of the key or value it took (readErr) from the node's keys not agreeing after it (err). +func (k *Keys) put(key string, value []byte, revision uint64, deleted bool) (node string, readErr, err error) { + node, module, kind, err := ParseKey(key) + if err != nil { + return "", err, nil + } + k.mu.Lock() + defer k.mu.Unlock() + of := k.nodes[node] + if of == nil { + of = &keysOf{modules: map[string]seat.PlacementByNodeModuleValue{}, moduleRevs: map[string]uint64{}} + k.nodes[node] = of + } + switch kind { + case NodeKind: + of.node, of.nodeRev = nil, 0 + if !deleted { + n, derr := DecodeNode(value) + switch { + case derr != nil: + readErr = fmt.Errorf("the node key %s does not read, so it places nothing there: %w", key, derr) + case n.Node != node: + readErr = fmt.Errorf("the node key %s names the node %q, so it places nothing there", key, n.Node) + default: + of.node, of.nodeRev = &n, revision + } + } + case ModuleKind: + delete(of.modules, module) + delete(of.moduleRevs, module) + if !deleted { + m, derr := DecodeModule(value) + switch { + case derr != nil: + readErr = fmt.Errorf("the module key %s does not read: %w", key, derr) + case m.Node != node || m.Placed.Module != module: + readErr = fmt.Errorf("the module key %s holds %s on %s", key, m.Placed.Module, m.Node) + default: + of.modules[module] = m + } + // Its revision is kept even when its value does not read, so the holder can write over it. + of.moduleRevs[module] = revision + } + case LegacyKind: + of.legacy, of.legacyRev = nil, 0 + if !deleted { + p, derr := DecodePlacement(value) + switch { + case derr != nil: + readErr = fmt.Errorf("the placement of %s does not read, so it places nothing there: %w", node, derr) + case p.Node != node: + readErr = fmt.Errorf("the placement under %s names the node %q, so it places nothing there", key, p.Node) + default: + of.legacy, of.legacyRev = &p, revision + } + } + } + p, whole, err := of.assemble(node) + switch { + case p == nil: + of.last = nil + case whole || of.last == nil: + of.last = p + } + return node, readErr, err +} + +// assemble is the node's placement from its keys: nil when none places it, whole false (and an error) when the node key +// names a module digest no module key's value has. +func (of *keysOf) assemble(node string) (*seat.Placement, bool, error) { + if of.node == nil { + if of.legacy == nil { + return nil, true, nil + } + p := clonePlacement(*of.legacy) + return &p, true, nil + } + n := of.node + p := seat.Placement{V: Version, Node: node, Decided: n.Decided, Pins: n.Pins, Holds: n.Holds, Modules: make([]seat.PlacedModule, 0, len(n.Modules))} + var astray []string + for _, r := range n.Modules { + m, ok := of.modules[r.Module] + switch { + case ok && Digest(m.Placed) == r.Digest: + p.Modules = append(p.Modules, m.Placed) + case ok && m.Previous != nil && Digest(*m.Previous) == r.Digest: + p.Modules = append(p.Modules, *m.Previous) + case ok: + astray = append(astray, r.Module) + p.Modules = append(p.Modules, m.Placed) + default: + astray = append(astray, r.Module) + } + } + if len(astray) > 0 { + return &p, false, fmt.Errorf("the node key of %s names %s at a value no module key holds: %s keeps the placement it had", + node, strings.Join(astray, ", "), node) + } + return &p, true, nil +} + +// Of is node's placement and the revision of the key it was read from (its node key, or its legacy key), and whether +// any key places it. +func (k *Keys) Of(node string) (seat.Placement, uint64, bool) { + k.mu.Lock() + defer k.mu.Unlock() + of := k.nodes[node] + if of == nil || of.last == nil { + return seat.Placement{}, 0, false + } + rev := of.nodeRev + if of.node == nil { + rev = of.legacyRev + } + return clonePlacement(*of.last), rev, true +} + +// All is every node's placement, by node, and the revision of each one's key (Of). +func (k *Keys) All() map[string]seat.Placement { + out, _ := k.AllRevisions() + return out +} + +// AllRevisions is All with the revision of each node's key. +func (k *Keys) AllRevisions() (map[string]seat.Placement, map[string]uint64) { + k.mu.Lock() + nodes := make([]string, 0, len(k.nodes)) + for n := range k.nodes { + nodes = append(nodes, n) + } + k.mu.Unlock() + out, revs := map[string]seat.Placement{}, map[string]uint64{} + for _, n := range nodes { + if p, rev, ok := k.Of(n); ok { + out[n], revs[n] = p, rev + } + } + return out, revs +} + +// KeyWrite is one write of a key of the state `placement`, by compare-and-set: Value nil deletes the key at +// Base, Base 0 creates it, any other Base updates it at that revision. +type KeyWrite struct { + Node string + Key string + Value []byte + Base uint64 + // Commit says this write is the node key's: once it is written, the node's placement is the new one. + Commit bool + // Mirror says this write is the legacy key's, `placement.`, kept in step after the commit for the readers not + // yet delivered: a reader of both forms never reads it once the node key is there, so a mirror write that fails + // harms no such reader, and is made again at the node's next write. + Mirror bool +} + +// Writes are the writes that take node from the placement its keys hold to after (nil: no placement), in the order +// they are made: the module keys that change, each carrying the value it replaces; then the node key, the commit; then +// the legacy key `placement.` with the whole placement, a mirror for the readers not yet delivered, unless it +// holds it already; then the deletes of the module keys after no longer names. For no placement the legacy key goes +// first, then the node key, then the module keys, so a crash between them never leaves the legacy value to be read +// again. A module key whose value stays is not written. +func (k *Keys) Writes(node string, after *seat.Placement) ([]KeyWrite, error) { + nodeKey, err := NodeKey(node) + if err != nil { + return nil, err + } + k.mu.Lock() + defer k.mu.Unlock() + of := k.nodes[node] + if of == nil { + of = &keysOf{modules: map[string]seat.PlacementByNodeModuleValue{}, moduleRevs: map[string]uint64{}} + } + var out []KeyWrite + deleteModules := func(keep map[string]bool) { + for _, module := range sortedKeys(of.moduleRevs) { + if keep[module] { + continue + } + key, _ := ModuleKey(node, module) + out = append(out, KeyWrite{Node: node, Key: key, Base: of.moduleRevs[module]}) + } + } + if after == nil { + if of.legacy != nil { + key, _ := LegacyKey(node) + out = append(out, KeyWrite{Node: node, Key: key, Base: of.legacyRev}) + } + if of.node != nil { + out = append(out, KeyWrite{Node: node, Key: nodeKey, Base: of.nodeRev, Commit: true}) + } + deleteModules(nil) + return out, nil + } + if after.Node != node { + return nil, fmt.Errorf("meshmoduleassignerclient: the placement written under %s names the node %q", node, after.Node) + } + if err := CheckPlacement(*after); err != nil { + return nil, err + } + committed := map[string]seat.PlacedModule{} + if of.last != nil { + for _, m := range of.last.Modules { + committed[m.Module] = m + } + } + n := seat.PlacementByNodeNodeValue{Node: node, Decided: after.Decided, Pins: after.Pins, Holds: after.Holds, Modules: make([]seat.PlacementByNodeNodeValueModulesItem, 0, len(after.Modules))} + keep := map[string]bool{} + for _, m := range after.Modules { + if err := checkPlacementModule(m.Module); err != nil { + return nil, err + } + digest := Digest(m) + n.Modules = append(n.Modules, seat.PlacementByNodeNodeValueModulesItem{Module: m.Module, Digest: digest}) + keep[m.Module] = true + was, wasPlaced := committed[m.Module] + if held, ok := of.modules[m.Module]; ok && Digest(held.Placed) == digest && + (held.Previous == nil || wasPlaced && Digest(was) == digest) { + continue // the key holds it, and no previous value a reader could take in its place + } + v := seat.PlacementByNodeModuleValue{Node: node, Placed: m} + if wasPlaced && Digest(was) != digest { + v.Previous = &was + } + raw, err := EncodeModule(v) + if err != nil { + return nil, err + } + key, _ := ModuleKey(node, m.Module) + out = append(out, KeyWrite{Node: node, Key: key, Value: raw, Base: of.moduleRevs[m.Module]}) + } + raw, err := EncodeNode(n) + if err != nil { + return nil, err + } + out = append(out, KeyWrite{Node: node, Key: nodeKey, Value: raw, Base: of.nodeRev, Commit: true}) + // The mirror of the form before, while readers of it alone may run (the delivery of this change); a later change + // stops writing it and deletes it. + if of.legacy == nil || !SamePlacement(*of.legacy, *after) { + legacy, err := EncodePlacement(*after) + if err != nil { + return nil, err + } + key, _ := LegacyKey(node) + out = append(out, KeyWrite{Node: node, Key: key, Value: legacy, Base: of.legacyRev, Mirror: true}) + } + deleteModules(keep) + return out, nil +} + +// Migrations are, by node, the writes that convert every node held only in the form before (its legacy key, no node +// key) into its node and module keys, its legacy key kept as the mirror for the readers not yet delivered (Writes); empty +// once every node is converted, so the holder's conversion at each start is idempotent. +func (k *Keys) Migrations() (map[string][]KeyWrite, error) { + k.mu.Lock() + var legacy []string + for node, of := range k.nodes { + if of.node == nil && of.legacy != nil && checkPlacementNode(node) == nil { + legacy = append(legacy, node) + } + } + k.mu.Unlock() + sort.Strings(legacy) + out := map[string][]KeyWrite{} + var errs []error + for _, node := range legacy { + p, _, _ := k.Of(node) + w, err := k.Writes(node, &p) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", node, err)) + continue + } + out[node] = w + } + return out, errors.Join(errs...) +} + +// Legacy says node is held only in the form before. +func (k *Keys) Legacy(node string) bool { + k.mu.Lock() + defer k.mu.Unlock() + of := k.nodes[node] + return of != nil && of.node == nil && of.legacy != nil +} + +// PlacementValues is every key and value of p written whole onto no keys before: its module keys, its node key and the +// legacy mirror, as a writer that holds no key of the node (a test's bus, a fresh mesh) writes them. +func PlacementValues(p seat.Placement) (map[string][]byte, error) { + writes, err := NewKeys().Writes(p.Node, &p) + if err != nil { + return nil, err + } + out := make(map[string][]byte, len(writes)) + for _, w := range writes { + out[w.Key] = w.Value + } + return out, nil +} + +// clonePlacement is p with its own maps and slices, so a reader's copy is not a held one. +func clonePlacement(p seat.Placement) seat.Placement { + raw, _ := json.Marshal(p) + var out seat.Placement + _ = json.Unmarshal(raw, &out) + if out.Modules == nil { + out.Modules = []seat.PlacedModule{} + } + return out +} diff --git a/placement/go/clients/meshmoduleassignerclient/placed.go b/placement/go/clients/meshmoduleassignerclient/placed.go new file mode 100644 index 0000000..bf9fe53 --- /dev/null +++ b/placement/go/clients/meshmoduleassignerclient/placed.go @@ -0,0 +1,159 @@ +package meshmoduleassignerclient + +import ( + "fmt" + "maps" + "slices" + "sync" + "time" + + seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" +) + +// Placed is where the placement (the seat mesh-placement's state `placement`, held by the assigner; ADR 0567, ADR 0549 §3) places each module, and since when: +// the one reader of that state for every part that asks "does module stand on node" (issue 774, design 47's third +// milestone), in place of each part reading the controller's assignments (`mesh-controller_assignments`, which goes +// with the controller's lease, ADR 0549 §6). +// +// Its input is the state's revisions in the order the bus wrote them, as a watch with the history (jetstream's +// IncludeHistory) hands them: Put takes each, in either form of the state's keys (keys.go): a node's node key +// names its modules (a module key says nothing of which stand), and its legacy key `placement.` does until a node +// key of it is heard. A module stands since the revision of its node's key that first named it +// in an unbroken run. A module in the first revision heard of a key stands since the zero time: that revision is the +// key's first (the seed of ADR 0549 §4 placed what already ran there) or the oldest the bus still keeps +// (the state's history), and either way the module may have run there before it. A key's value that does not read places +// nothing on its node, and Put says why. +// +// Safe for use from a watch and from a reader at once. +type Placed struct { + mu sync.Mutex + since map[string]map[string]time.Time // node -> module -> since when it stands + heard map[string]bool // the nodes whose key was heard at all + // newForm are the nodes whose node key `.node` was heard: their legacy key `placement.` is no longer + // read (keys.go). + newForm map[string]bool +} + +// NewPlaced is a Placed that holds no placement yet. +func NewPlaced() *Placed { + return &Placed{since: map[string]map[string]time.Time{}, heard: map[string]bool{}, newForm: map[string]bool{}} +} + +// Put takes one revision of a key of the state `placement` (its value, whether it is a delete, and when the bus wrote +// it). It answers the node the key is of and the modules whose standing on it changed (placed or removed), in name +// order, and an error for a key or a value that does not read (the node then places nothing). +func (p *Placed) Put(key string, value []byte, deleted bool, at time.Time) (node string, changed []string, err error) { + node, _, kind, err := ParseKey(key) + if err != nil { + return "", nil, err + } + if kind == ModuleKind { + return node, nil, nil // which modules stand is the node key's to say + } + p.mu.Lock() + if kind == LegacyKind && p.newForm[node] { + p.mu.Unlock() + return node, nil, nil // the node is read from its node key once one was heard + } + if kind == NodeKind { + p.newForm[node] = true + } + p.mu.Unlock() + now := map[string]bool{} + if !deleted { + var modules []string + var derr error + var named string + if kind == NodeKind { + var n seat.PlacementByNodeNodeValue + n, derr = DecodeNode(value) + named = n.Node + for _, r := range n.Modules { + modules = append(modules, r.Module) + } + } else { + var pl seat.Placement + pl, derr = DecodePlacement(value) + named, modules = pl.Node, Assigned(pl) + } + switch { + case derr != nil: + err = fmt.Errorf("the placement of %s does not read, so it places nothing there: %w", node, derr) + case named != node: + err = fmt.Errorf("the placement under %s names the node %q, so it places nothing there", key, named) + default: + for _, m := range modules { + now[m] = true + } + } + } + p.mu.Lock() + defer p.mu.Unlock() + if !p.heard[node] { + p.heard[node] = true + at = time.Time{} + } + was := p.since[node] + next := map[string]time.Time{} + for m := range now { + if s, ok := was[m]; ok { + next[m] = s + } else { + next[m] = at + changed = append(changed, m) + } + } + for m := range was { + if !now[m] { + changed = append(changed, m) + } + } + if len(next) == 0 { + delete(p.since, node) + } else { + p.since[node] = next + } + slices.Sort(changed) + return node, changed, err +} + +// Seeded says some node's placement is held: before the assigner's adopt writes the first placement nothing is placed, +// and a reader keeps what it did before (ADR 0549 §5). +func (p *Placed) Seeded() bool { + p.mu.Lock() + defer p.mu.Unlock() + return len(p.since) > 0 +} + +// Since says whether module stands on node, and since when (the zero time: since before the revisions kept). +func (p *Placed) Since(node, module string) (time.Time, bool) { + p.mu.Lock() + defer p.mu.Unlock() + s, ok := p.since[node][module] + return s, ok +} + +// PlacedNodes is every node module is placed on, in name order. +func (p *Placed) PlacedNodes(module string) []string { + p.mu.Lock() + defer p.mu.Unlock() + var out []string + for node, mods := range p.since { + if _, ok := mods[module]; ok { + out = append(out, node) + } + } + slices.Sort(out) + return out +} + +// ByNode is, by node, the modules placed on it in name order. +func (p *Placed) ByNode() map[string][]string { + p.mu.Lock() + defer p.mu.Unlock() + out := make(map[string][]string, len(p.since)) + for node, mods := range p.since { + out[node] = slices.Sorted(maps.Keys(mods)) + } + return out +} diff --git a/placement/go/clients/meshmoduleassignerclient/placement.go b/placement/go/clients/meshmoduleassignerclient/placement.go new file mode 100644 index 0000000..823287e --- /dev/null +++ b/placement/go/clients/meshmoduleassignerclient/placement.go @@ -0,0 +1,295 @@ +// Package meshmoduleassignerclient is the logic every module that reads where modules are placed shares over the seat +// mesh-module-assigner (novox/hq ADR 0567, ADR 0634 §4, ADR 0625 §4): the keys of its state `placement` assembled into a +// node's placement, who stands on which node since when, who holds a seat, and what a placement says to the composition. +// The layouts are the seat's contract's (package meshmoduleassigner); the check of a proposal is the assigner's own. +package meshmoduleassignerclient + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + + seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// Version is the version of every value of the seat's states. +const Version = keys.Version + +// NodeWord is the last word of a node key, `.node`; no module is named it. +const NodeWord = "node" + +const ( + legacyWord = "placement" + digestLen = 16 +) + +// ErrUnknownVersion is what a decode returns for a value whose version is missing or not Version. +var ErrUnknownVersion = errors.New("meshmoduleassignerclient: unknown value version") + +var nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`) + +func checkName(kind, name string) error { + if name == "" { + return fmt.Errorf("meshmoduleassignerclient: the %s name is empty", kind) + } + if !nameOK.MatchString(name) { + return fmt.Errorf("meshmoduleassignerclient: the %s name %q holds a dot, a wildcard, whitespace or a character a key refuses", kind, name) + } + return nil +} + +func checkVersion(data []byte) error { + var probe struct { + V *int `json:"v"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return fmt.Errorf("meshmoduleassignerclient: value is not JSON: %w", err) + } + if probe.V == nil { + return fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownVersion, Version) + } + if *probe.V != Version { + return fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownVersion, *probe.V, Version) + } + return nil +} + +// CheckPlacement refuses a placement that names a node or module that is not a name, a module twice, a port that is not +// a number, a binding or pin naming no provider, and a holding by a module not placed on the node. +func CheckPlacement(p seat.Placement) error { + if err := checkName("node", p.Node); err != nil { + return err + } + seen := map[string]bool{} + for _, m := range p.Modules { + if err := checkName("module", m.Module); err != nil { + return err + } + if seen[m.Module] { + return fmt.Errorf("meshmoduleassignerclient: the placement of %s names %s twice", p.Node, m.Module) + } + seen[m.Module] = true + for port, at := range m.Ports { + if n, err := strconv.Atoi(port); err != nil || n <= 0 || n > 65535 { + return fmt.Errorf("meshmoduleassignerclient: %s's port %q on %s is not a port number", m.Module, port, p.Node) + } + if at <= 0 || at > 65535 { + return fmt.Errorf("meshmoduleassignerclient: %s's port %s on %s is published on %d, which is not a port number", m.Module, port, p.Node, at) + } + } + for provision, to := range m.Bindings { + if provision == "" || to.Node == "" || to.Module == "" { + return fmt.Errorf("meshmoduleassignerclient: %s on %s has a binding with no provision, node or module", m.Module, p.Node) + } + } + } + for provision, to := range p.Pins { + if provision == "" || to.Node == "" || to.Module == "" { + return fmt.Errorf("meshmoduleassignerclient: the placement of %s has a pin with no provision, node or module", p.Node) + } + } + for _, h := range p.Holds { + if h.Claim == "" || !seen[h.Module] { + return fmt.Errorf("meshmoduleassignerclient: the placement of %s has %s hold %q, which is not a module placed there or names no seat", p.Node, h.Module, h.Claim) + } + } + return nil +} + +// EncodePlacement is p as JSON at Version; it refuses what DecodePlacement refuses. +func EncodePlacement(p seat.Placement) ([]byte, error) { + if err := CheckPlacement(p); err != nil { + return nil, err + } + p.V = Version + return json.Marshal(p) +} + +// DecodePlacement reads a placement file or key value (the form before the node and module keys). +func DecodePlacement(data []byte) (seat.Placement, error) { + if err := checkVersion(data); err != nil { + return seat.Placement{}, err + } + var p seat.Placement + if err := json.Unmarshal(data, &p); err != nil { + return seat.Placement{}, fmt.Errorf("meshmoduleassignerclient: value does not fit its shape: %w", err) + } + if err := CheckPlacement(p); err != nil { + return seat.Placement{}, err + } + return p, nil +} + +// SamePlacement says whether two placements are the same value, so the holder writes only when they differ. +func SamePlacement(a, b seat.Placement) bool { + a.Revision, b.Revision = "", "" + ja, errA := EncodePlacement(a) + jb, errB := EncodePlacement(b) + return errA == nil && errB == nil && string(ja) == string(jb) +} + +// Assigned is the modules placed on the node, in the file's order. +func Assigned(p seat.Placement) []string { + out := make([]string, 0, len(p.Modules)) + for _, m := range p.Modules { + out = append(out, m.Module) + } + return out +} + +// Chosen is p as the composition names a chosen provider. +func Chosen(p seat.Provider) catalogue.Chosen { + return catalogue.Chosen{Node: p.Node, Module: p.Module} +} + +// ChosenPins is the node's pins as the composition reads them; nil when it has none. +func ChosenPins(p seat.Placement) map[string]catalogue.Chosen { + if len(p.Pins) == 0 { + return nil + } + out := make(map[string]catalogue.Chosen, len(p.Pins)) + for provision, to := range p.Pins { + out[provision] = Chosen(to) + } + return out +} + +// Bound is, by consumer module and provision, where each binding sends it; nil when none. +func Bound(p seat.Placement) map[string]map[string]catalogue.Chosen { + var out map[string]map[string]catalogue.Chosen + for _, m := range p.Modules { + if len(m.Bindings) == 0 { + continue + } + if out == nil { + out = map[string]map[string]catalogue.Chosen{} + } + out[m.Module] = make(map[string]catalogue.Chosen, len(m.Bindings)) + for provision, to := range m.Bindings { + out[m.Module][provision] = Chosen(to) + } + } + return out +} + +// HostPorts is, by module and the port it declares, the host port it is published on; nil when none. The check has +// refused a port that is not a number, so none is dropped here. +func HostPorts(p seat.Placement) map[string]map[int]int { + var out map[string]map[int]int + for _, m := range p.Modules { + if len(m.Ports) == 0 { + continue + } + if out == nil { + out = map[string]map[int]int{} + } + out[m.Module] = make(map[int]int, len(m.Ports)) + for port, at := range m.Ports { + n, _ := strconv.Atoi(port) + out[m.Module][n] = int(at) + } + } + return out +} + +// Layers is one module's settings layers on the node, as the composition reads them: the whole mesh's layer when it has +// one, then the node's, which carries the module's config values as mesh-config resolved them for this node (values, +// nil when none) beside its placement; a module with no layer and no value has none. A module in the settings form +// carries its values in the file (MeshWideValues, Values), which then stand for mesh-config's. +func Layers(m seat.PlacedModule, node string, values map[string]any) []catalogue.Layer { + var out []catalogue.Layer + if m.HasMeshWide || len(m.MeshWide) > 0 || len(m.MeshWideValues) > 0 { + mesh := map[string]any{} + for k, v := range m.MeshWideValues { + mesh[k] = v + } + out = append(out, catalogue.Layer{From: catalogue.MeshWideLayer, Values: mesh, Placement: m.MeshWide}) + } + if len(m.Values) > 0 { + values = make(map[string]any, len(m.Values)) + for k, v := range m.Values { + values[k] = v + } + } + if m.HasLayer || len(m.Layer) > 0 || len(values) > 0 { + if values == nil { + values = map[string]any{} + } + out = append(out, catalogue.Layer{From: node, Values: values, Placement: m.Layer}) + } + return out +} + +// Held is the node's holdings as the composition reads them. +func Held(p seat.Placement) []catalogue.Held { + out := make([]catalogue.Held, 0, len(p.Holds)) + for _, h := range p.Holds { + out = append(out, catalogue.Held{Claim: h.Claim, Scope: h.Scope, Node: p.Node, Module: h.Module, Site: h.Site, Kind: h.Kind}) + } + return out +} + +// InsideAddresses is every node's address on the private network, sorted and without repeats. +func InsideAddresses(placements map[string]seat.Placement) []string { + seen := map[string]bool{} + out := []string{} + for _, p := range placements { + if a := strings.TrimSpace(p.Decided.Address); a != "" && !seen[a] { + seen[a] = true + out = append(out, a) + } + } + sort.Strings(out) + return out +} + +// digestForm is a placed module as its digest has always been made: the fields in the order the first writer wrote them, +// so a node key written before this client names a digest this client computes the same. +type digestForm struct { + Module string `json:"module"` + Ports map[string]int64 `json:"ports,omitempty"` + Bindings map[string]digestProvider `json:"bindings,omitempty"` + MeshWide map[string]any `json:"mesh-wide,omitempty"` + HasMesh bool `json:"has-mesh-wide,omitempty"` + Layer map[string]any `json:"layer,omitempty"` + HasLayer bool `json:"has-layer,omitempty"` + MeshValues map[string]any `json:"mesh-wide-values,omitempty"` + Values map[string]any `json:"values,omitempty"` +} + +type digestProvider struct { + Node string `json:"node"` + Module string `json:"module"` +} + +// Digest is the digest of a placed module's value, as a node key names it. +func Digest(m seat.PlacedModule) string { + var bindings map[string]digestProvider + if len(m.Bindings) > 0 { + bindings = make(map[string]digestProvider, len(m.Bindings)) + for k, v := range m.Bindings { + bindings[k] = digestProvider{Node: v.Node, Module: v.Module} + } + } + raw, _ := json.Marshal(digestForm{Module: m.Module, Ports: m.Ports, Bindings: bindings, MeshWide: m.MeshWide, HasMesh: m.HasMeshWide, + Layer: m.Layer, HasLayer: m.HasLayer, MeshValues: m.MeshWideValues, Values: m.Values}) + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:])[:digestLen] +} + +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} From fb5451f6940e58696ad99bc84227f138ec7cdf1b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:16:00 +0100 Subject: [PATCH 3/3] =?UTF-8?q?mesh-vault=20and=20mesh-node-enroller=20cli?= =?UTF-8?q?ents:=20the=20signing,=20the=20node's=20two=20logins=20and=20th?= =?UTF-8?q?e=20own-secret=20drift;=20the=20token=20and=20the=20redeem=20pr?= =?UTF-8?q?oof=20(ADR=200634=20=C2=A74;=20C-10;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meshnodeenrollerclient/enrolment.go | 38 +++ .../meshnodeenrollerclient/enrolment_test.go | 30 ++ .../go/clients/meshnodeenrollerclient/go.mod | 3 + .../clients/meshnodeenrollerclient/redeem.go | 65 ++++ .../clients/meshnodeenrollerclient/token.go | 118 +++++++ .../meshnodeenrollerclient/token_test.go | 53 ++++ .../go/clients/meshvaultclient/client_test.go | 73 +++++ .../go/clients/meshvaultclient/go.mod | 8 + .../go/clients/meshvaultclient/nodelogins.go | 124 ++++++++ .../go/clients/meshvaultclient/owndrift.go | 95 ++++++ .../go/clients/meshvaultclient/signers.go | 296 ++++++++++++++++++ 11 files changed, 903 insertions(+) create mode 100644 identity-and-access/go/clients/meshnodeenrollerclient/enrolment.go create mode 100644 identity-and-access/go/clients/meshnodeenrollerclient/enrolment_test.go create mode 100644 identity-and-access/go/clients/meshnodeenrollerclient/go.mod create mode 100644 identity-and-access/go/clients/meshnodeenrollerclient/redeem.go create mode 100644 identity-and-access/go/clients/meshnodeenrollerclient/token.go create mode 100644 identity-and-access/go/clients/meshnodeenrollerclient/token_test.go create mode 100644 identity-and-access/go/clients/meshvaultclient/client_test.go create mode 100644 identity-and-access/go/clients/meshvaultclient/go.mod create mode 100644 identity-and-access/go/clients/meshvaultclient/nodelogins.go create mode 100644 identity-and-access/go/clients/meshvaultclient/owndrift.go create mode 100644 identity-and-access/go/clients/meshvaultclient/signers.go diff --git a/identity-and-access/go/clients/meshnodeenrollerclient/enrolment.go b/identity-and-access/go/clients/meshnodeenrollerclient/enrolment.go new file mode 100644 index 0000000..65a3ed0 --- /dev/null +++ b/identity-and-access/go/clients/meshnodeenrollerclient/enrolment.go @@ -0,0 +1,38 @@ +package meshnodeenrollerclient + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +const ( + // DefaultTokenLife is a token's life when issue names none; MostTokenLife the longest it may name (ADR 0587 §4). + DefaultTokenLife = time.Hour + MostTokenLife = 24 * time.Hour + + // UserPrefix is the bus user a joining machine dials as, `enrol.` (ADR 0587 §4). + UserPrefix = "enrol." +) + +// User is the bus user a joining machine dials as. +func User(node string) string { return UserPrefix + node } + +// IssuedID is the id of the enrolment-issued event of node's enrolment issued at issued: +// `enrolment-issued..`, what enrolment-expired names. +func IssuedID(node string, issued time.Time) string { + return "enrolment-issued." + node + "." + strconv.FormatInt(issued.UnixNano(), 10) +} + +// TokenLife is the life an issue's `expires` asks for: a duration, one hour when empty, at most MostTokenLife. +func TokenLife(expires string) (time.Duration, error) { + if strings.TrimSpace(expires) == "" { + return DefaultTokenLife, nil + } + d, err := time.ParseDuration(strings.TrimSpace(expires)) + if err != nil || d <= 0 || d > MostTokenLife { + return 0, fmt.Errorf("meshnodeenrollerclient: a token lasts a duration up to %s, such as 1h, not %q", MostTokenLife, expires) + } + return d, nil +} diff --git a/identity-and-access/go/clients/meshnodeenrollerclient/enrolment_test.go b/identity-and-access/go/clients/meshnodeenrollerclient/enrolment_test.go new file mode 100644 index 0000000..41098fe --- /dev/null +++ b/identity-and-access/go/clients/meshnodeenrollerclient/enrolment_test.go @@ -0,0 +1,30 @@ +package meshnodeenrollerclient + +import ( + "testing" + "time" +) + +func TestATokenLifeAndTheRedeemProofAreAsTheHolderMakesThem(t *testing.T) { + for life, ok := range map[string]bool{"": true, "1h": true, "24h": true, "25h": false, "0s": false, "-1h": false, "soon": false} { + if _, err := TokenLife(life); (err == nil) != ok { + t.Errorf("TokenLife(%q) = %v, want ok %v", life, err, ok) + } + } + if d, _ := TokenLife(""); d != DefaultTokenLife { + t.Errorf("an empty life is %s", d) + } + if got := IssuedID("g14", time.Unix(0, 42)); got != "enrolment-issued.g14.42" { + t.Errorf("IssuedID = %s", got) + } + if User("g14") != "enrol.g14" { + t.Error("the joining machine dials as enrol.") + } + r := Redeem{Node: "g14", PublicKey: "p", OverlayKey: "o", SealingKey: "s", ServingKey: "v"} + if string(RedeemProof("g14", "p", "o", "s", "v")) != "novox-mesh-redeem v1\x00g14\x00p\x00o\x00s\x00v" || r.KeysDigest() != KeysDigest("p", "o", "s", "v") { + t.Error("the proof bytes or the keys' digest changed") + } + if got := (RedeemAnswer{Node: "g14", Signature: "x"}).SignedBytes(); string(got) == "" || string(got) == "x" { + t.Errorf("SignedBytes = %s", got) + } +} diff --git a/identity-and-access/go/clients/meshnodeenrollerclient/go.mod b/identity-and-access/go/clients/meshnodeenrollerclient/go.mod new file mode 100644 index 0000000..4b99275 --- /dev/null +++ b/identity-and-access/go/clients/meshnodeenrollerclient/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient + +go 1.26.0 diff --git a/identity-and-access/go/clients/meshnodeenrollerclient/redeem.go b/identity-and-access/go/clients/meshnodeenrollerclient/redeem.go new file mode 100644 index 0000000..9ad4ac1 --- /dev/null +++ b/identity-and-access/go/clients/meshnodeenrollerclient/redeem.go @@ -0,0 +1,65 @@ +package meshnodeenrollerclient + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "strings" +) + +// **Redeem** (§6): what a joining node-engine sends, and what the holder answers. Shared by the holder and the engine, +// which live in two repositories. + +// Redeem is the redeem verb's call: the node, the public halves of its keys and its identity key's signature over +// RedeemProof. No token: the bus's login check verified it. +type Redeem struct { + Node string `json:"node"` + PublicKey string `json:"public_key"` + OverlayKey string `json:"overlay_key,omitempty"` + SealingKey string `json:"sealing_key"` + ServingKey string `json:"serving_key,omitempty"` + Profile map[string]any `json:"profile,omitempty"` + Tunnel json.RawMessage `json:"tunnel,omitempty"` + Proof string `json:"proof"` +} + +// RedeemProof is the bytes a node signs with its identity key when it redeems: its name and every public key it +// presents, so a proof cannot be moved to another node or other keys. The holder builds the same bytes. +func RedeemProof(node, public, overlay, sealing, serving string) []byte { + return []byte("novox-mesh-redeem v1\x00" + node + "\x00" + public + "\x00" + overlay + "\x00" + sealing + "\x00" + serving) +} + +// KeysDigest is the digest of the public keys a redeem presents, `sha256:`: what the key at redeeming and the +// events carry, the one hash an event carries (§1). +func KeysDigest(public, overlay, sealing, serving string) string { + sum := sha256.Sum256([]byte(strings.Join([]string{public, overlay, sealing, serving}, "\x00"))) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// KeysDigest is r's keys' digest. +func (r Redeem) KeysDigest() string { + return KeysDigest(r.PublicKey, r.OverlayKey, r.SealingKey, r.ServingKey) +} + +// ProofBytes is r's proof decoded from base64. +func (r Redeem) ProofBytes() ([]byte, error) { return base64.StdEncoding.DecodeString(r.Proof) } + +// RedeemAnswer is what redeem answers (§6): the node, the login it dials as next, its account sealed to the sealing key it +// presented (base64), its certificate as PEM, the vault's public signing half (base64), and the vault's signature over +// SignedBytes under the context of KindEnrolment. The engine refuses an answer not signed by the key its token carries. +type RedeemAnswer struct { + Node string `json:"node"` + Login string `json:"login"` + Account string `json:"account"` + Certificate string `json:"certificate"` + Signer string `json:"signer"` + Signature string `json:"signature,omitempty"` +} + +// SignedBytes is what the vault signs of a: a without its signature, as JSON. +func (a RedeemAnswer) SignedBytes() []byte { + a.Signature = "" + raw, _ := json.Marshal(a) + return raw +} diff --git a/identity-and-access/go/clients/meshnodeenrollerclient/token.go b/identity-and-access/go/clients/meshnodeenrollerclient/token.go new file mode 100644 index 0000000..d8d44d6 --- /dev/null +++ b/identity-and-access/go/clients/meshnodeenrollerclient/token.go @@ -0,0 +1,118 @@ +package meshnodeenrollerclient + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +// Token is what a person carries to a machine that is joining (novox/hq ADR 0004): where the bus is, what certificate +// to expect there, whose signature to believe afterwards, and a one-time right to join; and, when it was issued for the +// machine's tunnel key, the machine's first tunnel (ADR 0169). +// +// Shared so the vault, which makes the +// token from the lease write on (novox/hq ADR 0587 §3, issue 774), mesh-cli, which opens and prints it, and the +// node-engine, which parses it, read one shape. The field names are the wire format the controller's `token issue` +// writes, unchanged, so a token of either issuer reads the same; the tunnel stays in it (the lead's settlement of +// ADR 0587 §3, 2026-11-02). +type Token struct { + Version int `json:"v"` + + // Node is what the mesh calls this machine: the bus user it dials as is named after it, so it must be known before + // the mesh can say anything. + Node string `json:"node,omitempty"` + + Broker string `json:"broker,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Signer []byte `json:"signer,omitempty"` + Secret string `json:"secret"` + + // Adopted says this node joins adopted (novox/hq ADR 0100); absent for a converged node. + Adopted bool `json:"adopted,omitempty"` + + // Tunnel is this machine's first tunnel, when the token was issued for the key it made (novox/hq ADR 0169): its own + // address and the hub to reach, so it reaches the bus over the private network and the bus never faces the internet. + Tunnel *TokenTunnel `json:"tunnel,omitempty"` +} + +// TokenTunnel is the joining machine's side of its first tunnel, in the field names the controller writes. +type TokenTunnel struct { + Key string `json:"key"` + Address string `json:"address"` + Range string `json:"range"` + HubKey string `json:"hub_key"` + HubEndpoint string `json:"hub_endpoint"` +} + +// ParseToken reads a token a person pasted. Every refusal says *this is not a token* rather than *this is the wrong +// token*: telling "not from the mesh I joined" apart from "malformed" is a signature check later (ADR 0004). A token +// missing a part is refused whole, naming every part, never used partially: without the fingerprint a node connects +// to whatever answers, without the signing key it cannot tell a declaration from a forgery. +func ParseToken(encoded string) (Token, error) { + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded)) + if err != nil { + return Token{}, fmt.Errorf("this is not a token: %w", err) + } + var t Token + if err := json.Unmarshal(raw, &t); err != nil { + return Token{}, fmt.Errorf("this is not a token: %w", err) + } + if t.Version != 1 { + return Token{}, fmt.Errorf( + "this token says it is version %d, and this host understands version 1", t.Version) + } + if missing := t.Missing(); len(missing) > 0 { + return Token{}, fmt.Errorf( + "this token is missing %s, so it cannot be used to join anything", + strings.Join(missing, ", ")) + } + return t, nil +} + +// Missing names every part of t a joining machine needs and t lacks. +func (t Token) Missing() []string { + var missing []string + if strings.TrimSpace(t.Broker) == "" { + missing = append(missing, "the broker's address") + } + if strings.TrimSpace(t.Fingerprint) == "" { + missing = append(missing, "the broker certificate's fingerprint") + } + if len(t.Signer) != ed25519.PublicKeySize { + missing = append(missing, "the vault's signing key") + } + if strings.TrimSpace(t.Secret) == "" { + missing = append(missing, "the one-time secret") + } + if tt := t.Tunnel; tt != nil { + for _, part := range []struct{ value, says string }{ + {tt.Key, "the tunnel key it was issued for"}, {tt.Address, "this machine's address"}, + {tt.Range, "the private network's range"}, {tt.HubKey, "the hub's tunnel key"}, + {tt.HubEndpoint, "where the hub's tunnel is dialled"}, + } { + if strings.TrimSpace(part.value) == "" { + missing = append(missing, part.says) + } + } + } + return missing +} + +// Encode is t as one line a person carries: base64 (URL alphabet, no padding) of its JSON at version 1, as the +// controller's `token issue` writes it. Refused when a part is missing. +func (t Token) Encode() (string, error) { + t.Version = 1 + if missing := t.Missing(); len(missing) > 0 { + return "", fmt.Errorf("meshnodeenrollerclient: the token is missing %s", strings.Join(missing, ", ")) + } + raw, err := json.Marshal(t) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +// SignerKey is the signing key the token carries, as a key: the controller's before the lease write, the vault's after. +func (t Token) SignerKey() ed25519.PublicKey { return ed25519.PublicKey(t.Signer) } diff --git a/identity-and-access/go/clients/meshnodeenrollerclient/token_test.go b/identity-and-access/go/clients/meshnodeenrollerclient/token_test.go new file mode 100644 index 0000000..97e5832 --- /dev/null +++ b/identity-and-access/go/clients/meshnodeenrollerclient/token_test.go @@ -0,0 +1,53 @@ +package meshnodeenrollerclient + +import ( + "encoding/hex" + "reflect" + "strings" + "testing" +) + +// controllerToken is a token made by the controller's own `token issue` code: mesh-control origin/main 5c281ad9, +// internal/token Token.Encode, run on 2026-11-02 for node anchor, adopted, with a first tunnel, its signer the ed25519 +// key of the seed 00 01 .. 1f. Copied, never written by hand. +const controllerToken = "eyJ2IjoxLCJub2RlIjoiYW5jaG9yIiwiYnJva2VyIjoiMTkyLjAuMi4xMDo0MjIyIiwiZmluZ2VycHJpbnQiOiJzaGEyNTY6YWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYiIsInNpZ25lciI6IkE2RUh2L1BPRUw0ZGNOMFk1MHZBbVdmazFqQ2JwUTFmSGR5R1pCSlZNYmc9Iiwic2VjcmV0IjoiYS1vbmUtdGltZS1zZWNyZXQiLCJhZG9wdGVkIjp0cnVlLCJ0dW5uZWwiOnsia2V5IjoiZEdobExXMWhZMmhwYm1WekxXdGxlUT09IiwiYWRkcmVzcyI6IjEwLjAuMC43LzI0IiwicmFuZ2UiOiIxMC4wLjAuMC8yNCIsImh1Yl9rZXkiOiJkR2hsTFdoMVluTXRhMlY1IiwiaHViX2VuZHBvaW50IjoiMTk4LjUxLjEwMC4xOjUxODIwIn19" + +// 774/S36/AC1: a token the controller made reads, with the moved code, field for field as it was written; encoding it +// again gives the same line; a part missing is refused naming it; garbage and another version are not tokens. +func TestATokenTheControllerMadeReads(t *testing.T) { + signer, _ := hex.DecodeString("03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8") + want := Token{Version: 1, Node: "anchor", Broker: "192.0.2.10:4222", + Fingerprint: "sha256:" + strings.Repeat("ab", 32), Signer: signer, Secret: "a-one-time-secret", Adopted: true, + Tunnel: &TokenTunnel{Key: "dGhlLW1hY2hpbmVzLWtleQ==", Address: "10.0.0.7/24", Range: "10.0.0.0/24", + HubKey: "dGhlLWh1YnMta2V5", HubEndpoint: "198.51.100.1:51820"}} + got, err := ParseToken(" " + controllerToken + "\n") + if err != nil || !reflect.DeepEqual(got, want) || len(got.SignerKey()) != 32 { + t.Fatalf("parsed %+v, %v", got, err) + } + if again, err := got.Encode(); err != nil || again != controllerToken { + t.Errorf("encoded again: %q, %v", again, err) + } + for _, c := range []struct { + cut func(*Token) + expect string + }{ + {func(t *Token) { t.Broker = "" }, "broker's address"}, + {func(t *Token) { t.Fingerprint = "" }, "fingerprint"}, + {func(t *Token) { t.Signer = t.Signer[:5] }, "signing key"}, + {func(t *Token) { t.Secret = "" }, "one-time secret"}, + {func(t *Token) { t.Tunnel = &TokenTunnel{Key: "k"} }, "where the hub's tunnel is dialled"}, + } { + cut := want + cut.Tunnel = &TokenTunnel{} + *cut.Tunnel = *want.Tunnel + c.cut(&cut) + if _, err := cut.Encode(); err == nil || !strings.Contains(err.Error(), c.expect) { + t.Errorf("a token without %s: %v", c.expect, err) + } + } + for _, bad := range []string{"", "!!!not base64!!!", "aGVsbG8", "eyJ2Ijo5OX0"} { + if _, err := ParseToken(bad); err == nil { + t.Errorf("%q parsed as a token", bad) + } + } +} diff --git a/identity-and-access/go/clients/meshvaultclient/client_test.go b/identity-and-access/go/clients/meshvaultclient/client_test.go new file mode 100644 index 0000000..2c00bf5 --- /dev/null +++ b/identity-and-access/go/clients/meshvaultclient/client_test.go @@ -0,0 +1,73 @@ +package meshvaultclient + +import ( + "crypto/ed25519" + "strings" + "testing" + "time" + + seat "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" +) + +func TestSigningNodeLoginsAndOwnDrift(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + k := NewSignerKey(pub, time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)) + if got, err := SignerPublicKey(k); err != nil || !got.Equal(pub) { + t.Fatalf("the key round trips: %v", err) + } + k.Fingerprint = "sha256:00" + if _, err := SignerPublicKey(k); err == nil { + t.Error("a key with another fingerprint was taken") + } + ctx, err := SignContext(KindEnrolment) + if err != nil || ctx != "novox enrolment v1\n" { + t.Fatalf("SignContext = %q, %v", ctx, err) + } + if _, err := SignContext("other"); err == nil { + t.Error("an unknown kind has a context") + } + sig := SignMessage(priv, ctx, []byte("m")) + if s, err := VerifyBy([]Signer{{Name: SignerVault, Key: pub}}, ctx, []byte("m"), sig); err != nil || s.Name != SignerVault { + t.Errorf("VerifyBy = %v, %v", s, err) + } + other, _ := SignContext(KindAuthority) + if _, err := VerifyBy([]Signer{{Name: SignerVault, Key: pub}}, other, []byte("m"), sig); err == nil { + t.Error("a signature made for one kind verified as another's") + } + + // A node's two logins: the first, the second, a rotation to the other only, a stage only with a rotation. + since := "2026-11-02T01:00:00Z" + for name, c := range map[string]struct { + l seat.NodeLoginsByNodeValue + ok bool + }{ + "first login": {seat.NodeLoginsByNodeValue{Node: "g14", Login: "node.g14", Since: since}, true}, + "rotating to other": {seat.NodeLoginsByNodeValue{Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: RotationBegun, Since: since}, true}, + "rotating to itself": {seat.NodeLoginsByNodeValue{Node: "g14", Login: "node.g14", Rotating: "node.g14", Stage: RotationBegun, Since: since}, false}, + "stage without rotate": {seat.NodeLoginsByNodeValue{Node: "g14", Login: "node.g14", Stage: RotationReady, Since: since}, false}, + "another's login": {seat.NodeLoginsByNodeValue{Node: "g14", Login: "node.ace", Since: since}, false}, + } { + if err := CheckNodeLogin(c.l); (err == nil) != c.ok { + t.Errorf("%s: %v", name, err) + } + } + if raw, err := EncodeNodeLogin(seat.NodeLoginsByNodeValue{Node: "g14", Login: "node.g14", Since: since}); err != nil { + t.Fatal(err) + } else if back, err := DecodeNodeLogin(raw); err != nil || Rotates(back) { + t.Errorf("round trip: %+v %v", back, err) + } + if _, err := DecodeNodeLogin([]byte(`{"v":9,"node":"g14","login":"node.g14","since":"` + since + `"}`)); err == nil { + t.Error("version 9 was taken") + } + if n, ok := LoginNode("node.g14--b"); !ok || n != "g14" { + t.Errorf("LoginNode = %q %v", n, ok) + } + + masked := []ModuleBodies{{Module: "nats", Bodies: []Body{{ID: "nats.needs-jwt", Composed: "x " + MaskedAnotherValue}, {ID: "nats.conf", Running: MaskedAnotherValue}}}} + if own := OwnValuesDiffer(masked); len(own) != 1 || own[0] != (OwnValueDiffers{Module: "nats", Name: "jwt"}) || ValuesDiffer(masked) != 2 { + t.Errorf("OwnValuesDiffer = %v, ValuesDiffer = %d", own, ValuesDiffer(masked)) + } + if !strings.Contains(MaskedAnotherValue, "another value") { + t.Error("the mask word changed") + } +} diff --git a/identity-and-access/go/clients/meshvaultclient/go.mod b/identity-and-access/go/clients/meshvaultclient/go.mod new file mode 100644 index 0000000..39ad4cc --- /dev/null +++ b/identity-and-access/go/clients/meshvaultclient/go.mod @@ -0,0 +1,8 @@ +module git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-20261105000000-000000000000 +) diff --git a/identity-and-access/go/clients/meshvaultclient/nodelogins.go b/identity-and-access/go/clients/meshvaultclient/nodelogins.go new file mode 100644 index 0000000..314af7f --- /dev/null +++ b/identity-and-access/go/clients/meshvaultclient/nodelogins.go @@ -0,0 +1,124 @@ +package meshvaultclient + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + + seat "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" +) + +// **A node's two logins** (novox/hq ADR 0583): a node's bus account rotates between the login `node.` and +// `node.--b`. The layout of the state `node-logins` is the seat's contract's; this is the logic every reader of it +// shares. +const ( + // The stages of a rotation (ADR 0583 §2-§4). + RotationBegun = "begun" + RotationReady = "ready" + RotationConfirmed = "confirmed" + + nodeLoginPrefix = "node." + // SecondLoginSuffix marks a node's second login, as a module's account rotates over `--b` (ADR 0114). + SecondLoginSuffix = "--b" + + // Version is the version of every value of the seat's states. + Version = 1 +) + +// ErrUnknownVersion is what a decode returns for a value whose version is not Version. +var ErrUnknownVersion = errors.New("meshvaultclient: unknown value version") + +var nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`) + +func checkName(kind, name string) error { + if name == "" || !nameOK.MatchString(name) { + return fmt.Errorf("meshvaultclient: the %s name %q is empty or holds a dot, a wildcard, whitespace or a character a key refuses", kind, name) + } + return nil +} + +// FirstLogin is a node's first login, `node.`; SecondLogin its other. +func FirstLogin(node string) string { return nodeLoginPrefix + node } +func SecondLogin(node string) string { return nodeLoginPrefix + node + SecondLoginSuffix } + +// OtherLogin is the login a node rotates to from login: the second from the first, the first from the second. +func OtherLogin(node, login string) string { + if login == SecondLogin(node) { + return FirstLogin(node) + } + return SecondLogin(node) +} + +// LoginNode is the node a node login names, either of its two, and whether login is one. +func LoginNode(login string) (string, bool) { + rest, ok := strings.CutPrefix(login, nodeLoginPrefix) + rest = strings.TrimSuffix(rest, SecondLoginSuffix) + return rest, ok && checkName("node", rest) == nil +} + +// Rotates says the node is rotating: a login rotating is named. +func Rotates(l seat.NodeLoginsByNodeValue) bool { return l.Rotating != "" } + +// CheckNodeLogin refuses a login that is not one of the node's two, a rotation to anything but the other, and a stage +// without a rotation or an unknown one. +func CheckNodeLogin(l seat.NodeLoginsByNodeValue) error { + if err := checkName("node", l.Node); err != nil { + return err + } + if l.Login != FirstLogin(l.Node) && l.Login != SecondLogin(l.Node) { + return fmt.Errorf("meshvaultclient: the node %s logs in as %q, neither of its two logins", l.Node, l.Login) + } + for _, act := range l.Acts { + if strings.TrimSpace(act) == "" { + return fmt.Errorf("meshvaultclient: the node %s names an empty hand act among its rotations' warrants", l.Node) + } + } + if !Rotates(l) { + if l.Stage != "" || l.ListRevision != 0 { + return fmt.Errorf("meshvaultclient: the node %s rotates to nothing and names stage %q", l.Node, l.Stage) + } + return nil + } + if l.Rotating != OtherLogin(l.Node, l.Login) { + return fmt.Errorf("meshvaultclient: the node %s rotates from %s to %q, not its other login", l.Node, l.Login, l.Rotating) + } + switch l.Stage { + case RotationBegun, RotationReady, RotationConfirmed: + default: + return fmt.Errorf("meshvaultclient: the node %s's rotation is at stage %q, not begun, ready or confirmed", l.Node, l.Stage) + } + return nil +} + +// EncodeNodeLogin is l as JSON at Version; it refuses what DecodeNodeLogin refuses. +func EncodeNodeLogin(l seat.NodeLoginsByNodeValue) ([]byte, error) { + if err := CheckNodeLogin(l); err != nil { + return nil, err + } + l.V = Version + return json.Marshal(l) +} + +// DecodeNodeLogin reads a key of `node-logins`; it refuses an unknown version (ErrUnknownVersion) and what +// CheckNodeLogin refuses. +func DecodeNodeLogin(data []byte) (seat.NodeLoginsByNodeValue, error) { + var probe struct { + V *int `json:"v"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return seat.NodeLoginsByNodeValue{}, fmt.Errorf("meshvaultclient: value is not JSON: %w", err) + } + if probe.V == nil || *probe.V != Version { + return seat.NodeLoginsByNodeValue{}, fmt.Errorf("%w: this reader reads %d", ErrUnknownVersion, Version) + } + l, err := seat.DecodeNodeLoginsByNodeValue(data) + if err != nil { + return l, fmt.Errorf("meshvaultclient: value does not fit its shape: %w", err) + } + if err := CheckNodeLogin(l); err != nil { + return seat.NodeLoginsByNodeValue{}, err + } + return l, nil +} diff --git a/identity-and-access/go/clients/meshvaultclient/owndrift.go b/identity-and-access/go/clients/meshvaultclient/owndrift.go new file mode 100644 index 0000000..ebf5f9d --- /dev/null +++ b/identity-and-access/go/clients/meshvaultclient/owndrift.go @@ -0,0 +1,95 @@ +package meshvaultclient + +import ( + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The own secrets a node's shadow answer finds the vault holds another value for than the node runs (issue 1126, cause +// C4): the vault's `move-in` never replaces a value it holds, so a value made anew later runs while the vault keeps the +// first one it was handed. The node opens both seals with its own key and masks the value as MaskedAnotherValue; this +// reads which own secrets those are, for the node-engine (which hands the vault what runs) and for the vault's own +// drift tool, so both read the answer one way. + +// MaskedAnotherValue is the word a shown body masks a value with when the node opened both sides with its key and they +// are two values. +const MaskedAnotherValue = "" + +// OwnValueDiffers is one own secret of a module whose value composed (the vault's) is another than the one that runs. +type OwnValueDiffers struct { + Module string `json:"module"` + Name string `json:"name"` +} + +// Body is one differing resource of a module as a node's answer shows it: the resource's id and both sides, masked. +type Body struct { + ID string + Composed string + Running string +} + +// ModuleBodies is the shown bodies of one module whose composition differs. +type ModuleBodies struct { + Module string + Bodies []Body +} + +var ownNeedPrefix = catalogue.NeedID("") + +// OwnNeedName is the name of module's own secret whose file a resource id names, and whether it names one. On a node a +// resource's id carries its module's name (`only-office.needs-jwt`); the bare form a declaration composes +// (`needs-jwt`, catalogue.NeedID) is read too. An id of another module's is not this module's. +func OwnNeedName(module, id string) (string, bool) { + if module != "" { + id = strings.TrimPrefix(id, module+".") + } + name, own := strings.CutPrefix(id, ownNeedPrefix) + return name, own && name != "" && !strings.Contains(name, ".") +} + +// OwnValuesDiffer is every own secret whose shown bodies say it holds another value composed than running, sorted by +// module and name, each once. A body the answer did not show is not read. +func OwnValuesDiffer(differs []ModuleBodies) []OwnValueDiffers { + seen := map[OwnValueDiffers]bool{} + var out []OwnValueDiffers + for _, d := range differs { + for _, b := range d.Bodies { + name, own := OwnNeedName(d.Module, b.ID) + if !own || !anotherValue(b) { + continue + } + o := OwnValueDiffers{Module: d.Module, Name: name} + if !seen[o] { + seen[o] = true + out = append(out, o) + } + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Module != out[j].Module { + return out[i].Module < out[j].Module + } + return out[i].Name < out[j].Name + }) + return out +} + +// ValuesDiffer is how many shown bodies hold a value masked MaskedAnotherValue, own secrets and every other resource +// alike. +func ValuesDiffer(differs []ModuleBodies) int { + n := 0 + for _, d := range differs { + for _, b := range d.Bodies { + if anotherValue(b) { + n++ + } + } + } + return n +} + +func anotherValue(b Body) bool { + return strings.Contains(b.Composed, MaskedAnotherValue) || strings.Contains(b.Running, MaskedAnotherValue) +} diff --git a/identity-and-access/go/clients/meshvaultclient/signers.go b/identity-and-access/go/clients/meshvaultclient/signers.go new file mode 100644 index 0000000..1ef79ce --- /dev/null +++ b/identity-and-access/go/clients/meshvaultclient/signers.go @@ -0,0 +1,296 @@ +package meshvaultclient + +import ( + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + seat "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" + "math/big" + "slices" + "strings" + "time" +) + +// **The vault signs** (novox/hq ADR 0550 §2; design 47's L3 item d). The vault makes an ed25519 signing key and an x509 +// authority once, keeps both as its own secrets with their operator copies, and publishes their public halves in its +// state `signers`, which only the vault writes and every node principal and composing part reads. Its verb `sign` signs a +// node's certificate for that node's own node-engine, from bus state alone (the name from the node's placement, the +// serving key from its record), and says `signed` naming the caller, the kind, the node and the digest, never the key. +// +// A node trusts the vault's key through the bus grants alone (the lead's design limit, 2026-10-28): only the vault may +// write `signers`, the same trust the wants and the placement rest on. A node trusts the vault's key alone since the +// controller ended (ADR 0590). What a node trusts now it states in its record, the node record's trusts. + +const ( + // The verb sign, its answer and the event signed are the vault's contract's (mesh-contracts meshvault: VerbSign, + // SignAnswer, HolderEventSigned, HolderSubjectSigned, SignedHolderEvent; novox/hq ADR 0627 §1). + + // KindNodeCertificate is a node's certificate for its internal name, asked by that node's node-engine alone. + KindNodeCertificate = "node-certificate" + // KindEnrolment is a redeem answer (RedeemAnswer), signed under its own context for the seat mesh-node-enroller alone (ADR 0587 + // §3, §6). + KindEnrolment = "enrolment" + // KindAuthority is the vault's own authority certificate, which the vault signs for itself. + KindAuthority = "authority" + + // SignerVault names the signer a node trusts. + SignerVault = "vault" + + // CertificateLife is how long a certificate the vault signs for a node lasts: ten years, renewed by asking again, + // never by a clock running out unseen. + CertificateLife = 10 * 365 * 24 * time.Hour +) + +// SignKinds are the kinds `sign` knows. +var SignKinds = []string{KindNodeCertificate, KindEnrolment, KindAuthority} + +// SignContext is the line a message of kind is signed after, so a signature made for one kind never verifies as +// another's (as the composition's and the asks' contexts are kept apart). +func SignContext(kind string) (string, error) { + if !slices.Contains(SignKinds, kind) { + return "", fmt.Errorf("meshvaultclient: %q is not a kind the vault signs (%s)", kind, strings.Join(SignKinds, ", ")) + } + return "novox " + kind + " v1\n", nil +} + +// SignerFingerprint is an ed25519 public key's fingerprint, `sha256:` of its 32 bytes. +func SignerFingerprint(key ed25519.PublicKey) string { + sum := sha256.Sum256(key) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// AuthorityFingerprint is an authority certificate's fingerprint, `sha256:` of its DER; refused when certificate is +// not one PEM certificate. +func AuthorityFingerprint(certificate string) (string, error) { + c, err := ParseCertificate(certificate) + if err != nil { + return "", err + } + sum := sha256.Sum256(c.Raw) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +// ParseCertificate is the first certificate of a PEM text. +func ParseCertificate(text string) (*x509.Certificate, error) { + block, _ := pem.Decode([]byte(strings.TrimSpace(text))) + if block == nil || block.Type != "CERTIFICATE" { + return nil, errors.New("meshvaultclient: not a PEM certificate") + } + return x509.ParseCertificate(block.Bytes) +} + +// NewSignerKey is the value of `key` for pub. +func NewSignerKey(pub ed25519.PublicKey, made time.Time) seat.SignersByKeyValue { + return seat.SignersByKeyValue{V: 1, Key: base64.StdEncoding.EncodeToString(pub), Fingerprint: SignerFingerprint(pub), Made: made.UTC().Format(time.RFC3339Nano)} +} + +// PublicKey is k's key, checked: an ed25519 public key whose fingerprint is the one k names. +func SignerPublicKey(k seat.SignersByKeyValue) (ed25519.PublicKey, error) { + raw, err := base64.StdEncoding.DecodeString(k.Key) + if err != nil || len(raw) != ed25519.PublicKeySize { + return nil, errors.New("meshvaultclient: the signers' key is not an ed25519 public key in base64") + } + if fp := SignerFingerprint(raw); fp != k.Fingerprint { + return nil, fmt.Errorf("meshvaultclient: the signers' key has the fingerprint %s, and names %s", fp, k.Fingerprint) + } + return ed25519.PublicKey(raw), nil +} + +// DecodeSignerKey reads the key `key` of `signers`, refused when it does not check. +func DecodeSignerKey(raw []byte) (seat.SignersByKeyValue, error) { + k, err := seat.DecodeSignersByKeyValue(raw) + if err != nil { + return k, fmt.Errorf("meshvaultclient: signers %s: %w", "key", err) + } + if _, err := SignerPublicKey(k); err != nil { + return seat.SignersByKeyValue{}, err + } + return k, nil +} + +// DecodeSignerAuthority reads the key `authority` of `signers`: one authority certificate (a CA that signs leaves +// alone) whose fingerprint is the one named. +func DecodeSignerAuthority(raw []byte) (seat.SignersByAuthorityValue, error) { + a, err := seat.DecodeSignersByAuthorityValue(raw) + if err != nil { + return a, fmt.Errorf("meshvaultclient: signers %s: %w", "authority", err) + } + c, err := ParseCertificate(a.Certificate) + if err != nil { + return seat.SignersByAuthorityValue{}, err + } + if !c.IsCA { + return seat.SignersByAuthorityValue{}, errors.New("meshvaultclient: the signers' authority is not an authority certificate") + } + if fp, _ := AuthorityFingerprint(a.Certificate); fp != a.Fingerprint { + return seat.SignersByAuthorityValue{}, fmt.Errorf("meshvaultclient: the signers' authority has the fingerprint %s, and names %s", fp, a.Fingerprint) + } + return a, nil +} + +// Signer is one key a node trusts, by who holds it (SignerVault, or the operator's). +type Signer struct { + Name string + Key ed25519.PublicKey +} + +// Fingerprint is the signer's key's fingerprint. +func (s Signer) Fingerprint() string { return SignerFingerprint(s.Key) } + +// VerifyBy is the trusted signer whose key verifies sig over context and msg: refused when no trusted key does, saying +// so, and when a key is not an ed25519 key. A signature made under another context never verifies. +func VerifyBy(trusted []Signer, context string, msg, sig []byte) (Signer, error) { + if len(sig) != ed25519.SignatureSize { + return Signer{}, errors.New("meshvaultclient: not an ed25519 signature") + } + message := append([]byte(context), msg...) + for _, s := range trusted { + if len(s.Key) != ed25519.PublicKeySize { + continue + } + if ed25519.Verify(s.Key, message, sig) { + return s, nil + } + } + names := make([]string, 0, len(trusted)) + for _, s := range trusted { + names = append(names, s.Name) + } + return Signer{}, fmt.Errorf("meshvaultclient: the signature verifies against no trusted key (%s)", strings.Join(names, ", ")) +} + +// SignMessage is sig over context and msg by key: what the vault's `sign` does for a kind whose bytes are given. +func SignMessage(key ed25519.PrivateKey, context string, msg []byte) []byte { + return ed25519.Sign(key, append([]byte(context), msg...)) +} + +// DigestOf is the digest a `signed` names: `sha256:` of the bytes signed. +func DigestOf(b []byte) string { + sum := sha256.Sum256(b) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// NodeCertificateTemplate is the certificate the vault signs for node's internal name, as the controller's authority +// shaped it (identity.Certify): the name in the subject and the subject alternative names, server and client use, the +// serial derived from the name and the key so the same ask gives the same certificate, and its life from notBefore. +func NodeCertificateTemplate(name string, servingKey ed25519.PublicKey, notBefore time.Time) *x509.Certificate { + sum := sha256.Sum256(append([]byte(name+"\n"), servingKey...)) + return &x509.Certificate{ + SerialNumber: new(big.Int).SetBytes(sum[:16]), + Subject: pkix.Name{CommonName: name}, + DNSNames: []string{name}, + NotBefore: notBefore.UTC().Truncate(time.Second), + NotAfter: notBefore.UTC().Truncate(time.Second).Add(CertificateLife), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + } +} + +// AuthorityTemplate is the vault's authority certificate for its key, made once: an authority that signs leaves and no +// other authority, as the controller's. +func AuthorityTemplate(key ed25519.PublicKey, made time.Time) *x509.Certificate { + sum := sha256.Sum256(append([]byte("authority\n"), key...)) + return &x509.Certificate{ + SerialNumber: new(big.Int).SetBytes(sum[:16]), + Subject: pkix.Name{CommonName: "the mesh's vault"}, + NotBefore: made.UTC().Truncate(time.Second), + NotAfter: made.UTC().Truncate(time.Second).Add(2 * CertificateLife), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + MaxPathLen: 0, + MaxPathLenZero: true, + } +} + +// CheckNodeCertificate says why certificate is not a certificate of name for servingKey by one of the authorities, or +// nil: a node uses it to tell whether its record holds a certificate from an authority it trusts, and another node to +// check one the vault signed (ADR 0550 §2's live check). +func CheckNodeCertificate(certificate string, authorities []string, name string, servingKey ed25519.PublicKey, at time.Time) error { + c, err := ParseCertificate(certificate) + if err != nil { + return err + } + pool := x509.NewCertPool() + for _, a := range authorities { + pool.AppendCertsFromPEM([]byte(a)) // a bundle is every certificate it holds + } + if _, err := c.Verify(x509.VerifyOptions{Roots: pool, DNSName: name, CurrentTime: at, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}); err != nil { + return fmt.Errorf("meshvaultclient: the certificate of %s is not one a trusted authority signed for it: %w", name, err) + } + held, ok := c.PublicKey.(ed25519.PublicKey) + if !ok || !held.Equal(servingKey) { + return fmt.Errorf("meshvaultclient: the certificate of %s binds another key than the node serves with", name) + } + return nil +} + +// AuthorityBundle is the authorities given as one PEM text, each certificate once, in the order given, blanks left out: +// what a module asking for the mesh's authority is given once the vault signs beside the controller (ADR 0550 §2). An +// authority given is split into its certificates, so a bundle given again (the one a node kept from its last +// composition) adds nothing twice; a text that holds no PEM block is taken whole. +func AuthorityBundle(authorities ...string) string { + var out []string + for _, a := range authorities { + for _, piece := range pemPieces(a) { + if !slices.Contains(out, piece) { + out = append(out, piece) + } + } + } + if len(out) == 0 { + return "" + } + return strings.Join(out, "\n") + "\n" +} + +// pemPieces is each PEM block of text re-encoded, or the trimmed text whole when it holds none. +func pemPieces(text string) []string { + text = strings.TrimSpace(text) + if text == "" { + return nil + } + var out []string + rest := []byte(text) + for { + block, more := pem.Decode(rest) + if block == nil { + break + } + out = append(out, strings.TrimSpace(string(pem.EncodeToMemory(block)))) + rest = more + } + if len(out) == 0 { + return []string{text} + } + return out +} + +// Trusts is what a node states it trusts now (the node record's trusts): ` key ` for each signer and +// ` authority ` for each authority certificate, by who holds it, each once, sorted. The gate before the +// lease write reads `vault key ` in every node's record. +func Trusts(signers []Signer, authorities map[string]string) []string { + var out []string + for _, s := range signers { + if len(s.Key) == ed25519.PublicKeySize { + out = append(out, s.Name+" key "+s.Fingerprint()) + } + } + for who, a := range authorities { + if fp, err := AuthorityFingerprint(a); err == nil { + out = append(out, who+" authority "+fp) + } + } + slices.Sort(out) + return slices.Compact(out) +} + +// TrustsKey says whether trusts names the key with fingerprint fp held by who. +func TrustsKey(trusts []string, who, fp string) bool { return slices.Contains(trusts, who+" key "+fp) }