diff --git a/README.md b/README.md index 79cd1f7..ec0b10a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,42 @@ # mesh-contracts -Novox Mesh — contracts: every seat's definition (verbs, events, fixtures) and the Go and TypeScript packages generated from them (ADR 0613). +The mesh's seat contracts (novox/hq ADR 0608 §5, ADR 0613, ADR 0620, ADR 0625, ADR 0627; design 26 *A seat's +definition*): one language-neutral definition per seat, the Go and TypeScript code generated from it, and the client +of a seat whose callers share logic over it. This repository holds contracts only and no seat: a seat's holder +implements its contract, and no holder's code lives here. + +## Where a seat's code goes + +- **The definition**, written by hand: `seats//seat.json`, its fixtures in `seats//fixtures/`, and the + parts several seats include whole in `parts/` (none here now: the registry seats' part is mesh-delivery-contracts'). + mesh-sdk's `seatdef` is the one reader of the format; the indexer and the generators use it. +- **A state**, in the definition's `states` (ADR 0608 §1, ADR 0634 §6): `{name, template, writer, key-forms, history, + max-age-days, description}`. `template` is one of ADR 0595's six (`state`, `events`, `work-queue`, `log`, `objects`, + `ledger`); `writer` is `holder` (the default) or `caller`; each key form is `{key, value, description}`, the key a + pattern of named parts between angle brackets (`.`) and the value its JSON Schema. A definition names no + holder module: a reader takes the holder from its own manifest's `reads`. A fixture of a state is `state:/`. +- **The seat set**, generated: `go/seats` (module `git.novox.be/novox/mesh-contracts/go`), every seat's name, scope, + verbs and provision at the seat list's grain (ADR 0625 §2). mesh-sdk's `catalogue` is its only importer in mesh-sdk. + A change of it rebuilds every importer of the seat set. Its npm package is `ts/` (`@novox/mesh-contracts`, the + seat list alone). +- **A seat defined whole**, generated: `go/seats/`, a Go module of its own importing nothing, + holding the seat's verbs, events and subjects, its message types and a test of its fixtures (ADR 0625 §1). A module + imports only the seats it uses, so a change of one seat rebuilds only its users. Its npm package is + `ts/seats/` (`@novox/mesh-contracts-`), holding the parts it includes whole: the indexer + counts the npm package a lock resolves, so here too a module is rebuilt only for the seats it uses. +- **A seat's client**, written by hand: `go/clients/client`, a Go module of its own, only where + two or more callers share logic over the seat (ADR 0625 §4, ADR 0627 §1): operator-channel's (checking an ask, + its digest, a warrant's, and an asking module's keeping of its asks), node-engine's (checking compose-candidate's ask + and answer). A client imports its own seat's contract and mesh-sdk's runtime, never another seat's client, and names + no module's subject or tool. +- **Not here**: the value layouts of seats not yet defined whole (they stay in mesh-sdk until their domain moves, + ADR 0625 §9), and a role only one module could ever play (that module's own tools and events, ADR 0625 §8). + +## How it is checked + +Every file under `go/` and `ts/` outside `go/clients/` is written by the generator (seatgen, the source of the Go and +Node build modules' generate step) and compared on every build: a hand-written file there fails, named (ADR 0625 §5). +`seatgen -check` (mesh-catalog `modules/mesh-build-go/buildstep/cmd/seatgen`) also runs the client check (each client is for a seat defined whole, a module of its own, importing +only its seat's contract and mesh-sdk) and checks `module.json`'s Go and npm package artifacts against the tree. Made by the mesh from the gitea module's config value `repositories` (novox/hq ADR 0428). Main takes no push: a change reaches it by a pull request whose `mesh/check` passed: the checker's verdict on its head merged onto main, its tests run by their language's convention (novox/hq ADR 0447, ADR 0445). diff --git a/format/seat-definition.schema.json b/format/seat-definition.schema.json new file mode 100644 index 0000000..46a1557 --- /dev/null +++ b/format/seat-definition.schema.json @@ -0,0 +1,490 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.novox.be/novox/mesh-contracts/format/seat-definition.schema.json", + "title": "A seat's definition (novox/hq ADR 0608 \u00a71, design 26), format 1", + "description": "One file per seat, seats//seat.json. A whole definition (grain whole) gives every verb's input and answer, every message's body and where its events are kept; one at the seat list's grain (seat-list) holds what mesh-sdk's compiled seat list held (ADR 0613 \u00a72). At most 256 KiB. Read strictly: a field not named here is refused.", + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "seat", + "scope", + "grain", + "domain", + "decision" + ], + "properties": { + "format": { + "const": 1 + }, + "seat": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$" + }, + "scope": { + "enum": [ + "mesh", + "node" + ] + }, + "grain": { + "enum": [ + "whole", + "seat-list" + ] + }, + "domain": { + "description": "the glossary domain the seat belongs to, by its heading (ADR 0636 \u00a75)", + "enum": [ + "Module", + "Config", + "Core", + "Provisioning", + "Identity and access", + "Delivery", + "Health and repair", + "Data", + "Connectivity", + "Operator and conversation" + ] + }, + "decision": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "holder-declares": { + "type": "boolean", + "description": "the seat is not in the compiled seat list; its holder's manifest declares it" + }, + "delivers": { + "type": "string" + }, + "replicated": { + "type": "boolean" + }, + "pulled-by-kind": { + "type": "boolean" + }, + "yields": { + "type": "boolean" + }, + "held-by-the-engine": { + "type": "boolean" + }, + "includes": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "description": "parts/.json taken in whole" + }, + "verbs": { + "type": "array", + "items": { + "$ref": "#/$defs/verb" + } + }, + "accepts": { + "type": "array", + "items": { + "$ref": "#/$defs/message" + } + }, + "events": { + "type": "array", + "items": { + "$ref": "#/$defs/message" + }, + "description": "published on mesh.seat..event." + }, + "family": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "max-age-days" + ], + "properties": { + "name": { + "type": "string" + }, + "max-age-days": { + "const": 7, + "description": "the shared events stream's age: a seat's events ride it, filtered to the seat's subjects" + } + } + }, + "holder-events": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "subject" + ], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)*$" + }, + "subject": { + "type": "string" + }, + "family": { + "type": "string" + }, + "body": { + "type": "object", + "description": "a JSON Schema" + } + } + }, + "description": "the holder's own events listed beside the seat, keeping the holder's subjects (ADR 0620 \u00a72)" + }, + "states": { + "type": "array", + "description": "the layouts of the state the holder keeps on the bus for the seat (ADR 0608 \u00a71, ADR 0634 \u00a76); a whole definition's. No holder module is named: each reader takes the holder from its own manifest's reads", + "items": { + "$ref": "#/$defs/state" + } + }, + "provisions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "form" + ], + "properties": { + "name": { + "type": "string" + }, + "form": { + "type": "object", + "description": "a JSON Schema" + } + } + } + }, + "receives": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string" + }, + "comment": { + "type": "string" + }, + "dirs": { + "type": "boolean" + }, + "offered": { + "type": "boolean" + }, + "shape": { + "type": "object", + "additionalProperties": false, + "properties": { + "places": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "field" + ], + "properties": { + "field": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "order": { + "type": "string" + }, + "examples": { + "type": "array", + "items": { + "type": "object" + } + }, + "text": { + "type": "boolean" + } + } + } + } + } + }, + "paths": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "pattern": "^/" + }, + "steps-back-for": { + "type": "string" + } + } + } + }, + "defs": { + "type": "object", + "additionalProperties": { + "type": "object", + "description": "a JSON Schema" + } + } + }, + "$defs": { + "verb": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "description": { + "type": "string" + }, + "optional": { + "type": "boolean" + }, + "replaces": { + "type": "array", + "items": { + "type": "string" + } + }, + "input": { + "type": "object", + "description": "a JSON Schema" + }, + "answer": { + "type": "object", + "description": "a JSON Schema" + }, + "callers": { + "type": "string" + } + } + }, + "message": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "body": { + "type": "object", + "description": "a JSON Schema" + }, + "by-caller": { + "type": "boolean" + } + } + }, + "part": { + "description": "parts/.json", + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "part" + ], + "properties": { + "format": { + "const": 1 + }, + "part": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "description": { + "type": "string" + }, + "verbs": { + "type": "array", + "items": { + "$ref": "#/$defs/verb" + } + }, + "events": { + "type": "array", + "items": { + "$ref": "#/$defs/message" + } + }, + "defs": { + "type": "object", + "additionalProperties": { + "type": "object", + "description": "a JSON Schema" + } + } + } + }, + "fixtures": { + "description": "seats//fixtures/*.json: data, never functions", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "of", + "given" + ], + "properties": { + "name": { + "type": "string" + }, + "of": { + "type": "string", + "pattern": "^(verb:[a-z][a-z0-9-]*\\.(input|answer)|accept:[a-z][a-z0-9-]*|event:[a-z][a-z0-9-]*|state:[a-z][a-z0-9-]*/.+|holder-event:[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)*)$" + }, + "subject": { + "type": "string" + }, + "given": {}, + "wire": {}, + "why": { + "type": "string" + } + } + } + }, + "index": { + "description": "seats/index.json: the seat list's reading order", + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "seats" + ], + "properties": { + "format": { + "const": 1 + }, + "seats": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + }, + "state": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "template", + "key-forms" + ], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "template": { + "enum": [ + "state", + "events", + "work-queue", + "log", + "objects", + "ledger" + ], + "description": "ADR 0595 \u00a72" + }, + "writer": { + "enum": [ + "holder", + "caller" + ], + "description": "holder when not given; caller when the seat's callers write their own keys" + }, + "key-forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/keyForm" + } + }, + "history": { + "type": "integer", + "minimum": 0, + "description": "past values a key keeps; the state template only" + }, + "max-age-days": { + "type": "integer", + "minimum": 0, + "description": "days a message is kept; the events, work-queue and log templates only" + }, + "description": { + "type": "string" + } + } + }, + "keyForm": { + "type": "object", + "additionalProperties": false, + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string", + "minLength": 1, + "description": "a pattern of named parts between angle brackets, the rest literal: ." + }, + "value": { + "type": "object", + "description": "a JSON Schema" + }, + "description": { + "type": "string" + } + } + } + } +} diff --git a/go/clients/meshconfigclient/client.go b/go/clients/meshconfigclient/client.go new file mode 100644 index 0000000..9f99ddb --- /dev/null +++ b/go/clients/meshconfigclient/client.go @@ -0,0 +1,83 @@ +// Package meshconfigclient is the logic every reader of the seat mesh-config's state kept shares (novox/hq ADR 0565 +// clause 5, ADR 0634 §4): the checker and the assigner's composition check lay a pull request's own held values over its +// modules' values. The layout of a value is the seat's contract's; this holds what the readers share over it: a pull +// request's key, a held value's key and the rule that a value is the one its key names. +package meshconfigclient + +import ( + "encoding/json" + "fmt" + "strings" + + seat "git.novox.be/novox/mesh-contracts/go/seats/meshconfig" +) + +// PullKey is a pull request's key, `owner/repository#number` with `/` and `#` as dots, the form the seat keeps a pull +// request's values under. The three parts hold no dot, star, angle bracket or space. +func PullKey(pull string) (string, error) { + owner, repository, number, err := splitPull(pull) + if err != nil { + return "", err + } + return owner + "." + repository + "." + number, nil +} + +func splitPull(pull string) (owner, repository, number string, err error) { + owner, rest, ok := strings.Cut(strings.TrimSpace(pull), "/") + repository, number, ok2 := strings.Cut(rest, "#") + if !ok || !ok2 || owner == "" || repository == "" || number == "" || strings.ContainsAny(owner+repository+number, ".*> ") { + return "", "", "", fmt.Errorf("meshconfig: %q is not a pull request, owner/repository#number", pull) + } + return owner, repository, number, nil +} + +// HeldValueKey is a held value's key in the state kept: the pull request's key, the node, the module and the config key, +// joined by dots. +func HeldValueKey(pull, node, module, key string) (string, error) { + owner, repository, number, err := splitPull(pull) + if err != nil { + return "", err + } + if node == "" || module == "" || key == "" { + return "", fmt.Errorf("meshconfig: a held value names a node, a module and a key") + } + return seat.KeptByOwnerRepositoryNumberNodeModuleKeyKey(owner, repository, number, node, module, key), nil +} + +// Kept is one held value, as the seat types it. +type Kept = seat.KeptByOwnerRepositoryNumberNodeModuleKeyValue + +// CheckKept refuses a held value that names no pull request, head, declaration, node, module or key. +func CheckKept(v Kept) error { + if _, err := PullKey(v.Pull); err != nil { + return err + } + if v.Head == "" || v.Declaration == "" || v.Node == "" || v.Module == "" || v.Key == "" { + return fmt.Errorf("meshconfig: a held value of %s names a head, a declaration, a node, a module and a key", v.Pull) + } + return nil +} + +// DecodeKept reads a value of the state kept under its key: the value the seat types, checked, and the one its key +// names. A value kept under another key than its own fields make is refused, so a reader never lays a value over the +// wrong pull request or module. +func DecodeKept(key string, raw []byte) (Kept, error) { + v, err := seat.DecodeKeptByOwnerRepositoryNumberNodeModuleKeyValue(raw) + if err != nil { + return Kept{}, fmt.Errorf("meshconfig: the held value %s is not one: %w", key, err) + } + if err := CheckKept(v); err != nil { + return Kept{}, err + } + want, err := HeldValueKey(v.Pull, v.Node, v.Module, v.Key) + if err != nil { + return Kept{}, err + } + if want != key { + return Kept{}, fmt.Errorf("meshconfig: the held value kept under %s names %s", key, want) + } + return v, nil +} + +// ValueOf is the value held, as the operator approved it, in its JSON form. +func ValueOf(v Kept) (json.RawMessage, error) { return json.Marshal(v.Value) } diff --git a/go/clients/meshconfigclient/client_test.go b/go/clients/meshconfigclient/client_test.go new file mode 100644 index 0000000..ede862f --- /dev/null +++ b/go/clients/meshconfigclient/client_test.go @@ -0,0 +1,45 @@ +package meshconfigclient + +import ( + "strings" + "testing" +) + +// The keys are the earlier form of go/delivery's HeldValueKey and ConfigCandidateKey (golden), which the holder writes. +func TestKeysAreTheEarlierForm(t *testing.T) { + if k, err := HeldValueKey("novox/mesh-catalog#42", "novox", "mail", "quota"); k != "novox.mesh-catalog.42.novox.mail.quota" || err != nil { + t.Errorf("a held value's key is %q, %v", k, err) + } + for pull, want := range map[string]string{"novox/mesh-catalog#42": "novox.mesh-catalog.42", "x#1": "", "a/b": "", "a/b.c#1": ""} { + if got, err := PullKey(pull); got != want || (want == "") != (err != nil) { + t.Errorf("%s keyed %q, %v", pull, got, err) + } + } + if _, err := HeldValueKey("novox/mesh-catalog#42", "", "mail", "quota"); err == nil { + t.Error("a held value without a node was keyed") + } +} + +const held = `{"pull":"novox/mesh-catalog#434","head":"abc1234def","declaration":"sha256:3a7b","node":"ace","module":"n8n","key":"media-gid","value":2000,"by":"the operator","approved":"operator@telegram","at":"2026-11-05T12:30:00Z"}` + +func TestAHeldValueIsTheOneItsKeyNames(t *testing.T) { + key := "novox.mesh-catalog.434.ace.n8n.media-gid" + v, err := DecodeKept(key, []byte(held)) + if err != nil || v.Node != "ace" || v.Value != float64(2000) { + t.Fatalf("read %+v, %v", v, err) + } + if raw, err := ValueOf(v); err != nil || string(raw) != "2000" { + t.Errorf("the value is %s, %v", raw, err) + } + for name, c := range map[string]struct{ key, raw string }{ + "another pull request's key": {"novox.mesh-catalog.435.ace.n8n.media-gid", held}, + "another module's key": {"novox.mesh-catalog.434.ace.plex.media-gid", held}, + "no head": {key, strings.Replace(held, `"head":"abc1234def",`, "", 1)}, + "a field it does not know": {key, strings.Replace(held, `"by"`, `"extra":1,"by"`, 1)}, + "a pull that is not one": {key, strings.Replace(held, "novox/mesh-catalog#434", "novox", 1)}, + } { + if _, err := DecodeKept(c.key, []byte(c.raw)); err == nil { + t.Errorf("%s was read", name) + } + } +} diff --git a/go/clients/meshconfigclient/go.mod b/go/clients/meshconfigclient/go.mod new file mode 100644 index 0000000..69546e4 --- /dev/null +++ b/go/clients/meshconfigclient/go.mod @@ -0,0 +1,5 @@ +module git.novox.be/novox/mesh-contracts/go/clients/meshconfigclient + +go 1.26.0 + +require git.novox.be/novox/mesh-contracts/go/seats/meshconfig v0.0.0-00010101000000-000000000000 diff --git a/go/clients/meshmoduleassignerclient/assignments.go b/go/clients/meshmoduleassignerclient/assignments.go new file mode 100644 index 0000000..354c918 --- /dev/null +++ b/go/clients/meshmoduleassignerclient/assignments.go @@ -0,0 +1,292 @@ +// 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 `assignments` assembled into a +// node's assignments, who stands on which node since when, who holds a seat, and what a assignments 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/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 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 +} + +// CheckAssignments refuses a assignments 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 CheckAssignments(p seat.Assignments) 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 assignments 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 assignments 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 assignments 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 +} + +// EncodeAssignments is p as JSON at Version; it refuses what DecodeAssignments refuses. +func EncodeAssignments(p seat.Assignments) ([]byte, error) { + if err := CheckAssignments(p); err != nil { + return nil, err + } + p.V = Version + return json.Marshal(p) +} + +// DecodeAssignments reads a assignments file or key value (the form before the node and module keys). +func DecodeAssignments(data []byte) (seat.Assignments, error) { + if err := checkVersion(data); err != nil { + return seat.Assignments{}, err + } + var p seat.Assignments + if err := json.Unmarshal(data, &p); err != nil { + return seat.Assignments{}, fmt.Errorf("meshmoduleassignerclient: value does not fit its shape: %w", err) + } + if err := CheckAssignments(p); err != nil { + return seat.Assignments{}, err + } + return p, nil +} + +// SameAssignments says whether two assignments are the same value, so the holder writes only when they differ. +func SameAssignments(a, b seat.Assignments) bool { + a.Revision, b.Revision = "", "" + ja, errA := EncodeAssignments(a) + jb, errB := EncodeAssignments(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.Assignments) []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.Assignments) 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.Assignments) 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.Assignments) 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 assignments; 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.Assignments) []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(assignments map[string]seat.Assignments) []string { + seen := map[string]bool{} + out := []string{} + for _, p := range assignments { + 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 +} diff --git a/go/clients/meshmoduleassignerclient/client_test.go b/go/clients/meshmoduleassignerclient/client_test.go new file mode 100644 index 0000000..cbc750a --- /dev/null +++ b/go/clients/meshmoduleassignerclient/client_test.go @@ -0,0 +1,236 @@ +package meshmoduleassignerclient + +import ( + "errors" + "fmt" + "reflect" + "strings" + "testing" + + seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func TestAssignmentsClient(t *testing.T) { + t.Run("proposal id", func(t *testing.T) { + for id, ok := range map[string]bool{"p-20261028t183000-1a2b3c": true, "abc": true, "ab": false, "A-bc": false, "-abc": false, "a.bc": false, "": false} { + if (CheckProposalID(id) == nil) != ok { + t.Errorf("CheckProposalID(%q) = %v", id, CheckProposalID(id)) + } + } + }) + t.Run("key and file", func(t *testing.T) { + // Research 072 row 37: one node key and one key per module, any other refused. + for key, want := range map[string]struct { + node, module string + kind KeyKind + }{"g14.node": {"g14", "", NodeKind}, "g14.app": {"g14", "app", ModuleKind}} { + 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", "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) + } + for name, err := range map[string]error{"a dotted node": func() error { _, err := NodeKey("a.b"); 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.Assignments{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.Assignments{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-example", Scope: "mesh", Module: "postgres"}}} + + t.Run("value", func(t *testing.T) { + raw, err := EncodeAssignments(g14) + if err != nil { + t.Fatal(err) + } + back, err := DecodeAssignments(raw) + if err != nil || back.V != Version || !SameAssignments(back, g14) { + t.Fatalf("round trip: %+v, %v", back, err) + } + moved := g14 + moved.Revision = "def" + if !SameAssignments(g14, moved) { + t.Error("a new revision alone counts as a change") + } + if _, err := DecodeAssignments([]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 := DecodeAssignments([]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.Assignments{Node: "x"} + if ChosenPins(empty) != nil || Bound(empty) != nil || HostPorts(empty) != nil || len(Held(empty)) != 0 { + t.Error("an empty assignments reads as something") + } + }) + + // Research 072 row 37: a node's assignments as its node key and module keys, written so a reader never takes a part of + // a write, + t.Run("one key per module and node", func(t *testing.T) { + values, err := AssignmentsValues(g14) + if err != nil || len(values) != 3 || values["g14.node"] == nil || values["g14.zsh"] == nil || values["g14.app"] == nil { + t.Fatalf("AssignmentsValues = %v, %v", values, err) + } + got, err := AssignmentsOf(values) + if err != nil || !SameAssignments(got["g14"], g14) || len(got) != 1 { + t.Fatalf("AssignmentsOf = %+v, %v", got, err) + } + if got := InsideAddresses(map[string]seat.Assignments{"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) + } + 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) + } + } + } + // A node written whole onto no keys: its module keys, then its node key. + rev++ + first, err := keys.Writes("g14", &g14) + if err != nil || len(first) != 3 || !first[2].Commit { + t.Fatalf("Writes = %+v, %v", first, err) + } + apply(first, -1) + if p, r, ok := keys.Of("g14"); !ok || !SameAssignments(p, g14) || r != rev { + t.Fatalf("Of after the write = %+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 := cloneAssignments(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 g14.zsh:false:2" { + t.Fatalf("Writes = %v", kinds) + } + apply(writes, 2) + if p, _, _ := keys.Of("g14"); !SameAssignments(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"); !SameAssignments(p, after) { + t.Errorf("the write done is not read whole: %+v", p) + } + // A module key that stays is not written again. + if again, _ := keys.Writes("g14", &after); len(again) != 1 || !again[0].Commit { + t.Errorf("an unchanged assignments writes %+v", again) + } + // Keys that do not agree keep what the node had, and say so. + stray, _ := EncodeModule(seat.AssignmentsByNodeModuleValue{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"); !SameAssignments(p, after) { + t.Errorf("a stray module key moved the assignments: %+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 assignments: the node key first, 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, " ") != "g14.node g14.app g14.new" { + t.Errorf("Writes(nil) = %v", kinds) + } + apply(gone, 1) + 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/go/clients/meshmoduleassignerclient/go.mod b/go/clients/meshmoduleassignerclient/go.mod new file mode 100644 index 0000000..5ba273a --- /dev/null +++ b/go/clients/meshmoduleassignerclient/go.mod @@ -0,0 +1,8 @@ +module git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 +) diff --git a/go/clients/meshmoduleassignerclient/holders.go b/go/clients/meshmoduleassignerclient/holders.go new file mode 100644 index 0000000..8e0f845 --- /dev/null +++ b/go/clients/meshmoduleassignerclient/holders.go @@ -0,0 +1,53 @@ +package meshmoduleassignerclient + +import ( + "encoding/json" + "fmt" + "strings" + + seat "git.novox.be/novox/mesh-contracts/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/go/clients/meshmoduleassignerclient/holders_test.go b/go/clients/meshmoduleassignerclient/holders_test.go new file mode 100644 index 0000000..17d904d --- /dev/null +++ b/go/clients/meshmoduleassignerclient/holders_test.go @@ -0,0 +1,65 @@ +package meshmoduleassignerclient + +import ( + "testing" + "time" + + seat "git.novox.be/novox/mesh-contracts/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-example.ace": []byte(`{"v":1,"seat":"node-example","scope":"node","node":"ace","holders":[{"module":"node-example","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-example", "ace", 1, false}, {"node-example", "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.AssignmentsByNodeNodeValue{Node: "ace"} + for _, name := range modules { + n.Modules = append(n.Modules, seat.AssignmentsByNodeNodeValueModulesItem{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/go/clients/meshmoduleassignerclient/keys.go b/go/clients/meshmoduleassignerclient/keys.go new file mode 100644 index 0000000..72a01a5 --- /dev/null +++ b/go/clients/meshmoduleassignerclient/keys.go @@ -0,0 +1,449 @@ +package meshmoduleassignerclient + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "sync" + + seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" +) + +// **One key per module and node** (research 072 row 37; issue 774). The seat's state `assignments` keeps a node's assignments +// 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. + +// KeyKind is the kind of a key of the state `assignments`. +type KeyKind int + +// The kinds of key: a node key and a module key. +const ( + NodeKind KeyKind = iota + 1 + ModuleKind +) + +// NodeKey is a node's node key, `.node`. +func NodeKey(node string) (string, error) { + if err := checkAssignmentsNode(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 := checkAssignmentsNode(node); err != nil { + return "", err + } + if err := checkAssignmentsModule(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 assignments. +func NodeKeys(node string) (string, error) { + if err := checkAssignmentsNode(node); err != nil { + return "", err + } + return node + ".>", nil +} + +// ParseKey reads a key of the state `assignments` its node, its module (empty for a node 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 assignments key, .node or .", key) + } + switch { + case rest == NodeWord: + return first, "", NodeKind, nil + default: + return first, rest, ModuleKind, nil + } +} + +func checkAssignmentsNode(node string) error { + if err := checkName("node", node); err != nil { + return err + } + return nil +} + +func checkAssignmentsModule(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.AssignmentsByNodeNodeValue) ([]byte, error) { + if err := checkAssignmentsNode(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.AssignmentsByNodeNodeValue, error) { + if err := checkVersion(data); err != nil { + return seat.AssignmentsByNodeNodeValue{}, err + } + n, err := seat.DecodeAssignmentsByNodeNodeValue(data) + if err != nil { + return n, fmt.Errorf("meshmoduleassignerclient: value does not fit its shape: %w", err) + } + if err := checkAssignmentsNode(n.Node); err != nil { + return seat.AssignmentsByNodeNodeValue{}, err + } + seen := map[string]bool{} + for _, r := range n.Modules { + if err := checkAssignmentsModule(r.Module); err != nil { + return seat.AssignmentsByNodeNodeValue{}, err + } + if seen[r.Module] || r.Digest == "" { + return seat.AssignmentsByNodeNodeValue{}, 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.AssignmentsByNodeModuleValue) ([]byte, error) { + if err := checkAssignmentsNode(m.Node); err != nil { + return nil, err + } + if err := checkAssignmentsModule(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.AssignmentsByNodeModuleValue, error) { + if err := checkVersion(data); err != nil { + return seat.AssignmentsByNodeModuleValue{}, err + } + m, err := seat.DecodeAssignmentsByNodeModuleValue(data) + if err != nil { + return m, fmt.Errorf("meshmoduleassignerclient: value does not fit its shape: %w", err) + } + if err := checkAssignmentsNode(m.Node); err != nil { + return seat.AssignmentsByNodeModuleValue{}, err + } + if err := checkAssignmentsModule(m.Placed.Module); err != nil { + return seat.AssignmentsByNodeModuleValue{}, err + } + return m, nil +} + +// Keys is the state `assignments` as a reader holds it, key by key in either form, and every node's assignments +// 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 +// assignments 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.AssignmentsByNodeNodeValue + nodeRev uint64 + modules map[string]seat.AssignmentsByNodeModuleValue + moduleRevs map[string]uint64 + last *seat.Assignments // the assignments last assembled whole, nil when none +} + +// NewKeys holds no key yet. +func NewKeys() *Keys { return &Keys{nodes: map[string]*keysOf{}} } + +// AssignmentsOf is every node's assignments 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 AssignmentsOf(keys map[string][]byte) (map[string]seat.Assignments, 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.AssignmentsByNodeModuleValue{}, 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 + } + } + 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 assignments 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.Assignments, bool, error) { + if of.node == nil { + return nil, true, nil + } + n := of.node + p := seat.Assignments{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 assignments it had", + node, strings.Join(astray, ", "), node) + } + return &p, true, nil +} + +// Of is node's assignments and the revision of the key it was read from (its node key), and whether +// any key places it. +func (k *Keys) Of(node string) (seat.Assignments, uint64, bool) { + k.mu.Lock() + defer k.mu.Unlock() + of := k.nodes[node] + if of == nil || of.last == nil { + return seat.Assignments{}, 0, false + } + return cloneAssignments(*of.last), of.nodeRev, true +} + +// All is every node's assignments, by node, and the revision of each one's key (Of). +func (k *Keys) All() map[string]seat.Assignments { + out, _ := k.AllRevisions() + return out +} + +// AllRevisions is All with the revision of each node's key. +func (k *Keys) AllRevisions() (map[string]seat.Assignments, 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.Assignments{}, 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 `assignments`, 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 assignments is the new one. + Commit bool +} + +// Writes are the writes that take node from the assignments its keys hold to after (nil: no assignments), 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 deletes of the module keys after no longer names. For no assignments the node key goes first, then the module +// keys. A module key whose value stays is not written. +func (k *Keys) Writes(node string, after *seat.Assignments) ([]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.AssignmentsByNodeModuleValue{}, 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.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 assignments written under %s names the node %q", node, after.Node) + } + if err := CheckAssignments(*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.AssignmentsByNodeNodeValue{Node: node, Decided: after.Decided, Pins: after.Pins, Holds: after.Holds, Modules: make([]seat.AssignmentsByNodeNodeValueModulesItem, 0, len(after.Modules))} + keep := map[string]bool{} + for _, m := range after.Modules { + if err := checkAssignmentsModule(m.Module); err != nil { + return nil, err + } + digest := Digest(m) + n.Modules = append(n.Modules, seat.AssignmentsByNodeNodeValueModulesItem{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.AssignmentsByNodeModuleValue{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}) + deleteModules(keep) + return out, nil +} + +// AssignmentsValues is every key and value of p written whole onto no keys before: its module keys and its node key, as a writer that holds no key of the node (a test's bus, a fresh mesh) writes them. +func AssignmentsValues(p seat.Assignments) (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 +} + +// cloneAssignments is p with its own maps and slices, so a reader's copy is not a held one. +func cloneAssignments(p seat.Assignments) seat.Assignments { + raw, _ := json.Marshal(p) + var out seat.Assignments + _ = json.Unmarshal(raw, &out) + if out.Modules == nil { + out.Modules = []seat.PlacedModule{} + } + return out +} diff --git a/go/clients/meshmoduleassignerclient/ops.go b/go/clients/meshmoduleassignerclient/ops.go new file mode 100644 index 0000000..2343936 --- /dev/null +++ b/go/clients/meshmoduleassignerclient/ops.go @@ -0,0 +1,39 @@ +package meshmoduleassignerclient + +// The operations a proposal's change names (novox/hq ADR 0567 §2), the words of a change's `op`. The seat's contract +// carries the field; its callers (the enrolment builds OpAddNode and OpRemoveNode) and its holder share the words here. +const ( + // OpPlace places a module on a node, with its host ports and bindings. + OpPlace = "place" + // OpTakeOff takes a module off a node. + OpTakeOff = "take-off" + // OpPin pins the provider of a provision on a node; OpUnpin removes a pin. + OpPin = "pin" + OpUnpin = "unpin" + // OpHold says a module placed on the node holds a seat; OpUnhold, that it holds it no more. + OpHold = "hold" + OpUnhold = "unhold" + // OpDecide sets a decided field of the node (NodeDecided by its JSON name), its address among them. + OpDecide = "decide" + // OpConfig sets a module's config assignments or values on the node. + OpConfig = "config" + // OpSeed is the node's whole assignments: the take-over (adopt, §7), never given by a caller. + OpSeed = "seed" + // OpAddNode adds a joining node with its decided fields and the public halves of its keys, on the warrant of the + // enrolment that issued its token (novox/hq ADR 0587 §7): the enroller's alone, the proposal's one change. + OpAddNode = "add-node" + // OpRemoveNode takes a joining node off assignments again when its enrolment ends unconfirmed, revoked or expired: the + // enroller's alone, on the same warrant, the proposal's one change, of a node with no module and no holding. + OpRemoveNode = "remove-node" +) + +// The states of an open proposal (the field `state` of the state `proposals`). +const ( + // ProposalAnswering: its candidates are written and the nodes it must hear from have not all answered. + ProposalAnswering = "answering" + // ProposalFailed: its verdict failed; it stays open, for its proposer to read and withdraw, or for a node's later + // answer to judge it again. + ProposalFailed = "failed" + // ProposalAsked: its verdict passed and the operator is asked to approve it. + ProposalAsked = "asked" +) diff --git a/go/clients/meshmoduleassignerclient/ops_test.go b/go/clients/meshmoduleassignerclient/ops_test.go new file mode 100644 index 0000000..d935baa --- /dev/null +++ b/go/clients/meshmoduleassignerclient/ops_test.go @@ -0,0 +1,14 @@ +package meshmoduleassignerclient + +import "testing" + +// The words are the wire's: a holder and a caller that spell one differently refuse each other's proposals. +func TestOpsAndProposalStatesAreTheWire(t *testing.T) { + for got, want := range map[string]string{OpPlace: "place", OpTakeOff: "take-off", OpPin: "pin", OpUnpin: "unpin", OpHold: "hold", + OpUnhold: "unhold", OpDecide: "decide", OpConfig: "config", OpSeed: "seed", OpAddNode: "add-node", OpRemoveNode: "remove-node", + ProposalAnswering: "answering", ProposalFailed: "failed", ProposalAsked: "asked"} { + if got != want { + t.Errorf("%q, want %q", got, want) + } + } +} diff --git a/go/clients/meshmoduleassignerclient/placed.go b/go/clients/meshmoduleassignerclient/placed.go new file mode 100644 index 0000000..2f6a115 --- /dev/null +++ b/go/clients/meshmoduleassignerclient/placed.go @@ -0,0 +1,140 @@ +package meshmoduleassignerclient + +import ( + "fmt" + "maps" + "slices" + "sync" + "time" + + seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" +) + +// Placed is where the assignments (the seat mesh-assignments's state `assignments`, 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, a node's node key +// names its modules (a module key says nothing of which stand). 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 +} + +// NewPlaced is a Placed that holds no assignments yet. +func NewPlaced() *Placed { + return &Placed{since: map[string]map[string]time.Time{}, heard: map[string]bool{}} +} + +// Put takes one revision of a key of the state `assignments` (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 + } + now := map[string]bool{} + if !deleted { + var modules []string + var derr error + var named string + var n seat.AssignmentsByNodeNodeValue + n, derr = DecodeNode(value) + named = n.Node + for _, r := range n.Modules { + modules = append(modules, r.Module) + } + switch { + case derr != nil: + err = fmt.Errorf("the assignments of %s does not read, so it places nothing there: %w", node, derr) + case named != node: + err = fmt.Errorf("the assignments 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 assignments is held: before the assigner's adopt writes the first assignments 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/go/clients/meshmoduleassignerclient/proposal.go b/go/clients/meshmoduleassignerclient/proposal.go new file mode 100644 index 0000000..e715447 --- /dev/null +++ b/go/clients/meshmoduleassignerclient/proposal.go @@ -0,0 +1,17 @@ +package meshmoduleassignerclient + +import ( + "fmt" + "regexp" +) + +var proposalIDOK = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,62}$`) + +// CheckProposalID refuses an id that is no proposal's: lower-case letters, digits and dashes, 3 to 63 of them, so it is one +// key token. The holder writes proposals under it, and the keys of the asks about a proposal (`assignments.`) carry it. +func CheckProposalID(id string) error { + if !proposalIDOK.MatchString(id) { + return fmt.Errorf("mesh-module-assigner: %q is no proposal id (lower-case letters, digits and dashes, one key token)", id) + } + return nil +} diff --git a/go/clients/meshmoduleassignerclient/reads.go b/go/clients/meshmoduleassignerclient/reads.go new file mode 100644 index 0000000..8189ddd --- /dev/null +++ b/go/clients/meshmoduleassignerclient/reads.go @@ -0,0 +1,19 @@ +package meshmoduleassignerclient + +import "sort" + +// MachineNames is every machine the seat places modules on, sorted, from its state `assignments` as a reader holds its +// keys: each with a node key `.node`. Module keys and keys of the +// state's other kinds are passed over. +func MachineNames(keys map[string][]byte) []string { + seen := map[string]bool{} + out := []string{} + for k := range keys { + if node, _, kind, err := ParseKey(k); err == nil && kind != ModuleKind && !seen[node] { + seen[node] = true + out = append(out, node) + } + } + sort.Strings(out) + return out +} diff --git a/go/clients/meshmoduleassignerclient/reads_test.go b/go/clients/meshmoduleassignerclient/reads_test.go new file mode 100644 index 0000000..4b2d078 --- /dev/null +++ b/go/clients/meshmoduleassignerclient/reads_test.go @@ -0,0 +1,13 @@ +package meshmoduleassignerclient + +import ( + "reflect" + "testing" +) + +func TestMachineNamesAreTheNodesAKeyNames(t *testing.T) { + keys := map[string][]byte{"g14.node": nil, "g14.app": nil, "ace.node": nil, "want.x.y": nil, "bad": nil} + if got, want := MachineNames(keys), []string{"ace", "g14"}; !reflect.DeepEqual(got, want) { + t.Errorf("MachineNames = %v, want %v", got, want) + } +} diff --git a/go/clients/nodeengineclient/client.go b/go/clients/nodeengineclient/client.go new file mode 100644 index 0000000..b39982e --- /dev/null +++ b/go/clients/nodeengineclient/client.go @@ -0,0 +1,218 @@ +// Package nodeengineclient is the node-engine seat's client (novox/hq ADR 0627 §1, ADR 0625 §4): the checks its callers +// share over the verb compose-candidate's ask and answer (ADR 0593 §7, §9), over the seat's generated contract. The +// node-engine encodes its answer through it, and the checker and the assigner read answers and the asker's bound +// through it. The composing is the node-engine's own; the call is the caller's own, so it holds no transport. +package nodeengineclient + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + "time" + + seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +const ( + // AnswerBound is the default bound an asker of compose-candidate waits for a node's answer (the checker's setting + // compose-answer-bound, ADR 0593 §7, §9). + AnswerBound = 60 * time.Second + // NoAnswer is the words a check's verdict names a node with that did not answer within the bound (ADR 0593 §9). + NoAnswer = "not composed on that node: no answer" + + // Composed and Refused are an answer's verdict: no module and not the node's own composition is refused by the + // candidate, or at least one is (or the node could not compose with it at all, the answer's Refused). + Composed = "composed" + Refused = "refused" + // Unready is the verdict of a node that cannot compose yet (its shadow not ready, its agent starting): no module is + // named, Refused says why in words, and the checker takes it as a node that did not answer (NoAnswer), deciding by + // this verdict and never by the words. + Unready = "unready" + // NotJudged is the verdict for a node the checker's sandbox composed and could not judge (an older node-engine's + // input that does not say how it composes, ADR 0593 §9): no module is named, Refused says why in words, and the + // checker names the node with them and passes the check with that warning (ADR 0601 §4: a contract is a field, not + // a phrase). + NotJudged = "not-judged" + + // The states of one module's composition with the candidate: composed (Digest set), refused by the candidate + // (Words set), or held, refused without the candidate too (Words set), which counts toward no verdict. + ModuleComposed = "composed" + ModuleRefused = "refused" + ModuleHeld = "held" + + // Own is the Module name of the node's own composition in an answer. + Own = "node" + + // Version is the version an answer is written at and the only one read. + Version = 1 +) + +// ErrUnknownVersion is what DecodeAnswer returns for an answer whose version is missing or not Version. +var ErrUnknownVersion = errors.New("node-engine: unknown compose-candidate answer version") + +var nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`) + +func checkAsk(a seat.ComposeCandidateInput) error { + if _, _, err := ParseCompositionKey(a.Candidate); err != nil { + return fmt.Errorf("node-engine: a compose-candidate ask names no candidate: %w", err) + } + return nil +} + +func checkModule(m seat.Module, node string) error { + if m.Module == "" { + return fmt.Errorf("node-engine: a module's composition on %s names no module", node) + } + switch m.State { + case ModuleComposed: + if m.Digest == "" { + return fmt.Errorf("node-engine: %s is composed on %s and names no digest", m.Module, node) + } + case ModuleRefused, ModuleHeld: + if m.Words == "" { + return fmt.Errorf("node-engine: %s is %s on %s and says no why", m.Module, m.State, node) + } + default: + return fmt.Errorf("node-engine: %s on %s is %q, neither %s, %s nor %s", m.Module, node, m.State, + ModuleComposed, ModuleRefused, ModuleHeld) + } + return nil +} + +// CheckAnswer refuses an answer naming no node or candidate, a negative time taken, an unknown module state, a composed +// module without a digest, a refused or held one without words, modules not sorted and each once, a refusal of the +// whole naming modules, an unready or not-judged one saying no why, and a verdict the modules do not give. +func CheckAnswer(a seat.ComposeCandidateAnswer) error { + if a.Node == "" || !nameOK.MatchString(a.Node) { + return fmt.Errorf("node-engine: the answer's node name %q is empty or holds a dot, a wildcard, whitespace or a character a key refuses", a.Node) + } + if _, _, err := ParseCompositionKey(a.Candidate); err != nil { + return err + } + if a.Took < 0 { + return fmt.Errorf("node-engine: the answer of %s took %d ms", a.Node, a.Took) + } + if (a.Verdict == Unready || a.Verdict == NotJudged) && a.Refused == "" { + return fmt.Errorf("node-engine: %s's answer is %s and says no why", a.Node, a.Verdict) + } + if a.Refused != "" { + if len(a.Modules) > 0 || a.Verdict != Refused && a.Verdict != Unready && a.Verdict != NotJudged { + return fmt.Errorf("node-engine: %s could not compose the candidate (%s), and its answer names modules or is neither %s, %s nor %s", + a.Node, a.Refused, Refused, Unready, NotJudged) + } + return nil + } + refused := false + for i, m := range append([]seat.Module{a.Own}, a.Modules...) { + if err := checkModule(m, a.Node); err != nil { + return err + } + if i > 1 && a.Modules[i-2].Module >= m.Module { + return fmt.Errorf("node-engine: the modules of %s's answer are not sorted and each once (%s after %s)", a.Node, m.Module, a.Modules[i-2].Module) + } + refused = refused || m.State == ModuleRefused + } + switch { + case a.Own.Module != Own: + return fmt.Errorf("node-engine: the node's own composition in %s's answer is named %q, not %q", a.Node, a.Own.Module, Own) + case refused && a.Verdict != Refused: + return fmt.Errorf("node-engine: %s's answer refuses a module and its verdict is %q", a.Node, a.Verdict) + case !refused && a.Verdict != Composed: + return fmt.Errorf("node-engine: %s's answer refuses no module and its verdict is %q", a.Node, a.Verdict) + } + return nil +} + +// strictDecode reads data into v, refusing an unknown key. +func strictDecode(data []byte, v any, what string) error { + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + d.UseNumber() // a number in an object the contract leaves open (the composition input) keeps its digits as written + if err := d.Decode(v); err != nil { + return fmt.Errorf("node-engine: %s does not fit its shape: %w", what, err) + } + return nil +} + +// EncodeAsk is a as JSON; it refuses an ask naming no candidate key. +func EncodeAsk(a seat.ComposeCandidateInput) ([]byte, error) { + if err := checkAsk(a); err != nil { + return nil, err + } + return json.Marshal(a) +} + +// DecodeAsk reads an ask strictly; it refuses an unknown key and what EncodeAsk refuses. +func DecodeAsk(data []byte) (seat.ComposeCandidateInput, error) { + var a seat.ComposeCandidateInput + if err := strictDecode(data, &a, "a compose-candidate ask"); err != nil { + return seat.ComposeCandidateInput{}, err + } + return a, checkAsk(a) +} + +// EncodeAnswer is a as JSON at Version; it refuses what CheckAnswer refuses. +func EncodeAnswer(a seat.ComposeCandidateAnswer) ([]byte, error) { + if err := CheckAnswer(a); err != nil { + return nil, err + } + a.V = Version + return json.Marshal(a) +} + +// DecodeAnswer reads an answer strictly; it refuses an unknown version (ErrUnknownVersion), an unknown key and what +// CheckAnswer refuses. +func DecodeAnswer(data []byte) (seat.ComposeCandidateAnswer, error) { + var probe struct { + V *int64 `json:"v"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return seat.ComposeCandidateAnswer{}, fmt.Errorf("node-engine: a compose-candidate answer is not JSON: %w", err) + } + if probe.V == nil || *probe.V != Version { + return seat.ComposeCandidateAnswer{}, fmt.Errorf("%w: this reader reads %d", ErrUnknownVersion, Version) + } + var a seat.ComposeCandidateAnswer + if err := strictDecode(data, &a, "a compose-candidate answer"); err != nil { + return seat.ComposeCandidateAnswer{}, err + } + return a, CheckAnswer(a) +} + +var heads12 = regexp.MustCompile(`^[0-9a-f]{12}$`) + +// CompositionKey is the key of a candidate the node is asked to compose: the group's check at its heads, `.`, the group escaped as a key part and the heads hash 12 lowercase hex digits (the checker's verdict +// key of the same check). +func CompositionKey(group, headsHash string) (string, error) { + if group == "" { + return "", errors.New("node-engine: the group name is empty") + } + if !heads12.MatchString(headsHash) { + return "", fmt.Errorf("node-engine: %q is not a heads hash, 12 lowercase hex digits", headsHash) + } + return keys.EscapeName(group) + "." + headsHash, nil +} + +// ParseCompositionKey is the inverse of CompositionKey; it refuses a key that is not one, naming it. +func ParseCompositionKey(key string) (group, headsHash string, err error) { + i := strings.LastIndex(key, ".") + if i < 0 { + return "", "", fmt.Errorf("node-engine: %q is not a composition key, .", key) + } + part, headsHash := key[:i], key[i+1:] + if !heads12.MatchString(headsHash) { + return "", "", fmt.Errorf("node-engine: %q is not a composition key: %q is not a heads hash, 12 lowercase hex digits", key, headsHash) + } + if part == "" || strings.Contains(part, ".") { + return "", "", fmt.Errorf("node-engine: %q is not a composition key: the group part is empty or holds a dot", key) + } + if group, err = keys.UnescapeName(part); err != nil { + return "", "", fmt.Errorf("node-engine: %q is not a composition key: %w", key, err) + } + return group, headsHash, nil +} diff --git a/go/clients/nodeengineclient/client_test.go b/go/clients/nodeengineclient/client_test.go new file mode 100644 index 0000000..1586968 --- /dev/null +++ b/go/clients/nodeengineclient/client_test.go @@ -0,0 +1,115 @@ +package nodeengineclient + +import ( + "errors" + "fmt" + "reflect" + "strings" + "testing" + + seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" +) + +// The verb compose-candidate's ask and answer (ADR 0593 §7, §9): each round-trips, and a reader refuses what it could +// not take, naming it. +func TestComposeCandidateRoundTripsAndRefuses(t *testing.T) { + key, err := CompositionKey("mesh-sdk#7", "0123456789ab") + if err != nil { + t.Fatal(err) + } + raw, err := EncodeAsk(seat.ComposeCandidateInput{Candidate: key, Input: true}) + if err != nil { + t.Fatal(err) + } + if a, err := DecodeAsk(raw); err != nil || a.Candidate != key || !a.Input { + t.Fatalf("ask round trip: %v, %+v", err, a) + } + if _, err := DecodeAsk([]byte(`{"candidate":"` + key + `","other":1}`)); err == nil { + t.Error("an ask with an unknown key was read") + } + if _, err := EncodeAsk(seat.ComposeCandidateInput{Candidate: "nope"}); err == nil { + t.Error("an ask naming no candidate key was encoded") + } + + good := seat.ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: Refused, Took: 812, + Own: seat.Module{Module: Own, State: ModuleComposed, Digest: "sha256:00"}, + Modules: []seat.Module{ + {Module: "netcheck", Fp: "src2:x", State: ModuleComposed, Digest: "sha256:01", Changed: true}, + {Module: "route-proxy", State: ModuleRefused, Words: "the setting domain has no value"}, + {Module: "zz", State: ModuleHeld, Words: "held before"}, + }, Input: map[string]any{"machines": []any{"ace"}}} + raw, err = EncodeAnswer(good) + if err != nil { + t.Fatal(err) + } + back, err := DecodeAnswer(raw) + if err != nil || back.Verdict != Refused || len(back.Modules) != 3 || !reflect.DeepEqual(back.Input, good.Input) { + t.Fatalf("answer round trip: %v, %+v", err, back) + } + if _, err := DecodeAnswer([]byte(strings.Replace(string(raw), `"took"`, `"other":1,"took"`, 1))); err == nil { + t.Error("an answer with an unknown key was read") + } + if _, err := DecodeAnswer([]byte(strings.Replace(string(raw), `"v":1`, `"v":2`, 1))); !errors.Is(err, ErrUnknownVersion) { + t.Errorf("an answer at another version read as %v", err) + } + numbered := strings.Replace(string(raw), `"machines":["ace"]`, `"bound":12345678901234567890`, 1) + if back, err := DecodeAnswer([]byte(numbered)); err != nil || fmt.Sprint(back.Input["bound"]) != "12345678901234567890" { + t.Errorf("an open input's number did not keep its digits: %v, %+v", err, back.Input) + } + + whole := seat.ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: Refused, Refused: "the bound passed"} + unready := seat.ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: Unready, Refused: "the agent is starting"} + notJudged := seat.ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: NotJudged, Refused: "its node-engine sends no running state"} + for _, a := range []seat.ComposeCandidateAnswer{whole, unready, notJudged} { + if raw, err := EncodeAnswer(a); err != nil { + t.Errorf("a %s answer was not encoded: %v", a.Verdict, err) + } else if back, err := DecodeAnswer(raw); err != nil || back.Verdict != a.Verdict { + t.Errorf("a %s answer read back as %+v, %v", a.Verdict, back, err) + } + } + + for name, c := range map[string]struct { + from seat.ComposeCandidateAnswer + mutate func(a *seat.ComposeCandidateAnswer) + }{ + "verdict not the modules'": {good, func(a *seat.ComposeCandidateAnswer) { a.Verdict = Composed }}, + "unknown state": {good, func(a *seat.ComposeCandidateAnswer) { a.Modules[0].State = "fine" }}, + "composed without digest": {good, func(a *seat.ComposeCandidateAnswer) { a.Modules[0].Digest = "" }}, + "refused without words": {good, func(a *seat.ComposeCandidateAnswer) { a.Modules[1].Words = "" }}, + "unsorted": {good, func(a *seat.ComposeCandidateAnswer) { a.Modules[0], a.Modules[1] = a.Modules[1], a.Modules[0] }}, + "own misnamed": {good, func(a *seat.ComposeCandidateAnswer) { a.Own.Module = "ace" }}, + "whole refusal with modules": {good, func(a *seat.ComposeCandidateAnswer) { a.Refused = "the bound passed" }}, + "node with a dot": {good, func(a *seat.ComposeCandidateAnswer) { a.Node = "a.b" }}, + "negative took": {good, func(a *seat.ComposeCandidateAnswer) { a.Took = -1 }}, + "unready saying no why": {unready, func(a *seat.ComposeCandidateAnswer) { a.Refused = "" }}, + "unready naming modules": {unready, func(a *seat.ComposeCandidateAnswer) { a.Modules = good.Modules }}, + "not judged saying no why": {notJudged, func(a *seat.ComposeCandidateAnswer) { a.Refused = "" }}, + "not judged naming modules": {notJudged, func(a *seat.ComposeCandidateAnswer) { a.Modules = good.Modules }}, + } { + a := c.from + a.Modules = append([]seat.Module(nil), c.from.Modules...) + c.mutate(&a) + if _, err := EncodeAnswer(a); err == nil { + t.Errorf("%s: encoded", name) + } + } +} + +// A composition key round-trips its group and heads hash, and refuses what is not one. +func TestCompositionKeyRoundTripsAndRefuses(t *testing.T) { + key, err := CompositionKey("fix/1.2", "0123456789ab") + if err != nil { + t.Fatal(err) + } + if g, h, err := ParseCompositionKey(key); err != nil || g != "fix/1.2" || h != "0123456789ab" { + t.Fatalf("round trip: %q %q %v", g, h, err) + } + for _, bad := range []string{"", "nogroup", "g.0123456789AB", "g.short", ".0123456789ab", "a.b.0123456789ab"} { + if _, _, err := ParseCompositionKey(bad); err == nil { + t.Errorf("%q was taken for a composition key", bad) + } + } + if _, err := CompositionKey("", "0123456789ab"); err == nil { + t.Error("a key with no group was made") + } +} diff --git a/go/clients/nodeengineclient/composed.go b/go/clients/nodeengineclient/composed.go new file mode 100644 index 0000000..aded503 --- /dev/null +++ b/go/clients/nodeengineclient/composed.go @@ -0,0 +1,324 @@ +package nodeengineclient + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "strings" + + seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-sdk/go/declaration" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The compositions (novox/hq ADR 0539 clauses 1 to 4), the node-engine's state composed: its layout is the seat's contract +// (seat.ComposedByNodeModuleFingerprintValue, seat.ComposedByNodeNodeValue); this file is the codec and the digest the +// engine, the assigner, the grants part and the provisioner share. A composition's digest is a function of its content +// alone, so the encoding keeps the field order its first writer used, which the contract's generated type (fields sorted) +// does not: the wire structs below hold it. + +// sourcePrefix opens a source fingerprint of the form mesh-catalog's client defines (meshcatalogclient.SourcePrefix2, +// KeyPart); a client imports no other seat's client (ADR 0625 §4), so a composition's field is checked here for that form +// only, and a caller makes the key part with meshcatalogclient.KeyPart. +const sourcePrefix = "src2:" + +// checkFingerprintForm refuses a fingerprint that is not `src2:` and 64 lowercase hex digits, naming it. +func checkFingerprintForm(fp string) error { + part, ok := strings.CutPrefix(fp, sourcePrefix) + if !ok || !keys.IsHex64(part) { + return fmt.Errorf("node-engine: %q is not a fingerprint of the new form, %s and 64 lowercase hex digits", fp, sourcePrefix) + } + return nil +} + +// Composition is one module's resources composed for one node at one fingerprint, or, with Module and FP empty, the node's +// own resources and parts (Own). Each resource is a declaration resource, kept as JSON and written canonically (keys +// sorted, spacing dropped). Plan is the module's whole provisioning plan, kept as JSON too: part of the encoding, not of +// the digest. Refused says the module cannot be composed at this fingerprint; such a composition holds nothing else. +type Composition struct { + V int + Node string + Module string + FP string + Data bool + Resources []json.RawMessage + Filled []string + Own seat.NodeOwn + Plan json.RawMessage + Refused string +} + +type adoptionWire struct { + Taken []string `json:"taken"` + Untaken map[string][]string `json:"untaken,omitempty"` +} + +type identityWire struct { + Module string `json:"module"` + Name string `json:"name"` + UID int64 `json:"uid"` + GID int64 `json:"gid"` + Groups []int64 `json:"groups,omitempty"` + Runner string `json:"runner,omitempty"` +} + +type groupWire struct { + Name string `json:"name"` + GID int64 `json:"gid"` +} + +type libraryWire struct { + Library string `json:"library"` + Root string `json:"root"` + Group string `json:"group"` + GID int64 `json:"gid"` + Home bool `json:"home"` + Taken bool `json:"taken,omitempty"` + Paths []string `json:"paths,omitempty"` + Runner string `json:"runner,omitempty"` +} + +type ownWire struct { + Adoption *adoptionWire `json:"adoption,omitempty"` + Identities []identityWire `json:"identities,omitempty"` + Groups []groupWire `json:"groups,omitempty"` + Libraries []libraryWire `json:"libraries,omitempty"` + Provisioner string `json:"provisioner_account,omitempty"` +} + +func ownIsEmpty(o seat.NodeOwn) bool { + return o.Adoption == nil && len(o.Identities) == 0 && len(o.Groups) == 0 && len(o.Libraries) == 0 && o.ProvisionerAccount == "" +} + +func ownToWire(o seat.NodeOwn) *ownWire { + w := ownWire{Provisioner: o.ProvisionerAccount} + if o.Adoption != nil { + w.Adoption = &adoptionWire{Taken: o.Adoption.Taken, Untaken: o.Adoption.Untaken} + } + for _, i := range o.Identities { + w.Identities = append(w.Identities, identityWire{Module: i.Module, Name: i.Name, UID: i.Uid, GID: i.Gid, Groups: i.Groups, Runner: i.Runner}) + } + for _, g := range o.Groups { + w.Groups = append(w.Groups, groupWire{Name: g.Name, GID: g.Gid}) + } + for _, l := range o.Libraries { + w.Libraries = append(w.Libraries, libraryWire{Library: l.Library, Root: l.Root, Group: l.Group, GID: l.Gid, Home: l.Home, Taken: l.Taken, Paths: l.Paths, Runner: l.Runner}) + } + return &w +} + +func ownFromWire(w ownWire) seat.NodeOwn { + o := seat.NodeOwn{ProvisionerAccount: w.Provisioner} + if w.Adoption != nil { + o.Adoption = &seat.Adoption{Taken: w.Adoption.Taken, Untaken: w.Adoption.Untaken} + } + for _, i := range w.Identities { + o.Identities = append(o.Identities, seat.DeclaredIdentity{Module: i.Module, Name: i.Name, Uid: i.UID, Gid: i.GID, Groups: i.Groups, Runner: i.Runner}) + } + for _, g := range w.Groups { + o.Groups = append(o.Groups, seat.DeclaredGroup{Name: g.Name, Gid: g.GID}) + } + for _, l := range w.Libraries { + o.Libraries = append(o.Libraries, seat.DeclaredLibrary{Library: l.Library, Root: l.Root, Group: l.Group, Gid: l.GID, Home: l.Home, Taken: l.Taken, Paths: l.Paths, Runner: l.Runner}) + } + return o +} + +// composedWire is a composition as it is written. +type composedWire struct { + V int `json:"v"` + Node string `json:"node"` + Module string `json:"module,omitempty"` + FP string `json:"fp,omitempty"` + Data bool `json:"data,omitempty"` + Resources []json.RawMessage `json:"resources"` + Filled []string `json:"filled_files,omitempty"` + Own *ownWire `json:"own,omitempty"` + Plan json.RawMessage `json:"plan,omitempty"` + Refused string `json:"refused,omitempty"` + // Signature is read and dropped: a composition is unsigned (ADR 0593 §6), and a value the ended controller signed + // still decodes. Nothing writes it. + Signature json.RawMessage `json:"signature,omitempty"` +} + +// canonicalJSON is raw with its object keys sorted and its spacing dropped, its numbers kept as written. +func canonicalJSON(raw json.RawMessage) (json.RawMessage, error) { + var v any + d := json.NewDecoder(bytes.NewReader(raw)) + d.UseNumber() + if err := d.Decode(&v); err != nil { + return nil, err + } + if _, err := d.Token(); err != io.EOF { + return nil, fmt.Errorf("more after the value") + } + return json.Marshal(v) +} + +func (c Composition) check() error { + if err := keys.CheckName("node", c.Node); err != nil { + return err + } + if c.Module == "" { + if c.FP != "" { + return fmt.Errorf("node-engine: the node's own composition names no fingerprint, and this one names %s", c.FP) + } + if c.Data { + return fmt.Errorf("node-engine: the node's own composition holds no module's data") + } + if len(c.Plan) > 0 || c.Refused != "" { + return fmt.Errorf("node-engine: the node's own composition carries no plan and no refusal: only a module's does") + } + } else { + if err := checkFingerprintForm(c.FP); err != nil { + return err + } + if _, err := ComposedKey(c.Node, c.Module, strings.TrimPrefix(c.FP, sourcePrefix)); err != nil { + return err + } + if !ownIsEmpty(c.Own) { + return fmt.Errorf("node-engine: the composition of %s carries the node's own parts, which only .node carries", c.Module) + } + } + if c.Refused != "" && (len(c.Resources) > 0 || len(c.Filled) > 0 || c.Data || len(c.Plan) > 0) { + return fmt.Errorf("node-engine: the composition of %s on %s is refused and holds resources, filled files, data or a plan", c.Module, c.Node) + } + seen := map[string]bool{} + files := map[string][]*declaration.File{} + for _, raw := range c.Resources { + r, err := declaration.ParseResource(raw) + if err != nil { + return fmt.Errorf("node-engine: the composition of %q on %s: %w", c.Module, c.Node, err) + } + if seen[r.Identity()] { + return fmt.Errorf("node-engine: the composition of %q on %s holds the resource %q twice", c.Module, c.Node, r.Identity()) + } + seen[r.Identity()] = true + if r.DeclaredModule() != c.Module { + return fmt.Errorf("node-engine: the resource %q is of %q, and this is the composition of %q on %s", + r.Identity(), r.DeclaredModule(), c.Module, c.Node) + } + if f, ok := r.(*declaration.File); ok { + if f.TravelsByDigest() { + return fmt.Errorf("node-engine: the file %q is over %d bytes and travels by digest, not whole", + f.ID, declaration.FileReferenceThreshold) + } + files[f.Path] = append(files[f.Path], f) + } + } + // A file the provisioner fills says no content, so a composition that does not say it is filled would have the + // engine write it empty (issue 1945): refused, naming it. + filledHere := make(map[string]bool, len(c.Filled)) + for _, path := range c.Filled { + filledHere[path] = true + } + for path, fs := range files { + for _, f := range fs { + if f.OwnerAtRuntime == declaration.RuntimeOwned && !filledHere[path] && !saysContent(f) { + return fmt.Errorf("node-engine: the file %q says no content and the composition of %q on %s does not say "+ + "the provisioner fills %s: the engine would write it empty", f.ID, c.Module, c.Node, path) + } + } + } + for _, path := range c.Filled { + if len(files[path]) == 0 { + return fmt.Errorf("node-engine: the composition of %q fills %q, and declares no file there", c.Module, path) + } + for _, f := range files[path] { + if f.OwnerAtRuntime == "" { + return fmt.Errorf("node-engine: the file %q is filled by the provisioner and says no owner-at-runtime", f.ID) + } + } + } + return nil +} + +func saysContent(f *declaration.File) bool { + return f.Content != "" || f.Bytes != "" || f.Digest != "" || f.Sealed != "" || f.VaultSealed != nil +} + +func (c Composition) wire() (composedWire, error) { + if err := c.check(); err != nil { + return composedWire{}, err + } + w := composedWire{V: keys.Version, Node: c.Node, Module: c.Module, FP: c.FP, Data: c.Data, Filled: c.Filled, + Refused: c.Refused, Resources: make([]json.RawMessage, len(c.Resources))} + for i, r := range c.Resources { + canon, err := canonicalJSON(r) + if err != nil { + return composedWire{}, fmt.Errorf("node-engine: a resource of %q is not JSON: %w", c.Module, err) + } + w.Resources[i] = canon + } + if len(c.Plan) > 0 { + canon, err := canonicalJSON(c.Plan) + if err != nil { + return composedWire{}, fmt.Errorf("node-engine: the plan of %q is not JSON: %w", c.Module, err) + } + w.Plan = canon + } + if !ownIsEmpty(c.Own) { + w.Own = ownToWire(c.Own) + } + return w, nil +} + +// EncodeComposition is c as JSON at the version, each resource canonical. It refuses a composition whose node or +// fingerprint a key refuses, a resource its kind refuses, a resource of another module, a module's composition carrying +// the node's own parts, a file over 64 KiB carried whole, and a filled path no file declares. +func EncodeComposition(c Composition) ([]byte, error) { + w, err := c.wire() + if err != nil { + return nil, err + } + return json.Marshal(w) +} + +// DecodeComposition reads a composition; it refuses an unknown version (keys.ErrUnknownVersion), a field it does not +// know, and what EncodeComposition refuses. +func DecodeComposition(data []byte) (Composition, error) { + if err := keys.CheckVersion(data); err != nil { + return Composition{}, err + } + var w composedWire + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if err := d.Decode(&w); err != nil { + return Composition{}, fmt.Errorf("node-engine: a composition does not fit its shape: %w", err) + } + c := Composition{V: w.V, Node: w.Node, Module: w.Module, FP: w.FP, Data: w.Data, Resources: w.Resources, + Filled: w.Filled, Plan: w.Plan, Refused: w.Refused} + if w.Own != nil { + c.Own = ownFromWire(*w.Own) + } + if err := c.check(); err != nil { + return Composition{}, err + } + return c, nil +} + +// ComposedDigest is a composition's digest, "sha256:", of its encoding without its plan. A want names it (ADR 0539 +// clause 1), and the node's engine writes it beside the composition. A refused composition has no digest. +func ComposedDigest(c Composition) (string, error) { + if c.Refused != "" { + return "", fmt.Errorf("node-engine: the composition of %s on %s is refused (%s) and has no digest", c.Module, c.Node, c.Refused) + } + c.Plan = nil + raw, err := EncodeComposition(c) + if err != nil { + return "", err + } + sum := sha256.Sum256(raw) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +// SameComposition says whether two compositions are the same value, their plans included, so the engine writes only +// when they differ. +func SameComposition(a, b Composition) bool { + ra, errA := EncodeComposition(a) + rb, errB := EncodeComposition(b) + return errA == nil && errB == nil && bytes.Equal(ra, rb) +} diff --git a/go/clients/nodeengineclient/composed_test.go b/go/clients/nodeengineclient/composed_test.go new file mode 100644 index 0000000..0fe22fa --- /dev/null +++ b/go/clients/nodeengineclient/composed_test.go @@ -0,0 +1,130 @@ +package nodeengineclient + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "testing" + + seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" +) + +// captured is resources of ace's declaration of 2026-10-24 (the controller's `plan` verb answered it; testdata/captured-ace.json +// keeps three modules of it, never typed by hand), composed here by module under a stand-in fingerprint. +func captured(t *testing.T) (account string, byModule map[string][]json.RawMessage) { + t.Helper() + raw, err := os.ReadFile("testdata/captured-ace.json") + if err != nil { + t.Fatal(err) + } + var c struct { + Account string `json:"provisioner_account"` + Resources []json.RawMessage `json:"resources"` + } + if err := json.Unmarshal(raw, &c); err != nil { + t.Fatal(err) + } + byModule = map[string][]json.RawMessage{} + for _, r := range c.Resources { + var h struct { + Module string `json:"module"` + } + _ = json.Unmarshal(r, &h) + byModule[h.Module] = append(byModule[h.Module], r) + } + return c.Account, byModule +} + +func fp(n int) string { return sourcePrefix + fmt.Sprintf("%064x", n) } + +// TestCompositionDigests: a composition's digest is the one the first writer computed (the values are the digests +// mesh-sdk's delivery package gave for the same compositions when it moved here), whatever order the contract's generated +// types sort their fields in, and the plan is no part of it. +func TestCompositionDigests(t *testing.T) { + account, by := captured(t) + own := seat.NodeOwn{ + Adoption: &seat.Adoption{Taken: []string{"avahi"}, Untaken: map[string][]string{"plex": {"plex.bundle-code"}}}, + Identities: []seat.DeclaredIdentity{{Module: "plex", Name: "plex", Uid: 1001, Gid: 1001, Groups: []int64{1002}, Runner: "plex"}}, + Groups: []seat.DeclaredGroup{{Name: "mesh-media", Gid: 1002}}, + Libraries: []seat.DeclaredLibrary{{Library: "movies", Root: "/srv/media/movies", Group: "mesh-media", Gid: 1002, Taken: true, Paths: []string{"/srv/media/movies"}}}, + ProvisionerAccount: "mesh-provisioner-client", + } + cases := []struct { + name string + c Composition + digest string + }{ + {"a node's own, the account only", Composition{Node: "ace", Own: seat.NodeOwn{ProvisionerAccount: account}}, "sha256:17fde3a215d522619c18bc80b8c236975e25b2d2980a984aebd7c18376b095a2"}, + {"a node's own, every part", Composition{Node: "ace", Own: own}, "sha256:b8ac7ace490a3c483c3ae18a9cda58e607aa6b69167fa274c062949c7b34fdbf"}, + {"one package", Composition{Node: "ace", Module: "buildah", FP: fp(7), Resources: by["buildah"]}, "sha256:84e480a7a62333960b3e4be21a7548bbaa09572ef73ca8fb9dc6dfd1228aeb6b"}, + {"a service and a file", Composition{Node: "ace", Module: "mesh-wireguard", FP: fp(14), Resources: by["mesh-wireguard"]}, "sha256:d6edec2474f606503e55d4ee2e0b4f5d1ffc25d016e4bc595e86ea67fe205b8c"}, + {"a plan beside the resources", Composition{Node: "ace", Module: "hostname", FP: fp(8), Resources: by["hostname"], Plan: json.RawMessage(`{"files":[{"path":"/etc/hostname"}]}`)}, "sha256:49e17d6c471ee65cdef745ca3ac5cea4d37b6a100e11d8434dc64a6e2949e57e"}, + } + for _, c := range cases { + got, err := ComposedDigest(c.c) + if err != nil || got != c.digest { + t.Errorf("%s: digest %s, %v; want %s", c.name, got, err, c.digest) + } + raw, err := EncodeComposition(c.c) + if err != nil { + t.Errorf("%s: %v", c.name, err) + continue + } + back, err := DecodeComposition(raw) + if err != nil { + t.Errorf("%s: %v", c.name, err) + continue + } + if !SameComposition(c.c, back) { + t.Errorf("%s: reads back as another composition", c.name) + } + again, _ := ComposedDigest(back) + if again != c.digest { + t.Errorf("%s: read back, its digest is %s", c.name, again) + } + } + a := Composition{Node: "ace", Module: "hostname", FP: fp(8), Resources: by["hostname"], Plan: json.RawMessage(`{"a":1}`)} + b := a + b.Plan = json.RawMessage(`{"a":2}`) + da, _ := ComposedDigest(a) + db, _ := ComposedDigest(b) + if da != db || SameComposition(a, b) { + t.Error("a plan changes the composition's encoding and not its digest") + } +} + +// TestCompositionRefusals: what the codec refuses. +func TestCompositionRefusals(t *testing.T) { + _, by := captured(t) + ok := Composition{Node: "ace", Module: "buildah", FP: fp(7), Resources: by["buildah"]} + cases := []struct { + name string + c Composition + want string + }{ + {"an old-form fingerprint", Composition{Node: "ace", Module: "buildah", FP: "src1:abc", Resources: ok.Resources}, "new form"}, + {"a resource of another module", Composition{Node: "ace", Module: "hostname", FP: fp(8), Resources: ok.Resources}, "is of"}, + {"the node's own parts on a module", Composition{Node: "ace", Module: "buildah", FP: fp(7), Own: seat.NodeOwn{ProvisionerAccount: "x"}}, "node's own parts"}, + {"a refusal that holds resources", Composition{Node: "ace", Module: "buildah", FP: fp(7), Resources: ok.Resources, Refused: "no secret"}, "refused and holds"}, + {"the node's own with a fingerprint", Composition{Node: "ace", FP: fp(7)}, "names no fingerprint"}, + {"a node name with a dot", Composition{Node: "a.ce"}, "node"}, + } + for _, c := range cases { + if _, err := EncodeComposition(c.c); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: %v, want an error saying %q", c.name, err, c.want) + } + } + if _, err := ComposedDigest(Composition{Node: "ace", Module: "buildah", FP: fp(7), Refused: "no secret"}); err == nil { + t.Error("a refused composition has a digest") + } + if _, err := DecodeComposition([]byte(`{"v":2,"node":"ace","resources":[]}`)); err == nil { + t.Error("a composition of version 2 is read") + } + if _, err := DecodeComposition([]byte(`{"v":1,"node":"ace","resources":[],"extra":1}`)); err == nil { + t.Error("a composition with a field it does not know is read") + } + if _, err := DecodeComposition([]byte(`{"v":1,"node":"ace","resources":[],"signature":"x"}`)); err != nil { + t.Errorf("a signed composition of the ended controller is refused: %v", err) + } +} diff --git a/go/clients/nodeengineclient/enginereads/enginereads.go b/go/clients/nodeengineclient/enginereads/enginereads.go new file mode 100644 index 0000000..4ae20b2 --- /dev/null +++ b/go/clients/nodeengineclient/enginereads/enginereads.go @@ -0,0 +1,643 @@ +// Package enginereads is how a mesh-delivery part learns what each node's engine reads of a manifest (novox/hq ADR 0618 +// §3, §4): it asks the node seat node-engine's verb manifest-reads, on the part's start, on the seat's event +// manifest-reads-changed for that node and when a person asks again, and keeps each node's last answer in a state of its +// own, one key per node, so a node that is away keeps the statement it last made (ADR 0624 §1). The assigner, the +// builder and the delivery view share it; it is the seat node-engine's client's, not mesh-sdk's (ADR 0634 §1, §2). +package enginereads + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "sort" + "strings" + "sync" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/busretry" + + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" +) + +// The verb and the event as the seat's contract names them (mesh-contracts seats/nodeengine), as a manifest names them, +// and the subject the event is said on with the engine's machine last. +const ( + Verb = nodeengine.VerbManifestReads + Event = nodeengine.EventManifestReadsChanged + Invoke = "seat:" + nodeengine.Seat + "." + Verb + Consumes = nodeengine.Seat + "." + Event +) + +// SubjectPrefix is the event's subject up to the machine, its last token. +var SubjectPrefix = strings.TrimSuffix(nodeengine.SubjectManifestReadsChanged("*"), "*") + +// The reasons an engine says its reads, in the event's why (ADR 0618 §4). +const ( + // ReadsStarted: the engine started, or what it states changed. + ReadsStarted = "started" + // ReadsReturned: the engine's node returned after being away. + ReadsReturned = "returned" +) + +// ContractVersion is the layout version of the verb's answer and the event's body this reader reads. +const ContractVersion = 1 + +// checkNode refuses a node that is no subject token. +func checkNode(node string) error { + if node == "" || strings.ContainsAny(node, ".*> \t\n") { + return fmt.Errorf("%q is no node", node) + } + return nil +} + +// checkReads refuses an empty reader capability. +func checkReads(node string, reads []string) error { + for _, c := range reads { + if strings.TrimSpace(c) == "" { + return fmt.Errorf("the reads of %s name an empty reader capability", node) + } + } + return nil +} + +// DecodeAnswer reads the verb's answer as the contract lays it out, and judges it: the layout's version, the node a +// name, no empty capability, and its stated-at an RFC 3339 time (the contract carries no checks; its reader does). +func DecodeAnswer(raw []byte) (nodeengine.ManifestReadsAnswer, time.Time, error) { + var a nodeengine.ManifestReadsAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return a, time.Time{}, err + } + if a.V != ContractVersion { + return a, time.Time{}, fmt.Errorf("the answer's layout is version %d, not %d", a.V, ContractVersion) + } + if err := checkNode(a.Node); err != nil { + return a, time.Time{}, err + } + if err := checkReads(a.Node, a.ManifestReads); err != nil { + return a, time.Time{}, err + } + at, err := time.Parse(time.RFC3339Nano, a.StatedAt) + if err != nil { + return a, time.Time{}, fmt.Errorf("stated-at %q is no RFC 3339 time", a.StatedAt) + } + return a, at, nil +} + +// DecodeChanged reads the event's body as the contract lays it out, and judges it as DecodeAnswer does, its why one of +// ReadsStarted and ReadsReturned. +func DecodeChanged(raw []byte) (nodeengine.ManifestReadsChangedEvent, error) { + var e nodeengine.ManifestReadsChangedEvent + if err := json.Unmarshal(raw, &e); err != nil { + return e, err + } + if e.V != ContractVersion { + return e, fmt.Errorf("the event's layout is version %d, not %d", e.V, ContractVersion) + } + if err := checkNode(e.Node); err != nil { + return e, err + } + if e.Why != ReadsStarted && e.Why != ReadsReturned { + return e, fmt.Errorf("the reads of %s changed for %q, not %s or %s", e.Node, e.Why, ReadsStarted, ReadsReturned) + } + if err := checkReads(e.Node, e.ManifestReads); err != nil { + return e, err + } + if _, err := time.Parse(time.RFC3339Nano, e.At); err != nil { + return e, fmt.Errorf("at %q is no RFC 3339 time", e.At) + } + return e, nil +} + +// Where a node's statement came from. +const ( + // FromVerb: the node's engine answered the verb. + FromVerb = "verb" + // FromRecord: the engine did not answer it (an engine built before the verb, or nobody answered) and the node's record + // `.node` was read once in its place (the fallback, until every counted engine serves the verb; issue 774). + FromRecord = "record" + // FromNone: neither said anything, so the node is read as stating the baseline (ADR 0614 §4). + FromNone = "none" +) + +// Version is the version of a statement this package writes and the only one it reads. +const Version = 1 + +// Statement is one node's key: what its engine states it reads, when it stated it, when it was asked, where it came +// from, whether the engine answered that ask (it is present), and, while a person's ask again waits, that ask. +type Statement struct { + V int `json:"v"` + Node string `json:"node"` + ManifestReads []string `json:"manifest-reads,omitempty"` + StatedAt time.Time `json:"stated-at,omitzero"` + AskedAt time.Time `json:"asked-at,omitzero"` + From string `json:"from"` + Answered bool `json:"answered"` + Words string `json:"words,omitempty"` + AskAgain *Again `json:"ask-again,omitempty"` +} + +// Again is a person's ask again of one node, written by the tool and answered by the part that keeps the state. +type Again struct { + By string `json:"by"` + Why string `json:"why"` + At time.Time `json:"at"` +} + +// Encode is s as JSON at Version. +func Encode(s Statement) ([]byte, error) { + if s.Node == "" || strings.ContainsAny(s.Node, ".*> ") { + return nil, fmt.Errorf("enginereads: %q is no node", s.Node) + } + s.V = Version + return json.Marshal(s) +} + +// Decode reads a statement; it refuses another version and a value naming no node. +func Decode(raw []byte) (Statement, error) { + var s Statement + if err := json.Unmarshal(raw, &s); err != nil { + return Statement{}, err + } + if s.V != Version { + return Statement{}, fmt.Errorf("enginereads: version %d, not %d", s.V, Version) + } + if s.Node == "" { + return Statement{}, errors.New("enginereads: a statement names no node") + } + return s, nil +} + +// Caller calls a seat's verb on one node: process.Conn.CallOn. +type Caller func(node, key string, body any) (json.RawMessage, error) + +// Ask asks node's engine the verb once and says what it answered. An engine that answered the call with an error is +// present and states nothing through it; one that did not answer is not present. +func Ask(call Caller, node string, now time.Time) Statement { + s := Statement{Node: node, AskedAt: now.UTC(), From: FromNone} + raw, err := call(node, Invoke, map[string]any{}) + var answered *busretry.Answered + switch { + case err == nil: + a, at, derr := DecodeAnswer(raw) + if derr == nil && a.Node != node { + derr = fmt.Errorf("it names the node %s", a.Node) + } + if derr != nil { + s.Answered, s.Words = true, "the engine answered what is no statement: "+derr.Error() + return s + } + s.ManifestReads, s.StatedAt, s.From, s.Answered = slices.Clone(a.ManifestReads), at.UTC(), FromVerb, true + case errors.As(err, &answered): + s.Answered, s.Words = true, "the engine does not serve "+Verb+": "+answered.Words + default: + s.Words = "nobody answered " + Verb + ": " + err.Error() + } + return s +} + +// NodeOf is the node an event of manifest-reads-changed is about, read from its subject's last token and held equal to +// its body's; ok false for a subject of another event. +func NodeOf(subject string, data []byte) (string, bool, error) { + node, ok := strings.CutPrefix(subject, SubjectPrefix) + if !ok || node == "" || strings.Contains(node, ".") { + return "", false, nil + } + e, err := DecodeChanged(data) + if err != nil { + return node, true, err + } + if e.Node != node { + return node, true, fmt.Errorf("the event on %s names the node %s", subject, e.Node) + } + return node, true, nil +} + +// Keeper keeps every node's statement for one part: in memory, and in the part's state when Put is set, so a statement +// outlives the part's restart. Nothing in it waits on a timer: a node is asked when the part says so. +type Keeper struct { + // Call asks the verb; nil asks nothing (every node then reads as the fallback or the baseline). + Call Caller + // Fallback reads a node's record `.node` once where its engine did not answer the verb; nil reads none. + Fallback func(node string) ([]string, bool) + // Put writes a node's key of the part's state, Delete removes it; nil keeps the statements in memory only. + Put func(node string, value []byte) error + Delete func(node string) error + // Changed is told after a node's statement changed. + Changed func() + Logf func(string, ...any) + Now func() time.Time + // Sync asks in the caller's goroutine (tests); otherwise each ask runs on its own. + Sync bool + + mu sync.Mutex + by map[string]Statement + asking map[string]bool +} + +func (k *Keeper) logf(f string, a ...any) { + if k.Logf != nil { + k.Logf(f, a...) + } +} + +func (k *Keeper) now() time.Time { + if k.Now != nil { + return k.Now() + } + return time.Now() +} + +// AskNode asks node's engine, unless an ask of it runs already; why is said in the log. +func (k *Keeper) AskNode(node, why string) { + k.mu.Lock() + if k.asking == nil { + k.asking = map[string]bool{} + } + if k.asking[node] { + k.mu.Unlock() + return + } + k.asking[node] = true + k.mu.Unlock() + if k.Sync { + k.ask(node, why) + return + } + go k.ask(node, why) +} + +func (k *Keeper) ask(node, why string) { + defer func() { + k.mu.Lock() + delete(k.asking, node) + k.mu.Unlock() + }() + s := Statement{Node: node, AskedAt: k.now().UTC(), From: FromNone, Words: "nothing asks the verb"} + if k.Call != nil { + s = Ask(k.Call, node, k.now()) + } + if s.From != FromVerb && k.Fallback != nil { + if reads, ok := k.Fallback(node); ok { + s.ManifestReads, s.From = slices.Clone(reads), FromRecord + k.logf("%s on %s (%s): %s; its record %s.node is read once in its place (until every counted engine serves the verb)", + Verb, node, why, s.Words, node) + } + } + if s.From == FromNone { + k.logf("%s on %s (%s): %s; the node reads as its last statement, or the baseline", Verb, node, why, s.Words) + } + k.take(s) +} + +// take keeps s, unless the node did not answer and an earlier statement stands: an away node keeps its last one (ADR 0624 +// §1), marked as not answering. +func (k *Keeper) take(s Statement) { + k.mu.Lock() + if k.by == nil { + k.by = map[string]Statement{} + } + if old, ok := k.by[s.Node]; ok && !s.Answered && old.From != FromNone { + old.Answered, old.AskedAt, old.Words, old.AskAgain = false, s.AskedAt, s.Words, nil + s = old + } + k.by[s.Node] = s + k.mu.Unlock() + if k.Put != nil { + raw, err := Encode(s) + if err == nil { + err = k.Put(s.Node, raw) + } + if err != nil { + k.logf("the statement of %s could not be kept: %v", s.Node, err) + } + } + if k.Changed != nil { + k.Changed() + } +} + +// Heard takes a change of the part's own state in: at the start, every node's last statement; later, a person's ask again +// (asked now) or a key a person dropped (forgotten). The part's own writes come back here and change nothing. +func (k *Keeper) Heard(node string, raw []byte, deleted bool) { + if deleted { + k.mu.Lock() + _, had := k.by[node] + delete(k.by, node) + k.mu.Unlock() + if had && k.Changed != nil { + k.Changed() + } + return + } + s, err := Decode(raw) + if err != nil || s.Node != node { + k.logf("the key %s of the engine reads is no statement of it, left: %v", node, err) + return + } + if s.AskAgain != nil { + k.AskNode(node, fmt.Sprintf("asked again by %s: %s", s.AskAgain.By, s.AskAgain.Why)) + return + } + k.mu.Lock() + if k.by == nil { + k.by = map[string]Statement{} + } + _, had := k.by[node] + if !had { + k.by[node] = s + } + k.mu.Unlock() + if !had && k.Changed != nil { + k.Changed() + } +} + +// Forget drops a node the part no longer counts (it left the placement): its key too. +func (k *Keeper) Forget(node string) { + k.mu.Lock() + _, had := k.by[node] + delete(k.by, node) + k.mu.Unlock() + if had && k.Delete != nil { + if err := k.Delete(node); err != nil { + k.logf("the statement of %s could not be removed: %v", node, err) + } + } +} + +// Known says whether the part holds a statement of node, from any source. +func (k *Keeper) Known(node string) bool { + k.mu.Lock() + defer k.mu.Unlock() + _, ok := k.by[node] + return ok +} + +// Statements is every node's statement, by node. +func (k *Keeper) Statements() map[string]Statement { + k.mu.Lock() + defer k.mu.Unlock() + out := make(map[string]Statement, len(k.by)) + for n, s := range k.by { + out[n] = s + } + return out +} + +// Reads is, of the nodes given, what each states it reads: its last statement, nil (the baseline) for a node with none. +func (k *Keeper) Reads(nodes []string) map[string][]string { + k.mu.Lock() + defer k.mu.Unlock() + out := make(map[string][]string, len(nodes)) + for _, n := range nodes { + out[n] = slices.Clone(k.by[n].ManifestReads) + } + return out +} + +// Present is, of the nodes given, those whose engine answered when last asked, with what each states. +func (k *Keeper) Present(nodes []string) map[string][]string { + k.mu.Lock() + defer k.mu.Unlock() + out := map[string][]string{} + for _, n := range nodes { + if s, ok := k.by[n]; ok && s.Answered { + out[n] = slices.Clone(s.ManifestReads) + } + } + return out +} + +// State is what the tool needs of the part's state: the runtime's (stdio.KeptState through KeptState), or a test's. +type State interface { + Keys() ([]string, error) + Get(key string) ([]byte, uint64, bool, error) + Put(key string, value []byte) (uint64, error) + Delete(key string) error + // Await waits at most bound for a revision of key after after whose value done says is the answer. + Await(key string, after uint64, done func([]byte) bool, bound time.Duration) ([]byte, error) +} + +// Read lists every node's statement in st, by node name, or one node's; a key that does not read is named with why. +func Read(st State, node string) (map[string]any, error) { + keys := []string{node} + if node == "" { + var err error + if keys, err = st.Keys(); err != nil { + return nil, err + } + } + sort.Strings(keys) + nodes := map[string]any{} + for _, key := range keys { + raw, _, found, err := st.Get(key) + switch { + case err != nil: + return nil, err + case !found: + nodes[key] = map[string]any{"from": FromNone, "words": "no statement is kept: read as the baseline"} + continue + } + s, derr := Decode(raw) + if derr != nil { + nodes[key] = map[string]any{"unread": derr.Error()} + continue + } + nodes[key] = s + } + return map[string]any{"nodes": nodes}, nil +} + +// AskAgainOf writes node's key with a person's ask again and waits at most bound for the part's answer: the next +// statement it writes there. +func AskAgainOf(st State, node, by, why string, now time.Time, bound time.Duration) (Statement, error) { + if node == "" || strings.ContainsAny(node, ".*> ") { + return Statement{}, fmt.Errorf("%q is no node", node) + } + if strings.TrimSpace(why) == "" { + return Statement{}, errors.New("say why it is asked again") + } + s := Statement{Node: node, From: FromNone} + if raw, _, found, err := st.Get(node); err != nil { + return Statement{}, err + } else if found { + if old, err := Decode(raw); err == nil { + s = old + } + } + s.AskAgain = &Again{By: by, Why: why, At: now.UTC()} + raw, err := Encode(s) + if err != nil { + return Statement{}, err + } + rev, err := st.Put(node, raw) + if err != nil { + return Statement{}, err + } + answer, err := st.Await(node, rev, func(v []byte) bool { + got, err := Decode(v) + return err == nil && got.AskAgain == nil + }, bound) + if err != nil { + return Statement{}, fmt.Errorf("the part did not answer the ask again of %s within %s: %w", node, bound, err) + } + return Decode(answer) +} + +// KeptState is a part's state as the runtime keeps it, for the tool. +func KeptState(s stdio.KeptState) State { return keptState{s} } + +type keptState struct{ s stdio.KeptState } + +func (k keptState) Keys() ([]string, error) { return k.s.Keys() } + +func (k keptState) Get(key string) ([]byte, uint64, bool, error) { + e, err := k.s.Get(key) + if err != nil || e == nil { + return nil, 0, false, err + } + return e.Value, e.Revision, true, nil +} + +func (k keptState) Put(key string, value []byte) (uint64, error) { + return k.s.Put(key, json.RawMessage(value)) +} + +func (k keptState) Delete(key string) error { return k.s.Delete(key) } + +// Await watches key for the answer, and reads it once too in case the watch heard it before it began; a context's bound, +// no timer (ADR 0437 §8). +func (k keptState) Await(key string, after uint64, done func([]byte) bool, bound time.Duration) ([]byte, error) { + heard := make(chan []byte, 1) + if err := k.s.Watch(key, func(c stdio.StateChange) error { + if c.Op == "put" && c.Revision > after && done(c.Value) { + select { + case heard <- c.Value: + default: + } + } + return nil + }); err != nil { + return nil, err + } + if e, err := k.s.Get(key); err == nil && e != nil && e.Revision > after && done(e.Value) { + return e.Value, nil + } + wait, cancel := context.WithTimeout(context.Background(), bound) + defer cancel() + select { + case v := <-heard: + return v, nil + case <-wait.Done(): + return nil, errors.New("it is not running, or cannot read the verb's answer") + } +} + +// AnswerWithin is how long the tool waits for the part to answer an ask again: the verb's own call bound and a margin. +var AnswerWithin = 25 * time.Second + +// Tool is a part's tool over its state of engine reads (tools-first, ADR 0618 §3): read every node's statement or one, +// ask one node's engine again now, and, where drops, drop one node's key (a node the part keeps by mistake). owner names +// the part in the words. +func Tool(name, owner string, st State, drops bool, now func() time.Time) stdio.Tool { + props := map[string]any{ + "node": map[string]any{"type": "string", "description": "one node; every node when left out"}, + "ask_again": map[string]any{"type": "string", "description": "\"true\": ask the node's engine " + Verb + " again now, and answer what it states"}, + "why": map[string]any{"type": "string", "description": "why it is asked again or dropped, in plain words"}, + } + description := "What each node's engine states it reads of a manifest (manifest words, forms and seat names, novox/hq ADR 0618 §3), " + + "as " + owner + " last asked the node-engine seat's verb " + Verb + " and uses it: per node its capabilities, when the engine " + + "stated them and when it was asked, whether it answered (present) and where the statement came from: verb, record (the " + + "engine did not answer and its node record was read once in its place) or none (read as the baseline). A node that is " + + "away keeps its last statement. With node and ask_again, " + owner + " asks that node's engine again and answers its new " + + "statement." + if drops { + props["drop"] = map[string]any{"type": "string", "description": "\"true\": drop the node's key, so it reads as stating the baseline until its engine is asked again"} + description += " With node and drop, the node's key is dropped." + } + return stdio.Tool{Name: name, Description: description, Input: map[string]any{"type": "object", "properties": props}, + Run: func(args map[string]any) (any, error) { + str := func(k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) } + node := str("node") + switch { + case str("ask_again") == "true": + return AskAgainOf(st, node, stdio.CallerOf(args), str("why"), now(), AnswerWithin) + case drops && str("drop") == "true": + if node == "" || str("why") == "" { + return nil, errors.New("drop takes node and why") + } + if err := st.Delete(node); err != nil { + return nil, err + } + return map[string]any{"node": node, "dropped": true, + "next": "the node reads as stating the baseline until its engine is asked again (ask_again, its event, or " + owner + "'s start)"}, nil + } + return Read(st, node) + }} +} + +// How a node answered a survey (Survey). +const ( + // Stated: the engine answered the verb. + Stated = "stated" + // NotServed: the engine answered, but does not serve the verb (an engine built before it). + NotServed = "not served" + // Away: nobody answered within the bound: the node is away, or its engine does not serve the verb and nothing else + // answers for it. + Away = "away" +) + +// SurveyRow is one node's row of a survey: how it answered, its statement when it stated one, and its record's reads +// where it did not (the fallback the assigner reads in its place). +type SurveyRow struct { + Node string `json:"node"` + Answer string `json:"answer"` + ManifestReads []string `json:"manifest-reads,omitempty"` + StatedAt time.Time `json:"stated-at,omitzero"` + Record []string `json:"record,omitempty"` + RecordRead bool `json:"record-read,omitempty"` + Words string `json:"words,omitempty"` +} + +// Survey asks every node's engine the verb at once, each ask bounded by bound, so the whole survey takes at most about +// bound however many nodes there are; a node that did not answer is named with its record's reads where record reads +// one. Sorted by node. +func Survey(call Caller, nodes []string, record func(node string) ([]string, bool), bound time.Duration, now time.Time) []SurveyRow { + rows := make([]SurveyRow, len(nodes)) + var wg sync.WaitGroup + for i, node := range nodes { + wg.Add(1) + go func() { + defer wg.Done() + got := make(chan Statement, 1) + go func() { got <- Ask(call, node, now) }() + var s Statement + wait, cancel := context.WithTimeout(context.Background(), bound) + defer cancel() + select { + case s = <-got: + case <-wait.Done(): + s = Statement{Node: node, Words: fmt.Sprintf("no answer within %s", bound)} + } + row := SurveyRow{Node: node, Words: s.Words} + switch { + case s.From == FromVerb: + row.Answer, row.ManifestReads, row.StatedAt = Stated, s.ManifestReads, s.StatedAt + case s.Answered: + row.Answer = NotServed + default: + row.Answer = Away + } + if row.Answer != Stated && record != nil { + row.Record, row.RecordRead = record(node) + } + rows[i] = row + }() + } + wg.Wait() + sort.Slice(rows, func(i, j int) bool { return rows[i].Node < rows[j].Node }) + return rows +} diff --git a/go/clients/nodeengineclient/enginereads/enginereads_test.go b/go/clients/nodeengineclient/enginereads/enginereads_test.go new file mode 100644 index 0000000..b620d1c --- /dev/null +++ b/go/clients/nodeengineclient/enginereads/enginereads_test.go @@ -0,0 +1,40 @@ +package enginereads + +import ( + "strings" + "testing" +) + +// The answer and the event are judged as the contract lays them out and its reader checks: the layout's version, a node +// that is a name, no empty capability, a time, and an event's why one of the two. +func TestTheAnswerAndTheEventAreJudged(t *testing.T) { + const at = "2026-11-05T12:00:00Z" + for name, c := range map[string]struct{ body, bad string }{ + "whole": {`{"v":1,"node":"ace","manifest-reads":["mesh-config.resolved"],"stated-at":"` + at + `"}`, ""}, + "old layout": {`{"v":2,"node":"ace","stated-at":"` + at + `"}`, "version"}, + "bad node": {`{"v":1,"node":"a.b","stated-at":"` + at + `"}`, "no node"}, + "empty read": {`{"v":1,"node":"ace","manifest-reads":[" "],"stated-at":"` + at + `"}`, "empty reader"}, + "no time": {`{"v":1,"node":"ace","stated-at":"yesterday"}`, "RFC 3339"}, + } { + _, _, err := DecodeAnswer([]byte(c.body)) + if (c.bad == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), c.bad)) { + t.Errorf("answer %s: %v, want %q", name, err, c.bad) + } + } + for name, c := range map[string]struct{ body, bad string }{ + "whole": {`{"v":1,"node":"ace","why":"started","manifest-reads":["a.b"],"at":"` + at + `"}`, ""}, + "returned": {`{"v":1,"node":"ace","why":"returned","at":"` + at + `"}`, ""}, + "old layout": {`{"v":2,"node":"ace","why":"started","at":"` + at + `"}`, "version"}, + "no node": {`{"v":1,"node":"","why":"started","at":"` + at + `"}`, "no node"}, + "another why": {`{"v":1,"node":"ace","why":"bored","at":"` + at + `"}`, "bored"}, + "no time": {`{"v":1,"node":"ace","why":"started","at":"yesterday"}`, "RFC 3339"}, + } { + _, err := DecodeChanged([]byte(c.body)) + if (c.bad == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), c.bad)) { + t.Errorf("event %s: %v, want %q", name, err, c.bad) + } + } + if Verb != "manifest-reads" || Event != "manifest-reads-changed" || Consumes != "node-engine.manifest-reads-changed" || Invoke != "seat:node-engine.manifest-reads" { + t.Errorf("the names are %s %s %s %s", Verb, Event, Consumes, Invoke) + } +} diff --git a/go/clients/nodeengineclient/go.mod b/go/clients/nodeengineclient/go.mod new file mode 100644 index 0000000..b759ccf --- /dev/null +++ b/go/clients/nodeengineclient/go.mod @@ -0,0 +1,17 @@ +module git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 +) + +require ( + github.com/klauspost/compress v1.20.0 // indirect + github.com/nats-io/nats.go v1.54.0 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect +) diff --git a/go/clients/nodeengineclient/go.sum b/go/clients/nodeengineclient/go.sum new file mode 100644 index 0000000..65100b8 --- /dev/null +++ b/go/clients/nodeengineclient/go.sum @@ -0,0 +1,12 @@ +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/go/clients/nodeengineclient/handed.go b/go/clients/nodeengineclient/handed.go new file mode 100644 index 0000000..b8c7490 --- /dev/null +++ b/go/clients/nodeengineclient/handed.go @@ -0,0 +1,76 @@ +package nodeengineclient + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + "time" + + seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-sdk/go/seal" +) + +// The adoption handing of novox/hq ADR 0547 clause 9 (issue 1945): the node-engine of the node holding mesh-broker +// hands the bcrypt hashes of the bus accounts, in the bus module's have, sealed to the vault's own key (the seat's +// AccountsHanded), so that of every reader of the have state the vault alone can open them. These are the two ends of +// that box, which the node-engine and the vault both hold. + +// HandedAccount is one user of an AccountsHanded box, opened: the bus user and the bcrypt hash the loaded list carries. +type HandedAccount struct { + User string `json:"user"` + Hash string `json:"hash"` +} + +// KeyFingerprint is a public key's fingerprint as a handing names it: `sha256:` of its PEM, without the space +// around it. +func KeyFingerprint(publicPEM string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(publicPEM))) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// bcryptHash is the form of a bcrypt hash: version, cost, and 53 characters of salt and digest. +var bcryptHash = regexp.MustCompile(`^\$2[abxy]?\$[0-9]{2}\$[./A-Za-z0-9]{53}$`) + +// IsBcrypt says whether s has the form of a bcrypt hash. +func IsBcrypt(s string) bool { return bcryptHash.MatchString(s) } + +// HandAccounts seals the accounts to the vault's public key (SPKI PEM) as the handing of the list whose digest is from. +func HandAccounts(accounts []HandedAccount, vaultPEM, from string, at time.Time) (seat.AccountsHanded, error) { + if strings.TrimSpace(vaultPEM) == "" { + return seat.AccountsHanded{}, errors.New("node-engine: no vault key to seal the handed accounts to") + } + body, err := json.Marshal(accounts) + if err != nil { + return seat.AccountsHanded{}, err + } + box, err := seal.Seal(string(body), vaultPEM) + if err != nil { + return seat.AccountsHanded{}, fmt.Errorf("node-engine: the handed accounts could not be sealed to the vault's key: %w", err) + } + return seat.AccountsHanded{From: from, To: KeyFingerprint(vaultPEM), Users: int64(len(accounts)), + Box: seat.SealedBox{Ct: box.Ct, Eph: box.Eph, Iv: box.IV, Tag: box.Tag, V: int64(box.V)}, At: at.UTC()}, nil +} + +// OpenHanded opens a handing with the vault's private key (PEM), refusing one sealed to another key than publicPEM's +// and a box that does not hold as many users as it says. +func OpenHanded(h seat.AccountsHanded, publicPEM, privatePEM string) ([]HandedAccount, error) { + if h.To != KeyFingerprint(publicPEM) { + return nil, fmt.Errorf("node-engine: the handing is sealed to the key %s, not to this one (%s)", h.To, KeyFingerprint(publicPEM)) + } + plain, err := seal.Open(seal.Box{V: int(h.Box.V), Eph: h.Box.Eph, IV: h.Box.Iv, Tag: h.Box.Tag, Ct: h.Box.Ct}, privatePEM) + if err != nil { + return nil, fmt.Errorf("node-engine: the handing does not open: %w", err) + } + var out []HandedAccount + if err := json.Unmarshal([]byte(plain), &out); err != nil { + return nil, fmt.Errorf("node-engine: the handing holds no accounts: %w", err) + } + if int64(len(out)) != h.Users { + return nil, fmt.Errorf("node-engine: the handing says %d users and holds %d", h.Users, len(out)) + } + return out, nil +} diff --git a/go/clients/nodeengineclient/handed_test.go b/go/clients/nodeengineclient/handed_test.go new file mode 100644 index 0000000..e3cf50b --- /dev/null +++ b/go/clients/nodeengineclient/handed_test.go @@ -0,0 +1,47 @@ +package nodeengineclient + +import ( + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/seal" +) + +// A handing is sealed to the vault's key and opened with it alone; another key, a wrong count and a plain value are refused. +func TestAHandingOpensWithTheVaultsKeyAlone(t *testing.T) { + vault, err := seal.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + other, err := seal.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + hash := "$2a$10$" + strings.Repeat("a", 53) + accounts := []HandedAccount{{User: "a", Hash: hash}, {User: "b", Hash: hash}} + h, err := HandAccounts(accounts, vault.PublicKey, "sha256:list", time.Date(2026, 11, 5, 10, 0, 0, 0, time.FixedZone("x", 3600))) + if err != nil { + t.Fatal(err) + } + if h.Users != 2 || h.To != KeyFingerprint(vault.PublicKey) || h.At.Location() != time.UTC { + t.Errorf("handing = %+v", h) + } + got, err := OpenHanded(h, vault.PublicKey, vault.PrivateKey) + if err != nil || len(got) != 2 || got[1].User != "b" { + t.Errorf("opened %v, %v", got, err) + } + if _, err := OpenHanded(h, other.PublicKey, other.PrivateKey); err == nil { + t.Error("a handing sealed to the vault opened for another key") + } + h.Users = 3 + if _, err := OpenHanded(h, vault.PublicKey, vault.PrivateKey); err == nil { + t.Error("a handing that says 3 users and holds 2 was opened") + } + if !IsBcrypt(hash) || IsBcrypt("plain") { + t.Error("IsBcrypt") + } + if _, err := HandAccounts(accounts, " ", "x", time.Now()); err == nil { + t.Error("sealed to no key") + } +} diff --git a/go/clients/nodeengineclient/have.go b/go/clients/nodeengineclient/have.go new file mode 100644 index 0000000..7babbbe --- /dev/null +++ b/go/clients/nodeengineclient/have.go @@ -0,0 +1,206 @@ +package nodeengineclient + +import ( + "bytes" + "encoding/json" + "fmt" + "regexp" + + seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The state have (novox/hq ADR 0539 clause 9): a module's have on a node under `.` and the node's own facts +// under `.node`; their layouts are the seat's contract (seat.HaveByNodeModuleValue, seat.HaveByNodeNodeValue). This +// file is the codec the node-engine, the assigner, the watchman and every reader share: it writes the version, and refuses +// a phase, a health, a judgement or a digest outside the lists. + +// The phases of a module's change on a node, as the have states them (the seat's constants, short). +const ( + PhaseBackingUp = seat.HaveByNodeModuleValuePhaseBackingUp + PhaseApplying = seat.HaveByNodeModuleValuePhaseApplying + PhaseChecking = seat.HaveByNodeModuleValuePhaseChecking + PhaseRunning = seat.HaveByNodeModuleValuePhaseRunning + PhaseFailed = seat.HaveByNodeModuleValuePhaseFailed + PhaseRemoved = seat.HaveByNodeModuleValuePhaseRemoved + PhaseHeld = seat.HaveByNodeModuleValuePhaseHeld + PhaseWaitingYes = seat.HaveByNodeModuleValuePhaseWaitingYes +) + +// The healths a have carries. +const ( + HealthOK = seat.HaveByNodeModuleValueHealthOk + HealthFailed = seat.HaveByNodeModuleValueHealthFailed + HealthWaits = seat.HaveByNodeModuleValueHealthWaits + HealthUnknown = seat.HaveByNodeModuleValueHealthUnknown +) + +// The judgements of a move that waits for a yes. +const ( + JudgedRecreates = seat.HaveByNodeModuleValueJudgementRecreates + JudgedMovesData = seat.HaveByNodeModuleValueJudgementMovesData + JudgedCannot = seat.HaveByNodeModuleValueJudgementCannotJudge +) + +// ComposesOwn is a node applying its own compositions (the node facts' composes). +const ComposesOwn = "own" + +// composedByTheEndedController is what an engine from before the controller ended wrote in composes: read, never written. +const composedByTheEndedController = "controller" + +var digestForm = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +func checkDigest(digest, of string) error { + if !digestForm.MatchString(digest) { + return fmt.Errorf("node-engine: %s %q is not sha256:<64 lowercase hex digits>", of, digest) + } + return nil +} + +func checkHave(h seat.HaveByNodeModuleValue) error { + switch h.Phase { + case PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed, PhaseRemoved, PhaseHeld: + if h.Judgement != "" || h.Waits != "" || h.BuildAlone { + return fmt.Errorf("node-engine: only a have waiting for a yes names a judgement, the composition waiting and whether it changes the build alone") + } + if h.PullWait != nil && h.Phase != PhaseFailed { + return fmt.Errorf("node-engine: only a failed have names the pull it waits on, not one %s", h.Phase) + } + case PhaseWaitingYes: + if h.Judgement != JudgedRecreates && h.Judgement != JudgedMovesData && h.Judgement != JudgedCannot { + return fmt.Errorf("node-engine: a have waiting for a yes names the judgement %q, not recreates, moves-data or cannot-judge", h.Judgement) + } + if err := checkDigest(h.Waits, "the composition a have waits a yes for"); err != nil { + return err + } + default: + return fmt.Errorf("node-engine: phase %q is not backing-up, applying, checking, running, failed, removed, held or waiting-yes", h.Phase) + } + if w := h.PullWait; w != nil && (w.Image == "" || w.Holder == "") { + return fmt.Errorf("node-engine: a have's pull wait names no image or no holder") + } + if h.Composed != "" { + if err := checkDigest(h.Composed, "the have's composition digest"); err != nil { + return err + } + } + switch h.Health { + case HealthOK, HealthFailed, HealthWaits, HealthUnknown: + default: + return fmt.Errorf("node-engine: health %q is not ok, failed, waits or unknown", h.Health) + } + return nil +} + +// EncodeHave is h as JSON at the current version; it refuses a phase or health outside the lists. +func EncodeHave(h seat.HaveByNodeModuleValue) ([]byte, error) { + if err := checkHave(h); err != nil { + return nil, err + } + h.V = keys.Version + return json.Marshal(h) +} + +// DecodeHave reads a have; it refuses an unknown version (keys.ErrUnknownVersion), a field the contract does not know and +// what EncodeHave refuses. +func DecodeHave(data []byte) (seat.HaveByNodeModuleValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.HaveByNodeModuleValue{}, err + } + h, err := seat.DecodeHaveByNodeModuleValue(data) + if err != nil { + return seat.HaveByNodeModuleValue{}, fmt.Errorf("node-engine: value does not fit its shape: %w", err) + } + if err := checkHave(h); err != nil { + return seat.HaveByNodeModuleValue{}, err + } + return h, nil +} + +func checkHaveNode(n seat.HaveByNodeNodeValue) error { + switch n.Composes { + case "", ComposesOwn, composedByTheEndedController: + default: + return fmt.Errorf("node-engine: a node composes %q, not %s", n.Composes, ComposesOwn) + } + for group, a := range n.Candidates { + if group == "" { + return fmt.Errorf("node-engine: a node's candidate answer names no group") + } + switch a.Verdict { + case seat.CandidateAnswerVerdictSame, seat.CandidateAnswerVerdictDiffers, seat.CandidateAnswerVerdictRefused: + default: + return fmt.Errorf("node-engine: the answer to %s's candidate has the verdict %q", group, a.Verdict) + } + } + return nil +} + +// EncodeHaveNode is n as JSON at the current version; it refuses a candidate answer with a verdict outside the lists. +func EncodeHaveNode(n seat.HaveByNodeNodeValue) ([]byte, error) { + if err := checkHaveNode(n); err != nil { + return nil, err + } + n.V = keys.Version + return json.Marshal(n) +} + +// DecodeHaveNode reads a node's facts; it refuses an unknown version (keys.ErrUnknownVersion), a field the contract does +// not know and what EncodeHaveNode refuses. +func DecodeHaveNode(data []byte) (seat.HaveByNodeNodeValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.HaveByNodeNodeValue{}, err + } + n, err := seat.DecodeHaveByNodeNodeValue(data) + if err != nil { + return seat.HaveByNodeNodeValue{}, fmt.Errorf("node-engine: value does not fit its shape: %w", err) + } + if err := checkHaveNode(n); err != nil { + return seat.HaveByNodeNodeValue{}, err + } + return n, nil +} + +// SameHave says whether two haves are the same value, so a writer writes only when they differ: times compare as +// instants, the user list's and the handed accounts' time as well. +func SameHave(a, b seat.HaveByNodeModuleValue) bool { + return sameJSON(utcHave(a), utcHave(b)) +} + +// SameHaveNode says whether two node facts are the same value, compared as their JSON (maps sorted, spacing dropped). +func SameHaveNode(a, b seat.HaveByNodeNodeValue) bool { return sameJSON(a, b) } + +// SameNodeRecord says whether two records are the same value but for their version. +func SameNodeRecord(a, b seat.NodeRecord) bool { + a.V, b.V = 0, 0 + return sameJSON(a, b) +} + +func utcHave(h seat.HaveByNodeModuleValue) seat.HaveByNodeModuleValue { + if w := h.PullWait; w != nil { + c := *w + c.Began = c.Began.UTC() + if c.RetriedAt != nil { + t := c.RetriedAt.UTC() + c.RetriedAt = &t + } + h.PullWait = &c + } + if u := h.UserList; u != nil { + c := *u + c.At = c.At.UTC() + if c.Handed != nil { + hd := *c.Handed + hd.At = hd.At.UTC() + c.Handed = &hd + } + h.UserList = &c + } + return h +} + +func sameJSON(a, b any) bool { + ra, errA := json.Marshal(a) + rb, errB := json.Marshal(b) + return errA == nil && errB == nil && bytes.Equal(ra, rb) +} diff --git a/go/clients/nodeengineclient/have_test.go b/go/clients/nodeengineclient/have_test.go new file mode 100644 index 0000000..6a408d7 --- /dev/null +++ b/go/clients/nodeengineclient/have_test.go @@ -0,0 +1,163 @@ +package nodeengineclient + +import ( + "errors" + "strings" + "testing" + "time" + + seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +const digestA = "sha256:0000000000000000000000000000000000000000000000000000000000000001" + +func TestHaveCodec(t *testing.T) { + good := seat.HaveByNodeModuleValue{Phase: PhaseRunning, Health: HealthOK, Composed: digestA, Fp: "src2:x"} + waiting := seat.HaveByNodeModuleValue{Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedRecreates, Waits: digestA} + pull := &seat.PullWait{Image: "i", Holder: "h"} + tests := map[string]struct { + have seat.HaveByNodeModuleValue + bad string + }{ + "running": {good, ""}, + "waiting for a yes": {waiting, ""}, + "unknown phase": {seat.HaveByNodeModuleValue{Phase: "gone", Health: HealthOK}, "phase"}, + "unknown health": {seat.HaveByNodeModuleValue{Phase: PhaseRunning, Health: "fine"}, "health"}, + "judgement outside a yes": {seat.HaveByNodeModuleValue{Phase: PhaseRunning, Health: HealthOK, Judgement: JudgedCannot}, "judgement"}, + "a yes without a digest": {seat.HaveByNodeModuleValue{Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedCannot, Waits: "x"}, "sha256"}, + "pull wait on a running": {seat.HaveByNodeModuleValue{Phase: PhaseRunning, Health: HealthOK, PullWait: pull}, "pull"}, + "pull wait without holder": {seat.HaveByNodeModuleValue{Phase: PhaseFailed, Health: HealthFailed, PullWait: &seat.PullWait{Image: "i"}}, "holder"}, + "bad composed digest": {seat.HaveByNodeModuleValue{Phase: PhaseRunning, Health: HealthOK, Composed: "abc"}, "sha256"}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + raw, err := EncodeHave(tc.have) + if tc.bad != "" { + if err == nil || !strings.Contains(err.Error(), tc.bad) { + t.Fatalf("encode: %v, want it to name %q", err, tc.bad) + } + return + } + if err != nil { + t.Fatal(err) + } + back, err := DecodeHave(raw) + if err != nil { + t.Fatal(err) + } + tc.have.V = keys.Version + if !SameHave(back, tc.have) { + t.Fatalf("round trip differs: %+v", back) + } + }) + } + if _, err := DecodeHave([]byte(`{"v":2,"phase":"running","health":"ok"}`)); !errors.Is(err, keys.ErrUnknownVersion) { + t.Fatalf("version 2: %v", err) + } + if _, err := DecodeHave([]byte(`{"v":1,"phase":"running","health":"ok","extra":1}`)); err == nil { + t.Fatal("an unknown field is read") + } +} + +func TestSameHaveComparesTimesAsInstants(t *testing.T) { + at := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) + zone := time.FixedZone("x", 3600) + mk := func(tm time.Time) seat.HaveByNodeModuleValue { + return seat.HaveByNodeModuleValue{Phase: PhaseFailed, Health: HealthFailed, + PullWait: &seat.PullWait{Image: "i", Holder: "h", Began: tm}, + UserList: &seat.UserListWritten{Source: "s", At: tm}} + } + if !SameHave(mk(at), mk(at.In(zone))) { + t.Fatal("the same instant in another zone differs") + } + if SameHave(mk(at), mk(at.Add(time.Second))) { + t.Fatal("another instant is the same") + } +} + +func TestHaveNodeCodec(t *testing.T) { + n := seat.HaveByNodeNodeValue{Composed: digestA, Composes: ComposesOwn, + Candidates: map[string]seat.CandidateAnswer{"g": {Proposal: "p", Verdict: seat.CandidateAnswerVerdictSame}}} + raw, err := EncodeHaveNode(n) + if err != nil { + t.Fatal(err) + } + back, err := DecodeHaveNode(raw) + if err != nil { + t.Fatal(err) + } + n.V = keys.Version + if !SameHaveNode(back, n) { + t.Fatalf("round trip differs: %+v", back) + } + for name, bad := range map[string]seat.HaveByNodeNodeValue{ + "composes": {Composes: "other"}, + "no group": {Candidates: map[string]seat.CandidateAnswer{"": {Verdict: "same"}}}, + "a verdict": {Candidates: map[string]seat.CandidateAnswer{"g": {Verdict: "maybe"}}}, + } { + if _, err := EncodeHaveNode(bad); err == nil { + t.Errorf("%s: encoded", name) + } + } + a, b := seat.NodeRecord{Node: "n", V: 1}, seat.NodeRecord{Node: "n", V: 2} + if !SameNodeRecord(a, b) { + t.Error("a record differs by its version") + } +} + +func TestHealthAndDataCodecs(t *testing.T) { + at := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) + h := seat.HealthByNodeValue{Node: "n", At: at, Fallback: &seat.LoginFallback{Node: "n", From: "old", Why: "w", At: at}} + raw, err := EncodeHealth(h) + if err != nil { + t.Fatal(err) + } + back, err := DecodeHealth(raw) + h.V = keys.Version + if err != nil || !SameHealth(back, h) { + t.Fatalf("round trip: %v %+v", err, back) + } + later := h + later.At = at.Add(time.Hour) + if !SameHealth(h, later) { + t.Error("a report differs by its time of writing") + } + for name, bad := range map[string]seat.HealthByNodeValue{ + "no node": {}, + "another node": {Node: "n", Fallback: &seat.LoginFallback{Node: "m", From: "o", Why: "w", At: at}}, + "no time": {Node: "n", Fallback: &seat.LoginFallback{Node: "n", From: "o", Why: "w"}}, + "no login from": {Node: "n", Fallback: &seat.LoginFallback{Node: "n", Why: "w", At: at}}, + } { + if _, err := EncodeHealth(bad); err == nil { + t.Errorf("%s: encoded", name) + } + } + if _, err := DecodeHealth([]byte(`{"v":2,"node":"n"}`)); !errors.Is(err, keys.ErrUnknownVersion) { + t.Errorf("version 2: %v", err) + } + d := seat.DataByNodeModuleValue{Node: "n", Module: "m", Digest: digestA} + raw, err = EncodeData(d) + if err != nil || !strings.Contains(string(raw), `"items":[]`) { + t.Fatalf("data: %v %s", err, raw) + } + if _, err := EncodeData(seat.DataByNodeModuleValue{Node: "n", Module: "node"}); err == nil { + t.Error("the module name node keys") + } + if k, err := HaveNodeKey("n"); err != nil || k != "n.node" { + t.Errorf("node key %q %v", k, err) + } +} + +// The lists a node's facts carry (windows, busy, strays, filters) are arrays on the bus, as the engine has always written +// them; the contract once said objects and refused every real value. +func TestHaveNodeReadsTheListsTheEngineWrites(t *testing.T) { + raw := []byte(`{"v":1,"windows":[{"step":"backup"}],"busy":[{"id":"x"}],"strays":[{"kind":"container"}],"filters":[{"name":"f"}]}`) + n, err := DecodeHaveNode(raw) + if err != nil { + t.Fatal(err) + } + if len(n.Windows) != 1 || len(n.Busy) != 1 || len(n.Strays) != 1 || len(n.Filters) != 1 { + t.Fatalf("lists lost: %+v", n) + } +} diff --git a/go/clients/nodeengineclient/health.go b/go/clients/nodeengineclient/health.go new file mode 100644 index 0000000..a5d3b56 --- /dev/null +++ b/go/clients/nodeengineclient/health.go @@ -0,0 +1,84 @@ +package nodeengineclient + +import ( + "encoding/json" + "fmt" + "time" + + seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// A node's own health report (the state health, one key per node, written by that node's engine alone) and the data items +// a module declares as the engine placed them (the state data, `.`): their layouts are the seat's contract +// (seat.HealthByNodeValue, seat.DataByNodeModuleValue); this file is their codec, shared by the engine and its readers. + +// RootContract is the statement contract from which an engine judges an account's root: a report whose contract is lower +// judged nothing about root, and a reader must not read its empty root verdicts as healthy (HealthByNodeValue.Contract). +const RootContract = 3 + +// EncodeHealth is h as JSON at the current version; it refuses a report naming no node, and a fall back to the old login +// that names another node, no login fallen back from, no reason or no time. +func EncodeHealth(h seat.HealthByNodeValue) ([]byte, error) { + h.V = keys.Version + if h.Node == "" { + return nil, fmt.Errorf("node-engine: a node's health report names its node") + } + if f := h.Fallback; f != nil && (f.Node != h.Node || f.From == "" || f.Why == "" || f.At.IsZero()) { + return nil, fmt.Errorf("node-engine: a node's fall back to its old login names its node %s, the login fallen back from, why and when", h.Node) + } + return json.Marshal(h) +} + +// DecodeHealth reads a report; it refuses another version (keys.ErrUnknownVersion) and a field the contract does not know. +func DecodeHealth(raw []byte) (seat.HealthByNodeValue, error) { + if err := keys.CheckVersion(raw); err != nil { + return seat.HealthByNodeValue{}, err + } + h, err := seat.DecodeHealthByNodeValue(raw) + if err != nil { + return seat.HealthByNodeValue{}, fmt.Errorf("node-engine: a node's health report is not readable: %w", err) + } + return h, nil +} + +// SameHealth says two reports say the same, whenever each was written. +func SameHealth(a, b seat.HealthByNodeValue) bool { + a.At, b.At = time.Time{}, time.Time{} + return sameJSON(a, b) +} + +// DataKey is a module's key on a node in the state data, `.`, its module part escaped as a have's is. +func DataKey(node, module string) (string, error) { return HaveKey(node, module) } + +// DataNodeKeys is the pattern of every data key of a node, `.*`. +func DataNodeKeys(node string) (string, error) { + if err := keys.CheckName("node", node); err != nil { + return "", err + } + return node + ".*", nil +} + +// EncodeData is d as JSON at the current version; it refuses a node or module the key refuses. +func EncodeData(d seat.DataByNodeModuleValue) ([]byte, error) { + d.V = keys.Version + if _, err := DataKey(d.Node, d.Module); err != nil { + return nil, err + } + if d.Items == nil { + d.Items = []seat.PlacedDataItem{} + } + return json.Marshal(d) +} + +// DecodeData reads a module's data items; it refuses another version (keys.ErrUnknownVersion). +func DecodeData(raw []byte) (seat.DataByNodeModuleValue, error) { + if err := keys.CheckVersion(raw); err != nil { + return seat.DataByNodeModuleValue{}, err + } + d, err := seat.DecodeDataByNodeModuleValue(raw) + if err != nil { + return seat.DataByNodeModuleValue{}, fmt.Errorf("node-engine: a module's data items are not readable: %w", err) + } + return d, nil +} diff --git a/go/clients/nodeengineclient/health_test.go b/go/clients/nodeengineclient/health_test.go new file mode 100644 index 0000000..7dfdf28 --- /dev/null +++ b/go/clients/nodeengineclient/health_test.go @@ -0,0 +1,11 @@ +package nodeengineclient + +import "testing" + +// A report below RootContract judged nothing about root; the number is the one engines have written since the root +// judgement began, so a change of it is a change of what a reader trusts. +func TestRootContractIsTheOneEnginesJudgeRootFrom(t *testing.T) { + if RootContract != 3 { + t.Fatalf("RootContract = %d, the engines write 3", RootContract) + } +} diff --git a/go/clients/nodeengineclient/keys.go b/go/clients/nodeengineclient/keys.go new file mode 100644 index 0000000..8876150 --- /dev/null +++ b/go/clients/nodeengineclient/keys.go @@ -0,0 +1,130 @@ +package nodeengineclient + +import ( + "fmt" + "regexp" + "strings" + + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// NodeWord is the last part of a node's own key in the states have and composed. +const NodeWord = "node" + +var moduleNameOK = regexp.MustCompile(`^[-/_a-zA-Z0-9]+(\.[-/_a-zA-Z0-9]+)*$`) + +// moduleKeyPart is a module's name as a key part (issue 1150): a name may hold a dot (`novox.be`), which a key part may not, +// so a dot is escaped as keys.EscapeName writes it (`novox=2ebe`). A name that is empty, or holds a wildcard, whitespace, +// `=`, or an empty part between dots, is refused. +func moduleKeyPart(module string) (string, error) { + if module == "" { + return "", fmt.Errorf("node-engine: the module name is empty") + } + if !moduleNameOK.MatchString(module) { + return "", fmt.Errorf("node-engine: the module name %q holds a wildcard, whitespace, '=', an empty part between dots or a character a key refuses", module) + } + return keys.EscapeName(module), nil +} + +// parseModuleKeyPart is the inverse of moduleKeyPart; it refuses a part that is not the one moduleKeyPart writes for its name. +func parseModuleKeyPart(part string) (string, error) { + if err := keys.CheckName("module", part); err != nil { + return "", err + } + module, err := keys.UnescapeName(part) + if err != nil { + return "", err + } + if back, err := moduleKeyPart(module); err != nil || back != part { + return "", fmt.Errorf("node-engine: the module part %q is not a module name as a key writes it", part) + } + return module, nil +} + +// HaveKey is the key, in the state have, of a module's have on a node: `.`. A module named `node` is refused, +// since `.node` is the node's own facts. +func HaveKey(node, module string) (string, error) { + if err := keys.CheckName("node", node); err != nil { + return "", err + } + part, err := moduleKeyPart(module) + if err != nil { + return "", err + } + if module == NodeWord { + return "", fmt.Errorf(`node-engine: the module name "node" is refused: %s.node is the node's own key`, node) + } + return node + "." + part, nil +} + +// ParseHaveKey is the inverse of HaveKey and of the node's own key: isNode is true for `.node`, with module empty. +func ParseHaveKey(key string) (node, module string, isNode bool, err error) { + node, module, ok := strings.Cut(key, ".") + if !ok { + return "", "", false, fmt.Errorf("node-engine: %q is not a have key, .", key) + } + if err := keys.CheckName("node", node); err != nil { + return "", "", false, fmt.Errorf("node-engine: %q is not a have key: %w", key, err) + } + if module == NodeWord { + return node, "", true, nil + } + if module, err = parseModuleKeyPart(module); err != nil { + return "", "", false, fmt.Errorf("node-engine: %q is not a have key: %w", key, err) + } + return node, module, false, nil +} + +// ComposedKey is the key, in the state composed, of a module's composition for a node at a fingerprint: +// `..`, the hex being the fingerprint's key part (64 lowercase hex digits, the mesh-catalog client's +// KeyPart). A module named `node` is refused. +func ComposedKey(node, module, fingerprintPart string) (string, error) { + have, err := HaveKey(node, module) + if err != nil { + return "", err + } + if !keys.IsHex64(fingerprintPart) { + return "", fmt.Errorf("node-engine: %q is not the key part of a fingerprint, 64 lowercase hex digits", fingerprintPart) + } + return have + "." + fingerprintPart, nil +} + +// ParseComposedKey is the inverse of ComposedKey and of the node's own key `.node` (isNode, with module and +// fingerprintPart empty). It refuses any other shape, naming the key. +func ParseComposedKey(key string) (node, module, fingerprintPart string, isNode bool, err error) { + refused := func(why error) (string, string, string, bool, error) { + return "", "", "", false, fmt.Errorf("node-engine: %q is not a composition key, .. or .node: %w", key, why) + } + parts := strings.Split(key, ".") + switch len(parts) { + case 2: + if parts[1] != NodeWord { + return refused(fmt.Errorf("a module's composition names its fingerprint")) + } + if err := keys.CheckName("node", parts[0]); err != nil { + return refused(err) + } + return parts[0], "", "", true, nil + case 3: + node, module, isNode, err := ParseHaveKey(parts[0] + "." + parts[1]) + if err != nil { + return refused(err) + } + if isNode { + return refused(fmt.Errorf("the node's own key names no fingerprint")) + } + if !keys.IsHex64(parts[2]) { + return refused(fmt.Errorf("%q is not 64 lowercase hex digits", parts[2])) + } + return node, module, parts[2], false, nil + } + return refused(fmt.Errorf("the key has %d parts", len(parts))) +} + +// HaveNodeKey is the key, in the state have, of a node's own facts: `.node`. +func HaveNodeKey(node string) (string, error) { + if err := keys.CheckName("node", node); err != nil { + return "", err + } + return node + "." + NodeWord, nil +} diff --git a/go/clients/nodeengineclient/keys_test.go b/go/clients/nodeengineclient/keys_test.go new file mode 100644 index 0000000..d8500b1 --- /dev/null +++ b/go/clients/nodeengineclient/keys_test.go @@ -0,0 +1,45 @@ +package nodeengineclient + +import ( + "strings" + "testing" +) + +func TestTheHaveAndComposedKeysKeepTheirForms(t *testing.T) { + fp := strings.Repeat("ab", 32) + for module, want := range map[string]string{"gitea": "n1.gitea", "novox.be": "n1.novox=2ebe", "a/b": "n1.a/b"} { + key, err := HaveKey("n1", module) + if err != nil || key != want { + t.Errorf("HaveKey(%q) = %q, %v", module, key, err) + } + if node, back, isNode, err := ParseHaveKey(key); err != nil || node != "n1" || back != module || isNode { + t.Errorf("ParseHaveKey(%q) = %q %q %v %v", key, node, back, isNode, err) + } + ck, err := ComposedKey("n1", module, fp) + if err != nil || ck != want+"."+fp { + t.Errorf("ComposedKey(%q) = %q, %v", module, ck, err) + } + if node, back, part, isNode, err := ParseComposedKey(ck); err != nil || node != "n1" || back != module || part != fp || isNode { + t.Errorf("ParseComposedKey(%q) = %q %q %q %v %v", ck, node, back, part, isNode, err) + } + } + if node, module, isNode, err := ParseHaveKey("n1.node"); err != nil || node != "n1" || module != "" || !isNode { + t.Errorf("the node's own have key: %q %q %v %v", node, module, isNode, err) + } + if node, _, _, isNode, err := ParseComposedKey("n1.node"); err != nil || node != "n1" || !isNode { + t.Errorf("the node's own composition key: %q %v %v", node, isNode, err) + } + for _, bad := range [][2]string{{"", "m"}, {"n.1", "m"}, {"n1", ""}, {"n1", "node"}, {"n1", "a b"}, {"n1", "a=b"}, {"n1", "a..b"}} { + if _, err := HaveKey(bad[0], bad[1]); err == nil { + t.Errorf("HaveKey(%q, %q) keys", bad[0], bad[1]) + } + } + if _, err := ComposedKey("n1", "m", "src2:"+fp); err == nil { + t.Error("a fingerprint with its prefix is a key part") + } + for _, bad := range []string{"n1", "n1.m", "n1.m." + fp[:10], "n1.node." + fp, "n1.m." + fp + ".x"} { + if _, _, _, _, err := ParseComposedKey(bad); err == nil { + t.Errorf("ParseComposedKey(%q) reads a wrong key", bad) + } + } +} diff --git a/go/clients/nodeengineclient/testdata/captured-ace.json b/go/clients/nodeengineclient/testdata/captured-ace.json new file mode 100644 index 0000000..d727d90 --- /dev/null +++ b/go/clients/nodeengineclient/testdata/captured-ace.json @@ -0,0 +1,63 @@ +{ + "provisioner_account": "mesh-provisioner-client", + "resources": [ + { + "id": "buildah.buildah", + "module": "buildah", + "package": "buildah", + "type": "package" + }, + { + "id": "mesh-wireguard.overlay-tools", + "module": "mesh-wireguard", + "package": "wireguard-tools", + "type": "package" + }, + { + "content": "# Generated by the mesh. Do not edit \u2014 this file is replaced whenever the\n# peer graph changes, and an edit would survive until the next change and\n# then vanish, which is worse than not being applied at all.\n#\n# node ace, at home, not dialable \u2014 it opens every path itself\n\n[Interface]\nAddress = 10.10.0.2/32\nPostUp = wg set %i private-key /var/lib/mesh-host/overlay.key\n\n# novox \u2014 the hub \u2014 everything not at this site\n[Peer]\nPublicKey = xW0ETQwyR9rHi2XSfOJrKkairE+Fp70FPNNcirqMixY=\nAllowedIPs = 10.10.0.0/24\nEndpoint = 142.132.152.141:51820\nPersistentKeepalive = 25\n", + "id": "mesh-wireguard.overlay-config", + "mode": "0600", + "module": "mesh-wireguard", + "path": "/etc/wireguard/mesh0.conf", + "type": "file" + }, + { + "boot": "enabled", + "id": "mesh-wireguard.overlay-up", + "module": "mesh-wireguard", + "restart-on": [ + "mesh-wireguard.overlay-config" + ], + "state": "running", + "type": "service", + "unit": "wg-quick@mesh0" + }, + { + "digest": "sha256:71ed8f5b4e7ac73ab82b7b559b08e5951a82553197d153651cc8be36ff35bcda", + "id": "hostname.bundle-tools", + "module": "hostname", + "path": "/var/lib/mesh/bundles/hostname/tools", + "source": "http://novox.internal:5100/v2/hostname/tools/blobs/sha256:71ed8f5b4e7ac73ab82b7b559b08e5951a82553197d153651cc8be36ff35bcda", + "type": "archive" + }, + { + "at": "start", + "content": "# The machine's own names (module hostname, novox/hq ADR 0199, ADR 0223). Every line outside this\n# block is the operator's: kept across every push, changed through the node-hostname verbs add and\n# remove, and given back when this module goes. The mesh's names are not here: the mesh's resolver\n# answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n127.0.1.1\tace\n", + "id": "hostname.own", + "into": "block", + "mode": "0644", + "module": "hostname", + "path": "/etc/hosts", + "type": "file" + }, + { + "content": "Ace\n", + "id": "hostname.name", + "mode": "0644", + "module": "hostname", + "path": "/etc/hostname", + "type": "file" + } + ], + "filled_files": [] +} \ No newline at end of file diff --git a/go/seats/meshbroker/contract.go b/go/seats/meshbroker/contract.go new file mode 100644 index 0000000..5313b13 --- /dev/null +++ b/go/seats/meshbroker/contract.go @@ -0,0 +1,366 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package meshbroker is the contract of the seat "mesh-broker", defined whole (seats/mesh-broker/seat.json): The mesh's bus, and the bus state modules ask of it in requires (design 25 § 12). create, apply, recreate and drop are granted to the bus module's own credential alone. +package meshbroker + +// Seat is the seat's name. +const Seat = "mesh-broker" + +const ( + VerbList = "list" + VerbShow = "show" + VerbCheck = "check" + VerbOrphans = "orphans" + VerbCreate = "create" + VerbApply = "apply" + VerbRecreate = "recreate" + VerbDrop = "drop" +) + +const ( + EventCreated = "created" + EventRefused = "refused" + EventDrifted = "drifted" + EventDriftCleared = "drift-cleared" + EventFull = "full" + EventFullCleared = "full-cleared" + EventOrphaned = "orphaned" + EventRecreated = "recreated" + EventDropped = "dropped" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbList, VerbShow, VerbCheck, VerbOrphans, VerbCreate, VerbApply, VerbRecreate, VerbDrop} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventCreated, EventRefused, EventDrifted, EventDriftCleared, EventFull, EventFullCleared, EventOrphaned, EventRecreated, EventDropped} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "mesh-broker.events" + FamilyMaxAgeDays = 7 +) + +// SubjectCreated is where created is published. +const SubjectCreated = "mesh.seat.mesh-broker.event.created" + +// SubjectRefused is where refused is published. +const SubjectRefused = "mesh.seat.mesh-broker.event.refused" + +// SubjectDrifted is where drifted is published. +const SubjectDrifted = "mesh.seat.mesh-broker.event.drifted" + +// SubjectDriftCleared is where drift-cleared is published. +const SubjectDriftCleared = "mesh.seat.mesh-broker.event.drift-cleared" + +// SubjectFull is where full is published. +const SubjectFull = "mesh.seat.mesh-broker.event.full" + +// SubjectFullCleared is where full-cleared is published. +const SubjectFullCleared = "mesh.seat.mesh-broker.event.full-cleared" + +// SubjectOrphaned is where orphaned is published. +const SubjectOrphaned = "mesh.seat.mesh-broker.event.orphaned" + +// SubjectRecreated is where recreated is published. +const SubjectRecreated = "mesh.seat.mesh-broker.event.recreated" + +// SubjectDropped is where dropped is published. +const SubjectDropped = "mesh.seat.mesh-broker.event.dropped" + +// Binding is the binding a module receives in its membership for one entry (ADR 0595 §1, §7). +type Binding struct { + // the entry's name on the bus + BusName string `json:"bus-name"` + // how the bus keeps it + StorageType string `json:"storage-type"` + // its subjects, for a stream + Subjects []string `json:"subjects,omitempty"` + // its template + Template string `json:"template,omitempty"` +} + +// Entry is one piece of bus state as a manifest asks it. +type Entry struct { + // its own configuration, where no template fits + Configuration map[string]any `json:"configuration,omitempty"` + // its local name in the module's manifest + LocalName string `json:"local-name"` + // the module asking it + Module string `json:"module"` + // the template's options the module set, within their bounds + Options map[string]any `json:"options,omitempty"` + // how the bus keeps it + StorageType string `json:"storage-type"` + // its template; empty for a configuration of its own + Template string `json:"template,omitempty"` +} + +// The values of Entry's storage-type. +const ( + EntryStorageTypeStream = "stream" + EntryStorageTypeKeyValue = "key-value" + EntryStorageTypeObjectStore = "object-store" + EntryStorageTypeLedger = "ledger" +) + +// The values of Entry's template. +const ( + EntryTemplateState = "state" + EntryTemplateEvents = "events" + EntryTemplateWorkQueue = "work-queue" + EntryTemplateLog = "log" + EntryTemplateObjects = "objects" + EntryTemplateLedger = "ledger" + EntryTemplate = "" +) + +// Fill is a type of the contract. +type Fill struct { + // bytes held + Bytes int64 `json:"bytes"` + // its cap in bytes + Cap int64 `json:"cap"` + // messages, keys or objects held + Count int64 `json:"count"` +} + +// Held is one entry as the bus holds it. +type Held struct { + // its name on the bus, _ + BusName string `json:"bus-name"` + // its own configuration, where no template fits + Configuration map[string]any `json:"configuration,omitempty"` + Fill *Fill `json:"fill,omitempty"` + // its local name in the module's manifest + LocalName string `json:"local-name"` + // the module asking it + Module string `json:"module"` + // the template's options the module set, within their bounds + Options map[string]any `json:"options,omitempty"` + // the settings the bus holds + Settings map[string]any `json:"settings,omitempty"` + // the entry's state: held as asked, refused, waiting to be made, drifted from what is asked, full, or orphaned (no manifest asks it) + State string `json:"state,omitempty"` + // how the bus keeps it + StorageType string `json:"storage-type"` + // its template; empty for a configuration of its own + Template string `json:"template,omitempty"` + // why it is in its state, in plain words; empty when held as asked + Why string `json:"why,omitempty"` +} + +// The values of Held's state. +const ( + HeldStateHeld = "held" + HeldStateRefused = "refused" + HeldStateWaiting = "waiting" + HeldStateDrifted = "drifted" + HeldStateFull = "full" + HeldStateOrphaned = "orphaned" +) + +// The values of Held's storage-type. +const ( + HeldStorageTypeStream = "stream" + HeldStorageTypeKeyValue = "key-value" + HeldStorageTypeObjectStore = "object-store" + HeldStorageTypeLedger = "ledger" +) + +// The values of Held's template. +const ( + HeldTemplateState = "state" + HeldTemplateEvents = "events" + HeldTemplateWorkQueue = "work-queue" + HeldTemplateLog = "log" + HeldTemplateObjects = "objects" + HeldTemplateLedger = "ledger" + HeldTemplate = "" +) + +// Named is a type of the contract. +type Named struct { + // the entry's local name + LocalName string `json:"local-name"` + // the module + Module string `json:"module"` +} + +// Said is the body of every event of the seat. +type Said struct { + // the settings asked + Asked map[string]any `json:"asked,omitempty"` + Binding *Binding `json:"binding,omitempty"` + // its name on the bus + BusName string `json:"bus-name"` + // its own configuration, where no template fits + Configuration map[string]any `json:"configuration,omitempty"` + Fill *Fill `json:"fill,omitempty"` + // the settings held + Held map[string]any `json:"held,omitempty"` + // its local name in the module's manifest + LocalName string `json:"local-name"` + // the module asking it + Module string `json:"module"` + // the template's options the module set, within their bounds + Options map[string]any `json:"options,omitempty"` + // the indexer's revision of the verdict the bus module acted on (ADR 0622 §2), so a reader orders what it hears + Revision int64 `json:"revision,omitempty"` + // the stream snapshot taken before a recreate or drop + Snapshot string `json:"snapshot,omitempty"` + // how the bus keeps it + StorageType string `json:"storage-type,omitempty"` + // its template; empty for a configuration of its own + Template string `json:"template,omitempty"` + // why: a refusal's cause, a drift's difference, a drop's or recreate's reason + Why string `json:"why,omitempty"` +} + +// The values of Said's storage-type. +const ( + SaidStorageTypeStream = "stream" + SaidStorageTypeKeyValue = "key-value" + SaidStorageTypeObjectStore = "object-store" + SaidStorageTypeLedger = "ledger" +) + +// The values of Said's template. +const ( + SaidTemplateState = "state" + SaidTemplateEvents = "events" + SaidTemplateWorkQueue = "work-queue" + SaidTemplateLog = "log" + SaidTemplateObjects = "objects" + SaidTemplateLedger = "ledger" + SaidTemplate = "" +) + +// Warranted is a type of the contract. +type Warranted struct { + // the entry's local name + LocalName string `json:"local-name"` + // the module + Module string `json:"module"` + // the operator's warrant, as the operator channel decided it (ADR 0259 §6) + Warrant string `json:"warrant"` + // why, kept in the record + Why string `json:"why"` +} + +// ListInput is what list takes. +type ListInput struct { + // one module (optional) + Module string `json:"module,omitempty"` +} + +// ListAnswer is what list answers. +type ListAnswer struct { + // every entry + Entries []Held `json:"entries"` +} + +// ShowInput is what show takes. +type ShowInput = Named + +// ShowAnswer is what show answers. +type ShowAnswer = Held + +// CheckInput is what check takes. +type CheckInput struct { + // one module (optional) + Module string `json:"module,omitempty"` +} + +// CheckAnswerDifferencesItem is a type of the contract. +type CheckAnswerDifferencesItem struct { + // asked + Asked map[string]any `json:"asked,omitempty"` + // the difference only grows: apply may act on it + Grows bool `json:"grows,omitempty"` + // held + Held map[string]any `json:"held,omitempty"` + // the entry + LocalName string `json:"local-name"` + // the module + Module string `json:"module"` +} + +// CheckAnswer is what check answers. +type CheckAnswer struct { + // every difference + Differences []CheckAnswerDifferencesItem `json:"differences"` +} + +// OrphansInput is what orphans takes. +type OrphansInput struct { +} + +// OrphansAnswer is what orphans answers. +type OrphansAnswer struct { + // every orphan + Entries []Held `json:"entries"` +} + +// CreateInput is what create takes. +type CreateInput = Entry + +// CreateAnswer is what create answers. +type CreateAnswer = Held + +// ApplyInput is what apply takes. +type ApplyInput = Named + +// ApplyAnswer is what apply answers. +type ApplyAnswer = Held + +// RecreateInput is what recreate takes. +type RecreateInput = Warranted + +// RecreateAnswer is what recreate answers. +type RecreateAnswer = Held + +// DropInput is what drop takes. +type DropInput = Warranted + +// DropAnswer is what drop answers. +type DropAnswer struct { + // it is gone + Dropped bool `json:"dropped"` + // the snapshot taken first + Snapshot string `json:"snapshot,omitempty"` +} + +// CreatedEvent is the body of the event created. +type CreatedEvent = Said + +// RefusedEvent is the body of the event refused. +type RefusedEvent = Said + +// DriftedEvent is the body of the event drifted. +type DriftedEvent = Said + +// DriftClearedEvent is the body of the event drift-cleared. +type DriftClearedEvent = Said + +// FullEvent is the body of the event full. +type FullEvent = Said + +// FullClearedEvent is the body of the event full-cleared. +type FullClearedEvent = Said + +// OrphanedEvent is the body of the event orphaned. +type OrphanedEvent = Said + +// RecreatedEvent is the body of the event recreated. +type RecreatedEvent = Said + +// DroppedEvent is the body of the event dropped. +type DroppedEvent = Said + +// BusStateForm is the form of the provision bus-state. +type BusStateForm = Binding diff --git a/go/seats/meshbroker/contract_test.go b/go/seats/meshbroker/contract_test.go new file mode 100644 index 0000000..03c1f9c --- /dev/null +++ b/go/seats/meshbroker/contract_test.go @@ -0,0 +1,70 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package meshbroker + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/mesh-broker/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: "created on the seat's subject", subject: "mesh.seat.mesh-broker.event.created", given: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}", wire: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}", + decode: func(b []byte) (any, error) { var v CreatedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-broker\\.event\\.created$")}, + {name: "refused on the seat's subject", subject: "mesh.seat.mesh-broker.event.refused", given: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\",\"why\":\"the bus refused it\"}", wire: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\",\"why\":\"the bus refused it\"}", + decode: func(b []byte) (any, error) { var v RefusedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-broker\\.event\\.refused$")}, + {name: "drifted on the seat's subject", subject: "mesh.seat.mesh-broker.event.drifted", given: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\",\"why\":\"the bus refused it\"}", wire: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\",\"why\":\"the bus refused it\"}", + decode: func(b []byte) (any, error) { var v DriftedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-broker\\.event\\.drifted$")}, + {name: "drift-cleared on the seat's subject", subject: "mesh.seat.mesh-broker.event.drift-cleared", given: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}", wire: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}", + decode: func(b []byte) (any, error) { var v DriftClearedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-broker\\.event\\.drift-cleared$")}, + {name: "full on the seat's subject", subject: "mesh.seat.mesh-broker.event.full", given: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}", wire: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}", + decode: func(b []byte) (any, error) { var v FullEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-broker\\.event\\.full$")}, + {name: "full-cleared on the seat's subject", subject: "mesh.seat.mesh-broker.event.full-cleared", given: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}", wire: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}", + decode: func(b []byte) (any, error) { var v FullClearedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-broker\\.event\\.full-cleared$")}, + {name: "orphaned on the seat's subject", subject: "mesh.seat.mesh-broker.event.orphaned", given: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}", wire: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}", + decode: func(b []byte) (any, error) { var v OrphanedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-broker\\.event\\.orphaned$")}, + {name: "recreated on the seat's subject", subject: "mesh.seat.mesh-broker.event.recreated", given: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\",\"why\":\"the bus refused it\"}", wire: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\",\"why\":\"the bus refused it\"}", + decode: func(b []byte) (any, error) { var v RecreatedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-broker\\.event\\.recreated$")}, + {name: "dropped on the seat's subject", subject: "mesh.seat.mesh-broker.event.dropped", given: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\",\"why\":\"the bus refused it\"}", wire: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\",\"why\":\"the bus refused it\"}", + decode: func(b []byte) (any, error) { var v DroppedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-broker\\.event\\.dropped$")}, + {name: "create one entry", subject: "", given: "{\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"options\":{\"max-mib\":64},\"storage-type\":\"ledger\",\"template\":\"ledger\"}", wire: "{\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"options\":{\"max-mib\":64},\"storage-type\":\"ledger\",\"template\":\"ledger\"}", + decode: func(b []byte) (any, error) { var v CreateInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "drop on the operator's warrant", subject: "", given: "{\"local-name\":\"old\",\"module\":\"mesh-knowledge\",\"warrant\":\"w-1\",\"why\":\"the operator approved the drop\"}", wire: "{\"local-name\":\"old\",\"module\":\"mesh-knowledge\",\"warrant\":\"w-1\",\"why\":\"the operator approved the drop\"}", + decode: func(b []byte) (any, error) { var v DropInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "list answers every entry", subject: "", given: "{\"entries\":[{\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"options\":{\"max-mib\":64},\"settings\":{\"max-mib\":64},\"storage-type\":\"ledger\",\"template\":\"ledger\"}]}", wire: "{\"entries\":[{\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"options\":{\"max-mib\":64},\"settings\":{\"max-mib\":64},\"storage-type\":\"ledger\",\"template\":\"ledger\"}]}", + decode: func(b []byte) (any, error) { var v ListAnswer; 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/go/seats/meshbroker/go.mod b/go/seats/meshbroker/go.mod new file mode 100644 index 0000000..b84250f --- /dev/null +++ b/go/seats/meshbroker/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/go/seats/meshbroker + +go 1.26.0 diff --git a/go/seats/meshconfig/contract.go b/go/seats/meshconfig/contract.go new file mode 100644 index 0000000..f7107e2 --- /dev/null +++ b/go/seats/meshconfig/contract.go @@ -0,0 +1,285 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package meshconfig is the contract of the seat "mesh-config", defined whole (seats/mesh-config/seat.json): The mesh's config values: read and set one, and the renames still open. mesh-config's own events stay under its own subjects (ADR 0620 §2). +package meshconfig + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "time" +) + +// Seat is the seat's name. +const Seat = "mesh-config" + +const ( + VerbConfig = "config" + VerbRenames = "renames" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbConfig, VerbRenames} +} + +// The holder's own events, listed beside the seat and published under the holder's subjects (ADR 0620 §2). +const ( + HolderEventConfigChanged = "config-changed" + HolderEventRenamed = "renamed" + HolderEventRetiring = "retiring" + HolderEventRetired = "retired" +) + +// HolderSubjectConfigChanged is where the holder publishes config-changed. +func HolderSubjectConfigChanged(holder string) string { + return "mesh.mod." + holder + ".event.config-changed" +} + +// HolderSubjectRenamed is where the holder publishes renamed, kept in config.events. +func HolderSubjectRenamed(holder string) string { return "mesh.stream." + holder + ".event.renamed" } + +// HolderSubjectRetiring is where the holder publishes retiring, kept in config.events. +func HolderSubjectRetiring(holder string) string { return "mesh.stream." + holder + ".event.retiring" } + +// HolderSubjectRetired is where the holder publishes retired, kept in config.events. +func HolderSubjectRetired(holder string) string { return "mesh.stream." + holder + ".event.retired" } + +const ( + StateResolved = "resolved" + StateKept = "kept" +) + +// ResolvedByNodeModuleKeyKey is the key of one value of the state resolved under the form "..". +func ResolvedByNodeModuleKeyKey(node, module, key string) string { + return node + "." + module + "." + key +} + +// ParseResolvedByNodeModuleKeyKey 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 ParseResolvedByNodeModuleKeyKey(key string) (string, string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", "", false + } + rest = after0 + v2, after2, found2 := strings.Cut(rest, ".") + if !found2 || v2 == "" { + return "", "", "", false + } + rest = after2 + if rest == "" { + return "", "", "", false + } + v4 := rest + rest = "" + if rest != "" { + return "", "", "", false + } + return v0, v2, v4, true +} + +// DecodeResolvedByNodeModuleKeyValue reads one value of the state resolved under the form "..", refusing a field ResolvedByNodeModuleKeyValue does not know and any text after the value. +func DecodeResolvedByNodeModuleKeyValue(raw []byte) (ResolvedByNodeModuleKeyValue, error) { + var v ResolvedByNodeModuleKeyValue + 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 +} + +// KeptByOwnerRepositoryNumberNodeModuleKeyKey is the key of one value of the state kept under the form ".....". +func KeptByOwnerRepositoryNumberNodeModuleKeyKey(owner, repository, number, node, module, key string) string { + return owner + "." + repository + "." + number + "." + node + "." + module + "." + key +} + +// ParseKeptByOwnerRepositoryNumberNodeModuleKeyKey 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 ParseKeptByOwnerRepositoryNumberNodeModuleKeyKey(key string) (string, string, string, string, string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", "", "", "", "", false + } + rest = after0 + v2, after2, found2 := strings.Cut(rest, ".") + if !found2 || v2 == "" { + return "", "", "", "", "", "", false + } + rest = after2 + v4, after4, found4 := strings.Cut(rest, ".") + if !found4 || v4 == "" { + return "", "", "", "", "", "", false + } + rest = after4 + v6, after6, found6 := strings.Cut(rest, ".") + if !found6 || v6 == "" { + return "", "", "", "", "", "", false + } + rest = after6 + v8, after8, found8 := strings.Cut(rest, ".") + if !found8 || v8 == "" { + return "", "", "", "", "", "", false + } + rest = after8 + if rest == "" { + return "", "", "", "", "", "", false + } + v10 := rest + rest = "" + if rest != "" { + return "", "", "", "", "", "", false + } + return v0, v2, v4, v6, v8, v10, true +} + +// DecodeKeptByOwnerRepositoryNumberNodeModuleKeyValue reads one value of the state kept under the form ".....", refusing a field KeptByOwnerRepositoryNumberNodeModuleKeyValue does not know and any text after the value. +func DecodeKeptByOwnerRepositoryNumberNodeModuleKeyValue(raw []byte) (KeptByOwnerRepositoryNumberNodeModuleKeyValue, error) { + var v KeptByOwnerRepositoryNumberNodeModuleKeyValue + 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 +} + +// ConfigInput is what config takes. +type ConfigInput struct { + // list (when not given), set, clear or history + Action string `json:"action,omitempty"` + // who gave the value at the controller's terminal + By string `json:"by,omitempty"` + // the config key + Key string `json:"key,omitempty"` + // for history: at most this many, newest + Limit float64 `json:"limit,omitempty"` + // a module: its own value, with node + Module string `json:"module,omitempty"` + // a node + Node string `json:"node,omitempty"` + // for set: the value, of the key's type: a number, a string, a boolean, or for a trusted key an object or a list + Value any `json:"value,omitempty"` + // for set and clear: why + Why string `json:"why,omitempty"` +} + +// ConfigAnswer is what config answers. +type ConfigAnswer = map[string]any + +// RenamesInput is what renames takes. +type RenamesInput struct { + // one module (optional) + Module string `json:"module,omitempty"` +} + +// RenamesAnswerRenamesItem is a type of the contract. +type RenamesAnswerRenamesItem struct { + // when it moved, RFC 3339 + At string `json:"at,omitempty"` + // the old name + From string `json:"from"` + // the module + Module string `json:"module"` + // the nodes still holding the old name open + Open []string `json:"open,omitempty"` + // the new name + To string `json:"to"` +} + +// RenamesAnswer is what renames answers. +type RenamesAnswer struct { + // every open rename + Renames []RenamesAnswerRenamesItem `json:"renames"` +} + +// RenamedHolderEvent is the body of the holder's event renamed. +type RenamedHolderEvent struct { + // when, RFC 3339 + At string `json:"at"` + // the old name + From string `json:"from"` + // the module + Module string `json:"module"` + // the new name + To string `json:"to"` +} + +// RetiringHolderEvent is the body of the holder's event retiring. +type RetiringHolderEvent struct { + // the ask's id + Ask string `json:"ask,omitempty"` + // the value's or secret's name + Key string `json:"key"` + Kind string `json:"kind"` + // the module + Module string `json:"module"` + // since when, RFC 3339 + Since string `json:"since"` +} + +// The values of RetiringHolderEvent's kind. +const ( + RetiringHolderEventKindValue = "value" + RetiringHolderEventKindSecret = "secret" +) + +// RetiredHolderEvent is the body of the holder's event retired. +type RetiredHolderEvent struct { + // the ask's id + Ask string `json:"ask,omitempty"` + // the module + Module string `json:"module"` + // the name + Name string `json:"name"` + // the node + Node string `json:"node,omitempty"` +} + +// ResolvedByNodeModuleKeyValue is one value of the state resolved under the key ... +type ResolvedByNodeModuleKeyValue struct { + Key string `json:"key"` + Module string `json:"module"` + Node string `json:"node"` + // the predefined key the value falls back to + Predefined string `json:"predefined,omitempty"` + // an unset value is the operator's own to set + Required bool `json:"required,omitempty"` + Source string `json:"source"` + Trusted bool `json:"trusted,omitempty"` + Value any `json:"value,omitempty"` +} + +// KeptByOwnerRepositoryNumberNodeModuleKeyValue is one value of the state kept under the key ...... +type KeptByOwnerRepositoryNumberNodeModuleKeyValue struct { + // who approved it, as the proposal kept it + Approved string `json:"approved,omitempty"` + // when it was kept + At *time.Time `json:"at,omitempty"` + By string `json:"by,omitempty"` + // the digest of what the declaration holds the value to; the value stays only while it is the same + Declaration string `json:"declaration"` + // the head the declaration was read at + Head string `json:"head"` + // the config key + Key string `json:"key"` + Module string `json:"module"` + Node string `json:"node"` + // the proposal's id + Proposal string `json:"proposal,omitempty"` + // the pull request, owner/repository#number + Pull string `json:"pull"` + // the value held, as the operator approved it + Value any `json:"value"` + // where the value was proposed from + Via string `json:"via,omitempty"` + Why string `json:"why,omitempty"` +} diff --git a/go/seats/meshconfig/contract_test.go b/go/seats/meshconfig/contract_test.go new file mode 100644 index 0000000..fed1bff --- /dev/null +++ b/go/seats/meshconfig/contract_test.go @@ -0,0 +1,74 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package meshconfig + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/mesh-config/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: "config-changed, unchanged", subject: "mesh.mod.mesh-config.event.config-changed", given: "{\"key\":\"x\"}", wire: "{\"key\":\"x\"}", + 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\\.config-changed$")}, + {name: "renamed", subject: "mesh.stream.mesh-config.event.renamed", given: "{\"at\":\"2026-11-04T12:00:00Z\",\"from\":\"old-key\",\"module\":\"messenger\",\"to\":\"new-key\"}", wire: "{\"at\":\"2026-11-04T12:00:00Z\",\"from\":\"old-key\",\"module\":\"messenger\",\"to\":\"new-key\"}", + decode: func(b []byte) (any, error) { var v RenamedHolderEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.stream\\.[a-z0-9-]+\\.event\\.renamed$")}, + {name: "retiring", subject: "mesh.stream.mesh-config.event.retiring", given: "{\"ask\":\"a1\",\"key\":\"old-key\",\"kind\":\"value\",\"module\":\"messenger\",\"since\":\"2026-11-04T12:00:00Z\"}", wire: "{\"ask\":\"a1\",\"key\":\"old-key\",\"kind\":\"value\",\"module\":\"messenger\",\"since\":\"2026-11-04T12:00:00Z\"}", + decode: func(b []byte) (any, error) { var v RetiringHolderEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.stream\\.[a-z0-9-]+\\.event\\.retiring$")}, + {name: "retired", subject: "mesh.stream.mesh-config.event.retired", given: "{\"ask\":\"a1\",\"module\":\"messenger\",\"name\":\"old-key\",\"node\":\"ace\"}", wire: "{\"ask\":\"a1\",\"module\":\"messenger\",\"name\":\"old-key\",\"node\":\"ace\"}", + decode: func(b []byte) (any, error) { var v RetiredHolderEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.stream\\.[a-z0-9-]+\\.event\\.retired$")}, + {name: "a module's value that falls back to a predefined key (captured)", subject: "", given: "{\"key\":\"font-size\",\"module\":\"dunst\",\"node\":\"g14\",\"predefined\":\"font-size\",\"source\":\"module\",\"value\":13}", wire: "{\"key\":\"font-size\",\"module\":\"dunst\",\"node\":\"g14\",\"predefined\":\"font-size\",\"source\":\"module\",\"value\":13}", + decode: func(b []byte) (any, error) { + var v ResolvedByNodeModuleKeyValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a module's own value (captured)", subject: "", given: "{\"key\":\"width\",\"module\":\"dunst\",\"node\":\"g14\",\"source\":\"module\",\"value\":500}", wire: "{\"key\":\"width\",\"module\":\"dunst\",\"node\":\"g14\",\"source\":\"module\",\"value\":500}", + decode: func(b []byte) (any, error) { + var v ResolvedByNodeModuleKeyValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a held value, as mesh-config writes it (the holder's own test values)", subject: "", given: "{\"approved\":\"operator@telegram\",\"at\":\"2026-11-05T12:30:00Z\",\"by\":\"the operator\",\"declaration\":\"sha256:3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b\",\"head\":\"abc1234def\",\"key\":\"media-gid\",\"module\":\"n8n\",\"node\":\"ace\",\"proposal\":\"p-1\",\"pull\":\"novox/mesh-catalog#434\",\"value\":2000,\"via\":\"telegram\",\"why\":\"the group the media share is owned by\"}", wire: "{\"approved\":\"operator@telegram\",\"at\":\"2026-11-05T12:30:00Z\",\"by\":\"the operator\",\"declaration\":\"sha256:3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b\",\"head\":\"abc1234def\",\"key\":\"media-gid\",\"module\":\"n8n\",\"node\":\"ace\",\"proposal\":\"p-1\",\"pull\":\"novox/mesh-catalog#434\",\"value\":2000,\"via\":\"telegram\",\"why\":\"the group the media share is owned by\"}", + decode: func(b []byte) (any, error) { + var v KeptByOwnerRepositoryNumberNodeModuleKeyValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "renames answers the open windows", subject: "", given: "{\"renames\":[{\"at\":\"2026-11-04T12:00:00Z\",\"from\":\"old-key\",\"module\":\"messenger\",\"open\":[\"g14\"],\"to\":\"new-key\"}]}", wire: "{\"renames\":[{\"at\":\"2026-11-04T12:00:00Z\",\"from\":\"old-key\",\"module\":\"messenger\",\"open\":[\"g14\"],\"to\":\"new-key\"}]}", + decode: func(b []byte) (any, error) { var v RenamesAnswer; 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/go/seats/meshconfig/go.mod b/go/seats/meshconfig/go.mod new file mode 100644 index 0000000..7f014f1 --- /dev/null +++ b/go/seats/meshconfig/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/go/seats/meshconfig + +go 1.26.0 diff --git a/go/seats/meshmoduleassigner/contract.go b/go/seats/meshmoduleassigner/contract.go new file mode 100644 index 0000000..e4ca190 --- /dev/null +++ b/go/seats/meshmoduleassigner/contract.go @@ -0,0 +1,685 @@ +// 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 assignments and the proposals that change it. +package meshmoduleassigner + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "time" +) + +// Seat is the seat's name. +const Seat = "mesh-module-assigner" + +const ( + VerbAssignments = "assignments" + VerbProposals = "proposals" + VerbPropose = "propose" + VerbWithdraw = "withdraw" + VerbAgain = "again" +) + +const ( + EventAssignmentsProposed = "assignments-proposed" + EventAssignmentsWithdrawn = "assignments-withdrawn" + EventAssignmentsJudged = "assignments-judged" + EventAssignmentsChanged = "assignments-changed" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbAssignments, VerbProposals, VerbPropose, VerbWithdraw, VerbAgain} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventAssignmentsProposed, EventAssignmentsWithdrawn, EventAssignmentsJudged, EventAssignmentsChanged} +} + +// 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 +) + +// SubjectAssignmentsProposed is where assignments-proposed is published. +const SubjectAssignmentsProposed = "mesh.seat.mesh-module-assigner.event.assignments-proposed" + +// SubjectAssignmentsWithdrawn is where assignments-withdrawn is published. +const SubjectAssignmentsWithdrawn = "mesh.seat.mesh-module-assigner.event.assignments-withdrawn" + +// SubjectAssignmentsJudged is where assignments-judged is published. +const SubjectAssignmentsJudged = "mesh.seat.mesh-module-assigner.event.assignments-judged" + +// SubjectAssignmentsChanged is where assignments-changed is published. +const SubjectAssignmentsChanged = "mesh.seat.mesh-module-assigner.event.assignments-changed" + +const ( + StateAssignments = "assignments" + StateProposals = "proposals" + StateCandidates = "candidates" + StatePulses = "pulses" + StateSeatHolders = "seat-holders" +) + +// AssignmentsByNodeNodeKey is the key of one value of the state assignments under the form ".node". +func AssignmentsByNodeNodeKey(node string) string { return node + ".node" } + +// ParseAssignmentsByNodeNodeKey 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 ParseAssignmentsByNodeNodeKey(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 +} + +// DecodeAssignmentsByNodeNodeValue reads one value of the state assignments under the form ".node", refusing a field AssignmentsByNodeNodeValue does not know and any text after the value. +func DecodeAssignmentsByNodeNodeValue(raw []byte) (AssignmentsByNodeNodeValue, error) { + var v AssignmentsByNodeNodeValue + 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 +} + +// AssignmentsByNodeModuleKey is the key of one value of the state assignments under the form ".". +func AssignmentsByNodeModuleKey(node, module string) string { return node + "." + module } + +// ParseAssignmentsByNodeModuleKey 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 ParseAssignmentsByNodeModuleKey(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 +} + +// DecodeAssignmentsByNodeModuleValue reads one value of the state assignments under the form ".", refusing a field AssignmentsByNodeModuleValue does not know and any text after the value. +func DecodeAssignmentsByNodeModuleValue(raw []byte) (AssignmentsByNodeModuleValue, error) { + var v AssignmentsByNodeModuleValue + 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 +} + +// Assignments is one node's assignments assembled. +type Assignments 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"` +} + +// AssignmentsEventWarrant is who approved it, through which channel, when. +type AssignmentsEventWarrant struct { + At time.Time `json:"at"` + Warrant string `json:"warrant"` + Words string `json:"words"` +} + +// AssignmentsEvent is what each assignments event carries. +type AssignmentsEvent struct { + // for assignments-withdrawn, why it ended + Ended string `json:"ended,omitempty"` + // for assignments-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 *AssignmentsEventWarrant `json:"warrant,omitempty"` +} + +// FoundTunnel is a tunnel found on the machine when it was adopted: everything but its private key. +type FoundTunnel struct { + Address string `json:"address,omitempty"` + Config string `json:"config,omitempty"` + Interface string `json:"interface"` + Mtu int64 `json:"mtu,omitempty"` + Peers []FoundTunnelPeer `json:"peers,omitempty"` + Port int64 `json:"port,omitempty"` + PublicKey string `json:"public-key,omitempty"` + Range string `json:"range,omitempty"` + Unit string `json:"unit,omitempty"` +} + +// FoundTunnelPeer is one peer of a found tunnel. +type FoundTunnelPeer struct { + Address string `json:"address,omitempty"` + PublicKey string `json:"public-key"` +} + +// 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 *FoundTunnel `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 assignments + Layer map[string]any `json:"layer,omitempty"` + // the whole mesh's settings layer of the module's assignments + 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"` +} + +// Proposal is an open proposal. +type Proposal struct { + Again *time.Time `json:"again,omitempty"` + Ask string `json:"ask,omitempty"` + At time.Time `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 *time.Time `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 assignments, for a seed + Whole *Assignments `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 time.Time `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 *time.Time `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"` +} + +// AssignmentsInput is what assignments takes. +type AssignmentsInput 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 AssignmentsInput's history. +const ( + AssignmentsInputHistoryTrue = "true" + AssignmentsInputHistoryFalse = "false" +) + +// The values of AssignmentsInput's seats. +const ( + AssignmentsInputSeatsTrue = "true" + AssignmentsInputSeatsFalse = "false" +) + +// AssignmentsAnswer is what assignments answers. +type AssignmentsAnswer = 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 + +// AssignmentsProposedEvent is the body of the event assignments-proposed. +type AssignmentsProposedEvent = AssignmentsEvent + +// AssignmentsWithdrawnEvent is the body of the event assignments-withdrawn. +type AssignmentsWithdrawnEvent = AssignmentsEvent + +// AssignmentsJudgedEvent is the body of the event assignments-judged. +type AssignmentsJudgedEvent = AssignmentsEvent + +// AssignmentsChangedEvent is the body of the event assignments-changed. +type AssignmentsChangedEvent = AssignmentsEvent + +// AssignmentsByNodeNodeValueModulesItem is a type of the contract. +type AssignmentsByNodeNodeValueModulesItem struct { + // the digest of the module key's value it stands for + Digest string `json:"digest"` + Module string `json:"module"` +} + +// AssignmentsByNodeNodeValue is one value of the state assignments under the key .node. +type AssignmentsByNodeNodeValue struct { + Decided NodeDecided `json:"decided"` + Holds []Holding `json:"holds,omitempty"` + // the modules placed, in the order the composition reads them + Modules []AssignmentsByNodeNodeValueModulesItem `json:"modules"` + Node string `json:"node"` + Pins map[string]Provider `json:"pins,omitempty"` + V int64 `json:"v,omitempty"` +} + +// AssignmentsByNodeModuleValue is one value of the state assignments under the key .. +type AssignmentsByNodeModuleValue 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 assignments with the proposal laid over it; absent when the proposal takes its key away + Assignments *Assignments `json:"assignments,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/go/seats/meshmoduleassigner/contract_test.go b/go/seats/meshmoduleassigner/contract_test.go new file mode 100644 index 0000000..29ddac3 --- /dev/null +++ b/go/seats/meshmoduleassigner/contract_test.go @@ -0,0 +1,96 @@ +// 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 assignments-proposed", subject: "mesh.seat.mesh-module-assigner.event.assignments-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 AssignmentsProposedEvent + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-module-assigner\\.event\\.assignments-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 AssignmentsByNodeNodeValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a node that came with a tunnel", subject: "", given: "{\"decided\":{\"tunnel\":{\"interface\":\"wg0\",\"peers\":[{\"address\":\"10.77.0.9/32\",\"public-key\":\"k2\"}],\"port\":51820,\"public-key\":\"k1\"}},\"modules\":[],\"node\":\"ace\",\"v\":1}", wire: "{\"decided\":{\"tunnel\":{\"interface\":\"wg0\",\"peers\":[{\"address\":\"10.77.0.9/32\",\"public-key\":\"k2\"}],\"port\":51820,\"public-key\":\"k1\"}},\"modules\":[],\"node\":\"ace\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v AssignmentsByNodeNodeValue + 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 AssignmentsByNodeModuleValue + 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/go/seats/meshmoduleassigner/go.mod b/go/seats/meshmoduleassigner/go.mod new file mode 100644 index 0000000..2d1f766 --- /dev/null +++ b/go/seats/meshmoduleassigner/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner + +go 1.26.0 diff --git a/go/seats/nodeengine/contract.go b/go/seats/nodeengine/contract.go new file mode 100644 index 0000000..26dc404 --- /dev/null +++ b/go/seats/nodeengine/contract.go @@ -0,0 +1,1413 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package nodeengine is the contract of the seat "node-engine", defined whole (seats/node-engine/seat.json): The node seat every node's engine holds (novox/hq ADR 0563, ADR 0618 §3, §4): its hand acts on the operator's warrant, its read-only judgements, its compositions and what it reads of a manifest. Each verb is answered on this node's subject, mesh.seat.node-engine.tool.., and each event is published on mesh.seat.node-engine.event.., the node's name its last token. +package nodeengine + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "time" +) + +// Seat is the seat's name. +const Seat = "node-engine" + +const ( + VerbHandOver = "hand-over" + VerbAccessAct = "access-act" + VerbJudgeDirectories = "judge-directories" + VerbSetuidSearch = "setuid-search" + VerbRootFree = "root-free" + VerbActs = "acts" + VerbPackages = "packages" + VerbComposition = "composition" + VerbCompose = "compose" + VerbComposeCandidate = "compose-candidate" + VerbManifestReads = "manifest-reads" + VerbSeatList = "seat-list" + VerbSeatListAgain = "seat-list-again" +) + +const ( + EventAsked = "asked" + EventDone = "done" + EventRefused = "refused" + EventManifestReadsChanged = "manifest-reads-changed" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbHandOver, VerbAccessAct, VerbJudgeDirectories, VerbSetuidSearch, VerbRootFree, VerbActs, VerbPackages, VerbComposition, VerbCompose, VerbComposeCandidate, VerbManifestReads, VerbSeatList, VerbSeatListAgain} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventAsked, EventDone, EventRefused, EventManifestReadsChanged} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "node-engine.events" + FamilyMaxAgeDays = 7 +) + +// SubjectAsked is where asked is published for one caller. +func SubjectAsked(caller string) string { return "mesh.seat.node-engine.event.asked." + caller } + +// SubjectDone is where done is published for one caller. +func SubjectDone(caller string) string { return "mesh.seat.node-engine.event.done." + caller } + +// SubjectRefused is where refused is published for one caller. +func SubjectRefused(caller string) string { return "mesh.seat.node-engine.event.refused." + caller } + +// SubjectManifestReadsChanged is where manifest-reads-changed is published for one caller. +func SubjectManifestReadsChanged(caller string) string { + return "mesh.seat.node-engine.event.manifest-reads-changed." + caller +} + +const ( + StateHave = "have" + StateComposed = "composed" + StateData = "data" + StatePackages = "packages" + StateHealth = "health" + StateFindings = "findings" +) + +// HaveByNodeModuleKey is the key of one value of the state have under the form ".". +func HaveByNodeModuleKey(node, module string) string { return node + "." + module } + +// ParseHaveByNodeModuleKey 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 ParseHaveByNodeModuleKey(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 +} + +// DecodeHaveByNodeModuleValue reads one value of the state have under the form ".", refusing a field HaveByNodeModuleValue does not know and any text after the value. +func DecodeHaveByNodeModuleValue(raw []byte) (HaveByNodeModuleValue, error) { + var v HaveByNodeModuleValue + 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 +} + +// HaveByNodeNodeKey is the key of one value of the state have under the form ".node". +func HaveByNodeNodeKey(node string) string { return node + ".node" } + +// ParseHaveByNodeNodeKey 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 ParseHaveByNodeNodeKey(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 +} + +// DecodeHaveByNodeNodeValue reads one value of the state have under the form ".node", refusing a field HaveByNodeNodeValue does not know and any text after the value. +func DecodeHaveByNodeNodeValue(raw []byte) (HaveByNodeNodeValue, error) { + var v HaveByNodeNodeValue + 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 +} + +// ComposedByNodeModuleFingerprintKey is the key of one value of the state composed under the form "..". +func ComposedByNodeModuleFingerprintKey(node, module, fingerprint string) string { + return node + "." + module + "." + fingerprint +} + +// ParseComposedByNodeModuleFingerprintKey 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 ParseComposedByNodeModuleFingerprintKey(key string) (string, string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", "", false + } + rest = after0 + v2, after2, found2 := strings.Cut(rest, ".") + if !found2 || v2 == "" { + return "", "", "", false + } + rest = after2 + if rest == "" { + return "", "", "", false + } + v4 := rest + rest = "" + if rest != "" { + return "", "", "", false + } + return v0, v2, v4, true +} + +// DecodeComposedByNodeModuleFingerprintValue reads one value of the state composed under the form "..", refusing a field ComposedByNodeModuleFingerprintValue does not know and any text after the value. +func DecodeComposedByNodeModuleFingerprintValue(raw []byte) (ComposedByNodeModuleFingerprintValue, error) { + var v ComposedByNodeModuleFingerprintValue + 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 +} + +// ComposedByNodeNodeKey is the key of one value of the state composed under the form ".node". +func ComposedByNodeNodeKey(node string) string { return node + ".node" } + +// ParseComposedByNodeNodeKey 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 ParseComposedByNodeNodeKey(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 +} + +// DecodeComposedByNodeNodeValue reads one value of the state composed under the form ".node", refusing a field ComposedByNodeNodeValue does not know and any text after the value. +func DecodeComposedByNodeNodeValue(raw []byte) (ComposedByNodeNodeValue, error) { + var v ComposedByNodeNodeValue + 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 +} + +// DataByNodeModuleKey is the key of one value of the state data under the form ".". +func DataByNodeModuleKey(node, module string) string { return node + "." + module } + +// ParseDataByNodeModuleKey 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 ParseDataByNodeModuleKey(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 +} + +// DecodeDataByNodeModuleValue reads one value of the state data under the form ".", refusing a field DataByNodeModuleValue does not know and any text after the value. +func DecodeDataByNodeModuleValue(raw []byte) (DataByNodeModuleValue, error) { + var v DataByNodeModuleValue + 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 +} + +// PackagesByNodeKey is the key of one value of the state packages under the form "". +func PackagesByNodeKey(node string) string { return node } + +// ParsePackagesByNodeKey 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 ParsePackagesByNodeKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodePackagesByNodeValue reads one value of the state packages under the form "", refusing a field PackagesByNodeValue does not know and any text after the value. +func DecodePackagesByNodeValue(raw []byte) (PackagesByNodeValue, error) { + var v PackagesByNodeValue + 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 +} + +// HealthByNodeKey is the key of one value of the state health under the form "". +func HealthByNodeKey(node string) string { return node } + +// ParseHealthByNodeKey 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 ParseHealthByNodeKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeHealthByNodeValue reads one value of the state health under the form "", refusing a field HealthByNodeValue does not know and any text after the value. +func DecodeHealthByNodeValue(raw []byte) (HealthByNodeValue, error) { + var v HealthByNodeValue + 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 +} + +// FindingsByKeyKey is the key of one value of the state findings under the form "". +func FindingsByKeyKey(key string) string { return key } + +// ParseFindingsByKeyKey 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 ParseFindingsByKeyKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeFindingsByKeyValue reads one value of the state findings under the form "", refusing a field FindingsByKeyValue does not know and any text after the value. +func DecodeFindingsByKeyValue(raw []byte) (FindingsByKeyValue, error) { + var v FindingsByKeyValue + 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 +} + +// AccountsHanded is the hashes of the users of the list, sealed to the vault. +type AccountsHanded struct { + // when + At time.Time `json:"at"` + Box SealedBox `json:"box"` + From string `json:"from"` + // the fingerprint of the vault's key + To string `json:"to"` + Users int64 `json:"users"` +} + +// Act is one act asked through the seat, as acts reads it (ADR 0563 §9). +type Act struct { + // the act: hand-over, access-act, judge-directories or setuid-search + Act string `json:"act"` + // the operator's ask of a hand-over or an access act + Ask string `json:"ask,omitempty"` + // when it was asked, RFC 3339 + At string `json:"at"` + // the engine asked it itself, on finding a library's root holding data outside its group (ADR 0563 §4) + Auto bool `json:"auto,omitempty"` + // who answered, as the operator's warrant names them + By string `json:"by,omitempty"` + // the caller as the runtime proved it + Caller string `json:"caller"` + // what the engine read on disk when it judged + Disk string `json:"disk,omitempty"` + // when it ended, RFC 3339 + Ended string `json:"ended,omitempty"` + // when the ask ends unanswered, RFC 3339 + Expires string `json:"expires,omitempty"` + // the act's id + ID string `json:"id"` + // the directory the act changes + Path string `json:"path,omitempty"` + // the warrant's proofs + Proofs []string `json:"proofs,omitempty"` + // what the engine said of it: its outcome or why it was refused + Said string `json:"said,omitempty"` + // the owner, group and mode it sets, in words + Sets string `json:"sets,omitempty"` + // where it stands + State string `json:"state"` + // the path of a hand-over, or the access of an access act + Subject string `json:"subject,omitempty"` + // the channel the warrant came on + Via string `json:"via,omitempty"` + // why it was asked, in words + Why string `json:"why,omitempty"` +} + +// The values of Act's state. +const ( + ActStateAsked = "asked" + ActStateDone = "done" + ActStateRefused = "refused" + ActStateAnswered = "answered" +) + +// ActSaid is what the seat says of an act: the act, the path or access, the caller as the runtime proved it and the ask (ADR 0563 §1). +type ActSaid struct { + // the act + Act string `json:"act"` + // the operator's ask + Ask string `json:"ask,omitempty"` + // the caller as the runtime proved it + Caller string `json:"caller"` + // the node whose engine says it + Node string `json:"node"` + // what the engine said of it + Said string `json:"said,omitempty"` + // where the act stands + State string `json:"state"` + // the path or the access + Subject string `json:"subject,omitempty"` +} + +// The values of ActSaid's state. +const ( + ActSaidStateAsked = "asked" + ActSaidStateDone = "done" + ActSaidStateRefused = "refused" +) + +// Acts is the acts kept. +type Acts struct { + // the acts, newest first + Acts []Act `json:"acts"` + // the node + Node string `json:"node"` +} + +// Adoption is the node is adopted and which of its modules are taken. +type Adoption struct { + // the modules taken + Taken []string `json:"taken"` + // by module, the resources left untaken + Untaken map[string][]string `json:"untaken,omitempty"` +} + +// Asked is the answer of an act the operator is asked. +type Asked struct { + // the ask's id: acts shows where it stands + Asked string `json:"asked"` + // the node + Node string `json:"node"` + // what happens next, in words + Says string `json:"says"` +} + +// AttachedNetwork is a network attached to a link. +type AttachedNetwork struct { + Link string `json:"link"` + Network string `json:"network"` +} + +// CandidateAdded is a module the candidate adds. +type CandidateAdded struct { + Composed string `json:"composed,omitempty"` + Module string `json:"module"` + Unissued []string `json:"unissued,omitempty"` +} + +// CandidateAnswer is the node's answer to one placement candidate naming it. +type CandidateAnswer struct { + Added []CandidateAdded `json:"added,omitempty"` + Differs []CandidateDiffers `json:"differs,omitempty"` + Held []CandidateRefusal `json:"held,omitempty"` + Note string `json:"note,omitempty"` + Proposal string `json:"proposal"` + Refusals []CandidateRefusal `json:"refusals,omitempty"` + // why the node cannot compose with the candidate at all + Refused string `json:"refused,omitempty"` + // modules the candidate removes + Removed []string `json:"removed,omitempty"` + Returns string `json:"returns,omitempty"` + Revision int64 `json:"revision"` + // modules whose composition is the one running + Same []string `json:"same,omitempty"` + ValuesDiffer int64 `json:"values_differ,omitempty"` + // same, differs or refused + Verdict string `json:"verdict"` +} + +// The values of CandidateAnswer's verdict. +const ( + CandidateAnswerVerdictSame = "same" + CandidateAnswerVerdictDiffers = "differs" + CandidateAnswerVerdictRefused = "refused" +) + +// CandidateBodies is the bodies of one resource that differ. +type CandidateBodies struct { + Composed string `json:"composed,omitempty"` + ID string `json:"id"` + Paths []string `json:"paths,omitempty"` + Running string `json:"running,omitempty"` + Why string `json:"why"` +} + +// CandidateDiffers is a module whose composition with the candidate differs from the one running. +type CandidateDiffers struct { + Bodies []CandidateBodies `json:"bodies,omitempty"` + Composed string `json:"composed,omitempty"` + Module string `json:"module"` + Resource string `json:"resource,omitempty"` + Running string `json:"running,omitempty"` + Why string `json:"why"` +} + +// CandidateRefusal is a module refused or held with the candidate. +type CandidateRefusal struct { + Module string `json:"module"` + Today string `json:"today,omitempty"` + Why string `json:"why"` +} + +// DeclaredGroup is a group. +type DeclaredGroup struct { + Gid int64 `json:"gid"` + Name string `json:"name"` +} + +// DeclaredIdentity is the identity of a module assigned to the node. +type DeclaredIdentity struct { + Gid int64 `json:"gid"` + Groups []int64 `json:"groups,omitempty"` + Module string `json:"module"` + Name string `json:"name"` + Runner string `json:"runner,omitempty"` + Uid int64 `json:"uid"` +} + +// DeclaredLibrary is a library the node is reached by. +type DeclaredLibrary struct { + Gid int64 `json:"gid"` + Group string `json:"group"` + Home bool `json:"home"` + Library string `json:"library"` + Paths []string `json:"paths,omitempty"` + Root string `json:"root"` + Runner string `json:"runner,omitempty"` + Taken bool `json:"taken,omitempty"` +} + +// FailedUnit is a unit that failed. +type FailedUnit struct { + Load string `json:"load,omitempty"` + Resource string `json:"resource,omitempty"` + Result string `json:"result,omitempty"` + Scope string `json:"scope"` + // since when + Since time.Time `json:"since"` + Unit string `json:"unit"` +} + +// FindingAction is one answer the operator may give from a notification. +type FindingAction struct { + Arguments map[string]string `json:"arguments,omitempty"` + Label string `json:"label"` + Level string `json:"level,omitempty"` + Machine string `json:"machine,omitempty"` + // the seat verb the operator channel calls + Verb string `json:"verb"` +} + +// The values of FindingAction's level. +const ( + FindingActionLevelAcknowledge = "acknowledge" + FindingActionLevelApprove = "approve" +) + +// FindingEvidence is one observation, as it was said. +type FindingEvidence struct { + At time.Time `json:"at"` + Said string `json:"said"` +} + +// FindingSubject is what the condition is about. +type FindingSubject struct { + // the other machines it concerns + Also []string `json:"also,omitempty"` + // its id within the scope + ID string `json:"id"` + // the machine it concerns, when there is one + Machine string `json:"machine,omitempty"` + // machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module + Scope string `json:"scope"` +} + +// LoginFallback is a login that fell back to another node. +type LoginFallback struct { + // when + At time.Time `json:"at"` + From string `json:"from"` + Node string `json:"node"` + Why string `json:"why"` +} + +// ManifestReadsChanged is what a node-engine says when what it reads changed or its node returned (ADR 0618 §4). +type ManifestReadsChanged struct { + // when it says it, RFC 3339 + At string `json:"at"` + // what its engine states it reads now + ManifestReads []string `json:"manifest-reads,omitempty"` + // the node + Node string `json:"node"` + // the layout's version + V int64 `json:"v"` + // started: the engine started or what it states changed; returned: its node returned after being away + Why string `json:"why"` +} + +// The values of ManifestReadsChanged's why. +const ( + ManifestReadsChangedWhyStarted = "started" + ManifestReadsChangedWhyReturned = "returned" +) + +// Module is one module's composition with the candidate. +type Module struct { + // the candidate changes it from the one composed without it + Changed bool `json:"changed,omitempty"` + // the composition's digest when composed + Digest string `json:"digest,omitempty"` + // the fingerprint it was composed at + Fp string `json:"fp,omitempty"` + // the module, node for the node's own composition + Module string `json:"module"` + // composed (digest set), refused by the candidate, or held without it too (words set) + State string `json:"state"` + // why, for a refused or held one + Words string `json:"words,omitempty"` +} + +// The values of Module's state. +const ( + ModuleStateComposed = "composed" + ModuleStateRefused = "refused" + ModuleStateHeld = "held" +) + +// NetworkHealth is the node's network. +type NetworkHealth struct { + Parts []NetworkPart `json:"parts"` + // since when + Since time.Time `json:"since"` + // running, degraded or unknown + State string `json:"state"` +} + +// NetworkPart is one part of the node's network. +type NetworkPart struct { + Owner string `json:"owner,omitempty"` + Part string `json:"part"` + Reason string `json:"reason,omitempty"` + Said string `json:"said,omitempty"` + // since when + Since time.Time `json:"since"` + State string `json:"state"` + Streak int64 `json:"streak,omitempty"` + Targets []string `json:"targets,omitempty"` + Toward []string `json:"toward,omitempty"` + Writer string `json:"writer,omitempty"` +} + +// NodeAccount is an account the node needs to log in again. +type NodeAccount struct { + Account string `json:"account"` + Module string `json:"module,omitempty"` + Reason string `json:"reason"` + Resource string `json:"resource,omitempty"` + // since when + Since time.Time `json:"since"` +} + +// NodeDirectory is a directory found where the node expected none. +type NodeDirectory struct { + Module string `json:"module"` + Path string `json:"path"` + Reason string `json:"reason"` + Resource string `json:"resource"` + // since when + Since time.Time `json:"since"` +} + +// NodeOwn is what a node's own composition carries beside its resources. +type NodeOwn struct { + Adoption *Adoption `json:"adoption,omitempty"` + Groups []DeclaredGroup `json:"groups,omitempty"` + Identities []DeclaredIdentity `json:"identities,omitempty"` + Libraries []DeclaredLibrary `json:"libraries,omitempty"` + // the account the provisioner's client runs as on the node + ProvisionerAccount string `json:"provisioner_account,omitempty"` +} + +// NodeRecord is what the machine observes of itself, for every node and composing part to read. +type NodeRecord struct { + Authority string `json:"authority,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + Certificate string `json:"certificate,omitempty"` + Contract int64 `json:"contract,omitempty"` + ManifestReads []string `json:"manifest-reads,omitempty"` + Networks []AttachedNetwork `json:"networks,omitempty"` + NetworksSaid bool `json:"networks-said,omitempty"` + Node string `json:"node"` + OutwardLinks []string `json:"outward-links,omitempty"` + SealingKey string `json:"sealing-key,omitempty"` + ServingKey string `json:"serving-key,omitempty"` + Trusts []string `json:"trusts,omitempty"` + TunnelKey string `json:"tunnel-key,omitempty"` + V int64 `json:"v"` +} + +// PeerSeen is a tunnel peer and whether it is seen. +type PeerSeen struct { + Addresses []string `json:"addresses,omitempty"` + Key string `json:"key"` + Seen bool `json:"seen"` +} + +// PlacedDataItem is one data item a module declares, as placed on a node. +type PlacedDataItem struct { + Class string `json:"class"` + // the path whose snapshot keeps it, empty when it is not backed up + CoveredBy string `json:"covered_by,omitempty"` + Item string `json:"item"` + // walk, dataset or shallow + Measure string `json:"measure,omitempty"` + Module string `json:"module"` + // the item's place on the node + Path string `json:"path"` + // backup, redundancy, both or none + Protection string `json:"protection,omitempty"` +} + +// PullWait is the pull from a store a failed apply waits on. +type PullWait struct { + // when the pull began + Began time.Time `json:"began"` + // that seat's holder, whose settling the apply waits for + Holder string `json:"holder"` + Image string `json:"image"` + // what triggered the last try again, in words + Retried string `json:"retried,omitempty"` + // when the apply was last tried again + RetriedAt *time.Time `json:"retried-at,omitempty"` + // the seat the pull came through + Seat string `json:"seat,omitempty"` +} + +// Revision is one revision of a composition key. +type Revision struct { + // its digest + Digest string `json:"digest,omitempty"` + // the composition's key + Key string `json:"key"` + // the latest revision + Latest bool `json:"latest,omitempty"` + // why it cannot be read or trusted + Refused string `json:"refused,omitempty"` + // how many resources it names + Resources int64 `json:"resources,omitempty"` + // its revision + Revision int64 `json:"revision,omitempty"` +} + +// RootAccountVerdict is the engine's verdict of whether an account's resource is root-free, as the node's health lists it. +type RootAccountVerdict struct { + Account string `json:"account"` + Module string `json:"module"` + Reason string `json:"reason,omitempty"` + Resource string `json:"resource"` + // since when + Since time.Time `json:"since"` + State string `json:"state"` +} + +// RootVerdict is the engine's root-free verdict (ADR 0563 §6). +type RootVerdict struct { + // the machine is root-free + Free bool `json:"free"` + // when the engine judged it, RFC 3339 + Judged string `json:"judged"` + // the machine + Machine string `json:"machine"` + // why, in words + Why string `json:"why"` +} + +// Said is the answer of a read-only judgement. +type Said struct { + // the node + Node string `json:"node"` + // the engine's judgement or search, one line per directory or program + Said string `json:"said"` +} + +// SealedBox is a value sealed to a key. +type SealedBox struct { + Ct string `json:"ct"` + Eph string `json:"eph"` + Iv string `json:"iv"` + Tag string `json:"tag"` + V int64 `json:"v"` +} + +// SeatListStatus is a type of the contract. +type SeatListStatus struct { + // the seat mesh-catalog has answered + Heard bool `json:"heard"` + Held []SeatListHeld `json:"held"` + LastError string `json:"last-error,omitempty"` + // a trunk definition that does not parse, by seat: the one held stands + Refused map[string]string `json:"refused,omitempty"` + Revision int64 `json:"revision,omitempty"` + // the seats held from the bootstrap alone + Waiting []string `json:"waiting,omitempty"` +} + +// SeatList is what an engine holds of the seat list (novox/hq ADR 0636 §7). +type SeatList struct { + // why the read failed, for seat-list-again + Error string `json:"error,omitempty"` + // the one line the engine says of it; empty when nothing waits + Health string `json:"health"` + Status SeatListStatus `json:"status"` +} + +// SeatListHeld is a type of the contract. +type SeatListHeld struct { + // the digest of the definition held; empty for a bootstrapped one + Digest string `json:"digest,omitempty"` + Seat string `json:"seat"` + Source string `json:"source"` +} + +// The values of SeatListHeld's source. +const ( + SeatListHeldSourceTrunk = "trunk" + SeatListHeldSourceBootstrap = "bootstrap" +) + +// UnitsHealth is the node's units. +type UnitsHealth struct { + Away []string `json:"away,omitempty"` + Failed []FailedUnit `json:"failed"` + State string `json:"state"` + Unread []string `json:"unread,omitempty"` +} + +// UserListWritten is the bus's user list as the engine of the bus node wrote it to the bus module's file. +type UserListWritten struct { + // when + At time.Time `json:"at"` + // the digest of the file on the machine after the write + Digest string `json:"digest,omitempty"` + // why a write failed + Error string `json:"error,omitempty"` + // which list the node follows now + Follows string `json:"follows,omitempty"` + Handed *AccountsHanded `json:"handed,omitempty"` + // the bus node's sealing key, its public half + Key string `json:"key,omitempty"` + Revision int64 `json:"revision"` + // the key the list was taken from + Source string `json:"source"` + // why the bus node does not watch the grants part's key now + Unwatched string `json:"unwatched,omitempty"` + Users int64 `json:"users"` + // the digest of the list the source holds, sha256: + Wanted string `json:"wanted"` +} + +// HandOverInput is what hand-over takes. +type HandOverInput struct { + // the directory's absolute path, as the engine states it + Path string `json:"path"` + // why it is asked, in words the operator reads + Why string `json:"why,omitempty"` +} + +// HandOverAnswer is what hand-over answers. +type HandOverAnswer = Asked + +// AccessActInput is what access-act takes. +type AccessActInput struct { + // the access, library: + Access string `json:"access"` + // the act: take + Act string `json:"act"` + // why it is asked, in words the operator reads + Why string `json:"why,omitempty"` +} + +// AccessActAnswer is what access-act answers. +type AccessActAnswer = Asked + +// JudgeDirectoriesInput is what judge-directories takes. +type JudgeDirectoriesInput struct { +} + +// JudgeDirectoriesAnswer is what judge-directories answers. +type JudgeDirectoriesAnswer = Said + +// SetuidSearchInput is what setuid-search takes. +type SetuidSearchInput struct { + // why it is asked, in words + Why string `json:"why,omitempty"` +} + +// SetuidSearchAnswer is what setuid-search answers. +type SetuidSearchAnswer = Said + +// RootFreeInput is what root-free takes. +type RootFreeInput struct { +} + +// RootFreeAnswer is what root-free answers. +type RootFreeAnswer struct { + // this machine's verdict + Machines []RootVerdict `json:"machines"` +} + +// ActsInput is what acts takes. +type ActsInput struct { + // one act, by its id + ID string `json:"id,omitempty"` + // at most this many, newest + Limit string `json:"limit,omitempty"` +} + +// ActsAnswer is what acts answers. +type ActsAnswer = Acts + +// PackagesInput is what packages takes. +type PackagesInput struct { + // "true": write this machine's key again from what the engine composed + Write string `json:"write,omitempty"` +} + +// The values of PackagesInput's write. +const ( + PackagesInputWriteTrue = "true" + PackagesInputWriteFalse = "false" +) + +// PackagesAnswerKeyPackagesItem is a type of the contract. +type PackagesAnswerKeyPackagesItem struct { + // declared absent + Absent bool `json:"absent"` + // the modules that declare it + Modules []string `json:"modules"` + // the package + Package string `json:"package"` +} + +// PackagesAnswerKey is the node's key as the bus holds it; absent when the engine wrote none. +type PackagesAnswerKey struct { + // the node + Node string `json:"node"` + // its packages sorted by name, absent after present + Packages []PackagesAnswerKeyPackagesItem `json:"packages"` +} + +// PackagesAnswer is what packages answers. +type PackagesAnswer struct { + // the node's key as the bus holds it; absent when the engine wrote none + Key *PackagesAnswerKey `json:"key,omitempty"` + // why the key could not be read + KeyError string `json:"key_error,omitempty"` + // the node + Node string `json:"node"` + // a note, such as that no key is written yet + Note string `json:"note,omitempty"` + // where the key is read: the engine's state packages + Source string `json:"source"` + // with write: the key was written again (false: it already held what the engine composed) + Wrote bool `json:"wrote,omitempty"` +} + +// CompositionInput is what composition takes. +type CompositionInput struct { + // "true": list the resources that differ from what runs + Diff string `json:"diff,omitempty"` + // a composition's digest (sha256:…), to read that revision rather than the latest + Digest string `json:"digest,omitempty"` + // the module, as placed on this machine; node for the node's own + Module string `json:"module"` +} + +// CompositionAnswerAttempt is the module's last apply that did not end running; absent when none. +type CompositionAnswerAttempt struct { + // the want's revision the hold took + AnsweredRevision int64 `json:"answered_revision,omitempty"` + // the digest tried + Composed string `json:"composed,omitempty"` + // the composition's revision it was tried at + CompositionRevision int64 `json:"composition_revision"` + // the fingerprint tried + Fp string `json:"fp"` + // held for a failed backup + Held bool `json:"held,omitempty"` + // it failed because the bus did not answer for a moment + Transient bool `json:"transient,omitempty"` + // the want's revision it was tried at + WantRevision int64 `json:"want_revision"` +} + +// CompositionAnswerHave is what the module's have key says now; absent when it says nothing. +type CompositionAnswerHave struct { + // digest + Composed string `json:"composed,omitempty"` + // detail + Detail string `json:"detail,omitempty"` + // fingerprint + Fp string `json:"fp,omitempty"` + // judgement + Judgement string `json:"judgement,omitempty"` + // running, failed, held or waiting-yes + Phase string `json:"phase"` + // the digest that waits for a yes + Waits string `json:"waits,omitempty"` +} + +// CompositionAnswerJudged is the move from what runs to the composition read. +type CompositionAnswerJudged struct { + // the judgement + Judgement string `json:"judgement"` + // the agent would wait for a person's yes before applying it + WaitsForYes bool `json:"waits_for_yes"` + // its words + Words string `json:"words"` +} + +// CompositionAnswerOwn is of the node's own composition, its own fields beside what runs names. +type CompositionAnswerOwn struct { + // the revision's adoption; absent for a converged node + Adoption map[string]any `json:"adoption,omitempty"` + // groups + Groups int64 `json:"groups,omitempty"` + // each module the node's composer held in its last pass, with why + Held map[string]string `json:"held,omitempty"` + // identities the revision names + Identities int64 `json:"identities,omitempty"` + // libraries + Libraries int64 `json:"libraries,omitempty"` + // the account the provisioner's client runs as, as the revision names it + ProvisionerAccount string `json:"provisioner_account"` + // as what runs names it + RunsProvisionerAccount string `json:"runs_provisioner_account,omitempty"` +} + +// CompositionAnswerRuns is what runs of the module; absent when nothing runs. +type CompositionAnswerRuns struct { + // digest + Composed string `json:"composed,omitempty"` + // fingerprint + Fp string `json:"fp,omitempty"` + // applied, have or kept + From string `json:"from"` + // phase + Phase string `json:"phase,omitempty"` + // how many resources run + Resources int64 `json:"resources"` +} + +// CompositionAnswerWant is the module's want as the agent holds it; absent when it holds none. +type CompositionAnswerWant struct { + // the operator's answer to a failed backup, again or skip + Backup string `json:"backup,omitempty"` + // the composition's digest it names + Composed string `json:"composed,omitempty"` + // its build's fingerprint + Fp string `json:"fp"` + // the module is removed + Removed bool `json:"removed,omitempty"` + // the revision of the want's key read last + Revision int64 `json:"revision"` + // the node composes the module itself (ADR 0547 §2) + Self bool `json:"self,omitempty"` + // its words + Words string `json:"words,omitempty"` + // the digest a person said yes to + Yes string `json:"yes,omitempty"` +} + +// CompositionAnswer is what composition answers. +type CompositionAnswer struct { + // the module's last apply that did not end running; absent when none + Attempt *CompositionAnswerAttempt `json:"attempt,omitempty"` + Composition Revision `json:"composition"` + // every resource that differs from what runs, when asked + Diff []map[string]any `json:"diff,omitempty"` + // what the module's have key says now; absent when it says nothing + Have *CompositionAnswerHave `json:"have,omitempty"` + // the move from what runs to the composition read + Judged CompositionAnswerJudged `json:"judged"` + // the module + Module string `json:"module"` + // the node + Node string `json:"node"` + // of the node's own composition, its own fields beside what runs names + Own *CompositionAnswerOwn `json:"own,omitempty"` + // the revisions of its key the bucket keeps, oldest first + Revisions []Revision `json:"revisions,omitempty"` + // what runs of the module; absent when nothing runs + Runs *CompositionAnswerRuns `json:"runs,omitempty"` + // the agent has looked at the module once since it started + Started bool `json:"started"` + // the module's want as the agent holds it; absent when it holds none + Want *CompositionAnswerWant `json:"want,omitempty"` +} + +// ComposeInput is what compose takes. +type ComposeInput struct { + // a module placed on this machine whose apply is also tried again (optional) + Module string `json:"module,omitempty"` +} + +// ComposeAnswerPass is how the pass went. +type ComposeAnswerPass struct { + // when the node composes again on its own, RFC 3339 + Again string `json:"again,omitempty"` + // when the pass ended, RFC 3339 + At string `json:"at"` + // the answer is the last pass, made moments ago + Earlier bool `json:"earlier,omitempty"` + // how many compositions the pass handed the agent that differ from before + Handed int64 `json:"handed"` + // each module refused alone, with why + Held map[string]string `json:"held,omitempty"` + // why the node composed none of its modules + Refused string `json:"refused,omitempty"` +} + +// ComposeAnswer is what compose answers. +type ComposeAnswer struct { + // how the pass went + Pass ComposeAnswerPass `json:"pass"` + // the module whose apply was tried again + Retried string `json:"retried,omitempty"` +} + +// ComposeCandidateInput is what compose-candidate takes. +type ComposeCandidateInput struct { + // the key of the indexer's candidate, . + Candidate string `json:"candidate"` + // also answer the composition input the candidate was composed from, for the sandbox path + Input bool `json:"input,omitempty"` +} + +// ComposeCandidateAnswer is what compose-candidate answers. +type ComposeCandidateAnswer struct { + // the candidate's key + Candidate string `json:"candidate"` + // when asked: the composition input it composed from + Input map[string]any `json:"input,omitempty"` + // every module placed on the node, sorted by name + Modules []Module `json:"modules"` + // the node + Node string `json:"node"` + Own Module `json:"own"` + // why the node could not compose with the candidate at all + Refused string `json:"refused,omitempty"` + // how long the engine took, in milliseconds + Took int64 `json:"took"` + // the layout's version + V int64 `json:"v"` + // the node's verdict (ADR 0593 §7, §9) + Verdict string `json:"verdict"` +} + +// The values of ComposeCandidateAnswer's verdict. +const ( + ComposeCandidateAnswerVerdictComposed = "composed" + ComposeCandidateAnswerVerdictRefused = "refused" + ComposeCandidateAnswerVerdictUnready = "unready" + ComposeCandidateAnswerVerdictNotJudged = "not-judged" +) + +// ManifestReadsInput is what manifest-reads takes. +type ManifestReadsInput struct { +} + +// ManifestReadsAnswer is what manifest-reads answers. +type ManifestReadsAnswer struct { + // the reader capabilities its engine states: the manifest words, forms and seat names it reads; empty for an engine built before it said them, read as the baseline + ManifestReads []string `json:"manifest-reads,omitempty"` + // the node + Node string `json:"node"` + // when the engine last stated them, RFC 3339 + StatedAt string `json:"stated-at"` + // the layout's version + V int64 `json:"v"` +} + +// SeatListInput is what seat-list takes. +type SeatListInput struct { +} + +// SeatListAnswer is what seat-list answers. +type SeatListAnswer = SeatList + +// SeatListAgainInput is what seat-list-again takes. +type SeatListAgainInput struct { +} + +// SeatListAgainAnswer is what seat-list-again answers. +type SeatListAgainAnswer = SeatList + +// AskedEvent is the body of the event asked. +type AskedEvent = ActSaid + +// DoneEvent is the body of the event done. +type DoneEvent = ActSaid + +// RefusedEvent is the body of the event refused. +type RefusedEvent = ActSaid + +// ManifestReadsChangedEvent is the body of the event manifest-reads-changed. +type ManifestReadsChangedEvent = ManifestReadsChanged + +// HaveByNodeModuleValue is one value of the state have under the key .. +type HaveByNodeModuleValue struct { + Build string `json:"build,omitempty"` + // the move changes the build alone + BuildAlone bool `json:"build-alone,omitempty"` + // the digest of the composition the node runs + Composed string `json:"composed,omitempty"` + Detail string `json:"detail,omitempty"` + // the fingerprint held + Fp string `json:"fp,omitempty"` + Handed *AccountsHanded `json:"handed,omitempty"` + // what the module's own check says + Health string `json:"health"` + // in phase waiting-yes, the judgement of the move + Judgement string `json:"judgement,omitempty"` + // what the node keeps of the module as it runs + Kept []string `json:"kept,omitempty"` + // how far it has come + Phase string `json:"phase"` + PullWait *PullWait `json:"pull-wait,omitempty"` + // each value sealed to the node's key + Sealed []string `json:"sealed,omitempty"` + // the sealed values by slot + SealedAt map[string]string `json:"sealed-at,omitempty"` + UserList *UserListWritten `json:"user-list,omitempty"` + V int64 `json:"v"` + // in phase waiting-yes, the digest of the composition that waits + Waits string `json:"waits,omitempty"` +} + +// The values of HaveByNodeModuleValue's health. +const ( + HaveByNodeModuleValueHealthOk = "ok" + HaveByNodeModuleValueHealthFailed = "failed" + HaveByNodeModuleValueHealthWaits = "waits" + HaveByNodeModuleValueHealthUnknown = "unknown" +) + +// The values of HaveByNodeModuleValue's judgement. +const ( + HaveByNodeModuleValueJudgementRecreates = "recreates" + HaveByNodeModuleValueJudgementMovesData = "moves-data" + HaveByNodeModuleValueJudgementCannotJudge = "cannot-judge" +) + +// The values of HaveByNodeModuleValue's phase. +const ( + HaveByNodeModuleValuePhaseBackingUp = "backing-up" + HaveByNodeModuleValuePhaseApplying = "applying" + HaveByNodeModuleValuePhaseChecking = "checking" + HaveByNodeModuleValuePhaseRunning = "running" + HaveByNodeModuleValuePhaseFailed = "failed" + HaveByNodeModuleValuePhaseRemoved = "removed" + HaveByNodeModuleValuePhaseHeld = "held" + HaveByNodeModuleValuePhaseWaitingYes = "waiting-yes" +) + +// HaveByNodeNodeValue is one value of the state have under the key .node. +type HaveByNodeNodeValue struct { + Busy []map[string]any `json:"busy,omitempty"` + // the node's answer to each placement candidate naming it, by proposal id + Candidates map[string]CandidateAnswer `json:"candidates,omitempty"` + // the digest of the node's own composition it runs + Composed string `json:"composed,omitempty"` + // own, or none (empty) + Composes string `json:"composes,omitempty"` + ComposesWhy string `json:"composes-why,omitempty"` + Filters []map[string]any `json:"filters,omitempty"` + Profile map[string]any `json:"profile,omitempty"` + Record *NodeRecord `json:"record,omitempty"` + Sealed []string `json:"sealed,omitempty"` + SealedAt map[string]string `json:"sealed-at,omitempty"` + Strays []map[string]any `json:"strays,omitempty"` + V int64 `json:"v"` + Windows []map[string]any `json:"windows,omitempty"` +} + +// ComposedByNodeModuleFingerprintValue is one value of the state composed under the key ... +type ComposedByNodeModuleFingerprintValue struct { + // the module declares data + Data bool `json:"data,omitempty"` + // the files the provisioner fills + FilledFiles []string `json:"filled_files,omitempty"` + Fp string `json:"fp"` + Module string `json:"module"` + Node string `json:"node"` + // the module's whole provisioning plan, catalogue.Plan, kept whole: it is the manifest layer's type + Plan map[string]any `json:"plan,omitempty"` + // why the module cannot be composed at this fingerprint + Refused string `json:"refused,omitempty"` + // the declaration resources, each saying its module + Resources []map[string]any `json:"resources"` + V int64 `json:"v"` +} + +// ComposedByNodeNodeValue is one value of the state composed under the key .node. +type ComposedByNodeNodeValue struct { + // the module declares data + Data bool `json:"data,omitempty"` + // the files the provisioner fills + FilledFiles []string `json:"filled_files,omitempty"` + Node string `json:"node"` + Own *NodeOwn `json:"own,omitempty"` + // the declaration resources, each saying its module + Resources []map[string]any `json:"resources"` + V int64 `json:"v"` +} + +// DataByNodeModuleValue is one value of the state data under the key .. +type DataByNodeModuleValue struct { + Digest string `json:"digest"` + Items []PlacedDataItem `json:"items"` + Module string `json:"module"` + Node string `json:"node"` + V int64 `json:"v"` +} + +// PackagesByNodeValuePackagesItem is a type of the contract. +type PackagesByNodeValuePackagesItem struct { + Absent bool `json:"absent"` + Modules []string `json:"modules"` + Package string `json:"package"` +} + +// PackagesByNodeValue is one value of the state packages under the key . +type PackagesByNodeValue struct { + Node string `json:"node"` + Packages []PackagesByNodeValuePackagesItem `json:"packages"` +} + +// HealthByNodeValue is one value of the state health under the key . +type HealthByNodeValue struct { + // when + At time.Time `json:"at"` + Contract int64 `json:"contract,omitempty"` + Fallback *LoginFallback `json:"fallback,omitempty"` + Found []NodeDirectory `json:"found,omitempty"` + Network *NetworkHealth `json:"network,omitempty"` + Node string `json:"node"` + Peers []PeerSeen `json:"peers,omitempty"` + Relogin []NodeAccount `json:"relogin,omitempty"` + Root []RootAccountVerdict `json:"root,omitempty"` + Units *UnitsHealth `json:"units,omitempty"` + V int64 `json:"v"` +} + +// FindingsByKeyValue is one value of the state findings under the key . +type FindingsByKeyValue struct { + Actions []FindingAction `json:"actions,omitempty"` + At time.Time `json:"at"` + // newest first, at most 10 + Evidence []FindingEvidence `json:"evidence,omitempty"` + Explanation string `json:"explanation,omitempty"` + Headline string `json:"headline,omitempty"` + // scope.id.token + Key string `json:"key"` + Kind string `json:"kind"` + Needs string `json:"needs,omitempty"` + // the module that wrote it + Observer string `json:"observer"` + // the key's last part when it is not the kind + Ref string `json:"ref,omitempty"` + Resolved string `json:"resolved,omitempty"` + // self, operator, agent or healer: + Resolver string `json:"resolver,omitempty"` + // what of its own saw it last + Run string `json:"run,omitempty"` + Severity string `json:"severity"` + // the row, probe or event that saw it + Source string `json:"source"` + Subject FindingSubject `json:"subject"` + // one line + Summary string `json:"summary"` + // layout version, 1 + V int64 `json:"v"` +} + +// The values of FindingsByKeyValue's severity. +const ( + FindingsByKeyValueSeverityUrgent = "urgent" + FindingsByKeyValueSeverityWarning = "warning" +) diff --git a/go/seats/nodeengine/contract_test.go b/go/seats/nodeengine/contract_test.go new file mode 100644 index 0000000..5ca811b --- /dev/null +++ b/go/seats/nodeengine/contract_test.go @@ -0,0 +1,120 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package nodeengine + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/node-engine/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: "asked on the node's subject", subject: "mesh.seat.node-engine.event.asked.ace", given: "{\"act\":\"hand-over\",\"ask\":\"hand-over-1\",\"caller\":\"mesh-tools\",\"node\":\"ace\",\"state\":\"asked\",\"subject\":\"/srv/media\"}", wire: "{\"act\":\"hand-over\",\"ask\":\"hand-over-1\",\"caller\":\"mesh-tools\",\"node\":\"ace\",\"state\":\"asked\",\"subject\":\"/srv/media\"}", + decode: func(b []byte) (any, error) { var v AskedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-engine\\.event\\.asked\\.[A-Za-z0-9_-]+$")}, + {name: "done on the node's subject", subject: "mesh.seat.node-engine.event.done.ace", given: "{\"act\":\"hand-over\",\"ask\":\"hand-over-1\",\"caller\":\"mesh-tools\",\"node\":\"ace\",\"said\":\"handed over: owner media, group media, mode 2775\",\"state\":\"done\",\"subject\":\"/srv/media\"}", wire: "{\"act\":\"hand-over\",\"ask\":\"hand-over-1\",\"caller\":\"mesh-tools\",\"node\":\"ace\",\"said\":\"handed over: owner media, group media, mode 2775\",\"state\":\"done\",\"subject\":\"/srv/media\"}", + decode: func(b []byte) (any, error) { var v DoneEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-engine\\.event\\.done\\.[A-Za-z0-9_-]+$")}, + {name: "refused on the node's subject", subject: "mesh.seat.node-engine.event.refused.ace", given: "{\"act\":\"access-act\",\"ask\":\"access-act-1\",\"caller\":\"mesh-tools\",\"node\":\"ace\",\"said\":\"the operator said no\",\"state\":\"refused\",\"subject\":\"library:music\"}", wire: "{\"act\":\"access-act\",\"ask\":\"access-act-1\",\"caller\":\"mesh-tools\",\"node\":\"ace\",\"said\":\"the operator said no\",\"state\":\"refused\",\"subject\":\"library:music\"}", + decode: func(b []byte) (any, error) { var v RefusedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-engine\\.event\\.refused\\.[A-Za-z0-9_-]+$")}, + {name: "manifest-reads-changed when the engine started", subject: "mesh.seat.node-engine.event.manifest-reads-changed.ace", given: "{\"at\":\"2026-11-04T10:00:00Z\",\"manifest-reads\":[\"form:seat-reference\",\"word:series\"],\"node\":\"ace\",\"v\":1,\"why\":\"started\"}", wire: "{\"at\":\"2026-11-04T10:00:00Z\",\"manifest-reads\":[\"form:seat-reference\",\"word:series\"],\"node\":\"ace\",\"v\":1,\"why\":\"started\"}", + decode: func(b []byte) (any, error) { + var v ManifestReadsChangedEvent + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-engine\\.event\\.manifest-reads-changed\\.[A-Za-z0-9_-]+$")}, + {name: "manifest-reads-changed when the node returned, from an engine that states none", subject: "mesh.seat.node-engine.event.manifest-reads-changed.g14", given: "{\"at\":\"2026-11-04T10:05:00Z\",\"node\":\"g14\",\"v\":1,\"why\":\"returned\"}", wire: "{\"at\":\"2026-11-04T10:05:00Z\",\"node\":\"g14\",\"v\":1,\"why\":\"returned\"}", + decode: func(b []byte) (any, error) { + var v ManifestReadsChangedEvent + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-engine\\.event\\.manifest-reads-changed\\.[A-Za-z0-9_-]+$")}, + {name: "the finding of a seat list unread, as the engine writes it", subject: "", given: "{\"at\":\"2026-11-05T14:00:00Z\",\"key\":\"machine.ace.seat-list-unread\",\"kind\":\"seat-list-unread\",\"observer\":\"mesh-host\",\"severity\":\"warning\",\"source\":\"node-engine.seat-list\",\"subject\":{\"id\":\"ace\",\"machine\":\"ace\",\"scope\":\"machine\"},\"summary\":\"the seat list has not been read for five minutes\",\"v\":1}", wire: "{\"at\":\"2026-11-05T14:00:00Z\",\"key\":\"machine.ace.seat-list-unread\",\"kind\":\"seat-list-unread\",\"observer\":\"mesh-host\",\"severity\":\"warning\",\"source\":\"node-engine.seat-list\",\"subject\":{\"id\":\"ace\",\"machine\":\"ace\",\"scope\":\"machine\"},\"summary\":\"the seat list has not been read for five minutes\",\"v\":1}", + decode: func(b []byte) (any, error) { var v FindingsByKeyValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a node's packages, present and absent (captured from ace, shortened)", subject: "", given: "{\"node\":\"ace\",\"packages\":[{\"absent\":false,\"modules\":[\"avahi\"],\"package\":\"avahi\"},{\"absent\":false,\"modules\":[\"mesh-build-go\",\"mesh-build-node\"],\"package\":\"bubblewrap\"},{\"absent\":true,\"modules\":[\"time-sync\"],\"package\":\"ntp\"}]}", wire: "{\"node\":\"ace\",\"packages\":[{\"absent\":false,\"modules\":[\"avahi\"],\"package\":\"avahi\"},{\"absent\":false,\"modules\":[\"mesh-build-go\",\"mesh-build-node\"],\"package\":\"bubblewrap\"},{\"absent\":true,\"modules\":[\"time-sync\"],\"package\":\"ntp\"}]}", + decode: func(b []byte) (any, error) { var v PackagesByNodeValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "buildah composed on ace (resources captured from the declaration of 2026-10-24; the fingerprint stands in for the indexer's)", subject: "", given: "{\"fp\":\"src2:62756c6c6462756c6c6462756c6c6462756c6c6462756c6c6462756c6c646275\",\"module\":\"buildah\",\"node\":\"ace\",\"resources\":[{\"id\":\"buildah.buildah\",\"module\":\"buildah\",\"package\":\"buildah\",\"type\":\"package\"}],\"v\":1}", wire: "{\"fp\":\"src2:62756c6c6462756c6c6462756c6c6462756c6c6462756c6c6462756c6c646275\",\"module\":\"buildah\",\"node\":\"ace\",\"resources\":[{\"id\":\"buildah.buildah\",\"module\":\"buildah\",\"package\":\"buildah\",\"type\":\"package\"}],\"v\":1}", + decode: func(b []byte) (any, error) { + var v ComposedByNodeModuleFingerprintValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "node-tools composed with a filled file (captured; the fingerprint stands in)", subject: "", given: "{\"fp\":\"src2:6e6f64652d6e6f64652d6e6f64652d6e6f64652d6e6f64652d6e6f64652d6e6f\",\"module\":\"node-tools\",\"node\":\"ace\",\"resources\":[{\"id\":\"node-tools.mesh-state\",\"mode\":\"0755\",\"module\":\"node-tools\",\"path\":\"/var/lib/mesh/node-tools\",\"type\":\"directory\"},{\"id\":\"node-tools.interpreter\",\"module\":\"node-tools\",\"package\":\"nodejs\",\"type\":\"package\"},{\"digest\":\"sha256:ef790876783d817903b337b8418b1eb21fb49d34128a390bbf7c3d08a4b2be8a\",\"env\":{\"MESH_AGENT_ACCOUNT\":\"ace\",\"MESH_AGENT_HOME\":\"/home/ace\",\"MESH_BROKER_FILE\":\"/var/lib/mesh/node-tools/broker\",\"MESH_OPERATOR_ACCOUNT\":\"ace\",\"MESH_OPERATOR_HOME\":\"/home/ace\",\"MESH_TOOL_ENV\":\"{\\\"baserow\\\":{\\\"MESH_BASEROW_CONFIG_FILE\\\":\\\"/var/lib/mesh/baserow/config.json\\\",\\\"MESH_BASEROW_URL\\\":\\\"http://127.0.0.1:20011\\\"},\\\"bazarr\\\":{\\\"MESH_BAZARR_CONFIG_DIR\\\":\\\"/var/lib/bazarr/config\\\",\\\"MESH_BAZARR_CONFIG_FILE\\\":\\\"/var/lib/bazarr/config.json\\\",\\\"MESH_BAZARR_CONTAINER\\\":\\\"bazarr\\\",\\\"MESH_BAZARR_URL\\\":\\\"http://127.0.0.1:20020\\\"},\\\"bookshelf\\\":{\\\"MESH_BOOKSHELF_CONFIG_DIR\\\":\\\"/var/lib/bookshelf/config\\\",\\\"MESH_BOOKSHELF_URL\\\":\\\"http://127.0.0.1:20021\\\"},\\\"claude-code\\\":{\\\"MESH_CLAUDE_CODE_FACTS\\\":\\\"/var/lib/claude-code/facts.json\\\",\\\"MESH_CLAUDE_CODE_SETTINGS\\\":\\\"/var/lib/claude-code/settings.json\\\",\\\"MESH_CLAUDE_CODE_STATE\\\":\\\"/var/lib/claude-code\\\"},\\\"grafana\\\":{\\\"MESH_GRAFANA_CONFIG_FILE\\\":\\\"/var/lib/mesh/grafana/config.json\\\",\\\"MESH_GRAFANA_URL\\\":\\\"http://127.0.0.1:20012\\\"},\\\"home-assistant\\\":{\\\"MESH_HOMEASSISTANT_CONFIG_FILE\\\":\\\"/var/lib/mesh/home-assistant/config.json\\\",\\\"MESH_HOMEASSISTANT_TOKEN_FILE\\\":\\\"/var/lib/mesh/home-assistant/token\\\",\\\"MESH_HOMEASSISTANT_URL\\\":\\\"http://127.0.0.1:8123\\\"},\\\"icecast\\\":{\\\"MESH_ICECAST_ADMIN_PASSWORD_FILE\\\":\\\"/var/lib/icecast/admin.secret\\\",\\\"MESH_ICECAST_CONFIG_FILE\\\":\\\"/var/lib/mesh/icecast/config.json\\\",\\\"MESH_ICECAST_URL\\\":\\\"http://127.0.0.1:20005\\\"},\\\"influxdb\\\":{\\\"MESH_INFLUXDB_CONFIG_FILE\\\":\\\"/var/lib/mesh/influxdb/config.json\\\",\\\"MESH_INFLUXDB_TOKEN_FILE\\\":\\\"/var/lib/influxdb/admin-token.secret\\\",\\\"MESH_INFLUXDB_URL\\\":\\\"http://127.0.0.1:8086\\\",\\\"MESH_RECEIVES\\\":\\\"/var/lib/influxdb/grants/mesh.json\\\"},\\\"jackett\\\":{\\\"MESH_JACKETT_API_KEY_FILE\\\":\\\"/var/lib/mesh/jackett/api-key\\\",\\\"MESH_JACKETT_CONFIG_DIR\\\":\\\"/var/lib/jackett/config\\\",\\\"MESH_JACKETT_CONFIG_FILE\\\":\\\"/var/lib/jackett/config.json\\\",\\\"MESH_JACKETT_URL\\\":\\\"http://127.0.0.1:20004\\\"},\\\"kometa\\\":{\\\"MESH_KOMETA_CONFIG_DIR\\\":\\\"/var/lib/kometa/config\\\",\\\"MESH_KOMETA_CONFIG_FILE\\\":\\\"/var/lib/kometa/config.yml\\\"},\\\"letta\\\":{\\\"MESH_LETTA_CONFIG_FILE\\\":\\\"/var/lib/mesh/letta/config.json\\\",\\\"MESH_LETTA_URL\\\":\\\"http://127.0.0.1:20002\\\"},\\\"lidarr\\\":{\\\"MESH_LIDARR_API_KEY_FILE\\\":\\\"/var/lib/mesh/lidarr/api-key\\\",\\\"MESH_LIDARR_CONFIG_DIR\\\":\\\"/var/lib/lidarr/config\\\",\\\"MESH_LIDARR_RECYCLE_BIN\\\":\\\"/recycle/\\\",\\\"MESH_LIDARR_RECYCLE_DAYS\\\":\\\"7\\\",\\\"MESH_LIDARR_URL\\\":\\\"http://127.0.0.1:20022\\\",\\\"MESH_PLEX_BIND_FILE\\\":\\\"/var/lib/lidarr/plex-api.json\\\",\\\"MESH_PLEX_TOKEN_FILE\\\":\\\"/var/lib/lidarr/plex-api.secret\\\"},\\\"media-importer\\\":{\\\"MESH_MEDIA_IMPORTER_SETTINGS\\\":\\\"/var/lib/media-importer/settings.json\\\",\\\"MESH_MEDIA_IMPORTER_STATE\\\":\\\"/var/lib/media-importer\\\",\\\"MESH_MEDIA_LIBRARY\\\":\\\"/storage/media/poc\\\"},\\\"mesh-files\\\":{\\\"MESH_FILES_STATE\\\":\\\"/var/lib/mesh-files\\\",\\\"MESH_OWNED_PATHS\\\":\\\"{\\\\\\\"/etc/NetworkManager/conf.d/50-mesh.conf\\\\\\\":\\\\\\\"networkmanager (its own tools)\\\\\\\",\\\\\\\"/etc/claude-code\\\\\\\":\\\\\\\"claude-code (its own tools)\\\\\\\",\\\\\\\"/etc/claude-code/agent-account\\\\\\\":\\\\\\\"claude-code (its own tools)\\\\\\\",\\\\\\\"/etc/dnsmasq.conf\\\\\\\":\\\\\\\"dnsmasq (its own tools)\\\\\\\",\\\\\\\"/etc/docker/daemon.json\\\\\\\":\\\\\\\"docker (its own tools)\\\\\\\",\\\\\\\"/etc/fail2ban/action.d\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/etc/fail2ban/action.d/iptables-allports-dualchain.conf\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/etc/fail2ban/filter.d\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/etc/fail2ban/jail.d\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/etc/fail2ban/jail.d/recidive.conf\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/etc/fail2ban/jail.d/sshd.conf\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/etc/fail2ban/jail.local\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/etc/fail2ban/mesh-own-addresses.json\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/etc/hostname\\\\\\\":\\\\\\\"hostname (node-hostname)\\\\\\\",\\\\\\\"/etc/hosts\\\\\\\":\\\\\\\"hostname (node-hostname)\\\\\\\",\\\\\\\"/etc/locale.conf\\\\\\\":\\\\\\\"localization (its own tools)\\\\\\\",\\\\\\\"/etc/logrotate.conf\\\\\\\":\\\\\\\"logrotate (its own tools)\\\\\\\",\\\\\\\"/etc/logrotate.d/fail2ban\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-power\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-power/moments\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-power/moments/after-boot\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-power/moments/after-wake\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-power/moments/before-shutdown\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-power/moments/before-sleep\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-power/moments/on-battery\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-power/moments/on-mains\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-resolver/nodes.conf\\\\\\\":\\\\\\\"dnsmasq (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-resolver/zones.conf\\\\\\\":\\\\\\\"dnsmasq (its own tools)\\\\\\\",\\\\\\\"/etc/nfs-server\\\\\\\":\\\\\\\"nfs-server (its own tools)\\\\\\\",\\\\\\\"/etc/nfs-server/machines\\\\\\\":\\\\\\\"nfs-server (its own tools)\\\\\\\",\\\\\\\"/etc/nfs-server/shares.conf\\\\\\\":\\\\\\\"nfs-server (its own tools)\\\\\\\",\\\\\\\"/etc/nfs.conf.d/50-mesh.conf\\\\\\\":\\\\\\\"nfs-server (its own tools)\\\\\\\",\\\\\\\"/etc/pacman.conf\\\\\\\":\\\\\\\"pacman (its own tools)\\\\\\\",\\\\\\\"/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook\\\\\\\":\\\\\\\"kernel (its own tools)\\\\\\\",\\\\\\\"/etc/resolv.conf\\\\\\\":\\\\\\\"networkmanager (node-uplink)\\\\\\\",\\\\\\\"/etc/ssh/sshd_config.d/10-mesh.conf\\\\\\\":\\\\\\\"sshd (its own tools)\\\\\\\",\\\\\\\"/etc/sudoers.d/10-mesh-operator\\\\\\\":\\\\\\\"sudo (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/logind.conf.d\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/logind.conf.d/power.conf\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/dnsmasq.service.d\\\\\\\":\\\\\\\"dnsmasq (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/dnsmasq.service.d/mesh.conf\\\\\\\":\\\\\\\"dnsmasq (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/docker-prune.service\\\\\\\":\\\\\\\"docker (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/docker-prune.timer\\\\\\\":\\\\\\\"docker (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/hibernate.target.d\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/hibernate.target.d/mesh-power.conf\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/hybrid-sleep.target.d\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/hybrid-sleep.target.d/mesh-power.conf\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/mesh-ca-trust.service\\\\\\\":\\\\\\\"ca-trust (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/mesh-filter.service\\\\\\\":\\\\\\\"nftables (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/mesh-kernel-boot-entries.service\\\\\\\":\\\\\\\"kernel (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/mesh-kernel-huge-pages.service\\\\\\\":\\\\\\\"kernel (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/mesh-power-after-boot.service\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/mesh-power-after-wake.service\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/mesh-power-before-shutdown.service\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/mesh-power-before-sleep.service\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/mesh-power-supply.service\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/nftables.service.d/mesh.conf\\\\\\\":\\\\\\\"nftables (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/sleep.target.d\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/sleep.target.d/mesh-power.conf\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/suspend-then-hibernate.target.d\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/suspend.target.d\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/suspend.target.d/mesh-power.conf\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/timesyncd.conf.d/50-mesh.conf\\\\\\\":\\\\\\\"time-sync (its own tools)\\\\\\\",\\\\\\\"/etc/udev/rules.d/90-mesh-power.rules\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/vconsole.conf\\\\\\\":\\\\\\\"localization (its own tools)\\\\\\\",\\\\\\\"/etc/xdg/reflector/reflector.conf\\\\\\\":\\\\\\\"pacman (its own tools)\\\\\\\",\\\\\\\"/run/mesh-power\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/run/nfs-server\\\\\\\":\\\\\\\"nfs-server (its own tools)\\\\\\\",\\\\\\\"/usr/lib/nox-mesh-host/launch\\\\\\\":\\\\\\\"mesh-host (its own tools)\\\\\\\",\\\\\\\"/usr/lib/nox-mesh-host/versions/4e121b6e88f1\\\\\\\":\\\\\\\"mesh-host (its own tools)\\\\\\\",\\\\\\\"/usr/local/bin\\\\\\\":\\\\\\\"mesh-cli (its own tools)\\\\\\\",\\\\\\\"/usr/local/bin/claude-agent\\\\\\\":\\\\\\\"claude-code (its own tools)\\\\\\\",\\\\\\\"/usr/local/lib/mesh-power\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/var/lib/dnsmasq-watch\\\\\\\":\\\\\\\"dnsmasq (its own tools)\\\\\\\",\\\\\\\"/var/lib/mesh-fail2ban\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/var/lib/mesh-fail2ban/own-addresses\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/var/lib/mesh-store\\\\\\\":\\\\\\\"postgres (its own tools)\\\\\\\",\\\\\\\"/var/lib/nfs-server\\\\\\\":\\\\\\\"nfs-server (its own tools)\\\\\\\",\\\\\\\"/var/lib/nfs-server/from-bus\\\\\\\":\\\\\\\"nfs-server (its own tools)\\\\\\\",\\\\\\\"/var/lib/postgres\\\\\\\":\\\\\\\"postgres (its own tools)\\\\\\\",\\\\\\\"/var/lib/route-proxy/acme\\\\\\\":\\\\\\\"route-proxy (its own tools)\\\\\\\",\\\\\\\"/var/lib/route-proxy/ca\\\\\\\":\\\\\\\"route-proxy (its own tools)\\\\\\\",\\\\\\\"/var/lib/route-proxy/routes\\\\\\\":\\\\\\\"route-proxy (its own tools)\\\\\\\",\\\\\\\"/var/lib/systemd\\\\\\\":\\\\\\\"systemd (its own tools)\\\\\\\",\\\\\\\"/var/log/fail2ban.log\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/var/run/fail2ban\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\"}\\\",\\\"MESH_SECRET_PLACES\\\":\\\"[\\\\\\\"/var/lib/baserow\\\\\\\",\\\\\\\"/var/lib/bazarr/radarr-api.secret\\\\\\\",\\\\\\\"/var/lib/bazarr/sonarr-api.secret\\\\\\\",\\\\\\\"/var/lib/bookshelf/jackett-api.secret\\\\\\\",\\\\\\\"/var/lib/bookshelf/nzbget-api.secret\\\\\\\",\\\\\\\"/var/lib/bookshelf/qbittorrent-api.secret\\\\\\\",\\\\\\\"/var/lib/car-hunter\\\\\\\",\\\\\\\"/var/lib/claude-code/gitea-read.secret\\\\\\\",\\\\\\\"/var/lib/home-assistant/lidarr-api.secret\\\\\\\",\\\\\\\"/var/lib/home-assistant/mqtt-topic.secret\\\\\\\",\\\\\\\"/var/lib/home-assistant/radarr-api.secret\\\\\\\",\\\\\\\"/var/lib/home-assistant/sonarr-api.secret\\\\\\\",\\\\\\\"/var/lib/icecast/admin.secret\\\\\\\",\\\\\\\"/var/lib/icecast/relay.secret\\\\\\\",\\\\\\\"/var/lib/icecast/source.secret\\\\\\\",\\\\\\\"/var/lib/influxdb\\\\\\\",\\\\\\\"/var/lib/kometa\\\\\\\",\\\\\\\"/var/lib/letta\\\\\\\",\\\\\\\"/var/lib/lidarr/jackett-api.secret\\\\\\\",\\\\\\\"/var/lib/lidarr/nzbget-api.secret\\\\\\\",\\\\\\\"/var/lib/lidarr/plex-api.secret\\\\\\\",\\\\\\\"/var/lib/lidarr/qbittorrent-api.secret\\\\\\\",\\\\\\\"/var/lib/mesh-delivery-assigner\\\\\\\",\\\\\\\"/var/lib/mesh-provisioner-client\\\\\\\",\\\\\\\"/var/lib/mesh-reads\\\\\\\",\\\\\\\"/var/lib/mesh/dnsmasq\\\\\\\",\\\\\\\"/var/lib/mesh/grafana\\\\\\\",\\\\\\\"/var/lib/mesh/home-assistant\\\\\\\",\\\\\\\"/var/lib/mesh/jackett\\\\\\\",\\\\\\\"/var/lib/mesh/lidarr\\\\\\\",\\\\\\\"/var/lib/mesh/mosquitto\\\\\\\",\\\\\\\"/var/lib/mesh/node-tools\\\\\\\",\\\\\\\"/var/lib/mesh/nodered\\\\\\\",\\\\\\\"/var/lib/mesh/nzbget\\\\\\\",\\\\\\\"/var/lib/mesh/ombi\\\\\\\",\\\\\\\"/var/lib/mesh/pacman\\\\\\\",\\\\\\\"/var/lib/mesh/postgres\\\\\\\",\\\\\\\"/var/lib/mesh/qbittorrent\\\\\\\",\\\\\\\"/var/lib/mesh/radarr\\\\\\\",\\\\\\\"/var/lib/mesh/route-proxy\\\\\\\",\\\\\\\"/var/lib/mesh/searxng\\\\\\\",\\\\\\\"/var/lib/mesh/sonarr\\\\\\\",\\\\\\\"/var/lib/mesh/unifi\\\\\\\",\\\\\\\"/var/lib/mosquitto/grants\\\\\\\",\\\\\\\"/var/lib/mssql\\\\\\\",\\\\\\\"/var/lib/n8n/database.secret\\\\\\\",\\\\\\\"/var/lib/nodered/mqtt-topic.secret\\\\\\\",\\\\\\\"/var/lib/nzbgeek\\\\\\\",\\\\\\\"/var/lib/ombi/lidarr-api.secret\\\\\\\",\\\\\\\"/var/lib/ombi/plex-api.secret\\\\\\\",\\\\\\\"/var/lib/ombi/radarr-api.secret\\\\\\\",\\\\\\\"/var/lib/ombi/sonarr-api.secret\\\\\\\",\\\\\\\"/var/lib/radarr/jackett-api.secret\\\\\\\",\\\\\\\"/var/lib/radarr/nzbget-api.secret\\\\\\\",\\\\\\\"/var/lib/radarr/plex-api.secret\\\\\\\",\\\\\\\"/var/lib/radarr/qbittorrent-api.secret\\\\\\\",\\\\\\\"/var/lib/redis/default.secret\\\\\\\",\\\\\\\"/var/lib/redis/grants\\\\\\\",\\\\\\\"/var/lib/restic\\\\\\\",\\\\\\\"/var/lib/sonarr/jackett-api.secret\\\\\\\",\\\\\\\"/var/lib/sonarr/nzbget-api.secret\\\\\\\",\\\\\\\"/var/lib/sonarr/plex-api.secret\\\\\\\",\\\\\\\"/var/lib/sonarr/qbittorrent-api.secret\\\\\\\",\\\\\\\"/var/lib/supabase\\\\\\\",\\\\\\\"/var/lib/tautulli/plex-api.secret\\\\\\\",\\\\\\\"/var/lib/txt-game/database.secret\\\\\\\"]\\\"},\\\"mosquitto\\\":{\\\"MESH_MQTT_CTRL_CONTAINER\\\":\\\"mosquitto\\\",\\\"MESH_PROVISION_ADMIN_USER\\\":\\\"mesh-admin\\\",\\\"MESH_PROVISION_MQTT\\\":\\\"127.0.0.1:1883\\\",\\\"MESH_PROVISION_PASSWORD_FILE\\\":\\\"/var/lib/mesh/mosquitto/admin\\\",\\\"MESH_RECEIVES\\\":\\\"/var/lib/mosquitto/grants/mesh.json\\\"},\\\"mssql\\\":{\\\"MESH_MSSQL_READER_PASSWORD_FILE\\\":\\\"/var/lib/mssql/reader.secret\\\",\\\"MESH_PROVISION_MSSQL\\\":\\\"mssql://sa@127.0.0.1:4848/master\\\",\\\"MESH_PROVISION_PASSWORD_FILE\\\":\\\"/var/lib/mssql/sa.secret\\\",\\\"MESH_RECEIVES\\\":\\\"/var/lib/mssql/grants/mesh.json\\\"},\\\"nodered\\\":{\\\"MESH_NODERED_CONFIG_FILE\\\":\\\"/var/lib/mesh/nodered/config.json\\\",\\\"MESH_NODERED_URL\\\":\\\"http://127.0.0.1:20006\\\"},\\\"nzbgeek\\\":{\\\"MESH_NZBGEEK_KEY_FILE\\\":\\\"/var/lib/nzbgeek/api-key.secret\\\",\\\"MESH_NZBGEEK_SETTINGS\\\":\\\"/var/lib/nzbgeek/settings.json\\\"},\\\"nzbget\\\":{\\\"MESH_NZBGET_CONFIG_DIR\\\":\\\"/var/lib/nzbget/config\\\",\\\"MESH_NZBGET_CONFIG_FILE\\\":\\\"/var/lib/nzbget/config.json\\\",\\\"MESH_NZBGET_PASSWORD_FILE\\\":\\\"/var/lib/mesh/nzbget/password\\\",\\\"MESH_NZBGET_SECRETS_DIR\\\":\\\"/var/lib/mesh/nzbget\\\",\\\"MESH_NZBGET_STATE\\\":\\\"/var/lib/nzbget\\\",\\\"MESH_NZBGET_URL\\\":\\\"http://127.0.0.1:20017\\\"},\\\"ombi\\\":{\\\"MESH_OMBI_API_KEY_FILE\\\":\\\"/var/lib/mesh/ombi/api-key\\\",\\\"MESH_OMBI_CONFIG_FILE\\\":\\\"/var/lib/mesh/ombi/config.json\\\",\\\"MESH_OMBI_URL\\\":\\\"http://127.0.0.1:20024\\\"},\\\"pacman\\\":{\\\"PACMAN_KEEPER_CONFIG\\\":\\\"/var/lib/mesh/pacman/keeper.json\\\"},\\\"plex\\\":{\\\"MESH_PLEX_CONFIG_DIR\\\":\\\"/mnt/plex/config\\\",\\\"MESH_PLEX_URL\\\":\\\"http://127.0.0.1:32400\\\"},\\\"postgres\\\":{\\\"MESH_CONSUMERS_FILE\\\":\\\"/var/lib/mesh/postgres/consumers.json\\\",\\\"MESH_POSTGRES_READER_PASSWORD_FILE\\\":\\\"/var/lib/mesh/postgres/reader.secret\\\",\\\"MESH_PROVISION_PASSWORD_FILE\\\":\\\"/var/lib/mesh/postgres/superuser.secret\\\",\\\"MESH_PROVISION_POSTGRES\\\":\\\"postgres://postgres@127.0.0.1:5434/postgres?sslmode=disable\\\",\\\"MESH_RECEIVES\\\":\\\"/var/lib/mesh/postgres/grants/mesh.json\\\"},\\\"qbittorrent\\\":{\\\"MESH_QBITTORRENT_CONFIG_DIR\\\":\\\"/var/lib/qbittorrent/config\\\",\\\"MESH_QBITTORRENT_CONFIG_FILE\\\":\\\"/var/lib/qbittorrent/config.json\\\",\\\"MESH_QBITTORRENT_PASSWORD_FILE\\\":\\\"/var/lib/mesh/qbittorrent/password\\\",\\\"MESH_QBITTORRENT_URL\\\":\\\"http://127.0.0.1:8112\\\"},\\\"radarr\\\":{\\\"MESH_PLEX_BIND_FILE\\\":\\\"/var/lib/radarr/plex-api.json\\\",\\\"MESH_PLEX_TOKEN_FILE\\\":\\\"/var/lib/radarr/plex-api.secret\\\",\\\"MESH_RADARR_API_KEY_FILE\\\":\\\"/var/lib/mesh/radarr/api-key\\\",\\\"MESH_RADARR_CONFIG_DIR\\\":\\\"/var/lib/radarr/config\\\",\\\"MESH_RADARR_URL\\\":\\\"http://127.0.0.1:20019\\\"},\\\"redis\\\":{\\\"MESH_PROVISION_PASSWORD_FILE\\\":\\\"/var/lib/redis/default.secret\\\",\\\"MESH_PROVISION_REDIS\\\":\\\"127.0.0.1:20003\\\",\\\"MESH_RECEIVES\\\":\\\"/var/lib/redis/grants/mesh.json\\\"},\\\"restic\\\":{\\\"MESH_BACKUP_DATA\\\":\\\"/var/lib/restic/data.conf\\\",\\\"MESH_BACKUP_DECLARED\\\":\\\"/var/lib/restic/backups.conf\\\",\\\"MESH_BACKUP_PASSWORD_FILE\\\":\\\"/var/lib/restic/repository.secret\\\",\\\"MESH_BACKUP_REPOSITORY\\\":\\\"/storage/restic\\\",\\\"MESH_BACKUP_STATE\\\":\\\"/var/lib/restic\\\"},\\\"searxng\\\":{\\\"MESH_SEARXNG_CONFIG_FILE\\\":\\\"/var/lib/mesh/searxng/config.json\\\",\\\"MESH_SEARXNG_URL\\\":\\\"http://127.0.0.1:20000\\\"},\\\"sonarr\\\":{\\\"MESH_PLEX_BIND_FILE\\\":\\\"/var/lib/sonarr/plex-api.json\\\",\\\"MESH_PLEX_TOKEN_FILE\\\":\\\"/var/lib/sonarr/plex-api.secret\\\",\\\"MESH_SONARR_API_KEY_FILE\\\":\\\"/var/lib/mesh/sonarr/api-key\\\",\\\"MESH_SONARR_CONFIG_DIR\\\":\\\"/var/lib/sonarr/config\\\",\\\"MESH_SONARR_RECYCLE_DIR\\\":\\\"/var/lib/sonarr/recycle-bin\\\",\\\"MESH_SONARR_URL\\\":\\\"http://127.0.0.1:20018\\\"},\\\"systemd\\\":{\\\"MESH_SYSTEMD_STATE\\\":\\\"/var/lib/mesh/systemd\\\"},\\\"tautulli\\\":{\\\"MESH_TAUTULLI_CONFIG_DIR\\\":\\\"/var/lib/tautulli/config\\\",\\\"MESH_TAUTULLI_CONFIG_FILE\\\":\\\"/var/lib/mesh/tautulli/config.json\\\",\\\"MESH_TAUTULLI_CONTAINER\\\":\\\"tautulli\\\",\\\"MESH_TAUTULLI_URL\\\":\\\"http://127.0.0.1:20023\\\"},\\\"unifi\\\":{\\\"MESH_UNIFI_CONFIG_FILE\\\":\\\"/var/lib/mesh/unifi/config.json\\\",\\\"MESH_UNIFI_URL\\\":\\\"https://127.0.0.1:8443\\\"},\\\"youtube\\\":{\\\"MESH_YOUTUBE_DOWNLOADS\\\":\\\"/storage/downloads/youtube\\\",\\\"MESH_YOUTUBE_SETTINGS\\\":\\\"/var/lib/youtube/settings.json\\\"},\\\"zsh\\\":{\\\"ZSH_TOOLS_CONF\\\":\\\"/var/lib/zsh/tools.conf\\\"}}\",\"MESH_TOOL_MODULES\":\"avahi=/var/lib/mesh/bundles/avahi/tools/avahi-tools,baserow=/var/lib/mesh/bundles/baserow/tools/tools/index.serve.mjs,bazarr=/var/lib/mesh/bundles/bazarr/code/index.serve.mjs,bazarr=/var/lib/mesh/bundles/bazarr/code/tools/index.serve.mjs,bookshelf=/var/lib/mesh/bundles/bookshelf/code/index.serve.mjs,bookshelf=/var/lib/mesh/bundles/bookshelf/code/tools/index.serve.mjs,claude-code=/var/lib/mesh/bundles/claude-code/tools/claude-code,dbus=/var/lib/mesh/bundles/dbus/tools-go/dbus-tools,disk-load=/var/lib/mesh/bundles/disk-load/tools-go/disk-load,dnsmasq=/var/lib/mesh/bundles/dnsmasq/tools/dnsmasq-tools,docker=/var/lib/mesh/bundles/docker/tools/docker-tools,fail2ban=/var/lib/mesh/bundles/fail2ban/tools/fail2ban-tools,grafana=/var/lib/mesh/bundles/grafana/code/index.serve.mjs,grafana=/var/lib/mesh/bundles/grafana/code/tools/index.serve.mjs,home-assistant=/var/lib/mesh/bundles/home-assistant/code/index.serve.mjs,home-assistant=/var/lib/mesh/bundles/home-assistant/code/tools/index.serve.mjs,hostname=/var/lib/mesh/bundles/hostname/tools/hostname-tools,icecast=/var/lib/mesh/bundles/icecast/code/index.serve.mjs,icecast=/var/lib/mesh/bundles/icecast/code/tools/index.serve.mjs,influxdb=/var/lib/mesh/bundles/influxdb/code/provisioner/index.serve.mjs,influxdb=/var/lib/mesh/bundles/influxdb/code/tools/index.serve.mjs,jackett=/var/lib/mesh/bundles/jackett/code/tools/index.serve.mjs,kernel=/var/lib/mesh/bundles/kernel/tools/kernel-tools,kometa=/var/lib/mesh/bundles/kometa/code/tools/index.serve.mjs,letta=/var/lib/mesh/bundles/letta/tools/tools/index.serve.mjs,lidarr=/var/lib/mesh/bundles/lidarr/tools-go/lidarr,localization=/var/lib/mesh/bundles/localization/tools/localization-tools,logrotate=/var/lib/mesh/bundles/logrotate/tools/logrotate-tools,media-importer=/var/lib/mesh/bundles/media-importer/tools/media-importer,mesh-files=/var/lib/mesh/bundles/mesh-files/tools/mesh-files,mosquitto=/var/lib/mesh/bundles/mosquitto/code/index.serve.mjs,mosquitto=/var/lib/mesh/bundles/mosquitto/code/provisioner/index.serve.mjs,mosquitto=/var/lib/mesh/bundles/mosquitto/code/tools/index.serve.mjs,mssql=/var/lib/mesh/bundles/mssql/code/index.serve.mjs,mssql=/var/lib/mesh/bundles/mssql/code/provisioner/index.serve.mjs,mssql=/var/lib/mesh/bundles/mssql/code/tools/index.serve.mjs,netcheck=/var/lib/mesh/bundles/netcheck/tools-go/netcheck,netcheck=/var/lib/mesh/bundles/netcheck/tools-typescript/tools/index.serve.mjs,networkmanager=/var/lib/mesh/bundles/networkmanager/tools/uplink-tools,nfs-server=/var/lib/mesh/bundles/nfs-server/tools/nfs-server,nftables=/var/lib/mesh/bundles/nftables/tools/tools/index.serve.mjs,nodered=/var/lib/mesh/bundles/nodered/code/tools/index.serve.mjs,nzbgeek=/var/lib/mesh/bundles/nzbgeek/tools/nzbgeek,nzbget=/var/lib/mesh/bundles/nzbget/code/nzbget,ombi=/var/lib/mesh/bundles/ombi/code/index.serve.mjs,ombi=/var/lib/mesh/bundles/ombi/code/tools/index.serve.mjs,pacman=/var/lib/mesh/bundles/pacman/tools/pacman-tools,plex=/var/lib/mesh/bundles/plex/code/plex,postgres=/var/lib/mesh/bundles/postgres/code/postgres-provider,power=/var/lib/mesh/bundles/power/tools-go/power,qbittorrent=/var/lib/mesh/bundles/qbittorrent/code/qbittorrent,radarr=/var/lib/mesh/bundles/radarr/tools-go/radarr,redis=/var/lib/mesh/bundles/redis/code/index.serve.mjs,redis=/var/lib/mesh/bundles/redis/code/provisioner/index.serve.mjs,redis=/var/lib/mesh/bundles/redis/code/tools/index.serve.mjs,restic=/var/lib/mesh/bundles/restic/tools/restic-backups,route-proxy=/var/lib/mesh/bundles/route-proxy/provider/route-provider,searxng=/var/lib/mesh/bundles/searxng/tools/tools/index.serve.mjs,sonarr=/var/lib/mesh/bundles/sonarr/code/index.serve.mjs,sonarr=/var/lib/mesh/bundles/sonarr/code/tools/index.serve.mjs,ssh-client=/var/lib/mesh/bundles/ssh-client/tools/ssh-client-tools,sudo=/var/lib/mesh/bundles/sudo/tools/sudo-tools,systemd=/var/lib/mesh/bundles/systemd/tools/systemd-tools,tautulli=/var/lib/mesh/bundles/tautulli/code/index.serve.mjs,tautulli=/var/lib/mesh/bundles/tautulli/code/tools/index.serve.mjs,time-sync=/var/lib/mesh/bundles/time-sync/tools/time-sync-tools,unifi=/var/lib/mesh/bundles/unifi/tools/tools/index.serve.mjs,youtube=/var/lib/mesh/bundles/youtube/tools/youtube,zsh=/var/lib/mesh/bundles/zsh/tools/tools/index.serve.mjs\"},\"id\":\"node-tools.runtime\",\"module\":\"node-tools\",\"name\":\"node-tools\",\"restart-on\":[\"avahi.bundle-tools\",\"baserow.bundle-tools\",\"bazarr.bundle-code\",\"bookshelf.bundle-code\",\"claude-code.bundle-tools\",\"dbus.bundle-tools-go\",\"disk-load.bundle-tools-go\",\"dnsmasq.bundle-tools\",\"docker.bundle-tools\",\"fail2ban.bundle-tools\",\"grafana.bundle-code\",\"home-assistant.bundle-code\",\"hostname.bundle-tools\",\"icecast.bundle-code\",\"influxdb.bundle-code\",\"jackett.bundle-code\",\"kernel.bundle-tools\",\"kometa.bundle-code\",\"letta.bundle-tools\",\"lidarr.bundle-tools-go\",\"localization.bundle-tools\",\"logrotate.bundle-tools\",\"media-importer.bundle-tools\",\"mesh-files.bundle-tools\",\"mosquitto.bundle-code\",\"mssql.bundle-code\",\"netcheck.bundle-tools-go\",\"netcheck.bundle-tools-typescript\",\"networkmanager.bundle-tools\",\"nfs-server.bundle-tools\",\"nftables.bundle-tools\",\"node-tools.needs-broker\",\"nodered.bundle-code\",\"nzbgeek.bundle-tools\",\"nzbget.bundle-code\",\"ombi.bundle-code\",\"pacman.bundle-tools\",\"plex.bundle-code\",\"postgres.bundle-code\",\"power.bundle-tools-go\",\"qbittorrent.bundle-code\",\"radarr.bundle-tools-go\",\"redis.bundle-code\",\"restic.bundle-tools\",\"route-proxy.bundle-provider\",\"searxng.bundle-tools\",\"sonarr.bundle-code\",\"ssh-client.bundle-tools\",\"sudo.bundle-tools\",\"systemd.bundle-tools\",\"tautulli.bundle-code\",\"time-sync.bundle-tools\",\"unifi.bundle-tools\",\"youtube.bundle-tools\",\"zsh.bundle-tools\",\"baserow.runtime-config\",\"bazarr.runtime-config\",\"claude-code.facts\",\"claude-code.settings\",\"grafana.runtime-config\",\"home-assistant.needs-token\",\"home-assistant.runtime-config\",\"icecast.runtime-config\",\"icecast.secret-admin\",\"influxdb.needs-admin-token\",\"influxdb.received-influxdb-api\",\"influxdb.runtime-config\",\"jackett.needs-api-key\",\"jackett.runtime-config\",\"kometa.settings\",\"letta.runtime-config\",\"lidarr.bound-plex-api\",\"lidarr.needs-api-key\",\"lidarr.secret-plex-api\",\"media-importer.settings\",\"mosquitto.needs-admin\",\"mosquitto.received-mqtt-topic\",\"mssql.needs-reader\",\"mssql.needs-sa\",\"mssql.received-mssql-database\",\"nodered.runtime-config\",\"nzbgeek.needs-api-key\",\"nzbgeek.settings\",\"nzbget.needs-password\",\"nzbget.runtime-config\",\"ombi.needs-api-key\",\"ombi.runtime-config\",\"pacman.keeper-config\",\"postgres.consumers\",\"postgres.needs-reader\",\"postgres.needs-superuser\",\"postgres.received-postgres-database\",\"qbittorrent.needs-password\",\"qbittorrent.runtime-config\",\"radarr.bound-plex-api\",\"radarr.needs-api-key\",\"radarr.secret-plex-api\",\"redis.received-redis-cache\",\"redis.secret-secret\",\"restic.data-declared\",\"restic.declared\",\"restic.needs-repository\",\"searxng.runtime-config\",\"sonarr.bound-plex-api\",\"sonarr.needs-api-key\",\"sonarr.secret-plex-api\",\"tautulli.runtime-config\",\"unifi.runtime-config\",\"youtube.settings\",\"zsh.tools-conf\"],\"run\":[\"./node-tools\"],\"source\":\"http://novox.internal:5100/v2/node-tools/runtime/blobs/sha256:ef790876783d817903b337b8418b1eb21fb49d34128a390bbf7c3d08a4b2be8a\",\"type\":\"process\",\"user\":\"ace\"}],\"v\":1}", wire: "{\"fp\":\"src2:6e6f64652d6e6f64652d6e6f64652d6e6f64652d6e6f64652d6e6f64652d6e6f\",\"module\":\"node-tools\",\"node\":\"ace\",\"resources\":[{\"id\":\"node-tools.mesh-state\",\"mode\":\"0755\",\"module\":\"node-tools\",\"path\":\"/var/lib/mesh/node-tools\",\"type\":\"directory\"},{\"id\":\"node-tools.interpreter\",\"module\":\"node-tools\",\"package\":\"nodejs\",\"type\":\"package\"},{\"digest\":\"sha256:ef790876783d817903b337b8418b1eb21fb49d34128a390bbf7c3d08a4b2be8a\",\"env\":{\"MESH_AGENT_ACCOUNT\":\"ace\",\"MESH_AGENT_HOME\":\"/home/ace\",\"MESH_BROKER_FILE\":\"/var/lib/mesh/node-tools/broker\",\"MESH_OPERATOR_ACCOUNT\":\"ace\",\"MESH_OPERATOR_HOME\":\"/home/ace\",\"MESH_TOOL_ENV\":\"{\\\"baserow\\\":{\\\"MESH_BASEROW_CONFIG_FILE\\\":\\\"/var/lib/mesh/baserow/config.json\\\",\\\"MESH_BASEROW_URL\\\":\\\"http://127.0.0.1:20011\\\"},\\\"bazarr\\\":{\\\"MESH_BAZARR_CONFIG_DIR\\\":\\\"/var/lib/bazarr/config\\\",\\\"MESH_BAZARR_CONFIG_FILE\\\":\\\"/var/lib/bazarr/config.json\\\",\\\"MESH_BAZARR_CONTAINER\\\":\\\"bazarr\\\",\\\"MESH_BAZARR_URL\\\":\\\"http://127.0.0.1:20020\\\"},\\\"bookshelf\\\":{\\\"MESH_BOOKSHELF_CONFIG_DIR\\\":\\\"/var/lib/bookshelf/config\\\",\\\"MESH_BOOKSHELF_URL\\\":\\\"http://127.0.0.1:20021\\\"},\\\"claude-code\\\":{\\\"MESH_CLAUDE_CODE_FACTS\\\":\\\"/var/lib/claude-code/facts.json\\\",\\\"MESH_CLAUDE_CODE_SETTINGS\\\":\\\"/var/lib/claude-code/settings.json\\\",\\\"MESH_CLAUDE_CODE_STATE\\\":\\\"/var/lib/claude-code\\\"},\\\"grafana\\\":{\\\"MESH_GRAFANA_CONFIG_FILE\\\":\\\"/var/lib/mesh/grafana/config.json\\\",\\\"MESH_GRAFANA_URL\\\":\\\"http://127.0.0.1:20012\\\"},\\\"home-assistant\\\":{\\\"MESH_HOMEASSISTANT_CONFIG_FILE\\\":\\\"/var/lib/mesh/home-assistant/config.json\\\",\\\"MESH_HOMEASSISTANT_TOKEN_FILE\\\":\\\"/var/lib/mesh/home-assistant/token\\\",\\\"MESH_HOMEASSISTANT_URL\\\":\\\"http://127.0.0.1:8123\\\"},\\\"icecast\\\":{\\\"MESH_ICECAST_ADMIN_PASSWORD_FILE\\\":\\\"/var/lib/icecast/admin.secret\\\",\\\"MESH_ICECAST_CONFIG_FILE\\\":\\\"/var/lib/mesh/icecast/config.json\\\",\\\"MESH_ICECAST_URL\\\":\\\"http://127.0.0.1:20005\\\"},\\\"influxdb\\\":{\\\"MESH_INFLUXDB_CONFIG_FILE\\\":\\\"/var/lib/mesh/influxdb/config.json\\\",\\\"MESH_INFLUXDB_TOKEN_FILE\\\":\\\"/var/lib/influxdb/admin-token.secret\\\",\\\"MESH_INFLUXDB_URL\\\":\\\"http://127.0.0.1:8086\\\",\\\"MESH_RECEIVES\\\":\\\"/var/lib/influxdb/grants/mesh.json\\\"},\\\"jackett\\\":{\\\"MESH_JACKETT_API_KEY_FILE\\\":\\\"/var/lib/mesh/jackett/api-key\\\",\\\"MESH_JACKETT_CONFIG_DIR\\\":\\\"/var/lib/jackett/config\\\",\\\"MESH_JACKETT_CONFIG_FILE\\\":\\\"/var/lib/jackett/config.json\\\",\\\"MESH_JACKETT_URL\\\":\\\"http://127.0.0.1:20004\\\"},\\\"kometa\\\":{\\\"MESH_KOMETA_CONFIG_DIR\\\":\\\"/var/lib/kometa/config\\\",\\\"MESH_KOMETA_CONFIG_FILE\\\":\\\"/var/lib/kometa/config.yml\\\"},\\\"letta\\\":{\\\"MESH_LETTA_CONFIG_FILE\\\":\\\"/var/lib/mesh/letta/config.json\\\",\\\"MESH_LETTA_URL\\\":\\\"http://127.0.0.1:20002\\\"},\\\"lidarr\\\":{\\\"MESH_LIDARR_API_KEY_FILE\\\":\\\"/var/lib/mesh/lidarr/api-key\\\",\\\"MESH_LIDARR_CONFIG_DIR\\\":\\\"/var/lib/lidarr/config\\\",\\\"MESH_LIDARR_RECYCLE_BIN\\\":\\\"/recycle/\\\",\\\"MESH_LIDARR_RECYCLE_DAYS\\\":\\\"7\\\",\\\"MESH_LIDARR_URL\\\":\\\"http://127.0.0.1:20022\\\",\\\"MESH_PLEX_BIND_FILE\\\":\\\"/var/lib/lidarr/plex-api.json\\\",\\\"MESH_PLEX_TOKEN_FILE\\\":\\\"/var/lib/lidarr/plex-api.secret\\\"},\\\"media-importer\\\":{\\\"MESH_MEDIA_IMPORTER_SETTINGS\\\":\\\"/var/lib/media-importer/settings.json\\\",\\\"MESH_MEDIA_IMPORTER_STATE\\\":\\\"/var/lib/media-importer\\\",\\\"MESH_MEDIA_LIBRARY\\\":\\\"/storage/media/poc\\\"},\\\"mesh-files\\\":{\\\"MESH_FILES_STATE\\\":\\\"/var/lib/mesh-files\\\",\\\"MESH_OWNED_PATHS\\\":\\\"{\\\\\\\"/etc/NetworkManager/conf.d/50-mesh.conf\\\\\\\":\\\\\\\"networkmanager (its own tools)\\\\\\\",\\\\\\\"/etc/claude-code\\\\\\\":\\\\\\\"claude-code (its own tools)\\\\\\\",\\\\\\\"/etc/claude-code/agent-account\\\\\\\":\\\\\\\"claude-code (its own tools)\\\\\\\",\\\\\\\"/etc/dnsmasq.conf\\\\\\\":\\\\\\\"dnsmasq (its own tools)\\\\\\\",\\\\\\\"/etc/docker/daemon.json\\\\\\\":\\\\\\\"docker (its own tools)\\\\\\\",\\\\\\\"/etc/fail2ban/action.d\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/etc/fail2ban/action.d/iptables-allports-dualchain.conf\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/etc/fail2ban/filter.d\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/etc/fail2ban/jail.d\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/etc/fail2ban/jail.d/recidive.conf\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/etc/fail2ban/jail.d/sshd.conf\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/etc/fail2ban/jail.local\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/etc/fail2ban/mesh-own-addresses.json\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/etc/hostname\\\\\\\":\\\\\\\"hostname (node-hostname)\\\\\\\",\\\\\\\"/etc/hosts\\\\\\\":\\\\\\\"hostname (node-hostname)\\\\\\\",\\\\\\\"/etc/locale.conf\\\\\\\":\\\\\\\"localization (its own tools)\\\\\\\",\\\\\\\"/etc/logrotate.conf\\\\\\\":\\\\\\\"logrotate (its own tools)\\\\\\\",\\\\\\\"/etc/logrotate.d/fail2ban\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-power\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-power/moments\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-power/moments/after-boot\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-power/moments/after-wake\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-power/moments/before-shutdown\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-power/moments/before-sleep\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-power/moments/on-battery\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-power/moments/on-mains\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-resolver/nodes.conf\\\\\\\":\\\\\\\"dnsmasq (its own tools)\\\\\\\",\\\\\\\"/etc/mesh-resolver/zones.conf\\\\\\\":\\\\\\\"dnsmasq (its own tools)\\\\\\\",\\\\\\\"/etc/nfs-server\\\\\\\":\\\\\\\"nfs-server (its own tools)\\\\\\\",\\\\\\\"/etc/nfs-server/machines\\\\\\\":\\\\\\\"nfs-server (its own tools)\\\\\\\",\\\\\\\"/etc/nfs-server/shares.conf\\\\\\\":\\\\\\\"nfs-server (its own tools)\\\\\\\",\\\\\\\"/etc/nfs.conf.d/50-mesh.conf\\\\\\\":\\\\\\\"nfs-server (its own tools)\\\\\\\",\\\\\\\"/etc/pacman.conf\\\\\\\":\\\\\\\"pacman (its own tools)\\\\\\\",\\\\\\\"/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook\\\\\\\":\\\\\\\"kernel (its own tools)\\\\\\\",\\\\\\\"/etc/resolv.conf\\\\\\\":\\\\\\\"networkmanager (node-uplink)\\\\\\\",\\\\\\\"/etc/ssh/sshd_config.d/10-mesh.conf\\\\\\\":\\\\\\\"sshd (its own tools)\\\\\\\",\\\\\\\"/etc/sudoers.d/10-mesh-operator\\\\\\\":\\\\\\\"sudo (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/logind.conf.d\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/logind.conf.d/power.conf\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/dnsmasq.service.d\\\\\\\":\\\\\\\"dnsmasq (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/dnsmasq.service.d/mesh.conf\\\\\\\":\\\\\\\"dnsmasq (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/docker-prune.service\\\\\\\":\\\\\\\"docker (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/docker-prune.timer\\\\\\\":\\\\\\\"docker (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/hibernate.target.d\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/hibernate.target.d/mesh-power.conf\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/hybrid-sleep.target.d\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/hybrid-sleep.target.d/mesh-power.conf\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/mesh-ca-trust.service\\\\\\\":\\\\\\\"ca-trust (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/mesh-filter.service\\\\\\\":\\\\\\\"nftables (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/mesh-kernel-boot-entries.service\\\\\\\":\\\\\\\"kernel (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/mesh-kernel-huge-pages.service\\\\\\\":\\\\\\\"kernel (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/mesh-power-after-boot.service\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/mesh-power-after-wake.service\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/mesh-power-before-shutdown.service\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/mesh-power-before-sleep.service\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/mesh-power-supply.service\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/nftables.service.d/mesh.conf\\\\\\\":\\\\\\\"nftables (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/sleep.target.d\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/sleep.target.d/mesh-power.conf\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/suspend-then-hibernate.target.d\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/suspend.target.d\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/system/suspend.target.d/mesh-power.conf\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/systemd/timesyncd.conf.d/50-mesh.conf\\\\\\\":\\\\\\\"time-sync (its own tools)\\\\\\\",\\\\\\\"/etc/udev/rules.d/90-mesh-power.rules\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/etc/vconsole.conf\\\\\\\":\\\\\\\"localization (its own tools)\\\\\\\",\\\\\\\"/etc/xdg/reflector/reflector.conf\\\\\\\":\\\\\\\"pacman (its own tools)\\\\\\\",\\\\\\\"/run/mesh-power\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/run/nfs-server\\\\\\\":\\\\\\\"nfs-server (its own tools)\\\\\\\",\\\\\\\"/usr/lib/nox-mesh-host/launch\\\\\\\":\\\\\\\"mesh-host (its own tools)\\\\\\\",\\\\\\\"/usr/lib/nox-mesh-host/versions/4e121b6e88f1\\\\\\\":\\\\\\\"mesh-host (its own tools)\\\\\\\",\\\\\\\"/usr/local/bin\\\\\\\":\\\\\\\"mesh-cli (its own tools)\\\\\\\",\\\\\\\"/usr/local/bin/claude-agent\\\\\\\":\\\\\\\"claude-code (its own tools)\\\\\\\",\\\\\\\"/usr/local/lib/mesh-power\\\\\\\":\\\\\\\"power (its own tools)\\\\\\\",\\\\\\\"/var/lib/dnsmasq-watch\\\\\\\":\\\\\\\"dnsmasq (its own tools)\\\\\\\",\\\\\\\"/var/lib/mesh-fail2ban\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/var/lib/mesh-fail2ban/own-addresses\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/var/lib/mesh-store\\\\\\\":\\\\\\\"postgres (its own tools)\\\\\\\",\\\\\\\"/var/lib/nfs-server\\\\\\\":\\\\\\\"nfs-server (its own tools)\\\\\\\",\\\\\\\"/var/lib/nfs-server/from-bus\\\\\\\":\\\\\\\"nfs-server (its own tools)\\\\\\\",\\\\\\\"/var/lib/postgres\\\\\\\":\\\\\\\"postgres (its own tools)\\\\\\\",\\\\\\\"/var/lib/route-proxy/acme\\\\\\\":\\\\\\\"route-proxy (its own tools)\\\\\\\",\\\\\\\"/var/lib/route-proxy/ca\\\\\\\":\\\\\\\"route-proxy (its own tools)\\\\\\\",\\\\\\\"/var/lib/route-proxy/routes\\\\\\\":\\\\\\\"route-proxy (its own tools)\\\\\\\",\\\\\\\"/var/lib/systemd\\\\\\\":\\\\\\\"systemd (its own tools)\\\\\\\",\\\\\\\"/var/log/fail2ban.log\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\",\\\\\\\"/var/run/fail2ban\\\\\\\":\\\\\\\"fail2ban (its own tools)\\\\\\\"}\\\",\\\"MESH_SECRET_PLACES\\\":\\\"[\\\\\\\"/var/lib/baserow\\\\\\\",\\\\\\\"/var/lib/bazarr/radarr-api.secret\\\\\\\",\\\\\\\"/var/lib/bazarr/sonarr-api.secret\\\\\\\",\\\\\\\"/var/lib/bookshelf/jackett-api.secret\\\\\\\",\\\\\\\"/var/lib/bookshelf/nzbget-api.secret\\\\\\\",\\\\\\\"/var/lib/bookshelf/qbittorrent-api.secret\\\\\\\",\\\\\\\"/var/lib/car-hunter\\\\\\\",\\\\\\\"/var/lib/claude-code/gitea-read.secret\\\\\\\",\\\\\\\"/var/lib/home-assistant/lidarr-api.secret\\\\\\\",\\\\\\\"/var/lib/home-assistant/mqtt-topic.secret\\\\\\\",\\\\\\\"/var/lib/home-assistant/radarr-api.secret\\\\\\\",\\\\\\\"/var/lib/home-assistant/sonarr-api.secret\\\\\\\",\\\\\\\"/var/lib/icecast/admin.secret\\\\\\\",\\\\\\\"/var/lib/icecast/relay.secret\\\\\\\",\\\\\\\"/var/lib/icecast/source.secret\\\\\\\",\\\\\\\"/var/lib/influxdb\\\\\\\",\\\\\\\"/var/lib/kometa\\\\\\\",\\\\\\\"/var/lib/letta\\\\\\\",\\\\\\\"/var/lib/lidarr/jackett-api.secret\\\\\\\",\\\\\\\"/var/lib/lidarr/nzbget-api.secret\\\\\\\",\\\\\\\"/var/lib/lidarr/plex-api.secret\\\\\\\",\\\\\\\"/var/lib/lidarr/qbittorrent-api.secret\\\\\\\",\\\\\\\"/var/lib/mesh-delivery-assigner\\\\\\\",\\\\\\\"/var/lib/mesh-provisioner-client\\\\\\\",\\\\\\\"/var/lib/mesh-reads\\\\\\\",\\\\\\\"/var/lib/mesh/dnsmasq\\\\\\\",\\\\\\\"/var/lib/mesh/grafana\\\\\\\",\\\\\\\"/var/lib/mesh/home-assistant\\\\\\\",\\\\\\\"/var/lib/mesh/jackett\\\\\\\",\\\\\\\"/var/lib/mesh/lidarr\\\\\\\",\\\\\\\"/var/lib/mesh/mosquitto\\\\\\\",\\\\\\\"/var/lib/mesh/node-tools\\\\\\\",\\\\\\\"/var/lib/mesh/nodered\\\\\\\",\\\\\\\"/var/lib/mesh/nzbget\\\\\\\",\\\\\\\"/var/lib/mesh/ombi\\\\\\\",\\\\\\\"/var/lib/mesh/pacman\\\\\\\",\\\\\\\"/var/lib/mesh/postgres\\\\\\\",\\\\\\\"/var/lib/mesh/qbittorrent\\\\\\\",\\\\\\\"/var/lib/mesh/radarr\\\\\\\",\\\\\\\"/var/lib/mesh/route-proxy\\\\\\\",\\\\\\\"/var/lib/mesh/searxng\\\\\\\",\\\\\\\"/var/lib/mesh/sonarr\\\\\\\",\\\\\\\"/var/lib/mesh/unifi\\\\\\\",\\\\\\\"/var/lib/mosquitto/grants\\\\\\\",\\\\\\\"/var/lib/mssql\\\\\\\",\\\\\\\"/var/lib/n8n/database.secret\\\\\\\",\\\\\\\"/var/lib/nodered/mqtt-topic.secret\\\\\\\",\\\\\\\"/var/lib/nzbgeek\\\\\\\",\\\\\\\"/var/lib/ombi/lidarr-api.secret\\\\\\\",\\\\\\\"/var/lib/ombi/plex-api.secret\\\\\\\",\\\\\\\"/var/lib/ombi/radarr-api.secret\\\\\\\",\\\\\\\"/var/lib/ombi/sonarr-api.secret\\\\\\\",\\\\\\\"/var/lib/radarr/jackett-api.secret\\\\\\\",\\\\\\\"/var/lib/radarr/nzbget-api.secret\\\\\\\",\\\\\\\"/var/lib/radarr/plex-api.secret\\\\\\\",\\\\\\\"/var/lib/radarr/qbittorrent-api.secret\\\\\\\",\\\\\\\"/var/lib/redis/default.secret\\\\\\\",\\\\\\\"/var/lib/redis/grants\\\\\\\",\\\\\\\"/var/lib/restic\\\\\\\",\\\\\\\"/var/lib/sonarr/jackett-api.secret\\\\\\\",\\\\\\\"/var/lib/sonarr/nzbget-api.secret\\\\\\\",\\\\\\\"/var/lib/sonarr/plex-api.secret\\\\\\\",\\\\\\\"/var/lib/sonarr/qbittorrent-api.secret\\\\\\\",\\\\\\\"/var/lib/supabase\\\\\\\",\\\\\\\"/var/lib/tautulli/plex-api.secret\\\\\\\",\\\\\\\"/var/lib/txt-game/database.secret\\\\\\\"]\\\"},\\\"mosquitto\\\":{\\\"MESH_MQTT_CTRL_CONTAINER\\\":\\\"mosquitto\\\",\\\"MESH_PROVISION_ADMIN_USER\\\":\\\"mesh-admin\\\",\\\"MESH_PROVISION_MQTT\\\":\\\"127.0.0.1:1883\\\",\\\"MESH_PROVISION_PASSWORD_FILE\\\":\\\"/var/lib/mesh/mosquitto/admin\\\",\\\"MESH_RECEIVES\\\":\\\"/var/lib/mosquitto/grants/mesh.json\\\"},\\\"mssql\\\":{\\\"MESH_MSSQL_READER_PASSWORD_FILE\\\":\\\"/var/lib/mssql/reader.secret\\\",\\\"MESH_PROVISION_MSSQL\\\":\\\"mssql://sa@127.0.0.1:4848/master\\\",\\\"MESH_PROVISION_PASSWORD_FILE\\\":\\\"/var/lib/mssql/sa.secret\\\",\\\"MESH_RECEIVES\\\":\\\"/var/lib/mssql/grants/mesh.json\\\"},\\\"nodered\\\":{\\\"MESH_NODERED_CONFIG_FILE\\\":\\\"/var/lib/mesh/nodered/config.json\\\",\\\"MESH_NODERED_URL\\\":\\\"http://127.0.0.1:20006\\\"},\\\"nzbgeek\\\":{\\\"MESH_NZBGEEK_KEY_FILE\\\":\\\"/var/lib/nzbgeek/api-key.secret\\\",\\\"MESH_NZBGEEK_SETTINGS\\\":\\\"/var/lib/nzbgeek/settings.json\\\"},\\\"nzbget\\\":{\\\"MESH_NZBGET_CONFIG_DIR\\\":\\\"/var/lib/nzbget/config\\\",\\\"MESH_NZBGET_CONFIG_FILE\\\":\\\"/var/lib/nzbget/config.json\\\",\\\"MESH_NZBGET_PASSWORD_FILE\\\":\\\"/var/lib/mesh/nzbget/password\\\",\\\"MESH_NZBGET_SECRETS_DIR\\\":\\\"/var/lib/mesh/nzbget\\\",\\\"MESH_NZBGET_STATE\\\":\\\"/var/lib/nzbget\\\",\\\"MESH_NZBGET_URL\\\":\\\"http://127.0.0.1:20017\\\"},\\\"ombi\\\":{\\\"MESH_OMBI_API_KEY_FILE\\\":\\\"/var/lib/mesh/ombi/api-key\\\",\\\"MESH_OMBI_CONFIG_FILE\\\":\\\"/var/lib/mesh/ombi/config.json\\\",\\\"MESH_OMBI_URL\\\":\\\"http://127.0.0.1:20024\\\"},\\\"pacman\\\":{\\\"PACMAN_KEEPER_CONFIG\\\":\\\"/var/lib/mesh/pacman/keeper.json\\\"},\\\"plex\\\":{\\\"MESH_PLEX_CONFIG_DIR\\\":\\\"/mnt/plex/config\\\",\\\"MESH_PLEX_URL\\\":\\\"http://127.0.0.1:32400\\\"},\\\"postgres\\\":{\\\"MESH_CONSUMERS_FILE\\\":\\\"/var/lib/mesh/postgres/consumers.json\\\",\\\"MESH_POSTGRES_READER_PASSWORD_FILE\\\":\\\"/var/lib/mesh/postgres/reader.secret\\\",\\\"MESH_PROVISION_PASSWORD_FILE\\\":\\\"/var/lib/mesh/postgres/superuser.secret\\\",\\\"MESH_PROVISION_POSTGRES\\\":\\\"postgres://postgres@127.0.0.1:5434/postgres?sslmode=disable\\\",\\\"MESH_RECEIVES\\\":\\\"/var/lib/mesh/postgres/grants/mesh.json\\\"},\\\"qbittorrent\\\":{\\\"MESH_QBITTORRENT_CONFIG_DIR\\\":\\\"/var/lib/qbittorrent/config\\\",\\\"MESH_QBITTORRENT_CONFIG_FILE\\\":\\\"/var/lib/qbittorrent/config.json\\\",\\\"MESH_QBITTORRENT_PASSWORD_FILE\\\":\\\"/var/lib/mesh/qbittorrent/password\\\",\\\"MESH_QBITTORRENT_URL\\\":\\\"http://127.0.0.1:8112\\\"},\\\"radarr\\\":{\\\"MESH_PLEX_BIND_FILE\\\":\\\"/var/lib/radarr/plex-api.json\\\",\\\"MESH_PLEX_TOKEN_FILE\\\":\\\"/var/lib/radarr/plex-api.secret\\\",\\\"MESH_RADARR_API_KEY_FILE\\\":\\\"/var/lib/mesh/radarr/api-key\\\",\\\"MESH_RADARR_CONFIG_DIR\\\":\\\"/var/lib/radarr/config\\\",\\\"MESH_RADARR_URL\\\":\\\"http://127.0.0.1:20019\\\"},\\\"redis\\\":{\\\"MESH_PROVISION_PASSWORD_FILE\\\":\\\"/var/lib/redis/default.secret\\\",\\\"MESH_PROVISION_REDIS\\\":\\\"127.0.0.1:20003\\\",\\\"MESH_RECEIVES\\\":\\\"/var/lib/redis/grants/mesh.json\\\"},\\\"restic\\\":{\\\"MESH_BACKUP_DATA\\\":\\\"/var/lib/restic/data.conf\\\",\\\"MESH_BACKUP_DECLARED\\\":\\\"/var/lib/restic/backups.conf\\\",\\\"MESH_BACKUP_PASSWORD_FILE\\\":\\\"/var/lib/restic/repository.secret\\\",\\\"MESH_BACKUP_REPOSITORY\\\":\\\"/storage/restic\\\",\\\"MESH_BACKUP_STATE\\\":\\\"/var/lib/restic\\\"},\\\"searxng\\\":{\\\"MESH_SEARXNG_CONFIG_FILE\\\":\\\"/var/lib/mesh/searxng/config.json\\\",\\\"MESH_SEARXNG_URL\\\":\\\"http://127.0.0.1:20000\\\"},\\\"sonarr\\\":{\\\"MESH_PLEX_BIND_FILE\\\":\\\"/var/lib/sonarr/plex-api.json\\\",\\\"MESH_PLEX_TOKEN_FILE\\\":\\\"/var/lib/sonarr/plex-api.secret\\\",\\\"MESH_SONARR_API_KEY_FILE\\\":\\\"/var/lib/mesh/sonarr/api-key\\\",\\\"MESH_SONARR_CONFIG_DIR\\\":\\\"/var/lib/sonarr/config\\\",\\\"MESH_SONARR_RECYCLE_DIR\\\":\\\"/var/lib/sonarr/recycle-bin\\\",\\\"MESH_SONARR_URL\\\":\\\"http://127.0.0.1:20018\\\"},\\\"systemd\\\":{\\\"MESH_SYSTEMD_STATE\\\":\\\"/var/lib/mesh/systemd\\\"},\\\"tautulli\\\":{\\\"MESH_TAUTULLI_CONFIG_DIR\\\":\\\"/var/lib/tautulli/config\\\",\\\"MESH_TAUTULLI_CONFIG_FILE\\\":\\\"/var/lib/mesh/tautulli/config.json\\\",\\\"MESH_TAUTULLI_CONTAINER\\\":\\\"tautulli\\\",\\\"MESH_TAUTULLI_URL\\\":\\\"http://127.0.0.1:20023\\\"},\\\"unifi\\\":{\\\"MESH_UNIFI_CONFIG_FILE\\\":\\\"/var/lib/mesh/unifi/config.json\\\",\\\"MESH_UNIFI_URL\\\":\\\"https://127.0.0.1:8443\\\"},\\\"youtube\\\":{\\\"MESH_YOUTUBE_DOWNLOADS\\\":\\\"/storage/downloads/youtube\\\",\\\"MESH_YOUTUBE_SETTINGS\\\":\\\"/var/lib/youtube/settings.json\\\"},\\\"zsh\\\":{\\\"ZSH_TOOLS_CONF\\\":\\\"/var/lib/zsh/tools.conf\\\"}}\",\"MESH_TOOL_MODULES\":\"avahi=/var/lib/mesh/bundles/avahi/tools/avahi-tools,baserow=/var/lib/mesh/bundles/baserow/tools/tools/index.serve.mjs,bazarr=/var/lib/mesh/bundles/bazarr/code/index.serve.mjs,bazarr=/var/lib/mesh/bundles/bazarr/code/tools/index.serve.mjs,bookshelf=/var/lib/mesh/bundles/bookshelf/code/index.serve.mjs,bookshelf=/var/lib/mesh/bundles/bookshelf/code/tools/index.serve.mjs,claude-code=/var/lib/mesh/bundles/claude-code/tools/claude-code,dbus=/var/lib/mesh/bundles/dbus/tools-go/dbus-tools,disk-load=/var/lib/mesh/bundles/disk-load/tools-go/disk-load,dnsmasq=/var/lib/mesh/bundles/dnsmasq/tools/dnsmasq-tools,docker=/var/lib/mesh/bundles/docker/tools/docker-tools,fail2ban=/var/lib/mesh/bundles/fail2ban/tools/fail2ban-tools,grafana=/var/lib/mesh/bundles/grafana/code/index.serve.mjs,grafana=/var/lib/mesh/bundles/grafana/code/tools/index.serve.mjs,home-assistant=/var/lib/mesh/bundles/home-assistant/code/index.serve.mjs,home-assistant=/var/lib/mesh/bundles/home-assistant/code/tools/index.serve.mjs,hostname=/var/lib/mesh/bundles/hostname/tools/hostname-tools,icecast=/var/lib/mesh/bundles/icecast/code/index.serve.mjs,icecast=/var/lib/mesh/bundles/icecast/code/tools/index.serve.mjs,influxdb=/var/lib/mesh/bundles/influxdb/code/provisioner/index.serve.mjs,influxdb=/var/lib/mesh/bundles/influxdb/code/tools/index.serve.mjs,jackett=/var/lib/mesh/bundles/jackett/code/tools/index.serve.mjs,kernel=/var/lib/mesh/bundles/kernel/tools/kernel-tools,kometa=/var/lib/mesh/bundles/kometa/code/tools/index.serve.mjs,letta=/var/lib/mesh/bundles/letta/tools/tools/index.serve.mjs,lidarr=/var/lib/mesh/bundles/lidarr/tools-go/lidarr,localization=/var/lib/mesh/bundles/localization/tools/localization-tools,logrotate=/var/lib/mesh/bundles/logrotate/tools/logrotate-tools,media-importer=/var/lib/mesh/bundles/media-importer/tools/media-importer,mesh-files=/var/lib/mesh/bundles/mesh-files/tools/mesh-files,mosquitto=/var/lib/mesh/bundles/mosquitto/code/index.serve.mjs,mosquitto=/var/lib/mesh/bundles/mosquitto/code/provisioner/index.serve.mjs,mosquitto=/var/lib/mesh/bundles/mosquitto/code/tools/index.serve.mjs,mssql=/var/lib/mesh/bundles/mssql/code/index.serve.mjs,mssql=/var/lib/mesh/bundles/mssql/code/provisioner/index.serve.mjs,mssql=/var/lib/mesh/bundles/mssql/code/tools/index.serve.mjs,netcheck=/var/lib/mesh/bundles/netcheck/tools-go/netcheck,netcheck=/var/lib/mesh/bundles/netcheck/tools-typescript/tools/index.serve.mjs,networkmanager=/var/lib/mesh/bundles/networkmanager/tools/uplink-tools,nfs-server=/var/lib/mesh/bundles/nfs-server/tools/nfs-server,nftables=/var/lib/mesh/bundles/nftables/tools/tools/index.serve.mjs,nodered=/var/lib/mesh/bundles/nodered/code/tools/index.serve.mjs,nzbgeek=/var/lib/mesh/bundles/nzbgeek/tools/nzbgeek,nzbget=/var/lib/mesh/bundles/nzbget/code/nzbget,ombi=/var/lib/mesh/bundles/ombi/code/index.serve.mjs,ombi=/var/lib/mesh/bundles/ombi/code/tools/index.serve.mjs,pacman=/var/lib/mesh/bundles/pacman/tools/pacman-tools,plex=/var/lib/mesh/bundles/plex/code/plex,postgres=/var/lib/mesh/bundles/postgres/code/postgres-provider,power=/var/lib/mesh/bundles/power/tools-go/power,qbittorrent=/var/lib/mesh/bundles/qbittorrent/code/qbittorrent,radarr=/var/lib/mesh/bundles/radarr/tools-go/radarr,redis=/var/lib/mesh/bundles/redis/code/index.serve.mjs,redis=/var/lib/mesh/bundles/redis/code/provisioner/index.serve.mjs,redis=/var/lib/mesh/bundles/redis/code/tools/index.serve.mjs,restic=/var/lib/mesh/bundles/restic/tools/restic-backups,route-proxy=/var/lib/mesh/bundles/route-proxy/provider/route-provider,searxng=/var/lib/mesh/bundles/searxng/tools/tools/index.serve.mjs,sonarr=/var/lib/mesh/bundles/sonarr/code/index.serve.mjs,sonarr=/var/lib/mesh/bundles/sonarr/code/tools/index.serve.mjs,ssh-client=/var/lib/mesh/bundles/ssh-client/tools/ssh-client-tools,sudo=/var/lib/mesh/bundles/sudo/tools/sudo-tools,systemd=/var/lib/mesh/bundles/systemd/tools/systemd-tools,tautulli=/var/lib/mesh/bundles/tautulli/code/index.serve.mjs,tautulli=/var/lib/mesh/bundles/tautulli/code/tools/index.serve.mjs,time-sync=/var/lib/mesh/bundles/time-sync/tools/time-sync-tools,unifi=/var/lib/mesh/bundles/unifi/tools/tools/index.serve.mjs,youtube=/var/lib/mesh/bundles/youtube/tools/youtube,zsh=/var/lib/mesh/bundles/zsh/tools/tools/index.serve.mjs\"},\"id\":\"node-tools.runtime\",\"module\":\"node-tools\",\"name\":\"node-tools\",\"restart-on\":[\"avahi.bundle-tools\",\"baserow.bundle-tools\",\"bazarr.bundle-code\",\"bookshelf.bundle-code\",\"claude-code.bundle-tools\",\"dbus.bundle-tools-go\",\"disk-load.bundle-tools-go\",\"dnsmasq.bundle-tools\",\"docker.bundle-tools\",\"fail2ban.bundle-tools\",\"grafana.bundle-code\",\"home-assistant.bundle-code\",\"hostname.bundle-tools\",\"icecast.bundle-code\",\"influxdb.bundle-code\",\"jackett.bundle-code\",\"kernel.bundle-tools\",\"kometa.bundle-code\",\"letta.bundle-tools\",\"lidarr.bundle-tools-go\",\"localization.bundle-tools\",\"logrotate.bundle-tools\",\"media-importer.bundle-tools\",\"mesh-files.bundle-tools\",\"mosquitto.bundle-code\",\"mssql.bundle-code\",\"netcheck.bundle-tools-go\",\"netcheck.bundle-tools-typescript\",\"networkmanager.bundle-tools\",\"nfs-server.bundle-tools\",\"nftables.bundle-tools\",\"node-tools.needs-broker\",\"nodered.bundle-code\",\"nzbgeek.bundle-tools\",\"nzbget.bundle-code\",\"ombi.bundle-code\",\"pacman.bundle-tools\",\"plex.bundle-code\",\"postgres.bundle-code\",\"power.bundle-tools-go\",\"qbittorrent.bundle-code\",\"radarr.bundle-tools-go\",\"redis.bundle-code\",\"restic.bundle-tools\",\"route-proxy.bundle-provider\",\"searxng.bundle-tools\",\"sonarr.bundle-code\",\"ssh-client.bundle-tools\",\"sudo.bundle-tools\",\"systemd.bundle-tools\",\"tautulli.bundle-code\",\"time-sync.bundle-tools\",\"unifi.bundle-tools\",\"youtube.bundle-tools\",\"zsh.bundle-tools\",\"baserow.runtime-config\",\"bazarr.runtime-config\",\"claude-code.facts\",\"claude-code.settings\",\"grafana.runtime-config\",\"home-assistant.needs-token\",\"home-assistant.runtime-config\",\"icecast.runtime-config\",\"icecast.secret-admin\",\"influxdb.needs-admin-token\",\"influxdb.received-influxdb-api\",\"influxdb.runtime-config\",\"jackett.needs-api-key\",\"jackett.runtime-config\",\"kometa.settings\",\"letta.runtime-config\",\"lidarr.bound-plex-api\",\"lidarr.needs-api-key\",\"lidarr.secret-plex-api\",\"media-importer.settings\",\"mosquitto.needs-admin\",\"mosquitto.received-mqtt-topic\",\"mssql.needs-reader\",\"mssql.needs-sa\",\"mssql.received-mssql-database\",\"nodered.runtime-config\",\"nzbgeek.needs-api-key\",\"nzbgeek.settings\",\"nzbget.needs-password\",\"nzbget.runtime-config\",\"ombi.needs-api-key\",\"ombi.runtime-config\",\"pacman.keeper-config\",\"postgres.consumers\",\"postgres.needs-reader\",\"postgres.needs-superuser\",\"postgres.received-postgres-database\",\"qbittorrent.needs-password\",\"qbittorrent.runtime-config\",\"radarr.bound-plex-api\",\"radarr.needs-api-key\",\"radarr.secret-plex-api\",\"redis.received-redis-cache\",\"redis.secret-secret\",\"restic.data-declared\",\"restic.declared\",\"restic.needs-repository\",\"searxng.runtime-config\",\"sonarr.bound-plex-api\",\"sonarr.needs-api-key\",\"sonarr.secret-plex-api\",\"tautulli.runtime-config\",\"unifi.runtime-config\",\"youtube.settings\",\"zsh.tools-conf\"],\"run\":[\"./node-tools\"],\"source\":\"http://novox.internal:5100/v2/node-tools/runtime/blobs/sha256:ef790876783d817903b337b8418b1eb21fb49d34128a390bbf7c3d08a4b2be8a\",\"type\":\"process\",\"user\":\"ace\"}],\"v\":1}", + decode: func(b []byte) (any, error) { + var v ComposedByNodeModuleFingerprintValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a module the engine could not compose", subject: "", given: "{\"fp\":\"src2:62756c6c6462756c6c6462756c6c6462756c6c6462756c6c6462756c6c646275\",\"module\":\"nzbget\",\"node\":\"ace\",\"refused\":\"a secret the vault does not hold\",\"resources\":[],\"v\":1}", wire: "{\"fp\":\"src2:62756c6c6462756c6c6462756c6c6462756c6c6462756c6c6462756c6c646275\",\"module\":\"nzbget\",\"node\":\"ace\",\"refused\":\"a secret the vault does not hold\",\"resources\":[],\"v\":1}", + decode: func(b []byte) (any, error) { + var v ComposedByNodeModuleFingerprintValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "the node's own composition", subject: "", given: "{\"node\":\"ace\",\"own\":{\"adoption\":{\"taken\":[\"avahi\"],\"untaken\":{\"plex\":[\"plex.bundle-code\"]}},\"groups\":[{\"gid\":1002,\"name\":\"mesh-media\"}],\"identities\":[{\"gid\":1001,\"groups\":[1002],\"module\":\"plex\",\"name\":\"plex\",\"runner\":\"plex\",\"uid\":1001}],\"libraries\":[{\"gid\":1002,\"group\":\"mesh-media\",\"home\":false,\"library\":\"movies\",\"paths\":[\"/srv/media/movies\"],\"root\":\"/srv/media/movies\",\"taken\":true}],\"provisioner_account\":\"mesh-provisioner-client\"},\"resources\":[],\"v\":1}", wire: "{\"node\":\"ace\",\"own\":{\"adoption\":{\"taken\":[\"avahi\"],\"untaken\":{\"plex\":[\"plex.bundle-code\"]}},\"groups\":[{\"gid\":1002,\"name\":\"mesh-media\"}],\"identities\":[{\"gid\":1001,\"groups\":[1002],\"module\":\"plex\",\"name\":\"plex\",\"runner\":\"plex\",\"uid\":1001}],\"libraries\":[{\"gid\":1002,\"group\":\"mesh-media\",\"home\":false,\"library\":\"movies\",\"paths\":[\"/srv/media/movies\"],\"root\":\"/srv/media/movies\",\"taken\":true}],\"provisioner_account\":\"mesh-provisioner-client\"},\"resources\":[],\"v\":1}", + decode: func(b []byte) (any, error) { + var v ComposedByNodeNodeValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a module running (typed from the engine's own values; no read tool answers a have key)", subject: "", given: "{\"build\":\"b1\",\"composed\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fp\":\"src2:62756c6c6462756c6c6462756c6c6462756c6c6462756c6c6462756c6c646275\",\"health\":\"ok\",\"phase\":\"running\",\"sealed\":[\"web.secret\"],\"sealed-at\":{\"web.secret#sealed\":\"box1\"},\"v\":1}", wire: "{\"build\":\"b1\",\"composed\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fp\":\"src2:62756c6c6462756c6c6462756c6c6462756c6c6462756c6c6462756c6c646275\",\"health\":\"ok\",\"phase\":\"running\",\"sealed\":[\"web.secret\"],\"sealed-at\":{\"web.secret#sealed\":\"box1\"},\"v\":1}", + decode: func(b []byte) (any, error) { var v HaveByNodeModuleValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a failed apply waiting on a store's holder (typed from the engine's values)", subject: "", given: "{\"composed\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"detail\":\"the pull met a store that is not settled\",\"fp\":\"src2:62756c6c6462756c6c6462756c6c6462756c6c6462756c6c6462756c6c646275\",\"health\":\"failed\",\"phase\":\"failed\",\"pull-wait\":{\"began\":\"2026-11-05T12:00:00Z\",\"holder\":\"gitea\",\"image\":\"registry.example/web:1\",\"retried\":\"the holder settled\",\"retried-at\":\"2026-11-05T12:05:00Z\",\"seat\":\"container-image-registry\"},\"v\":1}", wire: "{\"composed\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"detail\":\"the pull met a store that is not settled\",\"fp\":\"src2:62756c6c6462756c6c6462756c6c6462756c6c6462756c6c6462756c6c646275\",\"health\":\"failed\",\"phase\":\"failed\",\"pull-wait\":{\"began\":\"2026-11-05T12:00:00Z\",\"holder\":\"gitea\",\"image\":\"registry.example/web:1\",\"retried\":\"the holder settled\",\"retried-at\":\"2026-11-05T12:05:00Z\",\"seat\":\"container-image-registry\"},\"v\":1}", + decode: func(b []byte) (any, error) { var v HaveByNodeModuleValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a move waiting for a person's yes", subject: "", given: "{\"build-alone\":true,\"composed\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fp\":\"src2:62756c6c6462756c6c6462756c6c6462756c6c6462756c6c6462756c6c646275\",\"health\":\"ok\",\"judgement\":\"recreates\",\"kept\":[\"tool runner not declared\"],\"phase\":\"waiting-yes\",\"v\":1,\"waits\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"}", wire: "{\"build-alone\":true,\"composed\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fp\":\"src2:62756c6c6462756c6c6462756c6c6462756c6c6462756c6c6462756c6c646275\",\"health\":\"ok\",\"judgement\":\"recreates\",\"kept\":[\"tool runner not declared\"],\"phase\":\"waiting-yes\",\"v\":1,\"waits\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"}", + decode: func(b []byte) (any, error) { var v HaveByNodeModuleValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "the bus module's have carries the user list written (typed from the engine's values)", subject: "", given: "{\"health\":\"ok\",\"phase\":\"running\",\"user-list\":{\"at\":\"2026-11-05T12:00:00Z\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"follows\":\"grants\",\"handed\":{\"at\":\"2026-11-05T12:00:00Z\",\"box\":{\"ct\":\"c\",\"eph\":\"e\",\"iv\":\"i\",\"tag\":\"t\",\"v\":1},\"from\":\"ace\",\"to\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"users\":3},\"key\":\"-----BEGIN PUBLIC KEY-----\",\"revision\":7,\"source\":\"user-list\",\"users\":3,\"wanted\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"},\"v\":1}", wire: "{\"health\":\"ok\",\"phase\":\"running\",\"user-list\":{\"at\":\"2026-11-05T12:00:00Z\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"follows\":\"grants\",\"handed\":{\"at\":\"2026-11-05T12:00:00Z\",\"box\":{\"ct\":\"c\",\"eph\":\"e\",\"iv\":\"i\",\"tag\":\"t\",\"v\":1},\"from\":\"ace\",\"to\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"users\":3},\"key\":\"-----BEGIN PUBLIC KEY-----\",\"revision\":7,\"source\":\"user-list\",\"users\":3,\"wanted\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"},\"v\":1}", + decode: func(b []byte) (any, error) { var v HaveByNodeModuleValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a node's own facts with its record and an answer to a candidate (typed from the engine's values)", subject: "", given: "{\"candidates\":{\"p1\":{\"added\":[{\"module\":\"db\",\"unissued\":[\"db.secret\"]}],\"differs\":[{\"bodies\":[{\"id\":\"api.cfg\",\"paths\":[\"/etc/api.conf\"],\"why\":\"content\"}],\"composed\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"module\":\"api\",\"running\":\"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"why\":\"a file differs\"}],\"proposal\":\"p1\",\"refusals\":[{\"module\":\"mail\",\"today\":\"runs\",\"why\":\"a value is missing\"}],\"removed\":[\"old\"],\"revision\":4,\"same\":[\"web\"],\"verdict\":\"differs\"}},\"composed\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"composes\":\"own\",\"record\":{\"capabilities\":[\"docker\"],\"contract\":3,\"networks\":[{\"link\":\"eth0\",\"network\":\"192.0.2.0/24\"}],\"networks-said\":true,\"node\":\"ace\",\"tunnel-key\":\"k\",\"v\":1},\"v\":1}", wire: "{\"candidates\":{\"p1\":{\"added\":[{\"module\":\"db\",\"unissued\":[\"db.secret\"]}],\"differs\":[{\"bodies\":[{\"id\":\"api.cfg\",\"paths\":[\"/etc/api.conf\"],\"why\":\"content\"}],\"composed\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"module\":\"api\",\"running\":\"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"why\":\"a file differs\"}],\"proposal\":\"p1\",\"refusals\":[{\"module\":\"mail\",\"today\":\"runs\",\"why\":\"a value is missing\"}],\"removed\":[\"old\"],\"revision\":4,\"same\":[\"web\"],\"verdict\":\"differs\"}},\"composed\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"composes\":\"own\",\"record\":{\"capabilities\":[\"docker\"],\"contract\":3,\"networks\":[{\"link\":\"eth0\",\"network\":\"192.0.2.0/24\"}],\"networks-said\":true,\"node\":\"ace\",\"tunnel-key\":\"k\",\"v\":1},\"v\":1}", + decode: func(b []byte) (any, error) { var v HaveByNodeNodeValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a module's data items on a node (typed from the engine's values)", subject: "", given: "{\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"items\":[{\"class\":\"database\",\"covered_by\":\"/var/lib/postgres\",\"item\":\"cluster\",\"measure\":\"dataset\",\"module\":\"postgres\",\"path\":\"/var/lib/postgres\",\"protection\":\"backup\"}],\"module\":\"postgres\",\"node\":\"ace\",\"v\":1}", wire: "{\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"items\":[{\"class\":\"database\",\"covered_by\":\"/var/lib/postgres\",\"item\":\"cluster\",\"measure\":\"dataset\",\"module\":\"postgres\",\"path\":\"/var/lib/postgres\",\"protection\":\"backup\"}],\"module\":\"postgres\",\"node\":\"ace\",\"v\":1}", + decode: func(b []byte) (any, error) { var v DataByNodeModuleValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a node's health (typed from the engine's values)", subject: "", given: "{\"at\":\"2026-11-05T12:00:00Z\",\"contract\":3,\"network\":{\"parts\":[{\"part\":\"resolver\",\"since\":\"2026-11-05T10:00:00Z\",\"state\":\"running\"}],\"since\":\"2026-11-05T10:00:00Z\",\"state\":\"running\"},\"node\":\"ace\",\"peers\":[{\"addresses\":[\"198.51.100.1\"],\"key\":\"k\",\"seen\":true}],\"relogin\":[{\"account\":\"web\",\"reason\":\"password changed\",\"since\":\"2026-11-05T11:00:00Z\"}],\"units\":{\"failed\":[{\"result\":\"exit-code\",\"scope\":\"system\",\"since\":\"2026-11-05T11:00:00Z\",\"unit\":\"x.service\"}],\"state\":\"degraded\"},\"v\":1}", wire: "{\"at\":\"2026-11-05T12:00:00Z\",\"contract\":3,\"network\":{\"parts\":[{\"part\":\"resolver\",\"since\":\"2026-11-05T10:00:00Z\",\"state\":\"running\"}],\"since\":\"2026-11-05T10:00:00Z\",\"state\":\"running\"},\"node\":\"ace\",\"peers\":[{\"addresses\":[\"198.51.100.1\"],\"key\":\"k\",\"seen\":true}],\"relogin\":[{\"account\":\"web\",\"reason\":\"password changed\",\"since\":\"2026-11-05T11:00:00Z\"}],\"units\":{\"failed\":[{\"result\":\"exit-code\",\"scope\":\"system\",\"since\":\"2026-11-05T11:00:00Z\",\"unit\":\"x.service\"}],\"state\":\"degraded\"},\"v\":1}", + decode: func(b []byte) (any, error) { var v HealthByNodeValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "manifest-reads answers what the engine reads and when it stated it", subject: "", given: "{\"manifest-reads\":[\"form:seat-reference\",\"word:series\"],\"node\":\"ace\",\"stated-at\":\"2026-11-04T10:00:00Z\",\"v\":1}", wire: "{\"manifest-reads\":[\"form:seat-reference\",\"word:series\"],\"node\":\"ace\",\"stated-at\":\"2026-11-04T10:00:00Z\",\"v\":1}", + decode: func(b []byte) (any, error) { var v ManifestReadsAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "manifest-reads of an engine built before it said them is the baseline", subject: "", given: "{\"node\":\"g14\",\"stated-at\":\"2026-11-04T10:05:00Z\",\"v\":1}", wire: "{\"node\":\"g14\",\"stated-at\":\"2026-11-04T10:05:00Z\",\"v\":1}", + decode: func(b []byte) (any, error) { var v ManifestReadsAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "compose-candidate asks the candidate and, as a boolean, its composition input", subject: "", given: "{\"candidate\":\"mesh-host.0123abcd\",\"input\":true}", wire: "{\"candidate\":\"mesh-host.0123abcd\",\"input\":true}", + decode: func(b []byte) (any, error) { var v ComposeCandidateInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "compose-candidate answers per module and the node's own", subject: "", given: "{\"candidate\":\"mesh-host.0123abcd\",\"modules\":[{\"changed\":true,\"digest\":\"sha256:aa\",\"fp\":\"fp-1\",\"module\":\"plex\",\"state\":\"composed\"}],\"node\":\"ace\",\"own\":{\"digest\":\"sha256:bb\",\"module\":\"node\",\"state\":\"composed\"},\"took\":1200,\"v\":1,\"verdict\":\"composed\"}", wire: "{\"candidate\":\"mesh-host.0123abcd\",\"modules\":[{\"changed\":true,\"digest\":\"sha256:aa\",\"fp\":\"fp-1\",\"module\":\"plex\",\"state\":\"composed\"}],\"node\":\"ace\",\"own\":{\"digest\":\"sha256:bb\",\"module\":\"node\",\"state\":\"composed\"},\"took\":1200,\"v\":1,\"verdict\":\"composed\"}", + decode: func(b []byte) (any, error) { var v ComposeCandidateAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "acts answers each act kept", subject: "", given: "{\"acts\":[{\"act\":\"hand-over\",\"ask\":\"hand-over-1\",\"at\":\"2026-11-04T09:00:00Z\",\"caller\":\"mesh-tools\",\"expires\":\"2026-11-05T09:00:00Z\",\"id\":\"hand-over-1\",\"state\":\"asked\",\"subject\":\"/srv/media\"}],\"node\":\"ace\"}", wire: "{\"acts\":[{\"act\":\"hand-over\",\"ask\":\"hand-over-1\",\"at\":\"2026-11-04T09:00:00Z\",\"caller\":\"mesh-tools\",\"expires\":\"2026-11-05T09:00:00Z\",\"id\":\"hand-over-1\",\"state\":\"asked\",\"subject\":\"/srv/media\"}],\"node\":\"ace\"}", + decode: func(b []byte) (any, error) { var v ActsAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "seat-list answers what the engine holds and what it waits for", subject: "", given: "{\"health\":\"the seat list is held from the bootstrap and nothing new is acted on\",\"status\":{\"heard\":false,\"held\":[{\"seat\":\"node-engine\",\"source\":\"bootstrap\"},{\"digest\":\"3f2a9c\",\"seat\":\"node-files\",\"source\":\"trunk\"}],\"last-error\":\"no answer\",\"waiting\":[\"node-engine\"]}}", wire: "{\"health\":\"the seat list is held from the bootstrap and nothing new is acted on\",\"status\":{\"heard\":false,\"held\":[{\"seat\":\"node-engine\",\"source\":\"bootstrap\"},{\"digest\":\"3f2a9c\",\"seat\":\"node-files\",\"source\":\"trunk\"}],\"last-error\":\"no answer\",\"waiting\":[\"node-engine\"]}}", + decode: func(b []byte) (any, error) { var v SeatListAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "seat-list-again answers the error when the read failed", subject: "", given: "{\"error\":\"seatlist: no answer\",\"health\":\"the seat list is held from the bootstrap\",\"status\":{\"heard\":false,\"held\":[{\"seat\":\"node-engine\",\"source\":\"bootstrap\"}],\"waiting\":[\"node-engine\"]}}", wire: "{\"error\":\"seatlist: no answer\",\"health\":\"the seat list is held from the bootstrap\",\"status\":{\"heard\":false,\"held\":[{\"seat\":\"node-engine\",\"source\":\"bootstrap\"}],\"waiting\":[\"node-engine\"]}}", + decode: func(b []byte) (any, error) { var v SeatListAgainAnswer; 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/go/seats/nodeengine/go.mod b/go/seats/nodeengine/go.mod new file mode 100644 index 0000000..cd3eb89 --- /dev/null +++ b/go/seats/nodeengine/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/go/seats/nodeengine + +go 1.26.0 diff --git a/health-and-repair/go/clients/meshconditionsclient/client.go b/health-and-repair/go/clients/meshconditionsclient/client.go new file mode 100644 index 0000000..2931dc8 --- /dev/null +++ b/health-and-repair/go/clients/meshconditionsclient/client.go @@ -0,0 +1,214 @@ +// Package meshconditionsclient is the logic every observer, healer and reader of conditions shares over the seat +// mesh-conditions (novox/hq ADR 0564, ADR 0634 §4): how a finding's key is made and checked, the scopes and the bounds, +// the order a person reads conditions in, and the answers a notification offers. The layouts are the seat's contract's. +package meshconditionsclient + +import ( + "fmt" + "regexp" + "sort" + "strings" + "time" + + seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// The two severities, how soon the operator is needed. +const ( + Urgent = "urgent" + Warning = "warning" +) + +// The scopes a condition's key starts with: what kind of thing is wrong. +const ( + ScopeMachine = "machine" + ScopePlan = "plan" + ScopeCall = "call" + ScopeBuild = "build" + ScopeMerge = "merge" + ScopeProvider = "provider" + ScopeSeat = "seat" + ScopeBus = "bus" + ScopeCore = "core" + ScopeProbe = "probe" + ScopeMesh = "mesh" + // ScopeDelivery is a delivery by its id (novox/hq ADR 0239); ScopeModule a module on a machine, `.`. + ScopeDelivery = "delivery" + ScopeModule = "module" +) + +// Scopes is every scope, in the order a person reads them. +var Scopes = []string{ScopeMachine, ScopePlan, ScopeCall, ScopeBuild, ScopeMerge, ScopeProvider, + ScopeSeat, ScopeBus, ScopeCore, ScopeProbe, ScopeMesh, ScopeDelivery, ScopeModule} + +// Who resolves a condition. +const ( + ResolverSelf = "self" + ResolverOperator = "operator" + ResolverAgent = "agent" +) + +// ResolverHealer is the resolver of a condition a registered healer works on. +func ResolverHealer(name string) string { return "healer:" + name } + +// The bounds a condition keeps. +const ( + KeptEvidence = 10 + KeptTried = 10 + KeptGaps = 8 + MaxSilence = 7 * 24 * time.Hour + ReopenWithin = 10 * time.Minute +) + +// CauseOperatorAnswer is the cause of an act the operator chose. +const CauseOperatorAnswer = "operator-answer" + +// The changes a condition event says. +const ( + ChangeRaised = "raised" + ChangeReopened = "reopened" + ChangeSeverity = "severity" + ChangeResolver = "resolver" + ChangeSilenced = "silenced" + ChangeUnsilenced = "silence-ended" + ChangeCleared = "cleared" + ChangeWords = "words" +) + +var unsafeKey = regexp.MustCompile(`[^A-Za-z0-9_=/-]`) + +// Key is a condition's key: its scope, the parts of its id and a token, each made safe for a bus key. +func Key(scope, id, token string) string { + var parts []string + for _, p := range append(append([]string{scope}, strings.Split(id, ".")...), token) { + p = unsafeKey.ReplaceAllString(strings.TrimSpace(p), "_") + if p == "" { + p = "_" + } + parts = append(parts, p) + } + return strings.Join(parts, ".") +} + +// EventFor is the seat's event that says a change. +func EventFor(change string) string { + switch change { + case ChangeRaised, ChangeReopened: + return seat.EventConditionRaised + case ChangeCleared: + return seat.EventConditionCleared + } + return seat.EventConditionChanged +} + +// ShowKeeper is the call that shows one condition whole. +func ShowKeeper(key string) string { return seat.Seat + "." + seat.VerbConditions + " key=" + key } + +// SilenceAction is the answer a notification offers to stop a condition's messages for a week. +func SilenceAction(key string) seat.Action { + return seat.Action{Label: "Silence for a week", Verb: seat.Seat + "." + seat.VerbSilence, Level: seat.ActionLevelAcknowledge, + Arguments: map[string]string{"key": key, "for": "7d", "why": "", "cause": CauseOperatorAnswer}} +} + +// CheckFinding refuses a finding the keeper could not make a condition of, naming why. +func CheckFinding(f seat.FindingsByKeyValue) error { + known := false + for _, s := range Scopes { + known = known || s == f.Subject.Scope + } + token := f.Ref + if token == "" { + token = f.Kind + } + switch { + case !known: + return fmt.Errorf("health: a finding's scope is one of %s, not %q", strings.Join(Scopes, ", "), f.Subject.Scope) + case strings.TrimSpace(f.Subject.ID) == "": + return fmt.Errorf("health: a %s finding names what it is about", f.Subject.Scope) + case strings.TrimSpace(f.Kind) == "": + return fmt.Errorf("health: the finding %s has no kind", f.Key) + case f.Key != Key(f.Subject.Scope, f.Subject.ID, token): + return fmt.Errorf("health: the finding's key %q is not %q, its scope, id and kind", f.Key, Key(f.Subject.Scope, f.Subject.ID, token)) + case f.Severity != Urgent && f.Severity != Warning: + return fmt.Errorf("health: the finding %s is urgent or a warning, not %q", f.Key, f.Severity) + case strings.TrimSpace(f.Summary) == "": + return fmt.Errorf("health: the finding %s says nothing", f.Key) + case strings.TrimSpace(f.Source) == "": + return fmt.Errorf("health: the finding %s does not say what saw it", f.Key) + case strings.TrimSpace(f.Observer) == "": + return fmt.Errorf("health: the finding %s does not name its observer", f.Key) + case len(f.Evidence) > KeptEvidence: + return fmt.Errorf("health: the finding %s keeps %d observations, at most %d", f.Key, len(f.Evidence), KeptEvidence) + } + return nil +} + +// Order sorts conditions as a person reads them: urgent first, then oldest raised, then by key. +func Order(list []seat.ConditionsByKeyValue) { + raised := func(c seat.ConditionsByKeyValue) time.Time { return c.Raised } + sort.SliceStable(list, func(i, j int) bool { + if list[i].Severity != list[j].Severity { + return list[i].Severity == Urgent + } + if ri, rj := raised(list[i]), raised(list[j]); !ri.Equal(rj) { + return ri.Before(rj) + } + return list[i].Key < list[j].Key + }) +} + +// SilencedAt says whether the condition's messages are silenced at now. +func SilencedAt(c seat.ConditionsByKeyValue, now time.Time) bool { + if c.Silenced == nil { + return false + } + until := c.Silenced.Until + return until != nil && now.Before(*until) +} + +// Escalated says the condition waits on the operator after healers tried. +func Escalated(c seat.ConditionsByKeyValue) bool { + return c.Resolver == ResolverOperator && len(c.Tried) > 0 +} + +// FindingVersion is the layout version of a finding's value. +const FindingVersion = 1 + +// FindingState is a part's own state `findings`, where the condition keeper reads what a part saw: the runtime's kept +// state (mesh-sdk's KeptState) satisfies it. +type FindingState interface { + Put(key string, value any) (uint64, error) + Delete(key string) error +} + +// FindingWriter writes and clears a part's findings of a module in its own state `findings`, in this seat's layout; it +// is mesh-sdk's views.FindingWriter. A finding is a warning about the module its ID names, and is checked +// (CheckFinding) before it is written. +type FindingWriter struct{ State FindingState } + +var _ views.FindingWriter = FindingWriter{} + +// FindingFor is the value and key of the seat's finding for f. +func FindingFor(f views.Finding) seat.FindingsByKeyValue { + at := f.At.UTC() + return seat.FindingsByKeyValue{V: FindingVersion, Key: Key(ScopeModule, f.ID, f.Kind), Kind: f.Kind, + Subject: seat.Subject{Scope: ScopeModule, ID: f.ID}, Severity: Warning, Summary: f.Summary, Headline: f.Headline, + Explanation: f.Explanation, Evidence: []seat.Evidence{{At: at, Said: f.Summary}}, Source: f.Observer + "." + f.Kind, + Observer: f.Observer, At: at} +} + +// Put writes the finding. +func (w FindingWriter) Put(f views.Finding) error { + v := FindingFor(f) + if err := CheckFinding(v); err != nil { + return err + } + _, err := w.State.Put(v.Key, v) + return err +} + +// Delete clears the finding. +func (w FindingWriter) Delete(f views.Finding) error { + return w.State.Delete(Key(ScopeModule, f.ID, f.Kind)) +} diff --git a/health-and-repair/go/clients/meshconditionsclient/client_test.go b/health-and-repair/go/clients/meshconditionsclient/client_test.go new file mode 100644 index 0000000..f96ebf8 --- /dev/null +++ b/health-and-repair/go/clients/meshconditionsclient/client_test.go @@ -0,0 +1,195 @@ +package meshconditionsclient + +import ( + "os" + "reflect" + "strings" + "testing" + "time" + + seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// ADR 0564 clause 3: the key rule, the checks of a finding, the order and the silence a person reads conditions by. +func TestAFindingsKeyAndTheOrderOfConditions(t *testing.T) { + if k := Key("machine", "g14.local", "silent"); k != "machine.g14.local.silent" { + t.Errorf("key %s", k) + } + if k := Key("call", "a b", ""); k != "call.a_b._" { + t.Errorf("an unsafe part and an empty token: %s", k) + } + good := seat.FindingsByKeyValue{Key: "machine.g14.silent", Kind: "silent", Severity: Warning, Summary: "x", Source: "S1", + Observer: "mesh-delivery-watchman", Subject: seat.Subject{Scope: ScopeMachine, ID: "g14"}} + for _, c := range []struct { + name string + change func(*seat.FindingsByKeyValue) + refuse string + }{ + {"a good finding", func(*seat.FindingsByKeyValue) {}, ""}, + {"an unknown scope", func(f *seat.FindingsByKeyValue) { f.Subject.Scope = "moon" }, "scope is one of"}, + {"a key that is not its subject's", func(f *seat.FindingsByKeyValue) { f.Key = "machine.g14.other" }, "is not"}, + {"a reference as the key's last part", func(f *seat.FindingsByKeyValue) { f.Ref = "gone"; f.Key = "machine.g14.gone" }, ""}, + {"no summary", func(f *seat.FindingsByKeyValue) { f.Summary = " " }, "says nothing"}, + {"no observer", func(f *seat.FindingsByKeyValue) { f.Observer = "" }, "observer"}, + {"a severity of neither kind", func(f *seat.FindingsByKeyValue) { f.Severity = "info" }, "urgent or a warning"}, + {"too much evidence", func(f *seat.FindingsByKeyValue) { f.Evidence = make([]seat.Evidence, KeptEvidence+1) }, "keeps 11"}, + } { + f := good + c.change(&f) + err := CheckFinding(f) + if (c.refuse == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), c.refuse)) { + t.Errorf("%s: %v", c.name, err) + } + } + list := []seat.ConditionsByKeyValue{ + {Key: "b", Severity: Warning, Raised: when("2026-11-01T10:00:00Z")}, + {Key: "c", Severity: Urgent, Raised: when("2026-11-02T10:00:00Z")}, + {Key: "a", Severity: Urgent, Raised: when("2026-11-01T10:00:00.5Z"), Silenced: &seat.ConditionsByKeyValueSilenced{Until: ptr(when("2026-11-03T00:00:00Z"))}}, + } + Order(list) + if got := list[0].Key + list[1].Key + list[2].Key; got != "acb" { + t.Errorf("order %s, want urgent oldest first", got) + } + now := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC) + if !SilencedAt(list[0], now) || SilencedAt(list[0], now.Add(48*time.Hour)) || SilencedAt(list[1], now) { + t.Error("silence is until its end") + } + if EventFor(ChangeReopened) != seat.EventConditionRaised || EventFor(ChangeSilenced) != seat.EventConditionChanged || + EventFor(ChangeCleared) != seat.EventConditionCleared { + t.Error("a change is said as its event") + } + if a := SilenceAction("k"); a.Verb != "mesh-conditions.silence" || ShowKeeper("k") != "mesh-conditions.conditions key=k" { + t.Errorf("the seat's addresses: %+v", a) + } +} + +type fakeFindings struct { + put map[string]any + deleted []string +} + +func (f *fakeFindings) Put(key string, value any) (uint64, error) { + if f.put == nil { + f.put = map[string]any{} + } + f.put[key] = value + return 1, nil +} +func (f *fakeFindings) Delete(key string) error { f.deleted = append(f.deleted, key); return nil } + +func TestTheFindingWriterWritesAndClearsAModulesFindingInTheSeatsLayout(t *testing.T) { + state := &fakeFindings{} + var w views.FindingWriter = FindingWriter{State: state} + f := views.Finding{Observer: "mesh-config", Kind: "unanswered", ID: "mesh-config.mesh-vault.secrets", Summary: "no first view", + Headline: "waits", Explanation: "look", At: time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)} + if err := w.Put(f); err != nil { + t.Fatal(err) + } + key := "module.mesh-config.mesh-vault.secrets.unanswered" + v, ok := state.put[key].(seat.FindingsByKeyValue) + if !ok || v.Key != key || v.Severity != Warning || v.Source != "mesh-config.unanswered" || v.Subject.Scope != ScopeModule || len(v.Evidence) != 1 { + t.Fatalf("written %#v", state.put) + } + if err := CheckFinding(v); err != nil { + t.Error(err) + } + if err := w.Delete(f); err != nil || len(state.deleted) != 1 || state.deleted[0] != key { + t.Errorf("deleted %v: %v", state.deleted, err) + } + if err := w.Put(views.Finding{Observer: "x", Kind: "k", ID: "i"}); err == nil { + t.Error("a finding that says nothing was written") + } +} + +func when(s string) time.Time { + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + panic(err) + } + return t +} + +func ptr(t time.Time) *time.Time { return &t } + +type fakeBucket struct { + kept map[string][]byte + puts, dels int +} + +func (b *fakeBucket) Get(key string) ([]byte, bool, error) { v, ok := b.kept[key]; return v, ok, nil } +func (b *fakeBucket) Put(key string, v []byte) error { + if b.kept == nil { + b.kept = map[string][]byte{} + } + b.kept[key] = v + b.puts++ + return nil +} +func (b *fakeBucket) Delete(key string) error { delete(b.kept, key); b.dels++; return nil } + +// The goldens are what mesh-sdk's go/health EncodeFinding wrote for the same findings: the helpers write the same +// values, read by the seat's strict decoder. +func TestAnObservationIsWrittenAsMeshSDKsHealthWroteItsFinding(t *testing.T) { + at := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) + for _, c := range []struct { + golden string + o Observation + run string + }{ + {"finding_full.json", Observation{Scope: ScopeMachine, ID: "g14", Kind: "silent-kind", Ref: "silent", Machine: "g14", Also: []string{"ace"}, + Severity: Urgent, Summary: "g14 is silent", Said: "no pulse", Headline: "g14 is silent", Explanation: "It said nothing.", + Resolved: "It speaks.", Needs: "look", Source: "S1", Resolver: "operator"}, "r1"}, + {"finding_minimal.json", Observation{Scope: ScopeModule, ID: "nats.g14", Kind: "build-failed", Summary: "failed", + Source: "builder.build-failed"}, ""}, + } { + raw, err := os.ReadFile("testdata/" + c.golden) + if err != nil { + t.Fatal(err) + } + want, err := seat.DecodeFindingsByKeyValue(raw) + if err != nil { + t.Fatalf("%s: %v", c.golden, err) + } + observer := want.Observer + got, err := EncodeFinding(c.o.Finding(observer, c.run, at)) + if err != nil { + t.Fatalf("%s: %v", c.golden, err) + } + have, err := seat.DecodeFindingsByKeyValue(got) + if err != nil || !reflect.DeepEqual(have, want) { + t.Errorf("%s: wrote %s, want %s (%v)", c.golden, got, raw, err) + } + if back, err := DecodeFinding(raw); err != nil || !reflect.DeepEqual(back, want) { + t.Errorf("%s: read back %v", c.golden, err) + } + } +} + +func TestRaiseWritesWhenTheWordsChangeAndClearDeletesWhatIsThere(t *testing.T) { + b := &fakeBucket{} + o := Observation{Scope: ScopeModule, ID: "m", Kind: "k", Summary: "one", Source: "x.k"} + at := when("2026-11-05T12:00:00Z") + if w, err := Raise(b, "obs", "", o, at); err != nil || !w { + t.Fatalf("first: %v %v", w, err) + } + if w, err := Raise(b, "obs", "r2", o, at.Add(time.Hour)); err != nil || w || b.puts != 1 { + t.Fatalf("the same words later: wrote %v, %d puts, %v", w, b.puts, err) + } + o.Summary = "two" + if w, err := Raise(b, "obs", "", o, at); err != nil || !w || b.puts != 2 { + t.Fatalf("changed: %v %d %v", w, b.puts, err) + } + if _, err := Raise(b, "obs", "", Observation{Scope: "moon", ID: "m", Kind: "k"}, at); err == nil || b.puts != 2 { + t.Errorf("a finding the keeper cannot use was written: %v", err) + } + if c, err := Clear(b, ScopeModule, "m", "k"); err != nil || !c || b.dels != 1 { + t.Fatalf("clear: %v %v %d", c, err, b.dels) + } + if c, err := Clear(b, ScopeModule, "m", "k"); err != nil || c || b.dels != 1 { + t.Errorf("clearing what is not there: %v %v %d", c, err, b.dels) + } + if FindingsBucket("obs") != "obs_findings" { + t.Error(FindingsBucket("obs")) + } +} diff --git a/health-and-repair/go/clients/meshconditionsclient/findings/findings.go b/health-and-repair/go/clients/meshconditionsclient/findings/findings.go new file mode 100644 index 0000000..903eb29 --- /dev/null +++ b/health-and-repair/go/clients/meshconditionsclient/findings/findings.go @@ -0,0 +1,88 @@ +// Package findings is a part's own bucket `findings` as the state the seat mesh-conditions' client writes into +// (meshconditionsclient.FindingState), so a part gives mesh-sdk's views the FindingWriter of the seat's layout without +// each writing the same adapter over its bucket (novox/hq ADR 0564 §2, ADR 0635 §4). +package findings + +import ( + "context" + "encoding/json" + "errors" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// ErrUnbound says the bucket is not on the bus yet, so the finding is not written (the part says it in its log). +var ErrUnbound = errors.New("the findings state is not bound yet") + +// State is the bucket the part's state `findings` is, given when it is bound (process.AwaitedBucket.KV): nil is not bound yet. +type State struct { + Ctx context.Context + KV func() jetstream.KeyValue +} + +var _ meshconditionsclient.FindingState = State{} + +// Put writes one value under its key as JSON. +func (s State) Put(key string, value any) (uint64, error) { + kv := s.KV() + if kv == nil { + return 0, ErrUnbound + } + data, err := json.Marshal(value) + if err != nil { + return 0, err + } + return kv.Put(s.Ctx, key, data) +} + +// Delete clears one key. +func (s State) Delete(key string) error { + kv := s.KV() + if kv == nil { + return ErrUnbound + } + return kv.Delete(s.Ctx, key) +} + +// Writer is the part's views.FindingWriter over its bucket; the function is what views.ProcessReader.Findings and the +// emitter's Findings take. +func Writer(ctx context.Context, kv func() jetstream.KeyValue) views.FindingWriter { + return meshconditionsclient.FindingWriter{State: State{Ctx: ctx, KV: kv}} +} + +// Funcs is a findings state held as the two functions a part keeps over it (the assigner's findings, which also say +// its conditions): a value is put as JSON. +type Funcs struct { + PutBytes func(key string, value []byte) error + DeleteKey func(key string) error +} + +var _ meshconditionsclient.FindingState = Funcs{} + +// Put writes one value under its key as JSON; the state not bound (no function) is ErrUnbound. +func (f Funcs) Put(key string, value any) (uint64, error) { + if f.PutBytes == nil { + return 0, ErrUnbound + } + data, err := json.Marshal(value) + if err != nil { + return 0, err + } + return 0, f.PutBytes(key, data) +} + +// Delete clears one key. +func (f Funcs) Delete(key string) error { + if f.DeleteKey == nil { + return ErrUnbound + } + return f.DeleteKey(key) +} + +// WriterOver is the part's views.FindingWriter over its two functions. +func WriterOver(put func(string, []byte) error, del func(string) error) views.FindingWriter { + return meshconditionsclient.FindingWriter{State: Funcs{PutBytes: put, DeleteKey: del}} +} diff --git a/health-and-repair/go/clients/meshconditionsclient/findings/findings_test.go b/health-and-repair/go/clients/meshconditionsclient/findings/findings_test.go new file mode 100644 index 0000000..01052ac --- /dev/null +++ b/health-and-repair/go/clients/meshconditionsclient/findings/findings_test.go @@ -0,0 +1,71 @@ +package findings + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/views" +) + +// fakeKV is the two calls a findings state makes of its bucket. +type fakeKV struct { + jetstream.KeyValue + held map[string][]byte +} + +func (f *fakeKV) Put(_ context.Context, key string, value []byte) (uint64, error) { + f.held[key] = value + return uint64(len(f.held)), nil +} + +func (f *fakeKV) Delete(_ context.Context, key string, _ ...jetstream.KVDeleteOpt) error { + delete(f.held, key) + return nil +} + +// A part's finding is written in the seat mesh-conditions' layout under the key the condition keeper reads, cleared by the +// same finding, and refused while the bucket is not bound (the part says that in its log). +func TestAFindingIsWrittenInTheSeatsLayoutAndClearedAndRefusedUnbound(t *testing.T) { + kv := &fakeKV{held: map[string][]byte{}} + var bound jetstream.KeyValue + w := Writer(context.Background(), func() jetstream.KeyValue { return bound }) + f := views.Finding{Observer: "mesh-delivery-teller", Kind: views.UnansweredKind, ID: "mesh-delivery-teller.asks", Summary: "s", Headline: "h", + Explanation: "e", At: time.Date(2026, 11, 5, 15, 0, 0, 0, time.UTC)} + if err := w.Put(f); !errors.Is(err, ErrUnbound) { + t.Fatalf("an unbound bucket took the finding: %v", err) + } + bound = kv + if err := w.Put(f); err != nil { + t.Fatal(err) + } + var got struct{ Key, Kind, Observer, Source string } + for key, value := range kv.held { + if err := json.Unmarshal(value, &got); err != nil || got.Key != key || got.Kind != views.UnansweredKind || + got.Observer != f.Observer || got.Source != f.Observer+"."+f.Kind { + t.Fatalf("%s holds %s (%v)", key, value, err) + } + } + if len(kv.held) != 1 { + t.Fatalf("the bucket holds %d findings, want 1", len(kv.held)) + } + if err := w.Delete(f); err != nil || len(kv.held) != 0 { + t.Fatalf("the finding is not cleared: %v, %d left", err, len(kv.held)) + } + // The same finding over two functions (a part whose findings also say its conditions). + held := map[string][]byte{} + o := WriterOver(func(k string, v []byte) error { held[k] = v; return nil }, func(k string) error { delete(held, k); return nil }) + if err := o.Put(f); err != nil || len(held) != 1 { + t.Fatalf("over functions: %v, %d held", err, len(held)) + } + if err := o.Delete(f); err != nil || len(held) != 0 { + t.Fatalf("over functions not cleared: %v, %d left", err, len(held)) + } + if err := WriterOver(nil, nil).Put(f); !errors.Is(err, ErrUnbound) { + t.Fatalf("no functions took the finding: %v", err) + } +} diff --git a/health-and-repair/go/clients/meshconditionsclient/go.mod b/health-and-repair/go/clients/meshconditionsclient/go.mod new file mode 100644 index 0000000..9e772c2 --- /dev/null +++ b/health-and-repair/go/clients/meshconditionsclient/go.mod @@ -0,0 +1,16 @@ +module git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient + +go 1.26.0 + +require git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 + +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 + +require ( + github.com/klauspost/compress v1.20.1 // indirect + github.com/nats-io/nats.go v1.54.0 + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect +) diff --git a/health-and-repair/go/clients/meshconditionsclient/go.sum b/health-and-repair/go/clients/meshconditionsclient/go.sum new file mode 100644 index 0000000..999068e --- /dev/null +++ b/health-and-repair/go/clients/meshconditionsclient/go.sum @@ -0,0 +1,24 @@ +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/health-and-repair/go/clients/meshconditionsclient/heard/heard.go b/health-and-repair/go/clients/meshconditionsclient/heard/heard.go new file mode 100644 index 0000000..e00d535 --- /dev/null +++ b/health-and-repair/go/clients/meshconditionsclient/heard/heard.go @@ -0,0 +1,115 @@ +// Package heard is the finding a part writes while one of its consumers or watches does not hear the +// family it follows (novox/hq ADR 0615 §5; review round 1 of the registry milestone): the consumer not there yet, a +// hearing that could not start, a watch that cannot be opened. Without it the part's health stays healthy and its +// status says answered while the events it waits on are lost to it. The finding names the observer and the family, +// says what is not heard and why, and is deleted when the consumer binds: at the part's next start, or where the part +// binds again at a change of its membership, then; the condition keeper raises and clears the condition from it (ADR 0564 §2). +package heard + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// Kind is the finding's kind. +const Kind = "family-not-heard" + +// ID is the finding's subject: `.`, the family by the name the part follows it under (a stream, an +// event, a state's watch). +func ID(observer, family string) string { return observer + "." + family } + +// Key is the finding's key in the observer's findings state. +func Key(observer, family string) string { + return meshconditionsclient.Key(meshconditionsclient.ScopeModule, ID(observer, family), Kind) +} + +// Words are the finding's words: its summary, headline and explanation. consequence says what the observer misses while +// it does not hear family ("the placement is the first view's alone"). +func Words(observer, family, consequence string, why error) (summary, headline, explanation string) { + reason := "it gave no reason" + if why != nil { + reason = strings.TrimSpace(why.Error()) + } + summary = fmt.Sprintf("%s does not hear %s: %s (%s)", observer, family, consequence, reason) + headline = fmt.Sprintf("%s does not hear %s", observer, family) + explanation = fmt.Sprintf("%s follows %s on a consumer of its own, and that consumer is not there or could not be heard, so %s. "+ + "This clears when the consumer binds: at %s's next start, or sooner where %s binds again at a change of its membership. "+ + "Look at whether the grants part made %s's consumers, then start %s again if it does not bind by itself.", observer, family, consequence, observer, observer, observer, observer) + return summary, headline, explanation +} + +// Finding is observer's finding that it does not hear family, in the layout of the seat mesh-conditions' state findings. +func Finding(observer, family, consequence string, why error, at time.Time) (string, []byte, error) { + summary, headline, explanation := Words(observer, family, consequence, why) + v := meshconditionsclient.FindingFor(views.Finding{Observer: observer, Kind: Kind, ID: ID(observer, family), Summary: summary, + Headline: headline, Explanation: explanation, At: at}) + if err := meshconditionsclient.CheckFinding(v); err != nil { + return v.Key, nil, err + } + value, err := json.Marshal(v) + return v.Key, value, err +} + +// Writer writes and deletes the finding in the observer's findings state. Put or Delete nil writes nothing, said in the +// log, so the part still says it. +type Writer struct { + Observer string + Put func(key string, value []byte) error + Delete func(key string) error + Logf func(string, ...any) + Now func() time.Time +} + +// Bind binds the observer's consumer of family (bind makes it and starts hearing it): the finding is deleted when it +// binds, and written, with why, when it does not. It answers bind's error, so a caller that cannot go on without the +// family still stops. +func (w Writer) Bind(family, consequence string, bind func() error) error { + if err := bind(); err != nil { + w.NotHeard(family, consequence, err) + return err + } + w.Heard(family) + return nil +} + +// NotHeard says in the log and in the finding that the observer does not hear family. +func (w Writer) NotHeard(family, consequence string, why error) { + now := time.Now + if w.Now != nil { + now = w.Now + } + summary, _, _ := Words(w.Observer, family, consequence, why) + w.logf("%s", summary) + if w.Put == nil { + w.logf("the finding %s is not written: no findings state", Key(w.Observer, family)) + return + } + key, value, err := Finding(w.Observer, family, consequence, why, now()) + if err == nil { + err = w.Put(key, value) + } + if err != nil { + w.logf("the finding %s is not written: %v", key, err) + } +} + +// Heard deletes the finding: the consumer of family is bound. +func (w Writer) Heard(family string) { + if w.Delete == nil { + return + } + if err := w.Delete(Key(w.Observer, family)); err != nil { + w.logf("the finding %s is not deleted: %v", Key(w.Observer, family), err) + } +} + +func (w Writer) logf(format string, args ...any) { + if w.Logf != nil { + w.Logf(format, args...) + } +} diff --git a/health-and-repair/go/clients/meshconditionsclient/heard/heard_test.go b/health-and-repair/go/clients/meshconditionsclient/heard/heard_test.go new file mode 100644 index 0000000..cc9b69a --- /dev/null +++ b/health-and-repair/go/clients/meshconditionsclient/heard/heard_test.go @@ -0,0 +1,36 @@ +package heard + +import ( + "errors" + "strings" + "testing" + "time" +) + +// A consumer that does not bind writes the finding, with why, under the observer's findings key; the one that binds +// deletes it; and a state that is not bound is said in the log, not written. +func TestBindWritesTheFindingUntilTheConsumerBinds(t *testing.T) { + puts := map[string][]byte{} + var logs []string + w := Writer{Observer: "mesh-x", Put: func(k string, v []byte) error { puts[k] = v; return nil }, + Delete: func(k string) error { delete(puts, k); return nil }, Logf: func(f string, a ...any) { logs = append(logs, f) }, + Now: func() time.Time { return time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) }} + key := Key("mesh-x", "placement") + if want := "mesh-x.placement"; ID("mesh-x", "placement") != want || !strings.Contains(key, want) || !strings.Contains(key, Kind) { + t.Fatalf("the finding's key %q", key) + } + if err := w.Bind("placement", "the first view is the only picture", func() error { return errors.New("no consumer") }); err == nil { + t.Fatal("a failed bind was not answered") + } + if v := string(puts[key]); !strings.Contains(v, "no consumer") || !strings.Contains(v, "mesh-x does not hear placement") { + t.Errorf("the finding is %q", v) + } + if err := w.Bind("placement", "", func() error { return nil }); err != nil || len(puts) != 0 { + t.Errorf("a bound consumer left %v (%v)", puts, err) + } + w.Put = nil + w.NotHeard("placement", "x", nil) + if len(logs) < 2 || !strings.Contains(logs[len(logs)-1], "not written") { + t.Errorf("an unbound state was not said: %v", logs) + } +} diff --git a/health-and-repair/go/clients/meshconditionsclient/observation.go b/health-and-repair/go/clients/meshconditionsclient/observation.go new file mode 100644 index 0000000..e8bc379 --- /dev/null +++ b/health-and-repair/go/clients/meshconditionsclient/observation.go @@ -0,0 +1,147 @@ +package meshconditionsclient + +import ( + "encoding/json" + "fmt" + "time" + + seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" +) + +// What an observer says it sees wrong, raised and cleared as a finding in its own state `findings` (novox/hq ADR 0564 +// clause 2, ADR 0634 §4): one write shared by every observer (the self-check, the healers, the watchman, the builder, +// the indexer, the node-engine), in this seat's layout. The condition keeper raises and clears the conditions from them. + +// FindingsState is the name of an observer's state of findings. +const FindingsState = "findings" + +// FindingsBucket is the bucket of an observer's findings, `_findings`. +func FindingsBucket(observer string) string { return observer + "_" + FindingsState } + +// Observation is one thing an observer sees wrong now: what it is about (Scope, ID, Machine, Also), which kind of fault +// (Kind; Ref when the key's last part is not the kind), and its words. +type Observation struct { + Scope, ID, Kind, Ref, Machine string + Also []string + // Severity is Urgent or Warning; Warning when empty. + Severity string + // Summary is one line; Said the one observation kept as evidence (the summary when empty). + Summary, Said string + Headline, Explanation, Resolved, Needs string + // Source is the row, probe or event that saw it. + Source string + // Resolver is who resolves it, self when empty. + Resolver string + Actions []seat.Action + // Since is when the finding is stamped; the zero value leaves it to the caller's moment. + Since time.Time +} + +// Key is the finding's key: `..`, the token Ref or else Kind. +func (o Observation) Key() string { + token := o.Ref + if token == "" { + token = o.Kind + } + return Key(o.Scope, o.ID, token) +} + +// Finding is the observation as the seat's finding written by observer in run, at the moment at. +func (o Observation) Finding(observer, run string, at time.Time) seat.FindingsByKeyValue { + at = at.UTC() + said := o.Said + if said == "" { + said = o.Summary + } + sev := o.Severity + if sev == "" { + sev = Warning + } + f := seat.FindingsByKeyValue{V: FindingVersion, Key: o.Key(), Kind: o.Kind, + Subject: seat.Subject{Scope: o.Scope, ID: o.ID, Machine: o.Machine, Also: o.Also}, + Severity: sev, Summary: o.Summary, Headline: o.Headline, Explanation: o.Explanation, Resolved: o.Resolved, + Needs: o.Needs, Actions: o.Actions, Evidence: []seat.Evidence{{At: at, Said: said}}, Source: o.Source, + Resolver: o.Resolver, Observer: observer, Run: run, At: at} + if o.Ref != "" && o.Ref != o.Kind { + f.Ref = o.Ref + } + return f +} + +// EncodeFinding is f as JSON at FindingVersion; it refuses what CheckFinding refuses. +func EncodeFinding(f seat.FindingsByKeyValue) ([]byte, error) { + f.V = FindingVersion + if f.Evidence == nil { + f.Evidence = []seat.Evidence{} + } + if err := CheckFinding(f); err != nil { + return nil, err + } + return json.Marshal(f) +} + +// DecodeFinding reads a finding's value; it refuses another version and what CheckFinding refuses. A finding written +// before the field was named `ref` (novox/hq issue 1952) is read by its old name, `token`. +func DecodeFinding(data []byte) (seat.FindingsByKeyValue, error) { + f, err := seat.DecodeFindingsByKeyValue(data) + if err != nil { + return seat.FindingsByKeyValue{}, fmt.Errorf("health: a finding is not readable: %w", err) + } + if f.V != FindingVersion { + return seat.FindingsByKeyValue{}, fmt.Errorf("health: a finding of version %d, and this reads %d", f.V, FindingVersion) + } + if err := CheckFinding(f); err != nil { + return seat.FindingsByKeyValue{}, err + } + return f, nil +} + +// Says is what a finding says, the moments and the run that wrote it aside: two findings that say the same are one +// write (novox/hq ADR 0289 §1), so the condition moves only when the words do. +func Says(f seat.FindingsByKeyValue) string { + f.At, f.Run, f.V = time.Time{}, "", 0 + ev := make([]seat.Evidence, len(f.Evidence)) + for i, e := range f.Evidence { + ev[i] = seat.Evidence{Said: e.Said} + } + f.Evidence = ev + raw, _ := json.Marshal(f) + return string(raw) +} + +// FindingBucket is the part of an observer's state `findings` that Raise and Clear use. +type FindingBucket interface { + // Get answers the value at key; found is false when there is none. + Get(key string) (value []byte, found bool, err error) + Put(key string, value []byte) error + Delete(key string) error +} + +// Raise writes the observation as observer's finding in run at at, only when what it says differs from the finding kept +// (so the keeper's condition moves only then); wrote says whether it wrote. A finding the keeper could not make a +// condition of is refused, nothing written. +func Raise(b FindingBucket, observer, run string, o Observation, at time.Time) (wrote bool, err error) { + f := o.Finding(observer, run, at) + value, err := EncodeFinding(f) + if err != nil { + return false, err + } + if kept, found, err := b.Get(f.Key); err != nil { + return false, err + } else if found { + if old, err := DecodeFinding(kept); err == nil && Says(old) == Says(f) { + return false, nil + } + } + return true, b.Put(f.Key, value) +} + +// Clear deletes the finding about scope and id of kind (its token); one not there is nothing to clear, and cleared says +// whether one was. +func Clear(b FindingBucket, scope, id, token string) (cleared bool, err error) { + key := Key(scope, id, token) + if _, found, err := b.Get(key); err != nil || !found { + return false, err + } + return true, b.Delete(key) +} diff --git a/health-and-repair/go/clients/meshconditionsclient/testdata/finding_full.json b/health-and-repair/go/clients/meshconditionsclient/testdata/finding_full.json new file mode 100644 index 0000000..54bf2f8 --- /dev/null +++ b/health-and-repair/go/clients/meshconditionsclient/testdata/finding_full.json @@ -0,0 +1 @@ +{"v":1,"key":"machine.g14.silent","kind":"silent-kind","ref":"silent","subject":{"scope":"machine","id":"g14","machine":"g14","also":["ace"]},"severity":"urgent","summary":"g14 is silent","headline":"g14 is silent","explanation":"It said nothing.","resolved":"It speaks.","needs":"look","evidence":[{"at":"2026-11-05T12:00:00Z","said":"no pulse"}],"source":"S1","resolver":"operator","observer":"mesh-delivery-watchman","run":"r1","at":"2026-11-05T12:00:00Z"} diff --git a/health-and-repair/go/clients/meshconditionsclient/testdata/finding_minimal.json b/health-and-repair/go/clients/meshconditionsclient/testdata/finding_minimal.json new file mode 100644 index 0000000..e0fef12 --- /dev/null +++ b/health-and-repair/go/clients/meshconditionsclient/testdata/finding_minimal.json @@ -0,0 +1 @@ +{"v":1,"key":"module.nats.g14.build-failed","kind":"build-failed","subject":{"scope":"module","id":"nats.g14"},"severity":"warning","summary":"failed","headline":"","explanation":"","resolved":"","evidence":[{"at":"2026-11-05T12:00:00Z","said":"failed"}],"source":"builder.build-failed","observer":"mesh-delivery-builder","at":"2026-11-05T12:00:00Z"} diff --git a/health-and-repair/go/clients/meshhandactsclient/client.go b/health-and-repair/go/clients/meshhandactsclient/client.go new file mode 100644 index 0000000..fd7fc97 --- /dev/null +++ b/health-and-repair/go/clients/meshhandactsclient/client.go @@ -0,0 +1,98 @@ +// Package meshhandactsclient is the logic every module that records an act by hand shares over the seat mesh-hand-acts +// (novox/hq ADR 0565 clauses 10 and 11, ADR 0634 §4): the recorder, which asks the seat alone, and what makes an act a +// repair. The act's layout is the seat's contract's. +package meshhandactsclient + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// The verbs an act is recorded under in the log, and the causes and kinds that are no repair. +const ( + VerbRecord = "hand-act record" + VerbDrill = "hand-act drill" + VerbWarrant = "warrant" + // CauseDrill is a drill's cause, never counted as a repair; CauseOperatorAnswer an act the operator chose. + CauseDrill = "drill" + CauseOperatorAnswer = "operator-answer" + // KindRecordedBuilds is a push that only moved recorded builds: no repair (ADR 0242). + KindRecordedBuilds = "recorded-builds" +) + +// Repair says the act counts toward a cause repeated: not a drill, not an operator's answer, not recorded builds. +func Repair(a seat.Act) bool { + return a.Cause != CauseDrill && a.Verb != VerbDrill && a.Cause != CauseOperatorAnswer && a.Kind != KindRecordedBuilds +} + +// Request is one act a recorder records: what, why, its cause, the condition it addresses, and its verb in the log (a +// repair when empty). +type Request struct { + What string `json:"what"` + Why string `json:"why"` + Cause string `json:"cause,omitempty"` + Condition string `json:"condition,omitempty"` + Verb string `json:"verb,omitempty"` +} + +// Taken is which log took an act, and what its holder answered. +type Taken struct { + Log string `json:"log"` + Answer json.RawMessage `json:"answer,omitempty"` +} + +// Recorder records acts for a module through the seat mesh-hand-acts alone. An act the seat does not take is an error +// that says nothing was recorded, never a quiet success. +type Recorder struct { + // Ask calls a seat verb by its address (`seat:.`): the runtime's ask. + Ask func(key string, body any) (json.RawMessage, error) +} + +// NoResponder says err is the bus finding nobody to answer: the seat has no holder now. +func NoResponder(err error) bool { + return err != nil && strings.Contains(err.Error(), "no responders") +} + +// Record records one act. +func (r Recorder) Record(q Request) (Taken, error) { + if strings.TrimSpace(q.What) == "" || strings.TrimSpace(q.Why) == "" { + return Taken{}, errors.New("a hand act says what was done and why: nothing was recorded") + } + body := map[string]any{"what": q.What, "why": q.Why} + for k, v := range map[string]string{"cause": q.Cause, "condition": q.Condition, "verb": q.Verb} { + if v != "" { + body[k] = v + } + } + return r.record(seat.VerbHandAct, body) +} + +// RecordHandAct records a part's act before it does it, as mesh-sdk's views.HandActs asks: the recorder is the +// interface's implementation, and an act the seat does not take is the error that says nothing was recorded. +func (r Recorder) RecordHandAct(a views.HandAct) error { + _, err := r.Record(Request{What: a.What, Why: a.Why, Cause: a.Cause, Verb: a.Verb}) + return err +} + +var _ views.HandActs = Recorder{} + +// RecordWarrant records what the asking module did on the operator's warrant for its ask. +func (r Recorder) RecordWarrant(asker, ask string) (Taken, error) { + return r.record(seat.VerbWarranted, map[string]any{"asker": asker, "ask": ask}) +} + +func (r Recorder) record(verb string, body any) (Taken, error) { + answer, err := r.Ask("seat:"+seat.Seat+"."+verb, body) + if err == nil { + return Taken{Log: seat.Seat, Answer: answer}, nil + } + if NoResponder(err) { + return Taken{}, fmt.Errorf("%s has no holder that answers: nothing was recorded (%w)", seat.Seat, err) + } + return Taken{}, fmt.Errorf("%s.%s refused, nothing was recorded: %w", seat.Seat, verb, err) +} diff --git a/health-and-repair/go/clients/meshhandactsclient/client_test.go b/health-and-repair/go/clients/meshhandactsclient/client_test.go new file mode 100644 index 0000000..a4ac41d --- /dev/null +++ b/health-and-repair/go/clients/meshhandactsclient/client_test.go @@ -0,0 +1,93 @@ +package meshhandactsclient + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "testing" + + seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// ADR 0565 clauses 10 and 11 (issue 1953 L5-S2, S4, L5b): an act reads the controller's entries unchanged, and a +// recorder records through the seat alone, the controller's verbs being gone, and says loudly when nothing was recorded. +func TestAnActIsRecordedInTheLogThatAnswers(t *testing.T) { + // The shape of mesh-controller internal/link HandAct, as its bucket keeps an entry (handacts.go at 0fbb5ef3). + raw := `{"id":"act-1","at":"2026-10-28T09:00:00Z","by":"person.jochen","verb":"hand-act record","why":"a stuck consumer",` + + `"cause":"consumer-lost","condition":"bus.EVENTS.x.consumer-lost","via":"telegram","ask":"a-1","proofs":["P1"],` + + `"requested-by":"bus.EVENTS.x.consumer-lost","outcome":"done"}` + var a seat.Act + if err := json.Unmarshal([]byte(raw), &a); err != nil { + t.Fatal(err) + } + again, _ := json.Marshal(a) + var want, got map[string]any + _ = json.Unmarshal([]byte(raw), &want) + _ = json.Unmarshal(again, &got) + if !reflect.DeepEqual(want, got) || !Repair(a) || Repair(seat.Act{Cause: CauseDrill}) || Repair(seat.Act{Verb: VerbDrill}) { + t.Errorf("the controller's entry reads back as %s", again) + } + noResponders := errors.New("nats: no responders available for request") + for _, c := range []struct { + name string + seat error + ctrl error + log string + refused string + asked string + warrants bool + }{ + {name: "the seat answers", log: seat.Seat, asked: "seat:mesh-hand-acts.hand-act"}, + {name: "no holder: nothing recorded, the controller never asked", seat: noResponders, refused: "nothing was recorded", + asked: "seat:mesh-hand-acts.hand-act"}, + {name: "the seat refuses", seat: errors.New("why is required"), refused: "refused, nothing was recorded", + asked: "seat:mesh-hand-acts.hand-act"}, + {name: "a warrant without a holder", seat: noResponders, warrants: true, refused: "nothing was recorded", + asked: "seat:mesh-hand-acts.warranted"}, + {name: "a warrant recorded", warrants: true, log: seat.Seat, asked: "seat:mesh-hand-acts.warranted"}, + } { + var asked []string + r := Recorder{Ask: func(key string, body any) (json.RawMessage, error) { + asked = append(asked, key) + if strings.HasPrefix(key, "seat:mesh-hand-acts.") { + return json.RawMessage(`{}`), c.seat + } + return json.RawMessage(`{}`), c.ctrl + }} + var taken Taken + var err error + if c.warrants { + taken, err = r.RecordWarrant("mesh-config", "ask-1") + } else { + taken, err = r.Record(Request{What: "silenced", Why: "known", Cause: "silent", Verb: "conditions silence"}) + } + if (c.refused != "") != (err != nil) || (err != nil && !strings.Contains(err.Error(), c.refused)) || taken.Log != c.log || + strings.Join(asked, ",") != c.asked { + t.Errorf("%s: took %q, %v, asked %v", c.name, taken.Log, err, asked) + } + } + if _, err := (Recorder{}).Record(Request{What: "x"}); err == nil { + t.Error("an act with no why was recorded") + } +} + +func TestTheRecorderIsMeshSDKsHandActRecorder(t *testing.T) { + var asked []string + r := Recorder{Ask: func(key string, body any) (json.RawMessage, error) { + asked = append(asked, key+" "+fmt.Sprint(body)) + return nil, nil + }} + var acts views.HandActs = r + if err := acts.RecordHandAct(views.HandAct{What: "asked a view again", Why: "it is stale", Cause: "stale"}); err != nil { + t.Fatal(err) + } + if len(asked) != 1 || !strings.HasPrefix(asked[0], "seat:mesh-hand-acts.hand-act ") || !strings.Contains(asked[0], "asked a view again") { + t.Errorf("asked %v", asked) + } + if err := acts.RecordHandAct(views.HandAct{What: "x"}); err == nil { + t.Error("an act with no why was taken") + } +} diff --git a/health-and-repair/go/clients/meshhandactsclient/go.mod b/health-and-repair/go/clients/meshhandactsclient/go.mod new file mode 100644 index 0000000..0b779d9 --- /dev/null +++ b/health-and-repair/go/clients/meshhandactsclient/go.mod @@ -0,0 +1,16 @@ +module git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient + +go 1.26.0 + +require git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 + +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 + +require ( + github.com/klauspost/compress v1.20.1 // indirect + github.com/nats-io/nats.go v1.54.0 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect +) diff --git a/health-and-repair/go/clients/meshhandactsclient/go.sum b/health-and-repair/go/clients/meshhandactsclient/go.sum new file mode 100644 index 0000000..999068e --- /dev/null +++ b/health-and-repair/go/clients/meshhandactsclient/go.sum @@ -0,0 +1,24 @@ +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/health-and-repair/go/clients/meshwatchdogsclient/go.mod b/health-and-repair/go/clients/meshwatchdogsclient/go.mod new file mode 100644 index 0000000..a10a1e7 --- /dev/null +++ b/health-and-repair/go/clients/meshwatchdogsclient/go.mod @@ -0,0 +1,8 @@ +module git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 +) diff --git a/health-and-repair/go/clients/meshwatchdogsclient/watchdogs.go b/health-and-repair/go/clients/meshwatchdogsclient/watchdogs.go new file mode 100644 index 0000000..2855912 --- /dev/null +++ b/health-and-repair/go/clients/meshwatchdogsclient/watchdogs.go @@ -0,0 +1,205 @@ +// Package meshwatchdogsclient is the client of the seat mesh-watchdogs: the keys, the check and the codec of the seat's +// three states presence, pulse and stuck, which every module that reads or writes them shares (the watchman that writes +// them, the assigner and the self-check that read them). The layouts are the seat's contract; this file writes the +// version and refuses what the earlier codec refused (novox/hq ADR 0433, ADR 0634 §4). +package meshwatchdogsclient + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The three values of the seat's states, in the seat's types. +type ( + // NodePresence is whether a node is online (state presence, key ``). + NodePresence = seat.PresenceByNodeValue + // PartPresence is whether a part's instance is online (state presence, key `part..`). + PartPresence = seat.PresenceByPartPartInstanceValue + // Pulse is the watched key mirrored, its revision, and who owes the next move (state pulse). + Pulse = seat.PulseByBucketKeyValue + // Stuck is the key that stopped moving, what it waited for, since when, and whether it moved again (state stuck). + Stuck = seat.StuckByBucketKeyValue +) + +// PartWord is the first part of a part's presence key; a node of that name is refused. +const PartWord = "part" + +// PulseMoveBound is how long a move may take: a pulse key lives for this time minus the move's age. +const PulseMoveBound = 120 * time.Second + +// PresenceHistory is the number of revisions the presence state keeps per key. +const PresenceHistory = 20 + +// PresenceNodeKey is the key of a node's presence: ``. A node named `part` is refused. +func PresenceNodeKey(node string) (string, error) { + if err := keys.CheckName("node", node); err != nil { + return "", err + } + if node == PartWord { + return "", errors.New(`the node name "part" is refused: part.. is a part's key`) + } + return seat.PresenceByNodeKey(node), nil +} + +// PresencePartKey is the key of a part's instance: `part..`. +func PresencePartKey(part, instance string) (string, error) { + if err := keys.CheckName("part", part); err != nil { + return "", err + } + if err := keys.CheckName("instance", instance); err != nil { + return "", err + } + return seat.PresenceByPartPartInstanceKey(part, instance), nil +} + +// ParsePresenceKey is the inverse of PresenceNodeKey and PresencePartKey: for a node's key part and instance are +// empty, for a part's key node is. +func ParsePresenceKey(key string) (node, part, instance string, err error) { + if !strings.Contains(key, ".") { + node, err = PresenceNodeKey(key) + if err != nil { + return "", "", "", fmt.Errorf("%q is not a presence key: %w", key, err) + } + return node, "", "", nil + } + fields := strings.Split(key, ".") + if len(fields) != 3 || fields[0] != PartWord { + return "", "", "", fmt.Errorf("%q is not a presence key, or part..", key) + } + if _, err := PresencePartKey(fields[1], fields[2]); err != nil { + return "", "", "", fmt.Errorf("%q is not a presence key: %w", key, err) + } + return "", fields[1], fields[2], nil +} + +// PulseKey is the key of the move watched at key of bucket: `.`. The bucket is the full bucket name and +// holds no dot; the key may. +func PulseKey(bucket, key string) (string, error) { + if err := keys.CheckName("bucket", bucket); err != nil { + return "", err + } + if key == "" || strings.HasPrefix(key, ".") || strings.HasSuffix(key, ".") || strings.Contains(key, "..") { + return "", fmt.Errorf("the pulse key %q is empty or has an empty part", key) + } + for _, part := range strings.Split(key, ".") { + if err := keys.CheckName("key", part); err != nil { + return "", err + } + } + return seat.PulseByBucketKeyKey(bucket, key), nil +} + +// ParsePulseKey is the inverse of PulseKey, splitting at the first dot. +func ParsePulseKey(pulseKey string) (bucket, key string, err error) { + bucket, key, ok := seat.ParsePulseByBucketKeyKey(pulseKey) + if !ok { + return "", "", fmt.Errorf("%q is not a pulse key, .", pulseKey) + } + if _, err := PulseKey(bucket, key); err != nil { + return "", "", fmt.Errorf("%q is not a pulse key: %w", pulseKey, err) + } + return bucket, key, nil +} + +// StuckKey is the key of the key that stopped moving: `.`, formed as PulseKey forms a pulse's. +func StuckKey(bucket, key string) (string, error) { + k, err := PulseKey(bucket, key) + if err != nil { + return "", fmt.Errorf("no stuck key: %w", err) + } + return k, nil +} + +// ParseStuckKey is the inverse of StuckKey, splitting at the first dot. +func ParseStuckKey(stuckKey string) (bucket, key string, err error) { + bucket, key, err = ParsePulseKey(stuckKey) + if err != nil { + return "", "", fmt.Errorf("%q is not a stuck key: %w", stuckKey, err) + } + return bucket, key, nil +} + +// PulseTTL is the time to live of a pulse key for a move of the given age: PulseMoveBound minus the age. At or past +// the bound the move is stalled, with no time left and nothing to write. +func PulseTTL(moveAge time.Duration) (ttl time.Duration, stalled bool) { + if moveAge >= PulseMoveBound { + return 0, true + } + return PulseMoveBound - moveAge, false +} + +// EncodeNodePresence is p as JSON at the current version. +func EncodeNodePresence(p NodePresence) ([]byte, error) { + p.V = keys.Version + return json.Marshal(p) +} + +// DecodeNodePresence reads a node's presence; it refuses an unknown version (keys.ErrUnknownVersion). +func DecodeNodePresence(data []byte) (NodePresence, error) { + if err := keys.CheckVersion(data); err != nil { + return NodePresence{}, err + } + return decodeStrict[NodePresence](data, "presence") +} + +// EncodePartPresence is p as JSON at the current version. +func EncodePartPresence(p PartPresence) ([]byte, error) { + p.V = keys.Version + return json.Marshal(p) +} + +// DecodePartPresence reads a part's presence; it refuses an unknown version (keys.ErrUnknownVersion). +func DecodePartPresence(data []byte) (PartPresence, error) { + if err := keys.CheckVersion(data); err != nil { + return PartPresence{}, err + } + return decodeStrict[PartPresence](data, "presence") +} + +// EncodePulse is p as JSON at the current version. +func EncodePulse(p Pulse) ([]byte, error) { + p.V = keys.Version + return json.Marshal(p) +} + +// DecodePulse reads a pulse; it refuses an unknown version (keys.ErrUnknownVersion). +func DecodePulse(data []byte) (Pulse, error) { + if err := keys.CheckVersion(data); err != nil { + return Pulse{}, err + } + return decodeStrict[Pulse](data, "pulse") +} + +// EncodeStuck is s as JSON at the current version. +func EncodeStuck(s Stuck) ([]byte, error) { + s.V = keys.Version + return json.Marshal(s) +} + +// DecodeStuck reads a stuck value; it refuses an unknown version (keys.ErrUnknownVersion). +func DecodeStuck(data []byte) (Stuck, error) { + if err := keys.CheckVersion(data); err != nil { + return Stuck{}, err + } + return decodeStrict[Stuck](data, "stuck") +} + +func decodeStrict[T any](data []byte, what string) (T, error) { + var v T + if err := json.Unmarshal(data, &v); err != nil { + return v, fmt.Errorf("the %s value does not fit its shape: %w", what, err) + } + return v, nil +} + +// SameStuck says whether two stuck values are the same, so a writer writes only when they differ. +func SameStuck(a, b Stuck) bool { + return a.V == b.V && a.Bucket == b.Bucket && a.Key == b.Key && a.Waited == b.Waited && a.Since.Equal(b.Since) && + a.Moved == b.Moved +} diff --git a/health-and-repair/go/clients/meshwatchdogsclient/watchdogs_test.go b/health-and-repair/go/clients/meshwatchdogsclient/watchdogs_test.go new file mode 100644 index 0000000..3f5c296 --- /dev/null +++ b/health-and-repair/go/clients/meshwatchdogsclient/watchdogs_test.go @@ -0,0 +1,140 @@ +package meshwatchdogsclient + +import ( + "encoding/json" + "errors" + "reflect" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/keys" +) + +func sameJSON(t *testing.T, got []byte, want string) { + t.Helper() + var g, w any + if err := json.Unmarshal(got, &g); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(want), &w); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(g, w) { + t.Fatalf("wrote %s, the earlier codec wrote %s", got, want) + } +} + +// TestTheValuesAreWhatTheEarlierCodecWrote holds the values equal to what go/delivery's codec wrote for the same values +// (the golden texts are its output; the field order is the contract's now, so values are compared decoded), reads them +// back, and refuses an unknown version. +func TestTheValuesAreWhatTheEarlierCodecWrote(t *testing.T) { + since := time.Date(2026, 10, 25, 11, 52, 58, 51397201, time.UTC) + for name, row := range map[string]struct { + encode func() ([]byte, error) + decode func([]byte) (any, error) + golden string + want any + }{ + "node online": { + func() ([]byte, error) { return EncodeNodePresence(NodePresence{Online: true}) }, + func(b []byte) (any, error) { return DecodeNodePresence(b) }, + `{"v":1,"online":true}`, NodePresence{V: 1, Online: true}, + }, + "part offline": { + func() ([]byte, error) { return EncodePartPresence(PartPresence{}) }, + func(b []byte) (any, error) { return DecodePartPresence(b) }, + `{"v":1,"online":false}`, PartPresence{V: 1}, + }, + "pulse": { + func() ([]byte, error) { + return EncodePulse(Pulse{Bucket: "mesh-delivery-assigner_wants", Key: "want.a.m", Revision: 7, Owes: "a"}) + }, + func(b []byte) (any, error) { return DecodePulse(b) }, + `{"v":1,"bucket":"mesh-delivery-assigner_wants","key":"want.a.m","revision":7,"owes":"a"}`, + Pulse{V: 1, Bucket: "mesh-delivery-assigner_wants", Key: "want.a.m", Revision: 7, Owes: "a"}, + }, + "stuck": { + func() ([]byte, error) { + return EncodeStuck(Stuck{Bucket: "mesh-delivery-watchman_presence", Key: "part.b.n", Waited: "b on n to come back", Since: since, Moved: true}) + }, + func(b []byte) (any, error) { return DecodeStuck(b) }, + `{"v":1,"bucket":"mesh-delivery-watchman_presence","key":"part.b.n","waited":"b on n to come back","since":"2026-10-25T11:52:58.051397201Z","moved":true}`, + Stuck{V: 1, Bucket: "mesh-delivery-watchman_presence", Key: "part.b.n", Waited: "b on n to come back", Since: since, Moved: true}, + }, + } { + t.Run(name, func(t *testing.T) { + raw, err := row.encode() + if err != nil { + t.Fatal(err) + } + sameJSON(t, raw, row.golden) + back, err := row.decode([]byte(row.golden)) + if err != nil || !reflect.DeepEqual(back, row.want) { + t.Fatalf("read %+v, %v; want %+v", back, err, row.want) + } + if _, err := row.decode([]byte(`{"v":2}`)); !errors.Is(err, keys.ErrUnknownVersion) { + t.Fatalf("version 2: %v", err) + } + if _, err := row.decode([]byte(`{}`)); !errors.Is(err, keys.ErrUnknownVersion) { + t.Fatalf("no version: %v", err) + } + }) + } + if !SameStuck(Stuck{V: 1, Since: since}, Stuck{V: 1, Since: since.In(time.FixedZone("x", 3600))}) || SameStuck(Stuck{Moved: true}, Stuck{}) { + t.Fatal("SameStuck") + } +} + +// TestTheKeysAreWhatTheEarlierCodecFormed holds the key forms and their refusals. +func TestTheKeysAreWhatTheEarlierCodecFormed(t *testing.T) { + if k, err := PresenceNodeKey("ace"); err != nil || k != "ace" { + t.Fatal(k, err) + } + if k, err := PresencePartKey("mesh-delivery-builder", "novox"); err != nil || k != "part.mesh-delivery-builder.novox" { + t.Fatal(k, err) + } + for _, bad := range []string{"part", "", "a.b"} { + if _, err := PresenceNodeKey(bad); err == nil { + t.Fatalf("node %q accepted", bad) + } + } + for key, want := range map[string][3]string{"ace": {"ace", "", ""}, "part.p.i": {"", "p", "i"}} { + n, p, i, err := ParsePresenceKey(key) + if err != nil || [3]string{n, p, i} != want { + t.Fatal(key, n, p, i, err) + } + } + for _, bad := range []string{"part.p", "x.p.i", "part..i", "a.b.c.d", "part"} { + if _, _, _, err := ParsePresenceKey(bad); err == nil { + t.Fatalf("presence key %q accepted", bad) + } + } + k, err := PulseKey("b_w", "want.a.m") + if err != nil || k != "b_w.want.a.m" { + t.Fatal(k, err) + } + if s, err := StuckKey("b_w", "want.a.m"); err != nil || s != k { + t.Fatal(s, err) + } + for _, f := range []func(string) (string, string, error){ParsePulseKey, ParseStuckKey} { + if b, key, err := f(k); err != nil || b != "b_w" || key != "want.a.m" { + t.Fatal(b, key, err) + } + for _, bad := range []string{"nodot", ".k", "b.", "b..k", "b.k."} { + if _, _, err := f(bad); err == nil { + t.Fatalf("pulse key %q accepted", bad) + } + } + } + for _, bad := range [][2]string{{"", "k"}, {"b.c", "k"}, {"b", ""}, {"b", "k..j"}, {"b", "k j"}} { + if _, err := PulseKey(bad[0], bad[1]); err == nil { + t.Fatalf("pulse key %q accepted", bad) + } + } + if ttl, stalled := PulseTTL(30 * time.Second); stalled || ttl != 90*time.Second { + t.Fatal(ttl, stalled) + } + if ttl, stalled := PulseTTL(PulseMoveBound); !stalled || ttl != 0 { + t.Fatal(ttl, stalled) + } +} diff --git a/health-and-repair/go/seats/meshconditions/contract.go b/health-and-repair/go/seats/meshconditions/contract.go new file mode 100644 index 0000000..0757680 --- /dev/null +++ b/health-and-repair/go/seats/meshconditions/contract.go @@ -0,0 +1,385 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package meshconditions is the contract of the seat "mesh-conditions", defined whole (seats/mesh-conditions/seat.json): What is wrong with the mesh now (ADR 0564): the open conditions raised from the observers' findings and cleared when a finding goes. +package meshconditions + +import ( + "bytes" + "encoding/json" + "errors" + "time" +) + +// Seat is the seat's name. +const Seat = "mesh-conditions" + +const ( + VerbConditions = "conditions" + VerbSilence = "silence" + VerbResync = "resync" +) + +const ( + EventConditionRaised = "condition-raised" + EventConditionChanged = "condition-changed" + EventConditionCleared = "condition-cleared" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbConditions, VerbSilence, VerbResync} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventConditionRaised, EventConditionChanged, EventConditionCleared} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "mesh-conditions.events" + FamilyMaxAgeDays = 7 +) + +// SubjectConditionRaised is where condition-raised is published. +const SubjectConditionRaised = "mesh.seat.mesh-conditions.event.condition-raised" + +// SubjectConditionChanged is where condition-changed is published. +const SubjectConditionChanged = "mesh.seat.mesh-conditions.event.condition-changed" + +// SubjectConditionCleared is where condition-cleared is published. +const SubjectConditionCleared = "mesh.seat.mesh-conditions.event.condition-cleared" + +const ( + StateConditions = "conditions" + StateFindings = "findings" +) + +// ConditionsByKeyKey is the key of one value of the state conditions under the form "". +func ConditionsByKeyKey(key string) string { return key } + +// ParseConditionsByKeyKey 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 ParseConditionsByKeyKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeConditionsByKeyValue reads one value of the state conditions under the form "", refusing a field ConditionsByKeyValue does not know and any text after the value. +func DecodeConditionsByKeyValue(raw []byte) (ConditionsByKeyValue, error) { + var v ConditionsByKeyValue + 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 +} + +// FindingsByKeyKey is the key of one value of the state findings under the form "". +func FindingsByKeyKey(key string) string { return key } + +// ParseFindingsByKeyKey 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 ParseFindingsByKeyKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeFindingsByKeyValue reads one value of the state findings under the form "", refusing a field FindingsByKeyValue does not know and any text after the value. +func DecodeFindingsByKeyValue(raw []byte) (FindingsByKeyValue, error) { + var v FindingsByKeyValue + 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 +} + +// Action is one answer the operator may give from a notification. +type Action struct { + Arguments map[string]string `json:"arguments,omitempty"` + Label string `json:"label"` + Level string `json:"level,omitempty"` + Machine string `json:"machine,omitempty"` + // the seat verb the operator channel calls + Verb string `json:"verb"` +} + +// The values of Action's level. +const ( + ActionLevelAcknowledge = "acknowledge" + ActionLevelApprove = "approve" +) + +// ConditionEventGapsItem is a type of the contract. +type ConditionEventGapsItem struct { + Cleared *time.Time `json:"cleared,omitempty"` + Reopened *time.Time `json:"reopened,omitempty"` +} + +// ConditionEventSilenced is a type of the contract. +type ConditionEventSilenced struct { + By string `json:"by,omitempty"` + Since *time.Time `json:"since,omitempty"` + Until *time.Time `json:"until,omitempty"` + Why string `json:"why,omitempty"` +} + +// ConditionEventTriedItem is a type of the contract. +type ConditionEventTriedItem struct { + At *time.Time `json:"at,omitempty"` + By string `json:"by,omitempty"` + Outcome string `json:"outcome,omitempty"` + What string `json:"what,omitempty"` +} + +// ConditionEvent is a condition and what changed. +type ConditionEvent struct { + Actions []Action `json:"actions,omitempty"` + At time.Time `json:"at"` + Change string `json:"change"` + Cleared *time.Time `json:"cleared,omitempty"` + Count int64 `json:"count,omitempty"` + Epoch int64 `json:"epoch,omitempty"` + Event string `json:"event"` + // newest first, at most 10 + Evidence []Evidence `json:"evidence,omitempty"` + Explanation string `json:"explanation,omitempty"` + First *time.Time `json:"first,omitempty"` + Gaps []ConditionEventGapsItem `json:"gaps,omitempty"` + Headline string `json:"headline,omitempty"` + // scope.id.token + Key string `json:"key"` + Kind string `json:"kind,omitempty"` + LastObserved *time.Time `json:"last-observed,omitempty"` + LooksUnread string `json:"looks-unread,omitempty"` + Needs string `json:"needs,omitempty"` + Observations int64 `json:"observations,omitempty"` + Raised *time.Time `json:"raised,omitempty"` + Resolved string `json:"resolved,omitempty"` + // self, operator, agent or healer: + Resolver string `json:"resolver,omitempty"` + Severity string `json:"severity,omitempty"` + // the tool call that shows it + Show string `json:"show,omitempty"` + Silenced *ConditionEventSilenced `json:"silenced,omitempty"` + // the row, probe or event that saw it + Source string `json:"source,omitempty"` + Subject *Subject `json:"subject,omitempty"` + // one line + Summary string `json:"summary,omitempty"` + Tried []ConditionEventTriedItem `json:"tried,omitempty"` + Was string `json:"was,omitempty"` + Why string `json:"why,omitempty"` +} + +// The values of ConditionEvent's change. +const ( + ConditionEventChangeRaised = "raised" + ConditionEventChangeReopened = "reopened" + ConditionEventChangeSeverity = "severity" + ConditionEventChangeResolver = "resolver" + ConditionEventChangeSilenced = "silenced" + ConditionEventChangeSilenceEnded = "silence-ended" + ConditionEventChangeCleared = "cleared" + ConditionEventChangeWords = "words" +) + +// The values of ConditionEvent's severity. +const ( + ConditionEventSeverityUrgent = "urgent" + ConditionEventSeverityWarning = "warning" +) + +// Evidence is one observation, as it was said. +type Evidence struct { + At time.Time `json:"at"` + Said string `json:"said"` +} + +// Subject is what the condition is about. +type Subject struct { + // the other machines it concerns + Also []string `json:"also,omitempty"` + // its id within the scope + ID string `json:"id"` + // the machine it concerns, when there is one + Machine string `json:"machine,omitempty"` + // machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module + Scope string `json:"scope"` +} + +// ConditionsInput is what conditions takes. +type ConditionsInput struct { + // with history: how many days back (default 7, at most 90) + Days string `json:"days,omitempty"` + // "true": every raising, change, silence and clearing lately, oldest first + History string `json:"history,omitempty"` + // a condition's key: that one whole; with history, only its transitions + Key string `json:"key,omitempty"` + // only those about this machine + Machine string `json:"machine,omitempty"` + // only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module + Scope string `json:"scope,omitempty"` + // only urgent, or only warning + Severity string `json:"severity,omitempty"` +} + +// The values of ConditionsInput's history. +const ( + ConditionsInputHistoryTrue = "true" + ConditionsInputHistoryFalse = "false" +) + +// ConditionsAnswer is what conditions answers. +type ConditionsAnswer = map[string]any + +// SilenceInput is what silence takes. +type SilenceInput struct { + // the cause in a word (the condition's kind when absent) + Cause string `json:"cause,omitempty"` + // how long: 30m, 4h, 2d; at most 7d + For string `json:"for"` + // the condition's key + Key string `json:"key"` + // why, required and recorded + Why string `json:"why"` +} + +// SilenceAnswer is what silence answers. +type SilenceAnswer = map[string]any + +// ResyncInput is what resync takes. +type ResyncInput struct { +} + +// ResyncAnswer is what resync answers. +type ResyncAnswer = map[string]any + +// ConditionRaisedEvent is the body of the event condition-raised. +type ConditionRaisedEvent = ConditionEvent + +// ConditionChangedEvent is the body of the event condition-changed. +type ConditionChangedEvent = ConditionEvent + +// ConditionClearedEvent is the body of the event condition-cleared. +type ConditionClearedEvent = ConditionEvent + +// ConditionsByKeyValueGapsItem is a type of the contract. +type ConditionsByKeyValueGapsItem struct { + Cleared *time.Time `json:"cleared,omitempty"` + Reopened *time.Time `json:"reopened,omitempty"` +} + +// ConditionsByKeyValueSilenced is a type of the contract. +type ConditionsByKeyValueSilenced struct { + By string `json:"by,omitempty"` + Since *time.Time `json:"since,omitempty"` + Until *time.Time `json:"until,omitempty"` + Why string `json:"why,omitempty"` +} + +// ConditionsByKeyValueTriedItem is a type of the contract. +type ConditionsByKeyValueTriedItem struct { + At *time.Time `json:"at,omitempty"` + By string `json:"by,omitempty"` + Outcome string `json:"outcome,omitempty"` + What string `json:"what,omitempty"` +} + +// ConditionsByKeyValue is one value of the state conditions under the key . +type ConditionsByKeyValue struct { + Actions []Action `json:"actions,omitempty"` + Count int64 `json:"count,omitempty"` + Epoch int64 `json:"epoch,omitempty"` + // newest first, at most 10 + Evidence []Evidence `json:"evidence,omitempty"` + Explanation string `json:"explanation,omitempty"` + First *time.Time `json:"first,omitempty"` + Gaps []ConditionsByKeyValueGapsItem `json:"gaps,omitempty"` + Headline string `json:"headline,omitempty"` + // scope.id.token + Key string `json:"key"` + Kind string `json:"kind"` + LastObserved time.Time `json:"last-observed"` + LooksUnread string `json:"looks-unread,omitempty"` + Needs string `json:"needs,omitempty"` + Observations int64 `json:"observations,omitempty"` + Raised time.Time `json:"raised"` + Resolved string `json:"resolved,omitempty"` + // self, operator, agent or healer: + Resolver string `json:"resolver,omitempty"` + Severity string `json:"severity"` + Silenced *ConditionsByKeyValueSilenced `json:"silenced,omitempty"` + // the row, probe or event that saw it + Source string `json:"source,omitempty"` + Subject Subject `json:"subject"` + // one line + Summary string `json:"summary,omitempty"` + Tried []ConditionsByKeyValueTriedItem `json:"tried,omitempty"` +} + +// The values of ConditionsByKeyValue's severity. +const ( + ConditionsByKeyValueSeverityUrgent = "urgent" + ConditionsByKeyValueSeverityWarning = "warning" +) + +// FindingsByKeyValue is one value of the state findings under the key . +type FindingsByKeyValue struct { + Actions []Action `json:"actions,omitempty"` + At time.Time `json:"at"` + // newest first, at most 10 + Evidence []Evidence `json:"evidence,omitempty"` + Explanation string `json:"explanation,omitempty"` + Headline string `json:"headline,omitempty"` + // scope.id.token + Key string `json:"key"` + Kind string `json:"kind"` + Needs string `json:"needs,omitempty"` + // the module that wrote it + Observer string `json:"observer"` + // the key's last part when it is not the kind + Ref string `json:"ref,omitempty"` + Resolved string `json:"resolved,omitempty"` + // self, operator, agent or healer: + Resolver string `json:"resolver,omitempty"` + // what of its own saw it last + Run string `json:"run,omitempty"` + Severity string `json:"severity"` + // the row, probe or event that saw it + Source string `json:"source"` + Subject Subject `json:"subject"` + // one line + Summary string `json:"summary"` + // layout version, 1 + V int64 `json:"v"` +} + +// The values of FindingsByKeyValue's severity. +const ( + FindingsByKeyValueSeverityUrgent = "urgent" + FindingsByKeyValueSeverityWarning = "warning" +) diff --git a/health-and-repair/go/seats/meshconditions/contract_test.go b/health-and-repair/go/seats/meshconditions/contract_test.go new file mode 100644 index 0000000..fc0717f --- /dev/null +++ b/health-and-repair/go/seats/meshconditions/contract_test.go @@ -0,0 +1,54 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package meshconditions + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/mesh-conditions/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 condition raised from a finding, as the keeper holds it (captured; empty fields left out)", subject: "", given: "{\"count\":1,\"evidence\":[{\"at\":\"2026-10-26T20:19:41.474823121Z\",\"said\":\"mesh-build-node on ace uses mesh-build-node.cache as found: used as found: owned by 10005:10005, mode 700, as found; mesh-build-node, mode 0711 was declared and not given it — `ace/node-engine.hand-over` with the directory's path, then your yes on your channel, with its path, hands it to the mesh\"}],\"explanation\":\"Needs you: hand the directory over from the control-node, as the operator; the details name it and the line to type. A directory of mesh-build-node was already on ace, with another owner or mode than mesh-build-node declares.\",\"headline\":\"mesh-build-node waits for a directory on ace\",\"key\":\"module.mesh-build-node.ace.directory-used-as-found\",\"kind\":\"directory-used-as-found\",\"last-observed\":\"2026-10-26T20:19:41.474823121Z\",\"needs\":\"hand the directory over from the control-node, as the operator; the details name it and the line to type.\",\"observations\":1,\"raised\":\"2026-10-31T08:19:57.076843727Z\",\"resolved\":\"mesh-build-node's directory on ace is the mesh's\",\"resolver\":\"self\",\"severity\":\"warning\",\"source\":\"health-report\",\"subject\":{\"id\":\"mesh-build-node.ace\",\"machine\":\"ace\",\"scope\":\"module\"},\"summary\":\"mesh-build-node on ace uses mesh-build-node.cache as found: used as found: owned by 10005:10005, mode 700, as found; mesh-build-node, mode 0711 was declared and not given it — `ace/node-engine.hand-over` with the directory's path, then your yes on your channel, with its path, hands it to the mesh\"}", wire: "{\"count\":1,\"evidence\":[{\"at\":\"2026-10-26T20:19:41.474823121Z\",\"said\":\"mesh-build-node on ace uses mesh-build-node.cache as found: used as found: owned by 10005:10005, mode 700, as found; mesh-build-node, mode 0711 was declared and not given it — `ace/node-engine.hand-over` with the directory's path, then your yes on your channel, with its path, hands it to the mesh\"}],\"explanation\":\"Needs you: hand the directory over from the control-node, as the operator; the details name it and the line to type. A directory of mesh-build-node was already on ace, with another owner or mode than mesh-build-node declares.\",\"headline\":\"mesh-build-node waits for a directory on ace\",\"key\":\"module.mesh-build-node.ace.directory-used-as-found\",\"kind\":\"directory-used-as-found\",\"last-observed\":\"2026-10-26T20:19:41.474823121Z\",\"needs\":\"hand the directory over from the control-node, as the operator; the details name it and the line to type.\",\"observations\":1,\"raised\":\"2026-10-31T08:19:57.076843727Z\",\"resolved\":\"mesh-build-node's directory on ace is the mesh's\",\"resolver\":\"self\",\"severity\":\"warning\",\"source\":\"health-report\",\"subject\":{\"id\":\"mesh-build-node.ace\",\"machine\":\"ace\",\"scope\":\"module\"},\"summary\":\"mesh-build-node on ace uses mesh-build-node.cache as found: used as found: owned by 10005:10005, mode 700, as found; mesh-build-node, mode 0711 was declared and not given it — `ace/node-engine.hand-over` with the directory's path, then your yes on your channel, with its path, hands it to the mesh\"}", + decode: func(b []byte) (any, error) { var v ConditionsByKeyValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a condition with an empty evidence trail of headline (captured; empty fields left out)", subject: "", given: "{\"count\":1,\"evidence\":[{\"at\":\"2026-10-30T13:03:46.432309782Z\",\"said\":\"The backup of postgres failed on ace\"}],\"explanation\":\"Needs you: answer whether to back up again or go on without a backup. The backup before applying postgres at 978b5db8cb4e on ace failed: node-backup.now could not be asked: not asked: nothing on this machine answers mesh.seat.node-backup.tool.now.ace.\",\"headline\":\"The backup of postgres failed on ace\",\"key\":\"module.ace.postgres.978b5db8cb4ef746ce68d82cc616d1439a53266d335e60f368dc44c4cb25610f.backup\",\"kind\":\"backup\",\"last-observed\":\"2026-10-30T13:03:46.432309782Z\",\"needs\":\"answer whether to back up again or go on without a backup\",\"observations\":1,\"raised\":\"2026-10-31T08:19:57.069818178Z\",\"resolver\":\"self\",\"severity\":\"warning\",\"source\":\"assigner.backup\",\"subject\":{\"id\":\"ace.postgres.978b5db8cb4ef746ce68d82cc616d1439a53266d335e60f368dc44c4cb25610f\",\"machine\":\"ace\",\"scope\":\"module\"},\"summary\":\"The backup of postgres failed on ace\"}", wire: "{\"count\":1,\"evidence\":[{\"at\":\"2026-10-30T13:03:46.432309782Z\",\"said\":\"The backup of postgres failed on ace\"}],\"explanation\":\"Needs you: answer whether to back up again or go on without a backup. The backup before applying postgres at 978b5db8cb4e on ace failed: node-backup.now could not be asked: not asked: nothing on this machine answers mesh.seat.node-backup.tool.now.ace.\",\"headline\":\"The backup of postgres failed on ace\",\"key\":\"module.ace.postgres.978b5db8cb4ef746ce68d82cc616d1439a53266d335e60f368dc44c4cb25610f.backup\",\"kind\":\"backup\",\"last-observed\":\"2026-10-30T13:03:46.432309782Z\",\"needs\":\"answer whether to back up again or go on without a backup\",\"observations\":1,\"raised\":\"2026-10-31T08:19:57.069818178Z\",\"resolver\":\"self\",\"severity\":\"warning\",\"source\":\"assigner.backup\",\"subject\":{\"id\":\"ace.postgres.978b5db8cb4ef746ce68d82cc616d1439a53266d335e60f368dc44c4cb25610f\",\"machine\":\"ace\",\"scope\":\"module\"},\"summary\":\"The backup of postgres failed on ace\"}", + decode: func(b []byte) (any, error) { var v ConditionsByKeyValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a finding of the bus scope with its zero time (captured from the watchman; empty fields left out)", subject: "", given: "{\"at\":\"0001-01-01T00:00:00Z\",\"evidence\":[{\"at\":\"0001-01-01T00:00:00Z\",\"said\":\"1 held for EVENTS.controller (counted again every 5m0s while any is held, so one delivered again or dropped ends this within it)\"}],\"key\":\"bus.EVENTS.controller.max-deliveries\",\"kind\":\"max-deliveries\",\"needs\":\"deliver them again or drop them, from the mesh MCP server.\",\"observer\":\"mesh-delivery-watchman\",\"severity\":\"warning\",\"source\":\"S9\",\"subject\":{\"id\":\"EVENTS.controller\",\"scope\":\"bus\"},\"summary\":\"the controller's consumer on EVENTS gave up on a message; it is kept in DEAD_LETTERS until delivered again or dropped, with why\",\"v\":1}", wire: "{\"at\":\"0001-01-01T00:00:00Z\",\"evidence\":[{\"at\":\"0001-01-01T00:00:00Z\",\"said\":\"1 held for EVENTS.controller (counted again every 5m0s while any is held, so one delivered again or dropped ends this within it)\"}],\"key\":\"bus.EVENTS.controller.max-deliveries\",\"kind\":\"max-deliveries\",\"needs\":\"deliver them again or drop them, from the mesh MCP server.\",\"observer\":\"mesh-delivery-watchman\",\"severity\":\"warning\",\"source\":\"S9\",\"subject\":{\"id\":\"EVENTS.controller\",\"scope\":\"bus\"},\"summary\":\"the controller's consumer on EVENTS gave up on a message; it is kept in DEAD_LETTERS until delivered again or dropped, with why\",\"v\":1}", + decode: func(b []byte) (any, error) { var v FindingsByKeyValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a finding of the module scope with its machine (captured from the watchman; empty fields left out)", subject: "", given: "{\"at\":\"2026-10-26T20:19:41.474823121Z\",\"evidence\":[{\"at\":\"2026-10-26T20:19:41.474823121Z\",\"said\":\"mesh-build-node on ace uses mesh-build-node.cache as found: owned by 10005:10005, mode 700, as found\"}],\"explanation\":\"Needs you: hand the directory over from the control-node, as the operator.\",\"headline\":\"mesh-build-node waits for a directory on ace\",\"key\":\"module.mesh-build-node.ace.directory-used-as-found\",\"kind\":\"directory-used-as-found\",\"needs\":\"hand the directory over from the control-node, as the operator; the details name it and the line to type.\",\"observer\":\"mesh-delivery-watchman\",\"resolved\":\"mesh-build-node's directory on ace is the mesh's\",\"severity\":\"warning\",\"source\":\"health-report\",\"subject\":{\"id\":\"mesh-build-node.ace\",\"machine\":\"ace\",\"scope\":\"module\"},\"summary\":\"mesh-build-node on ace uses mesh-build-node.cache as found\",\"v\":1}", wire: "{\"at\":\"2026-10-26T20:19:41.474823121Z\",\"evidence\":[{\"at\":\"2026-10-26T20:19:41.474823121Z\",\"said\":\"mesh-build-node on ace uses mesh-build-node.cache as found: owned by 10005:10005, mode 700, as found\"}],\"explanation\":\"Needs you: hand the directory over from the control-node, as the operator.\",\"headline\":\"mesh-build-node waits for a directory on ace\",\"key\":\"module.mesh-build-node.ace.directory-used-as-found\",\"kind\":\"directory-used-as-found\",\"needs\":\"hand the directory over from the control-node, as the operator; the details name it and the line to type.\",\"observer\":\"mesh-delivery-watchman\",\"resolved\":\"mesh-build-node's directory on ace is the mesh's\",\"severity\":\"warning\",\"source\":\"health-report\",\"subject\":{\"id\":\"mesh-build-node.ace\",\"machine\":\"ace\",\"scope\":\"module\"},\"summary\":\"mesh-build-node on ace uses mesh-build-node.cache as found\",\"v\":1}", + decode: func(b []byte) (any, error) { var v FindingsByKeyValue; 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/health-and-repair/go/seats/meshconditions/go.mod b/health-and-repair/go/seats/meshconditions/go.mod new file mode 100644 index 0000000..4662aa6 --- /dev/null +++ b/health-and-repair/go/seats/meshconditions/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions + +go 1.26.0 diff --git a/health-and-repair/go/seats/meshhandacts/contract.go b/health-and-repair/go/seats/meshhandacts/contract.go new file mode 100644 index 0000000..12c84db --- /dev/null +++ b/health-and-repair/go/seats/meshhandacts/contract.go @@ -0,0 +1,223 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package meshhandacts is the contract of the seat "mesh-hand-acts", defined whole (seats/mesh-hand-acts/seat.json): Acts done by hand, recorded with who, why and cause (ADR 0565): the log every module records through. +package meshhandacts + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "time" +) + +// Seat is the seat's name. +const Seat = "mesh-hand-acts" + +const ( + VerbHandAct = "hand-act" + VerbHandActs = "hand-acts" + VerbWarranted = "warranted" +) + +const ( + EventActRecorded = "act-recorded" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbHandAct, VerbHandActs, VerbWarranted} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventActRecorded} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "mesh-hand-acts.events" + FamilyMaxAgeDays = 7 +) + +// SubjectActRecorded is where act-recorded is published. +const SubjectActRecorded = "mesh.seat.mesh-hand-acts.event.act-recorded" + +const ( + StateActs = "acts" + StateWarrants = "warrants" +) + +// ActsByIDKey is the key of one value of the state acts under the form "". +func ActsByIDKey(id string) string { return id } + +// ParseActsByIDKey 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 ParseActsByIDKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeActsByIDValue reads one value of the state acts under the form "", refusing a field ActsByIDValue does not know and any text after the value. +func DecodeActsByIDValue(raw []byte) (ActsByIDValue, error) { + var v ActsByIDValue + 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 +} + +// WarrantsByAskerAskKey is the key of one value of the state warrants under the form ".". +func WarrantsByAskerAskKey(asker, ask string) string { return asker + "." + ask } + +// ParseWarrantsByAskerAskKey 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 ParseWarrantsByAskerAskKey(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 +} + +// DecodeWarrantsByAskerAskValue reads one value of the state warrants under the form ".", refusing a field WarrantsByAskerAskValue does not know and any text after the value. +func DecodeWarrantsByAskerAskValue(raw []byte) (WarrantsByAskerAskValue, error) { + var v WarrantsByAskerAskValue + 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 +} + +// Act is one act done by hand. +type Act struct { + Arguments []string `json:"arguments,omitempty"` + Ask string `json:"ask,omitempty"` + At time.Time `json:"at"` + // the caller the runtime proved + By string `json:"by"` + Carried []string `json:"carried,omitempty"` + Cause string `json:"cause"` + Condition string `json:"condition,omitempty"` + ID string `json:"id"` + // what the act was read to be from what it did + Kind string `json:"kind,omitempty"` + // which log holds it, set when it is read + Log string `json:"log,omitempty"` + Outcome string `json:"outcome,omitempty"` + Proofs []string `json:"proofs,omitempty"` + RequestedBy string `json:"requested-by,omitempty"` + Verb string `json:"verb"` + Via string `json:"via,omitempty"` + Why string `json:"why"` +} + +// HandActInput is what hand-act takes. +type HandActInput struct { + // the cause in a word, or a condition's kind + Cause string `json:"cause,omitempty"` + // the key of the condition it addressed, if any (optional) + Condition string `json:"condition,omitempty"` + // the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own + Verb string `json:"verb,omitempty"` + // what was done, in a line + What string `json:"what"` + // why it had to be done by hand + Why string `json:"why"` +} + +// HandActAnswer is what hand-act answers. +type HandActAnswer = map[string]any + +// HandActsInput is what hand-acts takes. +type HandActsInput struct { + // only this cause + Cause string `json:"cause,omitempty"` + // how many days back (default 14) + Days string `json:"days,omitempty"` + // "true": every warrant recorded + Warrants string `json:"warrants,omitempty"` + // only acts whose who contains this + Who string `json:"who,omitempty"` +} + +// The values of HandActsInput's warrants. +const ( + HandActsInputWarrantsTrue = "true" + HandActsInputWarrantsFalse = "false" +) + +// HandActsAnswer is what hand-acts answers. +type HandActsAnswer = map[string]any + +// WarrantedInput is what warranted takes. +type WarrantedInput struct { + // its ask's id + Ask string `json:"ask"` + // the module that asked + Asker string `json:"asker"` +} + +// WarrantedAnswer is what warranted answers. +type WarrantedAnswer = map[string]any + +// ActRecordedEvent is the body of the event act-recorded. +type ActRecordedEvent = Act + +// ActsByIDValue is one value of the state acts under the key . +type ActsByIDValue struct { + Arguments []string `json:"arguments,omitempty"` + Ask string `json:"ask,omitempty"` + At time.Time `json:"at"` + // the caller the runtime proved + By string `json:"by"` + Carried []string `json:"carried,omitempty"` + Cause string `json:"cause"` + Condition string `json:"condition,omitempty"` + ID string `json:"id"` + // what the act was read to be from what it did + Kind string `json:"kind,omitempty"` + // which log holds it, set when it is read + Log string `json:"log,omitempty"` + Outcome string `json:"outcome,omitempty"` + Proofs []string `json:"proofs,omitempty"` + RequestedBy string `json:"requested-by,omitempty"` + Verb string `json:"verb"` + Via string `json:"via,omitempty"` + Why string `json:"why"` +} + +// WarrantsByAskerAskValue is one value of the state warrants under the key .. +type WarrantsByAskerAskValue struct { + Ask string `json:"ask"` + Asker string `json:"asker"` + At *time.Time `json:"at,omitempty"` + By string `json:"by,omitempty"` + Option string `json:"option,omitempty"` +} diff --git a/health-and-repair/go/seats/meshhandacts/go.mod b/health-and-repair/go/seats/meshhandacts/go.mod new file mode 100644 index 0000000..101f623 --- /dev/null +++ b/health-and-repair/go/seats/meshhandacts/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts + +go 1.26.0 diff --git a/health-and-repair/go/seats/meshhealers/contract.go b/health-and-repair/go/seats/meshhealers/contract.go new file mode 100644 index 0000000..4733ebc --- /dev/null +++ b/health-and-repair/go/seats/meshhealers/contract.go @@ -0,0 +1,198 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package meshhealers is the contract of the seat "mesh-healers", defined whole (seats/mesh-healers/seat.json): The healers (ADR 0564): each a registered response to one kind of condition, within a budget and the mesh-wide brake. +package meshhealers + +import ( + "bytes" + "encoding/json" + "errors" + "time" +) + +// Seat is the seat's name. +const Seat = "mesh-healers" + +const ( + VerbHealers = "healers" + VerbHeal = "heal" + VerbDrill = "drill" +) + +const ( + EventHealerActed = "healer-acted" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbHealers, VerbHeal, VerbDrill} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventHealerActed} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "mesh-healers.events" + FamilyMaxAgeDays = 7 +) + +// SubjectHealerActed is where healer-acted is published. +const SubjectHealerActed = "mesh.seat.mesh-healers.event.healer-acted" + +const ( + StateAttempts = "attempts" + StateWould = "would" +) + +// AttemptsByAttemptKey is the key of one value of the state attempts under the form "". +func AttemptsByAttemptKey(attempt string) string { return attempt } + +// ParseAttemptsByAttemptKey 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 ParseAttemptsByAttemptKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeAttemptsByAttemptValue reads one value of the state attempts under the form "", refusing a field AttemptsByAttemptValue does not know and any text after the value. +func DecodeAttemptsByAttemptValue(raw []byte) (AttemptsByAttemptValue, error) { + var v AttemptsByAttemptValue + 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 +} + +// WouldByWouldKey is the key of one value of the state would under the form "". +func WouldByWouldKey(would string) string { return would } + +// ParseWouldByWouldKey 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 ParseWouldByWouldKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeWouldByWouldValue reads one value of the state would under the form "", refusing a field WouldByWouldValue does not know and any text after the value. +func DecodeWouldByWouldValue(raw []byte) (WouldByWouldValue, error) { + var v WouldByWouldValue + 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 +} + +// HealersInput is what healers takes. +type HealersInput struct { + // how many days of acts back (default 7) + Days string `json:"days,omitempty"` +} + +// HealersAnswer is what healers answers. +type HealersAnswer = map[string]any + +// HealInput is what heal takes. +type HealInput struct { + // the condition's key + Key string `json:"key"` + // why now, kept with the act + Why string `json:"why"` +} + +// HealAnswer is what heal answers. +type HealAnswer = map[string]any + +// DrillInput is what drill takes. +type DrillInput struct { + // the key of the condition the drill is meant to raise, if any (optional) + Condition string `json:"condition,omitempty"` + // what was done on purpose, in a line + What string `json:"what"` + // what the drill tests + Why string `json:"why"` +} + +// DrillAnswer is what drill answers. +type DrillAnswer = map[string]any + +// HealerActedEvent is the body of the event healer-acted. +type HealerActedEvent struct { + Act string `json:"act,omitempty"` + At time.Time `json:"at"` + Budget string `json:"budget,omitempty"` + By string `json:"by,omitempty"` + Condition string `json:"condition"` + Event string `json:"event"` + Healer string `json:"healer"` + Kind string `json:"kind,omitempty"` + Outcome string `json:"outcome"` + Said string `json:"said,omitempty"` + Show string `json:"show,omitempty"` +} + +// The values of HealerActedEvent's outcome. +const ( + HealerActedEventOutcomeActing = "acting" + HealerActedEventOutcomeActed = "acted" + HealerActedEventOutcomeFailed = "failed" + HealerActedEventOutcomeEscalated = "escalated" +) + +// AttemptsByAttemptValue is one value of the state attempts under the key . +type AttemptsByAttemptValue struct { + Act string `json:"act"` + Begun time.Time `json:"begun"` + // what the budget counts by: the condition's key unless the healer says another + BudgetKey string `json:"budget-key"` + Condition string `json:"condition"` + Finished *time.Time `json:"finished,omitempty"` + Healer string `json:"healer"` + Kind string `json:"kind"` + Outcome string `json:"outcome"` + Said string `json:"said,omitempty"` +} + +// The values of AttemptsByAttemptValue's outcome. +const ( + AttemptsByAttemptValueOutcomeActing = "acting" + AttemptsByAttemptValueOutcomeActed = "acted" + AttemptsByAttemptValueOutcomeFailed = "failed" + AttemptsByAttemptValueOutcomeEscalated = "escalated" +) + +// WouldByWouldValue is one value of the state would under the key . +type WouldByWouldValue struct { + Act string `json:"act"` + At time.Time `json:"at"` + Condition string `json:"condition"` + Healer string `json:"healer"` + Kind string `json:"kind,omitempty"` + Why string `json:"why,omitempty"` +} diff --git a/health-and-repair/go/seats/meshhealers/contract_test.go b/health-and-repair/go/seats/meshhealers/contract_test.go new file mode 100644 index 0000000..957da9d --- /dev/null +++ b/health-and-repair/go/seats/meshhealers/contract_test.go @@ -0,0 +1,48 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package meshhealers + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/mesh-healers/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: "an act a healer would have made, kept from the controller's time (captured)", subject: "", given: "{\"act\":\"assert the module's buckets, logs and streams again through the grants part (grants_assert)\",\"at\":\"2026-11-02T08:15:56.49507336Z\",\"condition\":\"bus.EVENTS.4fs9xzyDY7CD2Cf1wVFYDK_1.consumer-lost\",\"healer\":\"H3\",\"kind\":\"consumer-lost\",\"why\":\"the controller's lease names a controller (or none is written, so one may take it): every act is written as would\"}", wire: "{\"act\":\"assert the module's buckets, logs and streams again through the grants part (grants_assert)\",\"at\":\"2026-11-02T08:15:56.49507336Z\",\"condition\":\"bus.EVENTS.4fs9xzyDY7CD2Cf1wVFYDK_1.consumer-lost\",\"healer\":\"H3\",\"kind\":\"consumer-lost\",\"why\":\"the controller's lease names a controller (or none is written, so one may take it): every act is written as would\"}", + decode: func(b []byte) (any, error) { var v WouldByWouldValue; 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/health-and-repair/go/seats/meshhealers/go.mod b/health-and-repair/go/seats/meshhealers/go.mod new file mode 100644 index 0000000..9eb5a51 --- /dev/null +++ b/health-and-repair/go/seats/meshhealers/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhealers + +go 1.26.0 diff --git a/health-and-repair/go/seats/meshselfcheck/contract.go b/health-and-repair/go/seats/meshselfcheck/contract.go new file mode 100644 index 0000000..ad3b5cb --- /dev/null +++ b/health-and-repair/go/seats/meshselfcheck/contract.go @@ -0,0 +1,103 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package meshselfcheck is the contract of the seat "mesh-self-check", defined whole (seats/mesh-self-check/seat.json): The mesh checking itself (ADR 0564): probes run every five minutes, each failure a finding the seat's conditions raise. +package meshselfcheck + +import "time" + +// Seat is the seat's name. +const Seat = "mesh-self-check" + +const ( + VerbDoctor = "doctor" + VerbProbes = "probes" +) + +const ( + EventChecked = "checked" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbDoctor, VerbProbes} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventChecked} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "mesh-self-check.events" + FamilyMaxAgeDays = 7 +) + +// SubjectChecked is where checked is published. +const SubjectChecked = "mesh.seat.mesh-self-check.event.checked" + +// ProbeVerdict is one probe's verdict. +type ProbeVerdict struct { + Error string `json:"error,omitempty"` + Found []string `json:"found,omitempty"` + FoundCount int64 `json:"found-count,omitempty"` + ID string `json:"id"` + More string `json:"more,omitempty"` + Took string `json:"took,omitempty"` + Unconfirmed []string `json:"unconfirmed,omitempty"` + Verdict string `json:"verdict"` +} + +// The values of ProbeVerdict's verdict. +const ( + ProbeVerdictVerdictPassed = "passed" + ProbeVerdictVerdictFailed = "failed" + ProbeVerdictVerdictFailedToRun = "failed-to-run" + ProbeVerdictVerdictDeferred = "deferred" +) + +// DoctorInput is what doctor takes. +type DoctorInput struct { + // one probe's id (D6): its verdict alone, with every finding + Probe string `json:"probe,omitempty"` + // "true": run every probe now and answer the verdict + Run string `json:"run,omitempty"` +} + +// The values of DoctorInput's run. +const ( + DoctorInputRunTrue = "true" + DoctorInputRunFalse = "false" +) + +// DoctorAnswer is what doctor answers. +type DoctorAnswer = map[string]any + +// ProbesInput is what probes takes. +type ProbesInput struct { +} + +// ProbesAnswer is what probes answers. +type ProbesAnswer = map[string]any + +// CheckedEventCounts is a type of the contract. +type CheckedEventCounts struct { + Deferred int64 `json:"deferred,omitempty"` + Failed int64 `json:"failed,omitempty"` + FailedToRun int64 `json:"failed-to-run,omitempty"` + Passed int64 `json:"passed,omitempty"` +} + +// CheckedEvent is the body of the event checked. +type CheckedEvent struct { + At time.Time `json:"at"` + Controller string `json:"controller,omitempty"` + Counts *CheckedEventCounts `json:"counts,omitempty"` + IntervalSeconds int64 `json:"interval-seconds,omitempty"` + Probes []ProbeVerdict `json:"probes"` + Run string `json:"run"` + Started *time.Time `json:"started,omitempty"` + Took string `json:"took,omitempty"` + Unsaid int64 `json:"unsaid,omitempty"` + Why string `json:"why,omitempty"` +} diff --git a/health-and-repair/go/seats/meshselfcheck/go.mod b/health-and-repair/go/seats/meshselfcheck/go.mod new file mode 100644 index 0000000..a45e4c3 --- /dev/null +++ b/health-and-repair/go/seats/meshselfcheck/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshselfcheck + +go 1.26.0 diff --git a/health-and-repair/go/seats/meshwatchdogs/contract.go b/health-and-repair/go/seats/meshwatchdogs/contract.go new file mode 100644 index 0000000..286d7dd --- /dev/null +++ b/health-and-repair/go/seats/meshwatchdogs/contract.go @@ -0,0 +1,267 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package meshwatchdogs is the contract of the seat "mesh-watchdogs", defined whole (seats/mesh-watchdogs/seat.json): The watchman's seat (ADR 0564, ADR 0433): signals that expire unless renewed, what stopped moving, who is present, the dead letters. +package meshwatchdogs + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "time" +) + +// Seat is the seat's name. +const Seat = "mesh-watchdogs" + +const ( + VerbSignals = "signals" + VerbStuck = "stuck" + VerbPresence = "presence" + VerbDeadLetters = "dead-letters" + VerbResync = "resync" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbSignals, VerbStuck, VerbPresence, VerbDeadLetters, VerbResync} +} + +const ( + StatePresence = "presence" + StatePulse = "pulse" + StateStuck = "stuck" +) + +// PresenceByNodeKey is the key of one value of the state presence under the form "". +func PresenceByNodeKey(node string) string { return node } + +// ParsePresenceByNodeKey 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 ParsePresenceByNodeKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodePresenceByNodeValue reads one value of the state presence under the form "", refusing a field PresenceByNodeValue does not know and any text after the value. +func DecodePresenceByNodeValue(raw []byte) (PresenceByNodeValue, error) { + var v PresenceByNodeValue + 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 +} + +// PresenceByPartPartInstanceKey is the key of one value of the state presence under the form "part..". +func PresenceByPartPartInstanceKey(part, instance string) string { + return "part." + part + "." + instance +} + +// ParsePresenceByPartPartInstanceKey reads the parts of a key of the form "part..", in the order of the form; the last result is false for a key of another form. +func ParsePresenceByPartPartInstanceKey(key string) (string, string, bool) { + rest := key + if !strings.HasPrefix(rest, "part.") { + 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 +} + +// DecodePresenceByPartPartInstanceValue reads one value of the state presence under the form "part..", refusing a field PresenceByPartPartInstanceValue does not know and any text after the value. +func DecodePresenceByPartPartInstanceValue(raw []byte) (PresenceByPartPartInstanceValue, error) { + var v PresenceByPartPartInstanceValue + 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 +} + +// PulseByBucketKeyKey is the key of one value of the state pulse under the form ".". +func PulseByBucketKeyKey(bucket, key string) string { return bucket + "." + key } + +// ParsePulseByBucketKeyKey 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 ParsePulseByBucketKeyKey(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 +} + +// DecodePulseByBucketKeyValue reads one value of the state pulse under the form ".", refusing a field PulseByBucketKeyValue does not know and any text after the value. +func DecodePulseByBucketKeyValue(raw []byte) (PulseByBucketKeyValue, error) { + var v PulseByBucketKeyValue + 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 +} + +// StuckByBucketKeyKey is the key of one value of the state stuck under the form ".". +func StuckByBucketKeyKey(bucket, key string) string { return bucket + "." + key } + +// ParseStuckByBucketKeyKey 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 ParseStuckByBucketKeyKey(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 +} + +// DecodeStuckByBucketKeyValue reads one value of the state stuck under the form ".", refusing a field StuckByBucketKeyValue does not know and any text after the value. +func DecodeStuckByBucketKeyValue(raw []byte) (StuckByBucketKeyValue, error) { + var v StuckByBucketKeyValue + 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 +} + +// SignalsInput is what signals takes. +type SignalsInput struct { + // one row, e.g. S1 + Row string `json:"row,omitempty"` +} + +// SignalsAnswer is what signals answers. +type SignalsAnswer = map[string]any + +// StuckInput is what stuck takes. +type StuckInput struct { + // only those on this machine + Machine string `json:"machine,omitempty"` +} + +// StuckAnswer is what stuck answers. +type StuckAnswer = map[string]any + +// PresenceInput is what presence takes. +type PresenceInput struct { + // one machine + Machine string `json:"machine,omitempty"` +} + +// PresenceAnswer is what presence answers. +type PresenceAnswer = map[string]any + +// DeadLettersInput is what dead-letters takes. +type DeadLettersInput struct { + // with deliver or drop: the cause in a word (dead-letter when absent) + Cause string `json:"cause,omitempty"` + // a consumer's name, or .: only what that one gave up on + Consumer string `json:"consumer,omitempty"` + // a dead letter's id: deliver it again to the consumer that gave it up (needs why) + Deliver string `json:"deliver,omitempty"` + // a dead letter's id: drop it for good (needs why) + Drop string `json:"drop,omitempty"` + // a dead letter's id, as the list gives it: that one whole, with its message + ID string `json:"id,omitempty"` + // how many to list, newest first (default 50); only when listing + Limit string `json:"limit,omitempty"` + // with deliver or drop: why, required and recorded + Why string `json:"why,omitempty"` +} + +// DeadLettersAnswer is what dead-letters answers. +type DeadLettersAnswer = map[string]any + +// ResyncInput is what resync takes. +type ResyncInput struct { +} + +// ResyncAnswer is what resync answers. +type ResyncAnswer = map[string]any + +// PresenceByNodeValue is one value of the state presence under the key . +type PresenceByNodeValue struct { + Online bool `json:"online"` + V int64 `json:"v,omitempty"` +} + +// PresenceByPartPartInstanceValue is one value of the state presence under the key part... +type PresenceByPartPartInstanceValue struct { + Online bool `json:"online"` + V int64 `json:"v,omitempty"` +} + +// PulseByBucketKeyValue is one value of the state pulse under the key .. +type PulseByBucketKeyValue struct { + Bucket string `json:"bucket"` + Key string `json:"key"` + Owes string `json:"owes,omitempty"` + Revision int64 `json:"revision"` + V int64 `json:"v,omitempty"` +} + +// StuckByBucketKeyValue is one value of the state stuck under the key .. +type StuckByBucketKeyValue struct { + Bucket string `json:"bucket"` + Key string `json:"key"` + // it has moved again + Moved bool `json:"moved,omitempty"` + Since time.Time `json:"since"` + V int64 `json:"v,omitempty"` + // what it waited for, in words + Waited string `json:"waited,omitempty"` +} diff --git a/health-and-repair/go/seats/meshwatchdogs/contract_test.go b/health-and-repair/go/seats/meshwatchdogs/contract_test.go new file mode 100644 index 0000000..564f824 --- /dev/null +++ b/health-and-repair/go/seats/meshwatchdogs/contract_test.go @@ -0,0 +1,56 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package meshwatchdogs + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/mesh-watchdogs/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 node's agent online (captured)", subject: "", given: "{\"online\":true,\"v\":1}", wire: "{\"online\":true,\"v\":1}", + decode: func(b []byte) (any, error) { var v PresenceByNodeValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a delivery part's instance online (captured)", subject: "", given: "{\"online\":true,\"v\":1}", wire: "{\"online\":true,\"v\":1}", + decode: func(b []byte) (any, error) { + var v PresenceByPartPartInstanceValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a thing that has not moved since a time, with what it waited for (captured)", subject: "", given: "{\"bucket\":\"mesh-delivery-watchman_presence\",\"key\":\"part.mesh-delivery-builder.novox\",\"since\":\"2026-10-25T11:52:58.051397201+01:00\",\"v\":1,\"waited\":\"mesh-delivery-builder on novox to come back online\"}", wire: "{\"bucket\":\"mesh-delivery-watchman_presence\",\"key\":\"part.mesh-delivery-builder.novox\",\"since\":\"2026-10-25T11:52:58.051397201+01:00\",\"v\":1,\"waited\":\"mesh-delivery-builder on novox to come back online\"}", + decode: func(b []byte) (any, error) { var v StuckByBucketKeyValue; 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/health-and-repair/go/seats/meshwatchdogs/go.mod b/health-and-repair/go/seats/meshwatchdogs/go.mod new file mode 100644 index 0000000..e2e0cf2 --- /dev/null +++ b/health-and-repair/go/seats/meshwatchdogs/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs + +go 1.26.0 diff --git a/health-and-repair/module.json b/health-and-repair/module.json new file mode 100644 index 0000000..d712009 --- /dev/null +++ b/health-and-repair/module.json @@ -0,0 +1,127 @@ +{ + "module": "mesh-health-and-repair-contracts", + "version": "1", + "build": { + "artifacts": [ + { + "name": "go-meshconditions", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshconditions" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshselfcheck", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshselfcheck" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshhealers", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshhealers" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshwatchdogs", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshwatchdogs" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshhandacts", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshhandacts" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshhandactsclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/meshhandactsclient" + ], + "series": "0.1" + }, + { + "name": "go-meshconditionsclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/meshconditionsclient" + ], + "series": "0.1" + }, + { + "name": "ts-meshconditions", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshconditions" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-meshselfcheck", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshselfcheck" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-meshhealers", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshhealers" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-meshwatchdogs", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshwatchdogs" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-meshhandacts", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshhandacts" + ], + "series": "0.1", + "generated-from": "." + } + ] + }, + "resources": [] +} diff --git a/health-and-repair/seats/index.json b/health-and-repair/seats/index.json new file mode 100644 index 0000000..8cbd1f8 --- /dev/null +++ b/health-and-repair/seats/index.json @@ -0,0 +1,10 @@ +{ + "format": 1, + "seats": [ + "mesh-conditions", + "mesh-hand-acts", + "mesh-healers", + "mesh-self-check", + "mesh-watchdogs" + ] +} diff --git a/health-and-repair/seats/mesh-conditions/fixtures/states.json b/health-and-repair/seats/mesh-conditions/fixtures/states.json new file mode 100644 index 0000000..0cac1dd --- /dev/null +++ b/health-and-repair/seats/mesh-conditions/fixtures/states.json @@ -0,0 +1,117 @@ +[ + { + "name": "a condition raised from a finding, as the keeper holds it (captured; empty fields left out)", + "of": "state:conditions/", + "given": { + "count": 1, + "evidence": [ + { + "at": "2026-10-26T20:19:41.474823121Z", + "said": "mesh-build-node on ace uses mesh-build-node.cache as found: used as found: owned by 10005:10005, mode 700, as found; mesh-build-node, mode 0711 was declared and not given it \u2014 `ace/node-engine.hand-over` with the directory's path, then your yes on your channel, with its path, hands it to the mesh" + } + ], + "explanation": "Needs you: hand the directory over from the control-node, as the operator; the details name it and the line to type. A directory of mesh-build-node was already on ace, with another owner or mode than mesh-build-node declares.", + "headline": "mesh-build-node waits for a directory on ace", + "key": "module.mesh-build-node.ace.directory-used-as-found", + "kind": "directory-used-as-found", + "last-observed": "2026-10-26T20:19:41.474823121Z", + "needs": "hand the directory over from the control-node, as the operator; the details name it and the line to type.", + "observations": 1, + "raised": "2026-10-31T08:19:57.076843727Z", + "resolved": "mesh-build-node's directory on ace is the mesh's", + "resolver": "self", + "severity": "warning", + "source": "health-report", + "subject": { + "id": "mesh-build-node.ace", + "machine": "ace", + "scope": "module" + }, + "summary": "mesh-build-node on ace uses mesh-build-node.cache as found: used as found: owned by 10005:10005, mode 700, as found; mesh-build-node, mode 0711 was declared and not given it \u2014 `ace/node-engine.hand-over` with the directory's path, then your yes on your channel, with its path, hands it to the mesh" + } + }, + { + "name": "a condition with an empty evidence trail of headline (captured; empty fields left out)", + "of": "state:conditions/", + "given": { + "count": 1, + "evidence": [ + { + "at": "2026-10-30T13:03:46.432309782Z", + "said": "The backup of postgres failed on ace" + } + ], + "explanation": "Needs you: answer whether to back up again or go on without a backup. The backup before applying postgres at 978b5db8cb4e on ace failed: node-backup.now could not be asked: not asked: nothing on this machine answers mesh.seat.node-backup.tool.now.ace.", + "headline": "The backup of postgres failed on ace", + "key": "module.ace.postgres.978b5db8cb4ef746ce68d82cc616d1439a53266d335e60f368dc44c4cb25610f.backup", + "kind": "backup", + "last-observed": "2026-10-30T13:03:46.432309782Z", + "needs": "answer whether to back up again or go on without a backup", + "observations": 1, + "raised": "2026-10-31T08:19:57.069818178Z", + "resolver": "self", + "severity": "warning", + "source": "assigner.backup", + "subject": { + "id": "ace.postgres.978b5db8cb4ef746ce68d82cc616d1439a53266d335e60f368dc44c4cb25610f", + "machine": "ace", + "scope": "module" + }, + "summary": "The backup of postgres failed on ace" + } + }, + { + "name": "a finding of the bus scope with its zero time (captured from the watchman; empty fields left out)", + "of": "state:findings/", + "given": { + "v": 1, + "key": "bus.EVENTS.controller.max-deliveries", + "kind": "max-deliveries", + "subject": { + "id": "EVENTS.controller", + "scope": "bus" + }, + "severity": "warning", + "summary": "the controller's consumer on EVENTS gave up on a message; it is kept in DEAD_LETTERS until delivered again or dropped, with why", + "needs": "deliver them again or drop them, from the mesh MCP server.", + "observer": "mesh-delivery-watchman", + "source": "S9", + "evidence": [ + { + "at": "0001-01-01T00:00:00Z", + "said": "1 held for EVENTS.controller (counted again every 5m0s while any is held, so one delivered again or dropped ends this within it)" + } + ], + "at": "0001-01-01T00:00:00Z" + } + }, + { + "name": "a finding of the module scope with its machine (captured from the watchman; empty fields left out)", + "of": "state:findings/", + "given": { + "v": 1, + "key": "module.mesh-build-node.ace.directory-used-as-found", + "kind": "directory-used-as-found", + "subject": { + "id": "mesh-build-node.ace", + "machine": "ace", + "scope": "module" + }, + "severity": "warning", + "summary": "mesh-build-node on ace uses mesh-build-node.cache as found", + "explanation": "Needs you: hand the directory over from the control-node, as the operator.", + "headline": "mesh-build-node waits for a directory on ace", + "needs": "hand the directory over from the control-node, as the operator; the details name it and the line to type.", + "resolved": "mesh-build-node's directory on ace is the mesh's", + "observer": "mesh-delivery-watchman", + "source": "health-report", + "evidence": [ + { + "at": "2026-10-26T20:19:41.474823121Z", + "said": "mesh-build-node on ace uses mesh-build-node.cache as found: owned by 10005:10005, mode 700, as found" + } + ], + "at": "2026-10-26T20:19:41.474823121Z" + } + } +] diff --git a/health-and-repair/seats/mesh-conditions/seat.json b/health-and-repair/seats/mesh-conditions/seat.json new file mode 100644 index 0000000..76f464c --- /dev/null +++ b/health-and-repair/seats/mesh-conditions/seat.json @@ -0,0 +1,647 @@ +{ + "format": 1, + "seat": "mesh-conditions", + "scope": "mesh", + "grain": "whole", + "domain": "Health and repair", + "decision": "novox/hq ADR 0564, ADR 0634, ADR 0635", + "description": "What is wrong with the mesh now (ADR 0564): the open conditions raised from the observers' findings and cleared when a finding goes.", + "verbs": [ + { + "name": "conditions", + "description": "What is wrong with the mesh now: every open condition, urgent first, then oldest, raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition lately.", + "input": { + "properties": { + "days": { + "description": "with history: how many days back (default 7, at most 90)", + "type": "string" + }, + "history": { + "description": "\"true\": every raising, change, silence and clearing lately, oldest first", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "key": { + "description": "a condition's key: that one whole; with history, only its transitions", + "type": "string" + }, + "machine": { + "description": "only those about this machine", + "type": "string" + }, + "scope": { + "description": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module", + "type": "string" + }, + "severity": { + "description": "only urgent, or only warning", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the open conditions, or one whole, or the history" + } + }, + { + "name": "silence", + "description": "Stop one condition's messages for a while, a hand act with why: it stays open and in status, and the silence is kept as its transition in history.", + "input": { + "properties": { + "cause": { + "description": "the cause in a word (the condition's kind when absent)", + "type": "string" + }, + "for": { + "description": "how long: 30m, 4h, 2d; at most 7d", + "type": "string" + }, + "key": { + "description": "the condition's key", + "type": "string" + }, + "why": { + "description": "why, required and recorded", + "type": "string" + } + }, + "required": [ + "key", + "for", + "why" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the condition now silenced" + } + }, + { + "name": "resync", + "description": "Read every observer's findings again and raise and clear what differs from the open conditions: the recovery of a keeper that missed a change.", + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what was raised and cleared again" + } + } + ], + "events": [ + { + "name": "condition-raised", + "body": { + "$ref": "#/defs/ConditionEvent" + } + }, + { + "name": "condition-changed", + "body": { + "$ref": "#/defs/ConditionEvent" + } + }, + { + "name": "condition-cleared", + "body": { + "$ref": "#/defs/ConditionEvent" + } + } + ], + "family": { + "name": "mesh-conditions.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "conditions", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "one open condition", + "properties": { + "key": { + "type": "string", + "description": "scope.id.token" + }, + "kind": { + "type": "string" + }, + "subject": { + "$ref": "#/defs/Subject" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "resolved": { + "type": "string" + }, + "needs": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/defs/Action" + } + }, + "evidence": { + "type": "array", + "description": "newest first, at most 10", + "items": { + "$ref": "#/defs/Evidence" + } + }, + "source": { + "type": "string", + "description": "the row, probe or event that saw it" + }, + "resolver": { + "type": "string", + "description": "self, operator, agent or healer:" + }, + "raised": { + "type": "string", + "format": "date-time" + }, + "last-observed": { + "type": "string", + "format": "date-time" + }, + "first": { + "type": "string", + "format": "date-time" + }, + "gaps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cleared": { + "type": "string", + "format": "date-time" + }, + "reopened": { + "type": "string", + "format": "date-time" + } + } + } + }, + "observations": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "tried": { + "type": "array", + "items": { + "type": "object", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "what": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "by": { + "type": "string" + } + } + } + }, + "silenced": { + "type": "object", + "properties": { + "until": { + "type": "string", + "format": "date-time" + }, + "by": { + "type": "string" + }, + "why": { + "type": "string" + }, + "since": { + "type": "string", + "format": "date-time" + } + } + }, + "epoch": { + "type": "integer" + }, + "looks-unread": { + "type": "string" + } + }, + "required": [ + "key", + "kind", + "subject", + "severity", + "raised", + "last-observed" + ] + }, + "description": "one open condition, without its looks it keeps from its finding" + } + ], + "description": "the open conditions the keeper holds, one key each" + }, + { + "name": "findings", + "template": "state", + "writer": "caller", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "one thing an observer sees wrong now", + "properties": { + "v": { + "type": "integer", + "description": "layout version, 1" + }, + "key": { + "type": "string", + "description": "scope.id.token" + }, + "kind": { + "type": "string" + }, + "subject": { + "$ref": "#/defs/Subject" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "resolved": { + "type": "string" + }, + "needs": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/defs/Action" + } + }, + "evidence": { + "type": "array", + "description": "newest first, at most 10", + "items": { + "$ref": "#/defs/Evidence" + } + }, + "source": { + "type": "string", + "description": "the row, probe or event that saw it" + }, + "resolver": { + "type": "string", + "description": "self, operator, agent or healer:" + }, + "ref": { + "type": "string", + "description": "the key's last part when it is not the kind" + }, + "observer": { + "type": "string", + "description": "the module that wrote it" + }, + "run": { + "type": "string", + "description": "what of its own saw it last" + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "v", + "key", + "kind", + "subject", + "severity", + "summary", + "source", + "observer", + "at" + ] + }, + "description": "one finding of the observer that wrote it" + } + ], + "description": "every observer's findings, one key per finding; a key written raises or changes the condition of the same key, a key deleted clears it" + } + ], + "defs": { + "Subject": { + "type": "object", + "description": "what the condition is about", + "properties": { + "scope": { + "type": "string", + "description": "machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module" + }, + "id": { + "type": "string", + "description": "its id within the scope" + }, + "machine": { + "type": "string", + "description": "the machine it concerns, when there is one" + }, + "also": { + "type": "array", + "description": "the other machines it concerns", + "items": { + "type": "string" + } + } + }, + "required": [ + "scope", + "id" + ] + }, + "Evidence": { + "type": "object", + "description": "one observation, as it was said", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "said": { + "type": "string" + } + }, + "required": [ + "at", + "said" + ] + }, + "Action": { + "type": "object", + "description": "one answer the operator may give from a notification", + "properties": { + "label": { + "type": "string" + }, + "verb": { + "type": "string", + "description": "the seat verb the operator channel calls" + }, + "machine": { + "type": "string" + }, + "arguments": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "level": { + "type": "string", + "enum": [ + "acknowledge", + "approve" + ] + } + }, + "required": [ + "label", + "verb" + ] + }, + "ConditionEvent": { + "type": "object", + "description": "a condition and what changed", + "properties": { + "key": { + "type": "string", + "description": "scope.id.token" + }, + "kind": { + "type": "string" + }, + "subject": { + "$ref": "#/defs/Subject" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "resolved": { + "type": "string" + }, + "needs": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/defs/Action" + } + }, + "evidence": { + "type": "array", + "description": "newest first, at most 10", + "items": { + "$ref": "#/defs/Evidence" + } + }, + "source": { + "type": "string", + "description": "the row, probe or event that saw it" + }, + "resolver": { + "type": "string", + "description": "self, operator, agent or healer:" + }, + "raised": { + "type": "string", + "format": "date-time" + }, + "last-observed": { + "type": "string", + "format": "date-time" + }, + "first": { + "type": "string", + "format": "date-time" + }, + "gaps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cleared": { + "type": "string", + "format": "date-time" + }, + "reopened": { + "type": "string", + "format": "date-time" + } + } + } + }, + "observations": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "tried": { + "type": "array", + "items": { + "type": "object", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "what": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "by": { + "type": "string" + } + } + } + }, + "silenced": { + "type": "object", + "properties": { + "until": { + "type": "string", + "format": "date-time" + }, + "by": { + "type": "string" + }, + "why": { + "type": "string" + }, + "since": { + "type": "string", + "format": "date-time" + } + } + }, + "epoch": { + "type": "integer" + }, + "looks-unread": { + "type": "string" + }, + "event": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "change": { + "type": "string", + "enum": [ + "raised", + "reopened", + "severity", + "resolver", + "silenced", + "silence-ended", + "cleared", + "words" + ] + }, + "was": { + "type": "string" + }, + "why": { + "type": "string" + }, + "cleared": { + "type": "string", + "format": "date-time" + }, + "show": { + "type": "string", + "description": "the tool call that shows it" + } + }, + "required": [ + "key", + "event", + "at", + "change" + ] + } + } +} diff --git a/health-and-repair/seats/mesh-hand-acts/seat.json b/health-and-repair/seats/mesh-hand-acts/seat.json new file mode 100644 index 0000000..a075cfc --- /dev/null +++ b/health-and-repair/seats/mesh-hand-acts/seat.json @@ -0,0 +1,321 @@ +{ + "format": 1, + "seat": "mesh-hand-acts", + "scope": "mesh", + "grain": "whole", + "domain": "Health and repair", + "decision": "novox/hq ADR 0565, ADR 0634, ADR 0635", + "description": "Acts done by hand, recorded with who, why and cause (ADR 0565): the log every module records through.", + "verbs": [ + { + "name": "hand-act", + "description": "Record one act done by hand: what, why, its cause in a word or a condition's kind (the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the runtime proves, never said by the caller. Said as act-recorded.", + "input": { + "properties": { + "cause": { + "description": "the cause in a word, or a condition's kind", + "type": "string" + }, + "condition": { + "description": "the key of the condition it addressed, if any (optional)", + "type": "string" + }, + "verb": { + "description": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own", + "type": "string" + }, + "what": { + "description": "what was done, in a line", + "type": "string" + }, + "why": { + "description": "why it had to be done by hand", + "type": "string" + } + }, + "required": [ + "what", + "why" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the act recorded" + } + }, + { + "name": "hand-acts", + "description": "What was done by hand lately, over this log and the controller's: who, why and the cause of each, and the causes recorded twice within a fortnight (a healer is wanted).", + "input": { + "properties": { + "cause": { + "description": "only this cause", + "type": "string" + }, + "days": { + "description": "how many days back (default 14)", + "type": "string" + }, + "warrants": { + "description": "\"true\": every warrant recorded", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "who": { + "description": "only acts whose who contains this", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the acts lately" + } + }, + { + "name": "warranted", + "description": "Record what a module did on the operator's warrant: who chose, through which channel, with which proofs and which answer read from the router's own record of that module's ask, never from the caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.", + "input": { + "properties": { + "ask": { + "description": "its ask's id", + "type": "string" + }, + "asker": { + "description": "the module that asked", + "type": "string" + } + }, + "required": [ + "asker", + "ask" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the warrant recorded" + } + } + ], + "events": [ + { + "name": "act-recorded", + "body": { + "$ref": "#/defs/Act" + } + } + ], + "family": { + "name": "mesh-hand-acts.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "acts", + "template": "log", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "one act done by hand", + "properties": { + "id": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "by": { + "type": "string", + "description": "the caller the runtime proved" + }, + "verb": { + "type": "string" + }, + "arguments": { + "type": "array", + "items": { + "type": "string" + } + }, + "why": { + "type": "string" + }, + "cause": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "what the act was read to be from what it did" + }, + "carried": { + "type": "array", + "items": { + "type": "string" + } + }, + "via": { + "type": "string" + }, + "ask": { + "type": "string" + }, + "proofs": { + "type": "array", + "items": { + "type": "string" + } + }, + "requested-by": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "log": { + "type": "string", + "description": "which log holds it, set when it is read" + } + }, + "required": [ + "id", + "at", + "by", + "verb", + "why", + "cause" + ] + }, + "description": "one act" + } + ], + "max-age-days": 400, + "description": "every act, kept 400 days" + }, + { + "name": "warrants", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "what a module did on the operator's warrant, made once per ask", + "properties": { + "ask": { + "type": "string" + }, + "asker": { + "type": "string" + }, + "option": { + "type": "string" + }, + "by": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "ask", + "asker" + ] + }, + "description": "one warrant" + } + ], + "description": "warrants recorded, one key per asker and ask" + } + ], + "defs": { + "Act": { + "type": "object", + "description": "one act done by hand", + "properties": { + "id": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "by": { + "type": "string", + "description": "the caller the runtime proved" + }, + "verb": { + "type": "string" + }, + "arguments": { + "type": "array", + "items": { + "type": "string" + } + }, + "why": { + "type": "string" + }, + "cause": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "what the act was read to be from what it did" + }, + "carried": { + "type": "array", + "items": { + "type": "string" + } + }, + "via": { + "type": "string" + }, + "ask": { + "type": "string" + }, + "proofs": { + "type": "array", + "items": { + "type": "string" + } + }, + "requested-by": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "log": { + "type": "string", + "description": "which log holds it, set when it is read" + } + }, + "required": [ + "id", + "at", + "by", + "verb", + "why", + "cause" + ] + } + } +} diff --git a/health-and-repair/seats/mesh-healers/fixtures/states.json b/health-and-repair/seats/mesh-healers/fixtures/states.json new file mode 100644 index 0000000..01ede17 --- /dev/null +++ b/health-and-repair/seats/mesh-healers/fixtures/states.json @@ -0,0 +1,14 @@ +[ + { + "name": "an act a healer would have made, kept from the controller's time (captured)", + "of": "state:would/", + "given": { + "act": "assert the module's buckets, logs and streams again through the grants part (grants_assert)", + "at": "2026-11-02T08:15:56.49507336Z", + "condition": "bus.EVENTS.4fs9xzyDY7CD2Cf1wVFYDK_1.consumer-lost", + "healer": "H3", + "kind": "consumer-lost", + "why": "the controller's lease names a controller (or none is written, so one may take it): every act is written as would" + } + } +] diff --git a/health-and-repair/seats/mesh-healers/seat.json b/health-and-repair/seats/mesh-healers/seat.json new file mode 100644 index 0000000..23772b0 --- /dev/null +++ b/health-and-repair/seats/mesh-healers/seat.json @@ -0,0 +1,250 @@ +{ + "format": 1, + "seat": "mesh-healers", + "scope": "mesh", + "grain": "whole", + "domain": "Health and repair", + "decision": "novox/hq ADR 0564, ADR 0634, ADR 0635", + "description": "The healers (ADR 0564): each a registered response to one kind of condition, within a budget and the mesh-wide brake.", + "verbs": [ + { + "name": "healers", + "description": "The healers: each registered response to one kind of condition, its repair, its budget and what happens when it is spent; every act taken lately with its outcome, and the mesh-wide brake.", + "input": { + "properties": { + "days": { + "description": "how many days of acts back (default 7)", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the registered healers, acts lately and the brake" + } + }, + { + "name": "heal", + "description": "Take one healer's act for one open condition now, within its budget and the brake.", + "input": { + "properties": { + "key": { + "description": "the condition's key", + "type": "string" + }, + "why": { + "description": "why now, kept with the act", + "type": "string" + } + }, + "required": [ + "key", + "why" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the act taken" + } + }, + { + "name": "drill", + "description": "Record a drill: something broken on purpose to see the mesh raise and clear it, with what it tests. A drill is never a repair: no healer is wanted for it however often it is run.", + "input": { + "properties": { + "condition": { + "description": "the key of the condition the drill is meant to raise, if any (optional)", + "type": "string" + }, + "what": { + "description": "what was done on purpose, in a line", + "type": "string" + }, + "why": { + "description": "what the drill tests", + "type": "string" + } + }, + "required": [ + "what", + "why" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the drill recorded" + } + } + ], + "events": [ + { + "name": "healer-acted", + "body": { + "type": "object", + "description": "a healer's act", + "properties": { + "event": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "healer": { + "type": "string" + }, + "by": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "act": { + "type": "string" + }, + "outcome": { + "type": "string", + "enum": [ + "acting", + "acted", + "failed", + "escalated" + ] + }, + "said": { + "type": "string" + }, + "budget": { + "type": "string" + }, + "show": { + "type": "string" + } + }, + "required": [ + "event", + "at", + "healer", + "condition", + "outcome" + ] + } + } + ], + "family": { + "name": "mesh-healers.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "attempts", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "one act of a healer, created before it is made and finished after, so a second instance cannot begin the same act", + "properties": { + "healer": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "budget-key": { + "type": "string", + "description": "what the budget counts by: the condition's key unless the healer says another" + }, + "act": { + "type": "string" + }, + "outcome": { + "type": "string", + "enum": [ + "acting", + "acted", + "failed", + "escalated" + ] + }, + "said": { + "type": "string" + }, + "begun": { + "type": "string", + "format": "date-time" + }, + "finished": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "healer", + "condition", + "kind", + "budget-key", + "act", + "outcome", + "begun" + ] + }, + "description": "one act, under the healers' own key" + } + ], + "description": "the acts begun, which the keeper reads to write a condition's tries" + }, + { + "name": "would", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "an act the healers would have taken while the controller's lease names a controller; it counts for no budget", + "properties": { + "healer": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "act": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "why": { + "type": "string" + } + }, + "required": [ + "healer", + "condition", + "act", + "at" + ] + }, + "description": "one act not taken" + } + ], + "description": "the acts the healers would have taken" + } + ] +} diff --git a/health-and-repair/seats/mesh-self-check/seat.json b/health-and-repair/seats/mesh-self-check/seat.json new file mode 100644 index 0000000..40025a8 --- /dev/null +++ b/health-and-repair/seats/mesh-self-check/seat.json @@ -0,0 +1,165 @@ +{ + "format": 1, + "seat": "mesh-self-check", + "scope": "mesh", + "grain": "whole", + "domain": "Health and repair", + "decision": "novox/hq ADR 0564, ADR 0634, ADR 0635", + "description": "The mesh checking itself (ADR 0564): probes run every five minutes, each failure a finding the seat's conditions raise.", + "verbs": [ + { + "name": "doctor", + "description": "The self-check: the last run's verdict at once, each probe passed, failed, deferred (its facts not on the bus yet, named) or could not run, and how long ago. With run, a run now; with probe, one probe's verdict whole. Runs every five minutes on its own; each failure is a finding.", + "input": { + "properties": { + "probe": { + "description": "one probe's id (D6): its verdict alone, with every finding", + "type": "string" + }, + "run": { + "description": "\"true\": run every probe now and answer the verdict", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the last run's verdict, or one probe's, or a run now" + } + }, + { + "name": "probes", + "description": "The registry: what each probe asserts, where it reads its facts, and the finding it writes.", + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the registry of probes" + } + } + ], + "events": [ + { + "name": "checked", + "body": { + "type": "object", + "description": "one run of the self-check", + "properties": { + "run": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "started": { + "type": "string", + "format": "date-time" + }, + "took": { + "type": "string" + }, + "interval-seconds": { + "type": "integer" + }, + "counts": { + "type": "object", + "properties": { + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "failed-to-run": { + "type": "integer" + }, + "deferred": { + "type": "integer" + } + } + }, + "probes": { + "type": "array", + "items": { + "$ref": "#/defs/ProbeVerdict" + } + }, + "controller": { + "type": "string" + }, + "why": { + "type": "string" + }, + "unsaid": { + "type": "integer" + } + }, + "required": [ + "run", + "at", + "probes" + ] + } + } + ], + "family": { + "name": "mesh-self-check.events", + "max-age-days": 7 + }, + "defs": { + "ProbeVerdict": { + "type": "object", + "description": "one probe's verdict", + "properties": { + "id": { + "type": "string" + }, + "verdict": { + "type": "string", + "enum": [ + "passed", + "failed", + "failed-to-run", + "deferred" + ] + }, + "found": { + "type": "array", + "items": { + "type": "string" + } + }, + "unconfirmed": { + "type": "array", + "items": { + "type": "string" + } + }, + "error": { + "type": "string" + }, + "took": { + "type": "string" + }, + "found-count": { + "type": "integer" + }, + "more": { + "type": "string" + } + }, + "required": [ + "id", + "verdict" + ] + } + } +} diff --git a/health-and-repair/seats/mesh-watchdogs/fixtures/states.json b/health-and-repair/seats/mesh-watchdogs/fixtures/states.json new file mode 100644 index 0000000..6d8a3f8 --- /dev/null +++ b/health-and-repair/seats/mesh-watchdogs/fixtures/states.json @@ -0,0 +1,29 @@ +[ + { + "name": "a node's agent online (captured)", + "of": "state:presence/", + "given": { + "online": true, + "v": 1 + } + }, + { + "name": "a delivery part's instance online (captured)", + "of": "state:presence/part..", + "given": { + "online": true, + "v": 1 + } + }, + { + "name": "a thing that has not moved since a time, with what it waited for (captured)", + "of": "state:stuck/.", + "given": { + "bucket": "mesh-delivery-watchman_presence", + "key": "part.mesh-delivery-builder.novox", + "since": "2026-10-25T11:52:58.051397201+01:00", + "v": 1, + "waited": "mesh-delivery-builder on novox to come back online" + } + } +] diff --git a/health-and-repair/seats/mesh-watchdogs/seat.json b/health-and-repair/seats/mesh-watchdogs/seat.json new file mode 100644 index 0000000..35a4341 --- /dev/null +++ b/health-and-repair/seats/mesh-watchdogs/seat.json @@ -0,0 +1,243 @@ +{ + "format": 1, + "seat": "mesh-watchdogs", + "scope": "mesh", + "grain": "whole", + "domain": "Health and repair", + "decision": "novox/hq ADR 0564, ADR 0634, ADR 0635", + "description": "The watchman's seat (ADR 0564, ADR 0433): signals that expire unless renewed, what stopped moving, who is present, the dead letters.", + "verbs": [ + { + "name": "signals", + "description": "Every watchdog of the signals table the watchman keeps, each a key that expires unless its signal renews it: its row, what renews it, its bound, the age of its newest signal, and the finding its expiry wrote.", + "input": { + "properties": { + "row": { + "description": "one row, e.g. S1", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the watchdog table" + } + }, + { + "name": "stuck", + "description": "Every want, build or step the watchman says stopped moving past its bound, with since when and the words of its finding.", + "input": { + "properties": { + "machine": { + "description": "only those on this machine", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what stopped moving" + } + }, + { + "name": "presence", + "description": "Every machine and tool runner the watchman sees connected, with its last word, and those it says silent; a machine that said it sleeps is shown as sleeping, not silent.", + "input": { + "properties": { + "machine": { + "description": "one machine", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "who is connected and who is silent" + } + }, + { + "name": "dead-letters", + "description": "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one whole. With deliver or drop, a hand act with why.", + "input": { + "properties": { + "cause": { + "description": "with deliver or drop: the cause in a word (dead-letter when absent)", + "type": "string" + }, + "consumer": { + "description": "a consumer's name, or .: only what that one gave up on", + "type": "string" + }, + "deliver": { + "description": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)", + "type": "string" + }, + "drop": { + "description": "a dead letter's id: drop it for good (needs why)", + "type": "string" + }, + "id": { + "description": "a dead letter's id, as the list gives it: that one whole, with its message", + "type": "string" + }, + "limit": { + "description": "how many to list, newest first (default 50); only when listing", + "type": "string" + }, + "why": { + "description": "with deliver or drop: why, required and recorded", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the dead letters, one whole, or the act done" + } + }, + { + "name": "resync", + "description": "Rebuild presence from the bus's connections, and the pulses and findings from the keys the watchman watches, and take every dead-letter notice waiting: the recovery of a watchman that missed a change.", + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what was rebuilt" + } + } + ], + "states": [ + { + "name": "presence", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "whether a node is online", + "properties": { + "v": { + "type": "integer" + }, + "online": { + "type": "boolean" + } + }, + "required": [ + "online" + ] + }, + "description": "a node" + }, + { + "key": "part..", + "value": { + "type": "object", + "description": "whether a part's instance is online", + "properties": { + "v": { + "type": "integer" + }, + "online": { + "type": "boolean" + } + }, + "required": [ + "online" + ] + }, + "description": "a part's instance" + } + ], + "history": 20, + "description": "who is connected; the entry's own time says since when" + }, + { + "name": "pulse", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "a watched key mirrored, its revision, and who owes the next move", + "properties": { + "v": { + "type": "integer" + }, + "bucket": { + "type": "string" + }, + "key": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "owes": { + "type": "string" + } + }, + "required": [ + "bucket", + "key", + "revision" + ] + }, + "description": "the move watched at a key of a state" + } + ], + "description": "one key per watched move, living for the move's bound minus its age" + }, + { + "name": "stuck", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "what stopped moving", + "properties": { + "v": { + "type": "integer" + }, + "bucket": { + "type": "string" + }, + "key": { + "type": "string" + }, + "waited": { + "type": "string", + "description": "what it waited for, in words" + }, + "since": { + "type": "string", + "format": "date-time" + }, + "moved": { + "type": "boolean", + "description": "it has moved again" + } + }, + "required": [ + "bucket", + "key", + "since" + ] + }, + "description": "a key whose pulse expired" + } + ], + "description": "what stopped moving; no key is removed, one that moves again is written as moved" + } + ] +} diff --git a/health-and-repair/ts/seats/meshconditions/package-lock.json b/health-and-repair/ts/seats/meshconditions/package-lock.json new file mode 100644 index 0000000..6aa81e6 --- /dev/null +++ b/health-and-repair/ts/seats/meshconditions/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-health-and-repair-contracts-mesh-conditions", + "packages": { + "": { + "name": "@novox/mesh-health-and-repair-contracts-mesh-conditions", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/health-and-repair/ts/seats/meshconditions/package.json b/health-and-repair/ts/seats/meshconditions/package.json new file mode 100644 index 0000000..0be4987 --- /dev/null +++ b/health-and-repair/ts/seats/meshconditions/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-conditions, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-health-and-repair-contracts-mesh-conditions", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/health-and-repair/ts/seats/meshconditions/src/index.ts b/health-and-repair/ts/seats/meshconditions/src/index.ts new file mode 100644 index 0000000..a18cdf0 --- /dev/null +++ b/health-and-repair/ts/seats/meshconditions/src/index.ts @@ -0,0 +1,280 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "mesh-conditions", defined whole (seats/mesh-conditions/seat.json): What is wrong with the mesh now (ADR 0564): the open conditions raised from the observers' findings and cleared when a finding goes. + +export const seat = "mesh-conditions"; + +export const verbs = { + conditions: "conditions", + silence: "silence", + resync: "resync", +} as const; + +export const events = { + conditionRaised: "condition-raised", + conditionChanged: "condition-changed", + conditionCleared: "condition-cleared", +} as const; + +export const family = { name: "mesh-conditions.events", maxAgeDays: 7 } as const; + +export const conditionRaisedSubject = "mesh.seat.mesh-conditions.event.condition-raised"; + +export const conditionChangedSubject = "mesh.seat.mesh-conditions.event.condition-changed"; + +export const conditionClearedSubject = "mesh.seat.mesh-conditions.event.condition-cleared"; + +export const states = { + conditions: "conditions", + findings: "findings", +} as const; + +/** The key of one value of the state conditions under the form "". */ +export function conditionsByKeyKey(key: string): string { + return key; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseConditionsByKeyKey(_key: string): { key: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const key = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { key }; +} + +/** The key of one value of the state findings under the form "". */ +export function findingsByKeyKey(key: string): string { + return key; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseFindingsByKeyKey(_key: string): { key: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const key = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { key }; +} + +/** one answer the operator may give from a notification */ +export interface Action { + "arguments"?: Record; + "label": string; + "level"?: "acknowledge" | "approve"; + "machine"?: string; + /** the seat verb the operator channel calls */ + "verb": string; +} + +export interface ConditionEventGapsItem { + "cleared"?: string; + "reopened"?: string; +} + +export interface ConditionEventSilenced { + "by"?: string; + "since"?: string; + "until"?: string; + "why"?: string; +} + +export interface ConditionEventTriedItem { + "at"?: string; + "by"?: string; + "outcome"?: string; + "what"?: string; +} + +/** a condition and what changed */ +export interface ConditionEvent { + "actions"?: Action[]; + "at": string; + "change": "raised" | "reopened" | "severity" | "resolver" | "silenced" | "silence-ended" | "cleared" | "words"; + "cleared"?: string; + "count"?: number; + "epoch"?: number; + "event": string; + /** newest first, at most 10 */ + "evidence"?: Evidence[]; + "explanation"?: string; + "first"?: string; + "gaps"?: ConditionEventGapsItem[]; + "headline"?: string; + /** scope.id.token */ + "key": string; + "kind"?: string; + "last-observed"?: string; + "looks-unread"?: string; + "needs"?: string; + "observations"?: number; + "raised"?: string; + "resolved"?: string; + /** self, operator, agent or healer: */ + "resolver"?: string; + "severity"?: "urgent" | "warning"; + /** the tool call that shows it */ + "show"?: string; + "silenced"?: ConditionEventSilenced; + /** the row, probe or event that saw it */ + "source"?: string; + "subject"?: Subject; + /** one line */ + "summary"?: string; + "tried"?: ConditionEventTriedItem[]; + "was"?: string; + "why"?: string; +} + +/** one observation, as it was said */ +export interface Evidence { + "at": string; + "said": string; +} + +/** what the condition is about */ +export interface Subject { + /** the other machines it concerns */ + "also"?: string[]; + /** its id within the scope */ + "id": string; + /** the machine it concerns, when there is one */ + "machine"?: string; + /** machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module */ + "scope": string; +} + +/** is what conditions takes. */ +export interface ConditionsInput { + /** with history: how many days back (default 7, at most 90) */ + "days"?: string; + /** "true": every raising, change, silence and clearing lately, oldest first */ + "history"?: "true" | "false"; + /** a condition's key: that one whole; with history, only its transitions */ + "key"?: string; + /** only those about this machine */ + "machine"?: string; + /** only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module */ + "scope"?: string; + /** only urgent, or only warning */ + "severity"?: string; +} + +/** is what conditions answers. */ +export type ConditionsAnswer = Record; + +/** is what silence takes. */ +export interface SilenceInput { + /** the cause in a word (the condition's kind when absent) */ + "cause"?: string; + /** how long: 30m, 4h, 2d; at most 7d */ + "for": string; + /** the condition's key */ + "key": string; + /** why, required and recorded */ + "why": string; +} + +/** is what silence answers. */ +export type SilenceAnswer = Record; + +/** is what resync takes. */ +export interface ResyncInput { +} + +/** is what resync answers. */ +export type ResyncAnswer = Record; + +/** is the body of the event condition-raised. */ +export type ConditionRaisedEvent = ConditionEvent; + +/** is the body of the event condition-changed. */ +export type ConditionChangedEvent = ConditionEvent; + +/** is the body of the event condition-cleared. */ +export type ConditionClearedEvent = ConditionEvent; + +export interface ConditionsByKeyValueGapsItem { + "cleared"?: string; + "reopened"?: string; +} + +export interface ConditionsByKeyValueSilenced { + "by"?: string; + "since"?: string; + "until"?: string; + "why"?: string; +} + +export interface ConditionsByKeyValueTriedItem { + "at"?: string; + "by"?: string; + "outcome"?: string; + "what"?: string; +} + +/** is one value of the state conditions under the key . */ +export interface ConditionsByKeyValue { + "actions"?: Action[]; + "count"?: number; + "epoch"?: number; + /** newest first, at most 10 */ + "evidence"?: Evidence[]; + "explanation"?: string; + "first"?: string; + "gaps"?: ConditionsByKeyValueGapsItem[]; + "headline"?: string; + /** scope.id.token */ + "key": string; + "kind": string; + "last-observed": string; + "looks-unread"?: string; + "needs"?: string; + "observations"?: number; + "raised": string; + "resolved"?: string; + /** self, operator, agent or healer: */ + "resolver"?: string; + "severity": "urgent" | "warning"; + "silenced"?: ConditionsByKeyValueSilenced; + /** the row, probe or event that saw it */ + "source"?: string; + "subject": Subject; + /** one line */ + "summary"?: string; + "tried"?: ConditionsByKeyValueTriedItem[]; +} + +/** is one value of the state findings under the key . */ +export interface FindingsByKeyValue { + "actions"?: Action[]; + "at": string; + /** newest first, at most 10 */ + "evidence"?: Evidence[]; + "explanation"?: string; + "headline"?: string; + /** scope.id.token */ + "key": string; + "kind": string; + "needs"?: string; + /** the module that wrote it */ + "observer": string; + /** the key's last part when it is not the kind */ + "ref"?: string; + "resolved"?: string; + /** self, operator, agent or healer: */ + "resolver"?: string; + /** what of its own saw it last */ + "run"?: string; + "severity": "urgent" | "warning"; + /** the row, probe or event that saw it */ + "source": string; + "subject": Subject; + /** one line */ + "summary": string; + /** layout version, 1 */ + "v": number; +} + diff --git a/health-and-repair/ts/seats/meshconditions/tsconfig.json b/health-and-repair/ts/seats/meshconditions/tsconfig.json new file mode 100644 index 0000000..60274c6 --- /dev/null +++ b/health-and-repair/ts/seats/meshconditions/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/health-and-repair/ts/seats/meshhandacts/package-lock.json b/health-and-repair/ts/seats/meshhandacts/package-lock.json new file mode 100644 index 0000000..c94f1e5 --- /dev/null +++ b/health-and-repair/ts/seats/meshhandacts/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-health-and-repair-contracts-mesh-hand-acts", + "packages": { + "": { + "name": "@novox/mesh-health-and-repair-contracts-mesh-hand-acts", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/health-and-repair/ts/seats/meshhandacts/package.json b/health-and-repair/ts/seats/meshhandacts/package.json new file mode 100644 index 0000000..20ebafd --- /dev/null +++ b/health-and-repair/ts/seats/meshhandacts/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-hand-acts, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-health-and-repair-contracts-mesh-hand-acts", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/health-and-repair/ts/seats/meshhandacts/src/index.ts b/health-and-repair/ts/seats/meshhandacts/src/index.ts new file mode 100644 index 0000000..0b4ea7b --- /dev/null +++ b/health-and-repair/ts/seats/meshhandacts/src/index.ts @@ -0,0 +1,160 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "mesh-hand-acts", defined whole (seats/mesh-hand-acts/seat.json): Acts done by hand, recorded with who, why and cause (ADR 0565): the log every module records through. + +export const seat = "mesh-hand-acts"; + +export const verbs = { + handAct: "hand-act", + handActs: "hand-acts", + warranted: "warranted", +} as const; + +export const events = { + actRecorded: "act-recorded", +} as const; + +export const family = { name: "mesh-hand-acts.events", maxAgeDays: 7 } as const; + +export const actRecordedSubject = "mesh.seat.mesh-hand-acts.event.act-recorded"; + +export const states = { + acts: "acts", + warrants: "warrants", +} as const; + +/** The key of one value of the state acts under the form "". */ +export function actsByIDKey(id: string): string { + return id; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseActsByIDKey(_key: string): { id: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const id = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { id }; +} + +/** The key of one value of the state warrants under the form ".". */ +export function warrantsByAskerAskKey(asker: string, ask: string): string { + return asker + "." + ask; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseWarrantsByAskerAskKey(_key: string): { asker: string; ask: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const asker = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + if (_rest === "") return undefined; + const ask = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { asker, ask }; +} + +/** one act done by hand */ +export interface Act { + "arguments"?: string[]; + "ask"?: string; + "at": string; + /** the caller the runtime proved */ + "by": string; + "carried"?: string[]; + "cause": string; + "condition"?: string; + "id": string; + /** what the act was read to be from what it did */ + "kind"?: string; + /** which log holds it, set when it is read */ + "log"?: string; + "outcome"?: string; + "proofs"?: string[]; + "requested-by"?: string; + "verb": string; + "via"?: string; + "why": string; +} + +/** is what hand-act takes. */ +export interface HandActInput { + /** the cause in a word, or a condition's kind */ + "cause"?: string; + /** the key of the condition it addressed, if any (optional) */ + "condition"?: string; + /** the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own */ + "verb"?: string; + /** what was done, in a line */ + "what": string; + /** why it had to be done by hand */ + "why": string; +} + +/** is what hand-act answers. */ +export type HandActAnswer = Record; + +/** is what hand-acts takes. */ +export interface HandActsInput { + /** only this cause */ + "cause"?: string; + /** how many days back (default 14) */ + "days"?: string; + /** "true": every warrant recorded */ + "warrants"?: "true" | "false"; + /** only acts whose who contains this */ + "who"?: string; +} + +/** is what hand-acts answers. */ +export type HandActsAnswer = Record; + +/** is what warranted takes. */ +export interface WarrantedInput { + /** its ask's id */ + "ask": string; + /** the module that asked */ + "asker": string; +} + +/** is what warranted answers. */ +export type WarrantedAnswer = Record; + +/** is the body of the event act-recorded. */ +export type ActRecordedEvent = Act; + +/** is one value of the state acts under the key . */ +export interface ActsByIDValue { + "arguments"?: string[]; + "ask"?: string; + "at": string; + /** the caller the runtime proved */ + "by": string; + "carried"?: string[]; + "cause": string; + "condition"?: string; + "id": string; + /** what the act was read to be from what it did */ + "kind"?: string; + /** which log holds it, set when it is read */ + "log"?: string; + "outcome"?: string; + "proofs"?: string[]; + "requested-by"?: string; + "verb": string; + "via"?: string; + "why": string; +} + +/** is one value of the state warrants under the key .. */ +export interface WarrantsByAskerAskValue { + "ask": string; + "asker": string; + "at"?: string; + "by"?: string; + "option"?: string; +} + diff --git a/health-and-repair/ts/seats/meshhandacts/tsconfig.json b/health-and-repair/ts/seats/meshhandacts/tsconfig.json new file mode 100644 index 0000000..60274c6 --- /dev/null +++ b/health-and-repair/ts/seats/meshhandacts/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/health-and-repair/ts/seats/meshhealers/package-lock.json b/health-and-repair/ts/seats/meshhealers/package-lock.json new file mode 100644 index 0000000..9a26d5f --- /dev/null +++ b/health-and-repair/ts/seats/meshhealers/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-health-and-repair-contracts-mesh-healers", + "packages": { + "": { + "name": "@novox/mesh-health-and-repair-contracts-mesh-healers", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/health-and-repair/ts/seats/meshhealers/package.json b/health-and-repair/ts/seats/meshhealers/package.json new file mode 100644 index 0000000..3d48314 --- /dev/null +++ b/health-and-repair/ts/seats/meshhealers/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-healers, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-health-and-repair-contracts-mesh-healers", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/health-and-repair/ts/seats/meshhealers/src/index.ts b/health-and-repair/ts/seats/meshhealers/src/index.ts new file mode 100644 index 0000000..3840537 --- /dev/null +++ b/health-and-repair/ts/seats/meshhealers/src/index.ts @@ -0,0 +1,127 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "mesh-healers", defined whole (seats/mesh-healers/seat.json): The healers (ADR 0564): each a registered response to one kind of condition, within a budget and the mesh-wide brake. + +export const seat = "mesh-healers"; + +export const verbs = { + healers: "healers", + heal: "heal", + drill: "drill", +} as const; + +export const events = { + healerActed: "healer-acted", +} as const; + +export const family = { name: "mesh-healers.events", maxAgeDays: 7 } as const; + +export const healerActedSubject = "mesh.seat.mesh-healers.event.healer-acted"; + +export const states = { + attempts: "attempts", + would: "would", +} as const; + +/** The key of one value of the state attempts under the form "". */ +export function attemptsByAttemptKey(attempt: string): string { + return attempt; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseAttemptsByAttemptKey(_key: string): { attempt: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const attempt = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { attempt }; +} + +/** The key of one value of the state would under the form "". */ +export function wouldByWouldKey(would: string): string { + return would; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseWouldByWouldKey(_key: string): { would: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const would = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { would }; +} + +/** is what healers takes. */ +export interface HealersInput { + /** how many days of acts back (default 7) */ + "days"?: string; +} + +/** is what healers answers. */ +export type HealersAnswer = Record; + +/** is what heal takes. */ +export interface HealInput { + /** the condition's key */ + "key": string; + /** why now, kept with the act */ + "why": string; +} + +/** is what heal answers. */ +export type HealAnswer = Record; + +/** is what drill takes. */ +export interface DrillInput { + /** the key of the condition the drill is meant to raise, if any (optional) */ + "condition"?: string; + /** what was done on purpose, in a line */ + "what": string; + /** what the drill tests */ + "why": string; +} + +/** is what drill answers. */ +export type DrillAnswer = Record; + +/** is the body of the event healer-acted. */ +export interface HealerActedEvent { + "act"?: string; + "at": string; + "budget"?: string; + "by"?: string; + "condition": string; + "event": string; + "healer": string; + "kind"?: string; + "outcome": "acting" | "acted" | "failed" | "escalated"; + "said"?: string; + "show"?: string; +} + +/** is one value of the state attempts under the key . */ +export interface AttemptsByAttemptValue { + "act": string; + "begun": string; + /** what the budget counts by: the condition's key unless the healer says another */ + "budget-key": string; + "condition": string; + "finished"?: string; + "healer": string; + "kind": string; + "outcome": "acting" | "acted" | "failed" | "escalated"; + "said"?: string; +} + +/** is one value of the state would under the key . */ +export interface WouldByWouldValue { + "act": string; + "at": string; + "condition": string; + "healer": string; + "kind"?: string; + "why"?: string; +} + diff --git a/health-and-repair/ts/seats/meshhealers/tsconfig.json b/health-and-repair/ts/seats/meshhealers/tsconfig.json new file mode 100644 index 0000000..60274c6 --- /dev/null +++ b/health-and-repair/ts/seats/meshhealers/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/health-and-repair/ts/seats/meshselfcheck/package-lock.json b/health-and-repair/ts/seats/meshselfcheck/package-lock.json new file mode 100644 index 0000000..1590123 --- /dev/null +++ b/health-and-repair/ts/seats/meshselfcheck/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-health-and-repair-contracts-mesh-self-check", + "packages": { + "": { + "name": "@novox/mesh-health-and-repair-contracts-mesh-self-check", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/health-and-repair/ts/seats/meshselfcheck/package.json b/health-and-repair/ts/seats/meshselfcheck/package.json new file mode 100644 index 0000000..f0027c9 --- /dev/null +++ b/health-and-repair/ts/seats/meshselfcheck/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-self-check, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-health-and-repair-contracts-mesh-self-check", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/health-and-repair/ts/seats/meshselfcheck/src/index.ts b/health-and-repair/ts/seats/meshselfcheck/src/index.ts new file mode 100644 index 0000000..b76fc1d --- /dev/null +++ b/health-and-repair/ts/seats/meshselfcheck/src/index.ts @@ -0,0 +1,70 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "mesh-self-check", defined whole (seats/mesh-self-check/seat.json): The mesh checking itself (ADR 0564): probes run every five minutes, each failure a finding the seat's conditions raise. + +export const seat = "mesh-self-check"; + +export const verbs = { + doctor: "doctor", + probes: "probes", +} as const; + +export const events = { + checked: "checked", +} as const; + +export const family = { name: "mesh-self-check.events", maxAgeDays: 7 } as const; + +export const checkedSubject = "mesh.seat.mesh-self-check.event.checked"; + +/** one probe's verdict */ +export interface ProbeVerdict { + "error"?: string; + "found"?: string[]; + "found-count"?: number; + "id": string; + "more"?: string; + "took"?: string; + "unconfirmed"?: string[]; + "verdict": "passed" | "failed" | "failed-to-run" | "deferred"; +} + +/** is what doctor takes. */ +export interface DoctorInput { + /** one probe's id (D6): its verdict alone, with every finding */ + "probe"?: string; + /** "true": run every probe now and answer the verdict */ + "run"?: "true" | "false"; +} + +/** is what doctor answers. */ +export type DoctorAnswer = Record; + +/** is what probes takes. */ +export interface ProbesInput { +} + +/** is what probes answers. */ +export type ProbesAnswer = Record; + +export interface CheckedEventCounts { + "deferred"?: number; + "failed"?: number; + "failed-to-run"?: number; + "passed"?: number; +} + +/** is the body of the event checked. */ +export interface CheckedEvent { + "at": string; + "controller"?: string; + "counts"?: CheckedEventCounts; + "interval-seconds"?: number; + "probes": ProbeVerdict[]; + "run": string; + "started"?: string; + "took"?: string; + "unsaid"?: number; + "why"?: string; +} + diff --git a/health-and-repair/ts/seats/meshselfcheck/tsconfig.json b/health-and-repair/ts/seats/meshselfcheck/tsconfig.json new file mode 100644 index 0000000..60274c6 --- /dev/null +++ b/health-and-repair/ts/seats/meshselfcheck/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/health-and-repair/ts/seats/meshwatchdogs/package-lock.json b/health-and-repair/ts/seats/meshwatchdogs/package-lock.json new file mode 100644 index 0000000..b236c0c --- /dev/null +++ b/health-and-repair/ts/seats/meshwatchdogs/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-health-and-repair-contracts-mesh-watchdogs", + "packages": { + "": { + "name": "@novox/mesh-health-and-repair-contracts-mesh-watchdogs", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/health-and-repair/ts/seats/meshwatchdogs/package.json b/health-and-repair/ts/seats/meshwatchdogs/package.json new file mode 100644 index 0000000..9dc0933 --- /dev/null +++ b/health-and-repair/ts/seats/meshwatchdogs/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-watchdogs, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-health-and-repair-contracts-mesh-watchdogs", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/health-and-repair/ts/seats/meshwatchdogs/src/index.ts b/health-and-repair/ts/seats/meshwatchdogs/src/index.ts new file mode 100644 index 0000000..3a5240a --- /dev/null +++ b/health-and-repair/ts/seats/meshwatchdogs/src/index.ts @@ -0,0 +1,182 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "mesh-watchdogs", defined whole (seats/mesh-watchdogs/seat.json): The watchman's seat (ADR 0564, ADR 0433): signals that expire unless renewed, what stopped moving, who is present, the dead letters. + +export const seat = "mesh-watchdogs"; + +export const verbs = { + signals: "signals", + stuck: "stuck", + presence: "presence", + deadLetters: "dead-letters", + resync: "resync", +} as const; + +export const states = { + presence: "presence", + pulse: "pulse", + stuck: "stuck", +} as const; + +/** The key of one value of the state presence under the form "". */ +export function presenceByNodeKey(node: string): string { + return node; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parsePresenceByNodeKey(_key: string): { node: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const node = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node }; +} + +/** The key of one value of the state presence under the form "part..". */ +export function presenceByPartPartInstanceKey(part: string, instance: string): string { + return "part." + part + "." + instance; +} + +/** The parts of a key of the form "part..", or undefined for a key of another form. */ +export function parsePresenceByPartPartInstanceKey(_key: string): { part: string; instance: string } | undefined { + let _rest = _key; + if (!_rest.startsWith("part.")) return undefined; + _rest = _rest.slice(5); + const _at1 = _rest.indexOf("."); + if (_at1 <= 0) return undefined; + const part = _rest.slice(0, _at1); + _rest = _rest.slice(_at1 + 1); + if (_rest === "") return undefined; + const instance = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { part, instance }; +} + +/** The key of one value of the state pulse under the form ".". */ +export function pulseByBucketKeyKey(bucket: string, key: string): string { + return bucket + "." + key; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parsePulseByBucketKeyKey(_key: string): { bucket: string; key: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const bucket = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + if (_rest === "") return undefined; + const key = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { bucket, key }; +} + +/** The key of one value of the state stuck under the form ".". */ +export function stuckByBucketKeyKey(bucket: string, key: string): string { + return bucket + "." + key; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseStuckByBucketKeyKey(_key: string): { bucket: string; key: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const bucket = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + if (_rest === "") return undefined; + const key = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { bucket, key }; +} + +/** is what signals takes. */ +export interface SignalsInput { + /** one row, e.g. S1 */ + "row"?: string; +} + +/** is what signals answers. */ +export type SignalsAnswer = Record; + +/** is what stuck takes. */ +export interface StuckInput { + /** only those on this machine */ + "machine"?: string; +} + +/** is what stuck answers. */ +export type StuckAnswer = Record; + +/** is what presence takes. */ +export interface PresenceInput { + /** one machine */ + "machine"?: string; +} + +/** is what presence answers. */ +export type PresenceAnswer = Record; + +/** is what dead-letters takes. */ +export interface DeadLettersInput { + /** with deliver or drop: the cause in a word (dead-letter when absent) */ + "cause"?: string; + /** a consumer's name, or .: only what that one gave up on */ + "consumer"?: string; + /** a dead letter's id: deliver it again to the consumer that gave it up (needs why) */ + "deliver"?: string; + /** a dead letter's id: drop it for good (needs why) */ + "drop"?: string; + /** a dead letter's id, as the list gives it: that one whole, with its message */ + "id"?: string; + /** how many to list, newest first (default 50); only when listing */ + "limit"?: string; + /** with deliver or drop: why, required and recorded */ + "why"?: string; +} + +/** is what dead-letters answers. */ +export type DeadLettersAnswer = Record; + +/** is what resync takes. */ +export interface ResyncInput { +} + +/** is what resync answers. */ +export type ResyncAnswer = Record; + +/** is one value of the state presence under the key . */ +export interface PresenceByNodeValue { + "online": boolean; + "v"?: number; +} + +/** is one value of the state presence under the key part... */ +export interface PresenceByPartPartInstanceValue { + "online": boolean; + "v"?: number; +} + +/** is one value of the state pulse under the key .. */ +export interface PulseByBucketKeyValue { + "bucket": string; + "key": string; + "owes"?: string; + "revision": number; + "v"?: number; +} + +/** is one value of the state stuck under the key .. */ +export interface StuckByBucketKeyValue { + "bucket": string; + "key": string; + /** it has moved again */ + "moved"?: boolean; + "since": string; + "v"?: number; + /** what it waited for, in words */ + "waited"?: string; +} + diff --git a/health-and-repair/ts/seats/meshwatchdogs/tsconfig.json b/health-and-repair/ts/seats/meshwatchdogs/tsconfig.json new file mode 100644 index 0000000..60274c6 --- /dev/null +++ b/health-and-repair/ts/seats/meshwatchdogs/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/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..fc61d8b --- /dev/null +++ b/identity-and-access/go/clients/meshnodeenrollerclient/enrolment_test.go @@ -0,0 +1,58 @@ +package meshnodeenrollerclient + +import ( + "bytes" + "encoding/json" + "reflect" + "testing" + "time" + + seat "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller" +) + +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) + } +} + +// TestTheRedeemAnswerIsTheContractsWire: the client's answer reads into the contract's type with no field dropped, and +// the contract's reads back as the client's wire. +func TestTheRedeemAnswerIsTheContractsWire(t *testing.T) { + mine := RedeemAnswer{Node: "g14", Login: "g14-login", Account: "YQ==", Certificate: "pem", Signer: "dg==", Signature: "cw=="} + wire, _ := json.Marshal(mine) + var theirs seat.RedeemAnswer + d := json.NewDecoder(bytes.NewReader(wire)) + d.DisallowUnknownFields() + if err := d.Decode(&theirs); err != nil { + t.Fatal(err) + } + if theirs.Node != "g14" || theirs.Signer != "dg==" || theirs.Signature != "cw==" { + t.Errorf("the contract reads %+v", theirs) + } + back, _ := json.Marshal(theirs) + var a, b map[string]string + _ = json.Unmarshal(wire, &a) + _ = json.Unmarshal(back, &b) + if len(a) != 6 || !reflect.DeepEqual(a, b) { + t.Errorf("the contract reads %s back as %s", wire, back) + } +} 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..9997570 --- /dev/null +++ b/identity-and-access/go/clients/meshnodeenrollerclient/go.mod @@ -0,0 +1,5 @@ +module git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient + +go 1.26.0 + +require git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller v0.0.0-00010101000000-000000000000 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..77dba63 --- /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 := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC) + 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.Format(time.RFC3339) + `"}`)); 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..eaf86d5 --- /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-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-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..476b4e6 --- /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()} +} + +// 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) } 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..fc7f677 --- /dev/null +++ b/identity-and-access/go/seats/meshnodeenroller/contract.go @@ -0,0 +1,274 @@ +// 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" + "time" +) + +// 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 *time.Time `json:"expires,omitempty"` + Issued *time.Time `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 struct { + // its account sealed to the sealing key it presented, base64 + Account string `json:"account"` + // its certificate, PEM + Certificate string `json:"certificate"` + // the login it dials as next + Login string `json:"login"` + // the node + Node string `json:"node"` + // the vault's signature over the answer without it, under the context of the enrolment kind, base64 + Signature string `json:"signature,omitempty"` + // the vault's public signing half, base64 + Signer string `json:"signer"` +} + +// 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 *time.Time `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..9fa2469 --- /dev/null +++ b/identity-and-access/go/seats/meshnodeenroller/contract_test.go @@ -0,0 +1,62 @@ +// 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}, + {name: "redeem answers the account sealed to the node, signed by the vault", subject: "", given: "{\"account\":\"c2VhbGVk\",\"certificate\":\"-----BEGIN CERTIFICATE-----\\nMIIB\\n-----END CERTIFICATE-----\\n\",\"login\":\"g14-login\",\"node\":\"g14\",\"signature\":\"c2ln\",\"signer\":\"dmF1bHQ=\"}", wire: "{\"account\":\"c2VhbGVk\",\"certificate\":\"-----BEGIN CERTIFICATE-----\\nMIIB\\n-----END CERTIFICATE-----\\n\",\"login\":\"g14-login\",\"node\":\"g14\",\"signature\":\"c2ln\",\"signer\":\"dmF1bHQ=\"}", + decode: func(b []byte) (any, error) { var v RedeemAnswer; 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/identity-and-access/go/seats/meshvault/contract.go b/identity-and-access/go/seats/meshvault/contract.go new file mode 100644 index 0000000..9d51257 --- /dev/null +++ b/identity-and-access/go/seats/meshvault/contract.go @@ -0,0 +1,571 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// 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" + "time" +) + +// Seat is the seat's name. +const Seat = "mesh-vault" + +const ( + VerbGet = "get" + VerbSecretAsk = "secret-ask" + VerbIssued = "issued" + VerbRotate = "rotate" + VerbMoveIn = "move-in" + VerbNodeKey = "node-key" + VerbOwn = "own" + VerbIssueAccount = "issue-account" + VerbSign = "sign" + VerbEnrolmentToken = "enrolment-token" + VerbNodeAccount = "node-account" + VerbRotateNode = "rotate-node" + VerbNodeAccountConfirm = "node-account-confirm" + VerbAbandon = "abandon" + VerbRetire = "retire" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbGet, VerbSecretAsk, VerbIssued, VerbRotate, VerbMoveIn, VerbNodeKey, VerbOwn, VerbIssueAccount, VerbSign, VerbEnrolmentToken, VerbNodeAccount, VerbRotateNode, VerbNodeAccountConfirm, VerbAbandon, VerbRetire} +} + +// The holder's own events, listed beside the seat and published under the holder's subjects (ADR 0620 §2). +const ( + HolderEventProvisioned = "provisioned" + HolderEventRotated = "rotated" + HolderEventSecretChanged = "secret-changed" + HolderEventAccountHashed = "account-hashed" + HolderEventOwnReplaced = "own-replaced" + HolderEventAccountIssued = "account-issued" + HolderEventSigned = "signed" + HolderEventRotationBegun = "rotation-begun" + HolderEventNodeAccountReady = "node-account-ready" + HolderEventRotationDone = "rotation-done" + HolderEventOwnRetired = "own-retired" +) + +// HolderSubjectProvisioned is where the holder publishes provisioned. +func HolderSubjectProvisioned(holder string) string { + return "mesh.mod." + holder + ".event.provisioned" +} + +// HolderSubjectRotated is where the holder publishes rotated. +func HolderSubjectRotated(holder string) string { return "mesh.mod." + holder + ".event.rotated" } + +// HolderSubjectSecretChanged is where the holder publishes secret-changed. +func HolderSubjectSecretChanged(holder string) string { + return "mesh.mod." + holder + ".event.secret-changed" +} + +// HolderSubjectAccountHashed is where the holder publishes account-hashed. +func HolderSubjectAccountHashed(holder string) string { + return "mesh.mod." + holder + ".event.account-hashed" +} + +// HolderSubjectOwnReplaced is where the holder publishes own-replaced. +func HolderSubjectOwnReplaced(holder string) string { + return "mesh.mod." + holder + ".event.own-replaced" +} + +// HolderSubjectAccountIssued is where the holder publishes account-issued. +func HolderSubjectAccountIssued(holder string) string { + return "mesh.mod." + holder + ".event.account-issued" +} + +// HolderSubjectSigned is where the holder publishes signed. +func HolderSubjectSigned(holder string) string { return "mesh.mod." + holder + ".event.signed" } + +// HolderSubjectRotationBegun is where the holder publishes rotation-begun. +func HolderSubjectRotationBegun(holder string) string { + return "mesh.mod." + holder + ".event.rotation-begun" +} + +// HolderSubjectNodeAccountReady is where the holder publishes node-account-ready. +func HolderSubjectNodeAccountReady(holder string) string { + return "mesh.mod." + holder + ".event.node-account-ready" +} + +// HolderSubjectRotationDone is where the holder publishes rotation-done. +func HolderSubjectRotationDone(holder string) string { + return "mesh.mod." + holder + ".event.rotation-done" +} + +// HolderSubjectOwnRetired is where the holder publishes own-retired. +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 + Name string `json:"name"` + // the asker's X25519 public key as SPKI PEM — never a private key + PublicKey string `json:"public_key"` +} + +// GetAnswer is what get answers. +type GetAnswer = map[string]any + +// SecretAskInput is what secret-ask takes. +type SecretAskInput struct { + // who asked, as the bus named its caller + AskedBy string `json:"asked_by,omitempty"` + // the second step: the call the first step answered + Call string `json:"call,omitempty"` + // the machine whose prompt asks + Desk string `json:"desk,omitempty"` + // the module + Module string `json:"module,omitempty"` + // the secret's name in the module + Name string `json:"name,omitempty"` + // the machine the secret is for + Node string `json:"node,omitempty"` + // instead of sealed: how the prompt ended + Outcome string `json:"outcome,omitempty"` + // the second step: the value sealed to the call's key — never the value + Sealed string `json:"sealed,omitempty"` +} + +// SecretAskAnswer is what secret-ask answers. +type SecretAskAnswer = map[string]any + +// IssuedInputHoldersItem is a type of the contract. +type IssuedInputHoldersItem struct { + // the consuming module + Module string `json:"module,omitempty"` + // the holder's machine + Node string `json:"node,omitempty"` +} + +// IssuedInput is what issued takes. +type IssuedInput struct { + // the second step: the call the first step answered + Call string `json:"call,omitempty"` + // the provision's holders from that provider, each {node, module}: its tool may answer a value for each, kept as that holder's own and handed to it alone + Holders []IssuedInputHoldersItem `json:"holders,omitempty"` + // the provider's module + Module string `json:"module,omitempty"` + // the provider's machine + Provider string `json:"provider,omitempty"` + // the provision + Provision string `json:"provision,omitempty"` + // the second step: the value sealed to the call's key — never the value + Sealed string `json:"sealed,omitempty"` +} + +// IssuedAnswer is what issued answers. +type IssuedAnswer = map[string]any + +// RotateInput is what rotate takes. +type RotateInput struct { + // who asked, when the controller carries the act + By string `json:"by,omitempty"` + // the module + Module string `json:"module,omitempty"` + // the secret's name in the module + Name string `json:"name,omitempty"` + // the machine, for a module's own secret + Node string `json:"node,omitempty"` + // the provider's machine + Provider string `json:"provider,omitempty"` + // the provision + Provision string `json:"provision,omitempty"` +} + +// RotateAnswer is what rotate answers. +type RotateAnswer = map[string]any + +// MoveInInput is what move-in takes. +type MoveInInput struct { + // the second step: the call the first step answered + Call string `json:"call,omitempty"` + // the consumer's machine + Consumer string `json:"consumer,omitempty"` + // the consuming module + ConsumerModule string `json:"consumer_module,omitempty"` + // the controller's generation for the value + Generation string `json:"generation,omitempty"` + // the credential's local name, when it has one + Local string `json:"local,omitempty"` + // when it was made (RFC 3339) + MadeAt string `json:"made_at,omitempty"` + // its module + Module string `json:"module,omitempty"` + // its name in the module + Name string `json:"name,omitempty"` + // a module's own secret: its machine + Node string `json:"node,omitempty"` + // made, accepted or issued, as the controller's store says + Origin string `json:"origin,omitempty"` + // a pair credential: the provider's machine + Provider string `json:"provider,omitempty"` + // the provision + Provision string `json:"provision,omitempty"` + // the second step: a raw X25519 public key the read-back is sealed to + ReadBackTo string `json:"read_back_to,omitempty"` + // the second step: the value sealed to the call's key — never the value + Sealed string `json:"sealed,omitempty"` +} + +// MoveInAnswer is what move-in answers. +type MoveInAnswer = map[string]any + +// NodeKeyInput is what node-key takes. +type NodeKeyInput struct { + // the machine's sealing key, a raw X25519 public key in base64 + Key string `json:"key,omitempty"` +} + +// NodeKeyAnswer is what node-key answers. +type NodeKeyAnswer = map[string]any + +// OwnInput is what own takes. +type OwnInput struct { + // the pair credentials the machine's composition reads, each {role: consumer|provider, provision, consumer, consumer_module, provider, local} + Credentials []map[string]any `json:"credentials,omitempty"` +} + +// OwnAnswer is what own answers. +type OwnAnswer = map[string]any + +// IssueAccountInput is what issue-account takes. +type IssueAccountInput struct { + // the bus user, e.g. ace.mesh-build-go + User string `json:"user"` +} + +// IssueAccountAnswer is what issue-account answers. +type IssueAccountAnswer = map[string]any + +// SignInput is what sign takes. +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-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-node-enroller: the serving key's public half the enrolling node presented + ServingKey string `json:"serving_key,omitempty"` +} + +// SignAnswer is what sign answers. +type SignAnswer struct { + // the authority's certificate that signed it, as PEM + Authority string `json:"authority,omitempty"` + // the node certificate as PEM, for a node certificate + Certificate string `json:"certificate,omitempty"` + // sha256: of the bytes signed + Digest string `json:"digest"` + // node-certificate or enrolment, as asked + Kind string `json:"kind"` + // the machine, for a node certificate + Node string `json:"node,omitempty"` + // the signature in base64, for an enrolment + Signature string `json:"signature,omitempty"` + // the signer's fingerprint + Signer string `json:"signer"` +} + +// 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"` + // the hub's endpoint, host:port + HubEndpoint string `json:"hub_endpoint"` + // the hub's tunnel public key + HubKey string `json:"hub_key"` + // the joining machine's own tunnel public key, as given at issue + Key string `json:"key"` + // the private network's range, a prefix + Range string `json:"range"` +} + +// EnrolmentTokenInput is what enrolment-token takes. +type EnrolmentTokenInput struct { + // "true": drop the node's token and its hash + Drop string `json:"drop,omitempty"` + // when the token expires (RFC 3339), as the enrolment's key says + Expires string `json:"expires,omitempty"` + // the node the token is for + 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-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"` +} + +// The values of EnrolmentTokenInput's drop. +const ( + EnrolmentTokenInputDropTrue = "true" + EnrolmentTokenInputDropFalse = "false" +) + +// The values of EnrolmentTokenInput's sealed. +const ( + EnrolmentTokenInputSealedTrue = "true" + EnrolmentTokenInputSealedFalse = "false" +) + +// EnrolmentTokenAnswer is what enrolment-token answers. +type EnrolmentTokenAnswer struct { + // the bus's address the machine dials + Broker string `json:"broker,omitempty"` + // a drop left neither the token nor its hash + Dropped bool `json:"dropped,omitempty"` + // the fingerprint of the bus's certificate + Fingerprint string `json:"fingerprint,omitempty"` + // the node the token is for + Node string `json:"node"` + // the token sealed to the operator key, when asked sealed, relayed as it is + Sealed string `json:"sealed,omitempty"` + // the vault's public signing key in base64 + Signer string `json:"signer,omitempty"` +} + +// NodeAccountInput is what node-account takes. +type NodeAccountInput struct { + // the digest of every public key the node presented (delivery.KeysDigest) + KeysDigest string `json:"keys_digest,omitempty"` + // the joining node + Node string `json:"node"` + // "true": retire the account minted for this node + Retire string `json:"retire,omitempty"` + // the sealing key's public half the node presented, which the account is sealed to + SealingKey string `json:"sealing_key,omitempty"` +} + +// The values of NodeAccountInput's retire. +const ( + NodeAccountInputRetireTrue = "true" + NodeAccountInputRetireFalse = "false" +) + +// NodeAccountAnswer is what node-account answers. +type NodeAccountAnswer struct { + // the account sealed to the node's sealing key, in base64 + Account string `json:"account,omitempty"` + // the login minted + Login string `json:"login,omitempty"` + // the joining node + Node string `json:"node"` + // a retire left no account + Retired bool `json:"retired,omitempty"` +} + +// RotateNodeInput is what rotate-node takes. +type RotateNodeInput struct { + // the id of the hand act recorded for it in mesh-hand-acts + Act string `json:"act"` + // the node whose account rotates + Node string `json:"node"` + // why, in a sentence + Why string `json:"why"` +} + +// RotateNodeAnswer is what rotate-node answers. +type RotateNodeAnswer = map[string]any + +// NodeAccountConfirmInput is what node-account-confirm takes. +type NodeAccountConfirmInput struct { +} + +// NodeAccountConfirmAnswer is what node-account-confirm answers. +type NodeAccountConfirmAnswer = map[string]any + +// AbandonInput is what abandon takes. +type AbandonInput struct { + // the node whose rotation is abandoned + Node string `json:"node"` +} + +// AbandonAnswer is what abandon answers. +type AbandonAnswer = map[string]any + +// RetireInput is what retire takes. +type RetireInput struct { + // the id of the operator's ask the warrant answers + Ask string `json:"ask"` + // the module + Module string `json:"module"` + // the own secret's name + Name string `json:"name"` + // the node + Node string `json:"node"` + // the operator's warrant for this node, module and name: the hand act's id + Warrant string `json:"warrant"` +} + +// RetireAnswer is what retire answers. +type RetireAnswer struct { + // the secret is gone + Retired bool `json:"retired"` +} + +// SignedHolderEvent is the body of the holder's event signed. +type SignedHolderEvent struct { + // when it was signed, RFC 3339 + At string `json:"at"` + // the caller the vault proved + Caller string `json:"caller"` + // sha256: of the bytes signed + Digest string `json:"digest"` + // node-certificate or enrolment + Kind string `json:"kind"` + // the machine, for a node certificate + Node string `json:"node,omitempty"` + // the signer's fingerprint + Signer string `json:"signer"` +} + +// OwnRetiredHolderEvent is the body of the holder's event own-retired. +type OwnRetiredHolderEvent struct { + // the ask's id + Ask string `json:"ask,omitempty"` + // the caller the vault proved + Caller string `json:"caller"` + // the module + Module string `json:"module"` + // the secret's name + Name string `json:"name"` + // 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 time.Time `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 time.Time `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 time.Time `json:"since"` + // begun, ready or confirmed; empty when no rotation + Stage string `json:"stage,omitempty"` + V int64 `json:"v,omitempty"` +} diff --git a/identity-and-access/go/seats/meshvault/contract_test.go b/identity-and-access/go/seats/meshvault/contract_test.go new file mode 100644 index 0000000..8b8c520 --- /dev/null +++ b/identity-and-access/go/seats/meshvault/contract_test.go @@ -0,0 +1,80 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package meshvault + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/mesh-vault/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: "provisioned under the vault's own subjects", subject: "mesh.mod.mesh-vault.event.provisioned", 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\\.provisioned$")}, + {name: "rotated under the vault's own subjects", subject: "mesh.mod.mesh-vault.event.rotated", 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\\.rotated$")}, + {name: "secret-changed under the vault's own subjects", subject: "mesh.mod.mesh-vault.event.secret-changed", 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\\.secret-changed$")}, + {name: "account-hashed under the vault's own subjects", subject: "mesh.mod.mesh-vault.event.account-hashed", 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-hashed$")}, + {name: "own-replaced under the vault's own subjects", subject: "mesh.mod.mesh-vault.event.own-replaced", 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\\.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-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$")}, + {name: "node-account-ready under the vault's own subjects", subject: "mesh.mod.mesh-vault.event.node-account-ready", 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\\.node-account-ready$")}, + {name: "rotation-done under the vault's own subjects", subject: "mesh.mod.mesh-vault.event.rotation-done", 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-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\"}", + decode: func(b []byte) (any, error) { var v SignAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "an enrolment token's trust", subject: "", given: "{\"broker\":\"tls://192.0.2.1:4222\",\"fingerprint\":\"sha256:2222222222222222222222222222222222222222222222222222222222222222\",\"node\":\"ace\",\"signer\":\"AAAA\"}", wire: "{\"broker\":\"tls://192.0.2.1:4222\",\"fingerprint\":\"sha256:2222222222222222222222222222222222222222222222222222222222222222\",\"node\":\"ace\",\"signer\":\"AAAA\"}", + decode: func(b []byte) (any, error) { var v EnrolmentTokenAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a node account retired", subject: "", given: "{\"node\":\"ace\",\"retired\":true}", wire: "{\"node\":\"ace\",\"retired\":true}", + decode: func(b []byte) (any, error) { var v NodeAccountAnswer; 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/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/identity-and-access/seats/mesh-node-enroller/fixtures/verbs.json b/identity-and-access/seats/mesh-node-enroller/fixtures/verbs.json new file mode 100644 index 0000000..d594ec3 --- /dev/null +++ b/identity-and-access/seats/mesh-node-enroller/fixtures/verbs.json @@ -0,0 +1,14 @@ +[ + { + "name": "redeem answers the account sealed to the node, signed by the vault", + "of": "verb:redeem.answer", + "given": { + "node": "g14", + "login": "g14-login", + "account": "c2VhbGVk", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n", + "signer": "dmF1bHQ=", + "signature": "c2ln" + } + } +] diff --git a/identity-and-access/seats/mesh-node-enroller/seat.json b/identity-and-access/seats/mesh-node-enroller/seat.json new file mode 100644 index 0000000..4d21cd0 --- /dev/null +++ b/identity-and-access/seats/mesh-node-enroller/seat.json @@ -0,0 +1,340 @@ +{ + "format": 1, + "seat": "mesh-node-enroller", + "scope": "mesh", + "grain": "whole", + "domain": "Identity and access", + "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", + "description": "Issue a one-time token for a new machine, on the operator's Approve: refused at once, naming every finding, for a name that is not a node name or that a node record, a placement key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise reserves the lowest free private-network address, asks the operator under the seat's own name and, on the warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. The token itself is read only through tokens.", + "input": { + "properties": { + "expires": { + "description": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours", + "type": "string" + }, + "node": { + "description": "the new machine's name in the mesh", + "type": "string" + }, + "overlay_key": { + "description": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it", + "type": "string" + }, + "why": { + "description": "why it joins, shown to the operator with the ask", + "type": "string" + } + }, + "required": [ + "node", + "why", + "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. 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": { + "node": { + "description": "the node the token was issued for", + "type": "string" + }, + "overlay_key": { + "description": "the private network key's public half", + "type": "string" + }, + "profile": { + "description": "what the machine is, as its record says it", + "type": "object" + }, + "proof": { + "description": "the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64", + "type": "string" + }, + "public_key": { + "description": "the identity key's public half, base64", + "type": "string" + }, + "sealing_key": { + "description": "the sealing key's public half, which the account is sealed to", + "type": "string" + }, + "serving_key": { + "description": "the serving key's public half, which the certificate binds", + "type": "string" + }, + "tunnel": { + "description": "a tunnel found on the machine: everything but its private key", + "type": "object" + } + }, + "required": [ + "node", + "public_key", + "sealing_key", + "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", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "login": { + "type": "string", + "description": "the login it dials as next" + }, + "account": { + "type": "string", + "description": "its account sealed to the sealing key it presented, base64" + }, + "certificate": { + "type": "string", + "description": "its certificate, PEM" + }, + "signer": { + "type": "string", + "description": "the vault's public signing half, base64" + }, + "signature": { + "type": "string", + "description": "the vault's signature over the answer without it, under the context of the enrolment kind, base64" + } + }, + "required": [ + "node", + "login", + "account", + "certificate", + "signer" + ] + } + }, + { + "name": "confirm", + "description": "Confirm an enrolment: asked by the node-engine dialled as its own node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the enrolment.", + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the enrolment ended" + } + }, + { + "name": "revoke", + "description": "End an enrolment at any stage, with why, without an Approve: withdraws an open ask, deletes the node's key and its address, has the vault retire an account it already minted for it, records the act as a hand act and says enrolment-revoked.", + "input": { + "properties": { + "node": { + "description": "the node", + "type": "string" + }, + "why": { + "description": "why, recorded with the hand act", + "type": "string" + } + }, + "required": [ + "node", + "why" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the enrolment ended" + } + }, + { + "name": "tokens", + "description": "Read every open enrolment, or one: its stage, expiry, ask and revision, the bucket's fill against its cap. For a node at live, its token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With history, the ended enrolments from the seat's events, as far back as the events are kept.", + "input": { + "properties": { + "history": { + "description": "\"true\": the ended enrolments too", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "node": { + "description": "one node", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "every open enrolment, or one" + } + }, + { + "name": "again", + "description": "Raise an issue's ask again when the operator's channel does not hold it, and nothing more.", + "input": { + "properties": { + "node": { + "description": "the node whose ask is raised again", + "type": "string" + } + }, + "required": [ + "node" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the ask raised again" + } + } + ], + "events": [ + { + "name": "enrolment-issued", + "body": { + "$ref": "#/defs/Enrolment" + } + }, + { + "name": "node-enrolled", + "body": { + "$ref": "#/defs/Enrolment" + } + }, + { + "name": "enrolment-refused", + "body": { + "$ref": "#/defs/Enrolment" + } + }, + { + "name": "enrolment-revoked", + "body": { + "$ref": "#/defs/Enrolment" + } + }, + { + "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/identity-and-access/seats/mesh-vault/fixtures/events.json b/identity-and-access/seats/mesh-vault/fixtures/events.json new file mode 100644 index 0000000..4a64125 --- /dev/null +++ b/identity-and-access/seats/mesh-vault/fixtures/events.json @@ -0,0 +1,80 @@ +[ + { + "name": "provisioned under the vault's own subjects", + "of": "holder-event:provisioned", + "subject": "mesh.mod.mesh-vault.event.provisioned", + "given": {} + }, + { + "name": "rotated under the vault's own subjects", + "of": "holder-event:rotated", + "subject": "mesh.mod.mesh-vault.event.rotated", + "given": {} + }, + { + "name": "secret-changed under the vault's own subjects", + "of": "holder-event:secret-changed", + "subject": "mesh.mod.mesh-vault.event.secret-changed", + "given": {} + }, + { + "name": "account-hashed under the vault's own subjects", + "of": "holder-event:account-hashed", + "subject": "mesh.mod.mesh-vault.event.account-hashed", + "given": {} + }, + { + "name": "own-replaced under the vault's own subjects", + "of": "holder-event:own-replaced", + "subject": "mesh.mod.mesh-vault.event.own-replaced", + "given": {} + }, + { + "name": "account-issued under the vault's own subjects", + "of": "holder-event:account-issued", + "subject": "mesh.mod.mesh-vault.event.account-issued", + "given": {} + }, + { + "name": "signed under the vault's own subjects", + "of": "holder-event:signed", + "subject": "mesh.mod.mesh-vault.event.signed", + "given": { + "caller": "mesh-node-enroller", + "kind": "enrolment", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "signer": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "at": "2026-11-04T13:00:00Z" + } + }, + { + "name": "rotation-begun under the vault's own subjects", + "of": "holder-event:rotation-begun", + "subject": "mesh.mod.mesh-vault.event.rotation-begun", + "given": {} + }, + { + "name": "node-account-ready under the vault's own subjects", + "of": "holder-event:node-account-ready", + "subject": "mesh.mod.mesh-vault.event.node-account-ready", + "given": {} + }, + { + "name": "rotation-done under the vault's own subjects", + "of": "holder-event:rotation-done", + "subject": "mesh.mod.mesh-vault.event.rotation-done", + "given": {} + }, + { + "name": "own-retired", + "of": "holder-event:own-retired", + "subject": "mesh.mod.mesh-vault.event.own-retired", + "given": { + "node": "ace", + "module": "messenger", + "name": "token", + "ask": "a1", + "caller": "mesh-config" + } + } +] 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/identity-and-access/seats/mesh-vault/fixtures/verbs.json b/identity-and-access/seats/mesh-vault/fixtures/verbs.json new file mode 100644 index 0000000..49b29f3 --- /dev/null +++ b/identity-and-access/seats/mesh-vault/fixtures/verbs.json @@ -0,0 +1,43 @@ +[ + { + "name": "retire on a warrant", + "of": "verb:retire.input", + "given": { + "node": "ace", + "module": "messenger", + "name": "token", + "warrant": "w-1", + "ask": "a1" + } + }, + { + "name": "a node certificate signed", + "of": "verb:sign.answer", + "given": { + "kind": "node-certificate", + "node": "ace", + "certificate": "-----BEGIN CERTIFICATE-----", + "authority": "-----BEGIN CERTIFICATE-----", + "signer": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + } + }, + { + "name": "an enrolment token's trust", + "of": "verb:enrolment-token.answer", + "given": { + "node": "ace", + "broker": "tls://192.0.2.1:4222", + "fingerprint": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "signer": "AAAA" + } + }, + { + "name": "a node account retired", + "of": "verb:node-account.answer", + "given": { + "node": "ace", + "retired": true + } + } +] diff --git a/identity-and-access/seats/mesh-vault/seat.json b/identity-and-access/seats/mesh-vault/seat.json new file mode 100644 index 0000000..7c93f1b --- /dev/null +++ b/identity-and-access/seats/mesh-vault/seat.json @@ -0,0 +1,885 @@ +{ + "format": 1, + "seat": "mesh-vault", + "scope": "mesh", + "grain": "whole", + "domain": "Identity and access", + "decision": "novox/hq ADR 0161, ADR 0634, ADR 0635", + "delivers": "secret", + "verbs": [ + { + "name": "get", + "description": "One secret, sealed to the public key the asker sends (the sealed box of ADR 0206), with its generation; answered only to an asker the vault allows, as the bus proved it. Never the value in the clear, and never to a private key.", + "optional": true, + "input": { + "properties": { + "name": { + "description": "the secret's name", + "type": "string" + }, + "public_key": { + "description": "the asker's X25519 public key as SPKI PEM — never a private key", + "type": "string" + } + }, + "required": [ + "name", + "public_key" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "secret-ask", + "description": "Ask the operator for a module's own secret at a desk, from the controller or a caller the vault's manifest names, named as the bus proved it (novox/hq ADR 0563 §8): the ask is recorded in the vault's store and bounded before the prompt opens, and what is typed is sealed to a key the vault made for the call and opens there alone.", + "optional": true, + "input": { + "properties": { + "asked_by": { + "description": "who asked, as the bus named its caller", + "type": "string" + }, + "call": { + "description": "the second step: the call the first step answered", + "type": "string" + }, + "desk": { + "description": "the machine whose prompt asks", + "type": "string" + }, + "module": { + "description": "the module", + "type": "string" + }, + "name": { + "description": "the secret's name in the module", + "type": "string" + }, + "node": { + "description": "the machine the secret is for", + "type": "string" + }, + "outcome": { + "description": "instead of sealed: how the prompt ended", + "type": "string" + }, + "sealed": { + "description": "the second step: the value sealed to the call's key — never the value", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "issued", + "description": "Take the credential a provider's software issued for a provision, from the controller alone, which asks the provider's tool with a key the vault made for the call; kept as the provision's one credential, or, when the tool answers a value per holder named, each as that holder's own; the same value again writes nothing.", + "optional": true, + "input": { + "properties": { + "call": { + "description": "the second step: the call the first step answered", + "type": "string" + }, + "holders": { + "description": "the provision's holders from that provider, each {node, module}: its tool may answer a value for each, kept as that holder's own and handed to it alone", + "items": { + "properties": { + "module": { + "description": "the consuming module", + "type": "string" + }, + "node": { + "description": "the holder's machine", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "module": { + "description": "the provider's module", + "type": "string" + }, + "provider": { + "description": "the provider's machine", + "type": "string" + }, + "provision": { + "description": "the provision", + "type": "string" + }, + "sealed": { + "description": "the second step: the value sealed to the call's key — never the value", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "rotate", + "description": "Make a secret anew. Refused for a value a provider's software issued and for an accepted credential. Never answers the value.", + "optional": true, + "input": { + "properties": { + "by": { + "description": "who asked, when the controller carries the act", + "type": "string" + }, + "module": { + "description": "the module", + "type": "string" + }, + "name": { + "description": "the secret's name in the module", + "type": "string" + }, + "node": { + "description": "the machine, for a module's own secret", + "type": "string" + }, + "provider": { + "description": "the provider's machine", + "type": "string" + }, + "provision": { + "description": "the provision", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "move-in", + "description": "Take one value of the controller's store in, as it is, from the node-engine of a machine it was sealed to and no other caller: name the row, its origin and generation and get a call and a key made for it, then send the call, the value sealed to that key and a key to read back to. Never replaces a value the vault holds; answers what it kept, read back sealed to that key.", + "optional": true, + "input": { + "properties": { + "call": { + "description": "the second step: the call the first step answered", + "type": "string" + }, + "consumer": { + "description": "the consumer's machine", + "type": "string" + }, + "consumer_module": { + "description": "the consuming module", + "type": "string" + }, + "generation": { + "description": "the controller's generation for the value", + "type": "string" + }, + "local": { + "description": "the credential's local name, when it has one", + "type": "string" + }, + "made_at": { + "description": "when it was made (RFC 3339)", + "type": "string" + }, + "module": { + "description": "its module", + "type": "string" + }, + "name": { + "description": "its name in the module", + "type": "string" + }, + "node": { + "description": "a module's own secret: its machine", + "type": "string" + }, + "origin": { + "description": "made, accepted or issued, as the controller's store says", + "type": "string" + }, + "provider": { + "description": "a pair credential: the provider's machine", + "type": "string" + }, + "provision": { + "description": "the provision", + "type": "string" + }, + "read_back_to": { + "description": "the second step: a raw X25519 public key the read-back is sealed to", + "type": "string" + }, + "sealed": { + "description": "the second step: the value sealed to the call's key — never the value", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "node-key", + "description": "State this machine's sealing key to the vault, in its own name, from the node-engine of the machine alone: at its start and whenever the key changes. A first or changed key is recorded and the machine's core secrets are said changed; the same key again changes nothing.", + "optional": true, + "input": { + "properties": { + "key": { + "description": "the machine's sealing key, a raw X25519 public key in base64", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "own", + "description": "Answer the node-engine of one machine, about its own machine alone (as the bus proved the caller, never an argument), its modules' own secrets and the pair credentials it consumes or provides that it names, each sealed to the key it stated with node-key. A credential the vault does not hold is named missing; never a value in the clear.", + "optional": true, + "input": { + "properties": { + "credentials": { + "description": "the pair credentials the machine's composition reads, each {role: consumer|provider, provision, consumer, consumer_module, provider, local}", + "items": { + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "issue-account", + "description": "Mint one bus account the grants part wants, now (novox/hq ADR 0561 §9): only while the controller's lease is handed away, never a node's account or the controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.", + "optional": true, + "input": { + "properties": { + "user": { + "description": "the bus user, e.g. ace.mesh-build-go", + "type": "string" + } + }, + "required": [ + "user" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "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-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": { + "kind": { + "description": "node-certificate or enrolment", + "type": "string" + }, + "node": { + "description": "the machine, for a node certificate: the caller's own, or the node mesh-node-enroller enrols", + "type": "string" + }, + "reply": { + "description": "the redeem answer's bytes in base64, for an enrolment", + "type": "string" + }, + "serving_key": { + "description": "from mesh-node-enroller: the serving key's public half the enrolling node presented", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "for a node certificate the certificate as PEM and the authority that signed it, for an enrolment the signature in base64; always the signer's fingerprint and the digest; never a key's private half", + "properties": { + "kind": { + "type": "string", + "description": "node-certificate or enrolment, as asked" + }, + "node": { + "type": "string", + "description": "the machine, for a node certificate" + }, + "certificate": { + "type": "string", + "description": "the node certificate as PEM, for a node certificate" + }, + "authority": { + "type": "string", + "description": "the authority's certificate that signed it, as PEM" + }, + "signature": { + "type": "string", + "description": "the signature in base64, for an enrolment" + }, + "signer": { + "type": "string", + "description": "the signer's fingerprint" + }, + "digest": { + "type": "string", + "description": "sha256: of the bytes signed" + } + }, + "required": [ + "kind", + "signer", + "digest" + ] + } + }, + { + "name": "enrolment-token", + "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": { + "drop": { + "description": "\"true\": drop the node's token and its hash", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "expires": { + "description": "when the token expires (RFC 3339), as the enrolment's key says", + "type": "string" + }, + "node": { + "description": "the node the token is for", + "type": "string" + }, + "sealed": { + "description": "\"true\": answer the token sealed to the operator key", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "tunnel": { + "additionalProperties": false, + "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", + "type": "string" + }, + "hub_endpoint": { + "description": "the hub's endpoint, host:port", + "type": "string" + }, + "hub_key": { + "description": "the hub's tunnel public key", + "type": "string" + }, + "key": { + "description": "the joining machine's own tunnel public key, as given at issue", + "type": "string" + }, + "range": { + "description": "the private network's range, a prefix", + "type": "string" + } + }, + "required": [ + "address", + "hub_endpoint", + "hub_key", + "key", + "range" + ], + "type": "object" + } + }, + "required": [ + "node" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "what the joining machine must trust, never the token: the bus's address, its certificate's fingerprint and the vault's public signing key; the token sealed to the operator key when asked sealed; or that a drop left neither the token nor its hash", + "properties": { + "node": { + "type": "string", + "description": "the node the token is for" + }, + "broker": { + "type": "string", + "description": "the bus's address the machine dials" + }, + "fingerprint": { + "type": "string", + "description": "the fingerprint of the bus's certificate" + }, + "signer": { + "type": "string", + "description": "the vault's public signing key in base64" + }, + "sealed": { + "type": "string", + "description": "the token sealed to the operator key, when asked sealed, relayed as it is" + }, + "dropped": { + "type": "boolean", + "description": "a drop left neither the token nor its hash" + } + }, + "required": [ + "node" + ] + } + }, + { + "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-node-enroller alone. Never the value in the clear.", + "optional": true, + "input": { + "properties": { + "keys_digest": { + "description": "the digest of every public key the node presented (delivery.KeysDigest)", + "type": "string" + }, + "node": { + "description": "the joining node", + "type": "string" + }, + "retire": { + "description": "\"true\": retire the account minted for this node", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "sealing_key": { + "description": "the sealing key's public half the node presented, which the account is sealed to", + "type": "string" + } + }, + "required": [ + "node" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the login minted and the account sealed to the sealing key the node presented, never in the clear; or that a retire left no account", + "properties": { + "node": { + "type": "string", + "description": "the joining node" + }, + "login": { + "type": "string", + "description": "the login minted" + }, + "account": { + "type": "string", + "description": "the account sealed to the node's sealing key, in base64" + }, + "retired": { + "type": "boolean", + "description": "a retire left no account" + } + }, + "required": [ + "node" + ] + } + }, + { + "name": "rotate-node", + "description": "Begin a node's account rotation to its other login: answered only for a call carrying a hand act recorded through mesh-hand-acts, which the vault reads back by its id. Makes the other login's value sealed to that node, writes it rotating in node-logins and says rotation-begun; says node-account-ready once the bus node's have shows a list holding both logins. One node at a time across the mesh, the bus node last: a call while a rotation runs waits until it is done.", + "optional": true, + "input": { + "properties": { + "act": { + "description": "the id of the hand act recorded for it in mesh-hand-acts", + "type": "string" + }, + "node": { + "description": "the node whose account rotates", + "type": "string" + }, + "why": { + "description": "why, in a sentence", + "type": "string" + } + }, + "required": [ + "node", + "why", + "act" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "node-account-confirm", + "description": "Confirm a node's switch to its new login: asked by the node-engine dialled as that login, the runtime's proof of the caller being the confirmation. The old login's hash is then removed, and rotation-done said once the bus node's have shows the list without it.", + "optional": true, + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "abandon", + "description": "Abandon a node's rotation before its old login is retired: the new login and its value dropped, the old one kept; a node that switched switches back first.", + "optional": true, + "input": { + "properties": { + "node": { + "description": "the node whose rotation is abandoned", + "type": "string" + } + }, + "required": [ + "node" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "retire", + "optional": true, + "description": "Retire one module's own secret, by node, module and name, on the operator's warrant bound to exactly those three (design 32 *A value no longer read*). Emits own-retired.", + "callers": "the holder of the seat mesh-config alone", + "input": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "module": { + "type": "string", + "description": "the module" + }, + "name": { + "type": "string", + "description": "the own secret's name" + }, + "warrant": { + "type": "string", + "description": "the operator's warrant for this node, module and name: the hand act's id" + }, + "ask": { + "type": "string", + "description": "the id of the operator's ask the warrant answers" + } + }, + "required": [ + "node", + "module", + "name", + "warrant", + "ask" + ] + }, + "answer": { + "type": "object", + "properties": { + "retired": { + "type": "boolean", + "description": "the secret is gone" + } + }, + "required": [ + "retired" + ] + } + } + ], + "description": "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).", + "holder-events": [ + { + "name": "provisioned", + "subject": "mesh.mod..event.provisioned" + }, + { + "name": "rotated", + "subject": "mesh.mod..event.rotated" + }, + { + "name": "secret-changed", + "subject": "mesh.mod..event.secret-changed" + }, + { + "name": "account-hashed", + "subject": "mesh.mod..event.account-hashed" + }, + { + "name": "own-replaced", + "subject": "mesh.mod..event.own-replaced" + }, + { + "name": "account-issued", + "subject": "mesh.mod..event.account-issued" + }, + { + "name": "signed", + "subject": "mesh.mod..event.signed", + "body": { + "type": "object", + "description": "who asked, the kind, the node and the digest of what was signed; never a key", + "properties": { + "caller": { + "type": "string", + "description": "the caller the vault proved" + }, + "kind": { + "type": "string", + "description": "node-certificate or enrolment" + }, + "node": { + "type": "string", + "description": "the machine, for a node certificate" + }, + "digest": { + "type": "string", + "description": "sha256: of the bytes signed" + }, + "signer": { + "type": "string", + "description": "the signer's fingerprint" + }, + "at": { + "type": "string", + "description": "when it was signed, RFC 3339" + } + }, + "required": [ + "caller", + "kind", + "digest", + "signer", + "at" + ] + } + }, + { + "name": "rotation-begun", + "subject": "mesh.mod..event.rotation-begun" + }, + { + "name": "node-account-ready", + "subject": "mesh.mod..event.node-account-ready" + }, + { + "name": "rotation-done", + "subject": "mesh.mod..event.rotation-done" + }, + { + "name": "own-retired", + "subject": "mesh.mod..event.own-retired", + "body": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "module": { + "type": "string", + "description": "the module" + }, + "name": { + "type": "string", + "description": "the secret's name" + }, + "ask": { + "type": "string", + "description": "the ask's id" + }, + "caller": { + "type": "string", + "description": "the caller the vault proved" + } + }, + "required": [ + "node", + "module", + "name", + "caller" + ] + } + } + ], + "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/identity-and-access/seats/node-secret-service/seat.json b/identity-and-access/seats/node-secret-service/seat.json new file mode 100644 index 0000000..9dc3e27 --- /dev/null +++ b/identity-and-access/seats/node-secret-service/seat.json @@ -0,0 +1,8 @@ +{ + "format": 1, + "seat": "node-secret-service", + "scope": "node", + "grain": "seat-list", + "domain": "Identity and access", + "decision": "novox/hq ADR 0208" +} 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..1312a13 --- /dev/null +++ b/identity-and-access/ts/seats/meshnodeenroller/src/index.ts @@ -0,0 +1,204 @@ +// 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 interface RedeemAnswer { + /** its account sealed to the sealing key it presented, base64 */ + "account": string; + /** its certificate, PEM */ + "certificate": string; + /** the login it dials as next */ + "login": string; + /** the node */ + "node": string; + /** the vault's signature over the answer without it, under the context of the enrolment kind, base64 */ + "signature"?: string; + /** the vault's public signing half, base64 */ + "signer": string; +} + +/** 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/identity-and-access/ts/seats/meshnodeenroller/tsconfig.json b/identity-and-access/ts/seats/meshnodeenroller/tsconfig.json new file mode 100644 index 0000000..60274c6 --- /dev/null +++ b/identity-and-access/ts/seats/meshnodeenroller/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/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/identity-and-access/ts/seats/meshvault/src/index.ts b/identity-and-access/ts/seats/meshvault/src/index.ts new file mode 100644 index 0000000..e3f2c11 --- /dev/null +++ b/identity-and-access/ts/seats/meshvault/src/index.ts @@ -0,0 +1,482 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// 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). + +export const seat = "mesh-vault"; + +export const verbs = { + get: "get", + secretAsk: "secret-ask", + issued: "issued", + rotate: "rotate", + moveIn: "move-in", + nodeKey: "node-key", + own: "own", + issueAccount: "issue-account", + sign: "sign", + enrolmentToken: "enrolment-token", + nodeAccount: "node-account", + rotateNode: "rotate-node", + nodeAccountConfirm: "node-account-confirm", + abandon: "abandon", + retire: "retire", +} as const; + +export const holderEvents = { + provisioned: "provisioned", + rotated: "rotated", + secretChanged: "secret-changed", + accountHashed: "account-hashed", + ownReplaced: "own-replaced", + accountIssued: "account-issued", + signed: "signed", + rotationBegun: "rotation-begun", + nodeAccountReady: "node-account-ready", + rotationDone: "rotation-done", + ownRetired: "own-retired", +} as const; + +export function provisionedHolderSubject(holder: string): string { + return "mesh.mod." + holder + ".event.provisioned"; +} + +export function rotatedHolderSubject(holder: string): string { + return "mesh.mod." + holder + ".event.rotated"; +} + +export function secretChangedHolderSubject(holder: string): string { + return "mesh.mod." + holder + ".event.secret-changed"; +} + +export function accountHashedHolderSubject(holder: string): string { + return "mesh.mod." + holder + ".event.account-hashed"; +} + +export function ownReplacedHolderSubject(holder: string): string { + return "mesh.mod." + holder + ".event.own-replaced"; +} + +export function accountIssuedHolderSubject(holder: string): string { + return "mesh.mod." + holder + ".event.account-issued"; +} + +export function signedHolderSubject(holder: string): string { + return "mesh.mod." + holder + ".event.signed"; +} + +export function rotationBegunHolderSubject(holder: string): string { + return "mesh.mod." + holder + ".event.rotation-begun"; +} + +export function nodeAccountReadyHolderSubject(holder: string): string { + return "mesh.mod." + holder + ".event.node-account-ready"; +} + +export function rotationDoneHolderSubject(holder: string): string { + return "mesh.mod." + holder + ".event.rotation-done"; +} + +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 */ + "name": string; + /** the asker's X25519 public key as SPKI PEM — never a private key */ + "public_key": string; +} + +/** is what get answers. */ +export type GetAnswer = Record; + +/** is what secret-ask takes. */ +export interface SecretAskInput { + /** who asked, as the bus named its caller */ + "asked_by"?: string; + /** the second step: the call the first step answered */ + "call"?: string; + /** the machine whose prompt asks */ + "desk"?: string; + /** the module */ + "module"?: string; + /** the secret's name in the module */ + "name"?: string; + /** the machine the secret is for */ + "node"?: string; + /** instead of sealed: how the prompt ended */ + "outcome"?: string; + /** the second step: the value sealed to the call's key — never the value */ + "sealed"?: string; +} + +/** is what secret-ask answers. */ +export type SecretAskAnswer = Record; + +export interface IssuedInputHoldersItem { + /** the consuming module */ + "module"?: string; + /** the holder's machine */ + "node"?: string; +} + +/** is what issued takes. */ +export interface IssuedInput { + /** the second step: the call the first step answered */ + "call"?: string; + /** the provision's holders from that provider, each {node, module}: its tool may answer a value for each, kept as that holder's own and handed to it alone */ + "holders"?: IssuedInputHoldersItem[]; + /** the provider's module */ + "module"?: string; + /** the provider's machine */ + "provider"?: string; + /** the provision */ + "provision"?: string; + /** the second step: the value sealed to the call's key — never the value */ + "sealed"?: string; +} + +/** is what issued answers. */ +export type IssuedAnswer = Record; + +/** is what rotate takes. */ +export interface RotateInput { + /** who asked, when the controller carries the act */ + "by"?: string; + /** the module */ + "module"?: string; + /** the secret's name in the module */ + "name"?: string; + /** the machine, for a module's own secret */ + "node"?: string; + /** the provider's machine */ + "provider"?: string; + /** the provision */ + "provision"?: string; +} + +/** is what rotate answers. */ +export type RotateAnswer = Record; + +/** is what move-in takes. */ +export interface MoveInInput { + /** the second step: the call the first step answered */ + "call"?: string; + /** the consumer's machine */ + "consumer"?: string; + /** the consuming module */ + "consumer_module"?: string; + /** the controller's generation for the value */ + "generation"?: string; + /** the credential's local name, when it has one */ + "local"?: string; + /** when it was made (RFC 3339) */ + "made_at"?: string; + /** its module */ + "module"?: string; + /** its name in the module */ + "name"?: string; + /** a module's own secret: its machine */ + "node"?: string; + /** made, accepted or issued, as the controller's store says */ + "origin"?: string; + /** a pair credential: the provider's machine */ + "provider"?: string; + /** the provision */ + "provision"?: string; + /** the second step: a raw X25519 public key the read-back is sealed to */ + "read_back_to"?: string; + /** the second step: the value sealed to the call's key — never the value */ + "sealed"?: string; +} + +/** is what move-in answers. */ +export type MoveInAnswer = Record; + +/** is what node-key takes. */ +export interface NodeKeyInput { + /** the machine's sealing key, a raw X25519 public key in base64 */ + "key"?: string; +} + +/** is what node-key answers. */ +export type NodeKeyAnswer = Record; + +/** is what own takes. */ +export interface OwnInput { + /** the pair credentials the machine's composition reads, each {role: consumer|provider, provision, consumer, consumer_module, provider, local} */ + "credentials"?: Record[]; +} + +/** is what own answers. */ +export type OwnAnswer = Record; + +/** is what issue-account takes. */ +export interface IssueAccountInput { + /** the bus user, e.g. ace.mesh-build-go */ + "user": string; +} + +/** is what issue-account answers. */ +export type IssueAccountAnswer = Record; + +/** is what sign takes. */ +export interface SignInput { + /** node-certificate or enrolment */ + "kind": string; + /** 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-node-enroller: the serving key's public half the enrolling node presented */ + "serving_key"?: string; +} + +/** is what sign answers. */ +export interface SignAnswer { + /** the authority's certificate that signed it, as PEM */ + "authority"?: string; + /** the node certificate as PEM, for a node certificate */ + "certificate"?: string; + /** sha256: of the bytes signed */ + "digest": string; + /** node-certificate or enrolment, as asked */ + "kind": string; + /** the machine, for a node certificate */ + "node"?: string; + /** the signature in base64, for an enrolment */ + "signature"?: string; + /** the signer's fingerprint */ + "signer": string; +} + +/** 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; + /** the hub's endpoint, host:port */ + "hub_endpoint": string; + /** the hub's tunnel public key */ + "hub_key": string; + /** the joining machine's own tunnel public key, as given at issue */ + "key": string; + /** the private network's range, a prefix */ + "range": string; +} + +/** is what enrolment-token takes. */ +export interface EnrolmentTokenInput { + /** "true": drop the node's token and its hash */ + "drop"?: "true" | "false"; + /** when the token expires (RFC 3339), as the enrolment's key says */ + "expires"?: string; + /** the node the token is for */ + "node": string; + /** "true": answer the token sealed to the operator key */ + "sealed"?: "true" | "false"; + /** 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; +} + +/** is what enrolment-token answers. */ +export interface EnrolmentTokenAnswer { + /** the bus's address the machine dials */ + "broker"?: string; + /** a drop left neither the token nor its hash */ + "dropped"?: boolean; + /** the fingerprint of the bus's certificate */ + "fingerprint"?: string; + /** the node the token is for */ + "node": string; + /** the token sealed to the operator key, when asked sealed, relayed as it is */ + "sealed"?: string; + /** the vault's public signing key in base64 */ + "signer"?: string; +} + +/** is what node-account takes. */ +export interface NodeAccountInput { + /** the digest of every public key the node presented (delivery.KeysDigest) */ + "keys_digest"?: string; + /** the joining node */ + "node": string; + /** "true": retire the account minted for this node */ + "retire"?: "true" | "false"; + /** the sealing key's public half the node presented, which the account is sealed to */ + "sealing_key"?: string; +} + +/** is what node-account answers. */ +export interface NodeAccountAnswer { + /** the account sealed to the node's sealing key, in base64 */ + "account"?: string; + /** the login minted */ + "login"?: string; + /** the joining node */ + "node": string; + /** a retire left no account */ + "retired"?: boolean; +} + +/** is what rotate-node takes. */ +export interface RotateNodeInput { + /** the id of the hand act recorded for it in mesh-hand-acts */ + "act": string; + /** the node whose account rotates */ + "node": string; + /** why, in a sentence */ + "why": string; +} + +/** is what rotate-node answers. */ +export type RotateNodeAnswer = Record; + +/** is what node-account-confirm takes. */ +export interface NodeAccountConfirmInput { +} + +/** is what node-account-confirm answers. */ +export type NodeAccountConfirmAnswer = Record; + +/** is what abandon takes. */ +export interface AbandonInput { + /** the node whose rotation is abandoned */ + "node": string; +} + +/** is what abandon answers. */ +export type AbandonAnswer = Record; + +/** is what retire takes. */ +export interface RetireInput { + /** the id of the operator's ask the warrant answers */ + "ask": string; + /** the module */ + "module": string; + /** the own secret's name */ + "name": string; + /** the node */ + "node": string; + /** the operator's warrant for this node, module and name: the hand act's id */ + "warrant": string; +} + +/** is what retire answers. */ +export interface RetireAnswer { + /** the secret is gone */ + "retired": boolean; +} + +/** is the body of the holder's event signed. */ +export interface SignedHolderEvent { + /** when it was signed, RFC 3339 */ + "at": string; + /** the caller the vault proved */ + "caller": string; + /** sha256: of the bytes signed */ + "digest": string; + /** node-certificate or enrolment */ + "kind": string; + /** the machine, for a node certificate */ + "node"?: string; + /** the signer's fingerprint */ + "signer": string; +} + +/** is the body of the holder's event own-retired. */ +export interface OwnRetiredHolderEvent { + /** the ask's id */ + "ask"?: string; + /** the caller the vault proved */ + "caller": string; + /** the module */ + "module": string; + /** the secret's name */ + "name": string; + /** the node */ + "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 new file mode 100644 index 0000000..1d7d5c0 --- /dev/null +++ b/module.json @@ -0,0 +1,117 @@ +{ + "module": "mesh-contracts", + "version": "1", + "slug": "contracts", + "build": { + "artifacts": [ + { + "name": "go-meshconfigclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/meshconfigclient" + ], + "series": "0.1" + }, + { + "name": "go-nodeengineclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/nodeengineclient" + ], + "series": "0.1" + }, + { + "name": "go-meshbroker", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshbroker" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshconfig", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshconfig" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-nodeengine", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/nodeengine" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-meshbroker", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshbroker" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-meshconfig", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshconfig" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-nodeengine", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/nodeengine" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshmoduleassigner", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshmoduleassigner" + ], + "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": "." + } + ] + }, + "resources": [] +} diff --git a/operator-and-conversation/go/clients/operatorchannelclient/ask.go b/operator-and-conversation/go/clients/operatorchannelclient/ask.go new file mode 100644 index 0000000..eb5a5ed --- /dev/null +++ b/operator-and-conversation/go/clients/operatorchannelclient/ask.go @@ -0,0 +1,393 @@ +// Package operatorchannelclient is the operator-channel seat's client (novox/hq ADR 0627 §1; ADR 0259): the logic +// every asker and the router share over the seat's contract. An asker checks its ask (Check, Glance), binds what each +// option performs (ActDigest), publishes it under its own name and acts on the warrant it hears only when the +// warrant answers that ask, as the person was shown it (Digest, For, Performs). A module says a condition to the +// operator by the seat's condition accept (EncodeCondition). The asker's keeping of its asks and warrants is Client. +// The shapes are the seat's contract's (operatorchannel); this package holds no type of its own for them. +package operatorchannelclient + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" + + seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" +) + +// The levels an option is answered at: how much proof its answer needs (novox/hq ADR 0234 §8, the glossary's +// assurance level). +const ( + // Acknowledge performs only what any granted principal may already do: silencing, details. No proof. + Acknowledge = "acknowledge" + // Approve needs one proof: a verified sender (a linked account, linked an hour or more) or a code. + Approve = "approve" + // Destroy needs two proofs, one of them a code. + Destroy = "destroy" +) + +// Rank orders the levels; an unknown level ranks above every known one, so it is never taken as less. +func Rank(level string) int { + switch level { + case Acknowledge: + return 0 + case Approve: + return 1 + case Destroy: + return 2 + } + return 3 +} + +// Operator is the one role an ask may be answered by today. +const Operator = "operator" + +// How an ask ended (a warrant's outcome). +const ( + OutcomeChosen = "chosen" // a person chose an option: a warrant + OutcomeExpired = "expired" // nobody answered in time + OutcomeCancelled = "cancelled" // its asker took it back + OutcomeReplaced = "replaced" // a newer ask about the same thing replaced it + OutcomeRefused = "refused" // it was never shown: Words says why +) + +// An Act is what an option binds: named fields, each a string — the verb, the machine, the level, and each +// argument under a name of its own ("arg.delivery"). Flat on purpose: its digest is over these names and +// values alone, never over how a language happens to encode a struct. +type Act map[string]string + +// ActDigest is the digest an asker puts in an option's Binds: SHA-256 over the act's canonical encoding +// (canonical), written "sha256:". The same names and values give the same digest in any language, whatever +// order they were set in; a field renamed, added or emptied gives another. +func ActDigest(act Act) (string, error) { + if len(act) == 0 { + return "", fmt.Errorf("the act cannot be digested: it names nothing") + } + keys := make([]string, 0, len(act)) + for k := range act { + if k == "" { + return "", fmt.Errorf("the act cannot be digested: a field has no name") + } + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + b.WriteString("novox.act.v1\n") + for _, k := range keys { + canonical(&b, k) + canonical(&b, act[k]) + } + sum := sha256.Sum256([]byte(b.String())) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +// canonical writes one value as its length in bytes, a colon, the bytes and a newline: no value can be read +// as another's end or start, so two different sequences of values never encode the same. +func canonical(b *strings.Builder, v string) { + b.WriteString(strconv.Itoa(len(v))) + b.WriteByte(':') + b.WriteString(v) + b.WriteByte('\n') +} + +// Time reads an RFC 3339 time of the contract (an ask's expiry, a warrant's time); empty is the zero time. +func Time(s string) (time.Time, error) { + if s == "" { + return time.Time{}, nil + } + return time.Parse(time.RFC3339Nano, s) +} + +// FormatTime is a time as the contract carries it: UTC, RFC 3339 to the nanosecond; the zero time is empty. +func FormatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339Nano) +} + +// Digest is the digest of the ask exactly as its asker published it: its id, words, options with what each +// binds, who answers, and its expiry. The router puts it in the warrant (AskDigest), and an asker acts only on a +// warrant whose digest is that of the ask it keeps — so a warrant answers one ask, as the person was shown it, and +// nothing published under the same id before or after. +// +// It is over the ask's named fields in a fixed order, each written canonically, and the expiry as UTC RFC 3339 to +// the nanosecond — never over a language's encoding of the struct, so a field added to the ask later changes no +// digest until it is added here, on purpose. Whole and Details (novox/hq issue 383) follow the options only when the +// ask gives either: an ask without them digests as it did before they existed, so a router and an asker of +// different builds still agree on every such ask. An expiry that is no RFC 3339 time is digested as written. +func Digest(a seat.Ask) string { + expires := a.Expires + if t, err := Time(a.Expires); err == nil { + expires = t.UTC().Format(time.RFC3339Nano) + } + var b strings.Builder + b.WriteString("novox.ask.v1\n") + for _, v := range []string{a.ID, a.Headline, a.Explanation, a.Who, expires, a.OnExpiry, a.About, + strconv.FormatBool(a.Urgent), strconv.Itoa(len(a.Options))} { + canonical(&b, v) + } + for _, o := range a.Options { + for _, v := range []string{o.ID, o.Label, o.Does, o.Level, o.Binds} { + canonical(&b, v) + } + } + // Whole and Details are written whenever either or WholeDetails is given, and WholeDetails after them whenever + // it is given (novox/hq ADR 0347): the fields after the options are then always one of three fixed shapes — + // none, the two, or the three — and the count of values the sequence holds tells them apart, so no two asks + // encode the same. An ask with none of them digests as before they existed, and one without WholeDetails as + // before it existed. + if a.Whole != "" || a.Details != "" || a.WholeDetails != "" { + canonical(&b, a.Whole) + canonical(&b, a.Details) + } + if a.WholeDetails != "" { + canonical(&b, a.WholeDetails) + } + sum := sha256.Sum256([]byte(b.String())) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// What an ask's explanation is held to, so it reads at a glance on a phone (novox/hq issue 639): a summary +// of two or three short sentences on its first line — what changes, why, what it costs or risks — then at +// most three points, a line each, only where they add something. Whole is held to the same. Who asked, when, +// the fingerprint and how to read it whole are Details, never the explanation. +const ( + SummaryLength = 300 + MostPoints = 3 + PointLength = 160 +) + +// Glance says why an explanation does not read at a glance, or nil: its first line, the summary, longer than +// SummaryLength; more than MostPoints lines after it; a point longer than PointLength. Blank lines are not +// counted. An asker holds its asks to it in its own tests; the router says, without refusing, an ask that +// does not hold to it. +func Glance(explanation string) error { + var lines []string + for _, l := range strings.Split(explanation, "\n") { + if strings.TrimSpace(l) != "" { + lines = append(lines, strings.TrimSpace(l)) + } + } + var problems []string + switch { + case len(lines) == 0: + problems = append(problems, "it has no summary") + case len([]rune(lines[0])) > SummaryLength: + problems = append(problems, fmt.Sprintf("its summary is %d characters, more than %d", len([]rune(lines[0])), SummaryLength)) + } + if len(lines) > 1+MostPoints { + problems = append(problems, fmt.Sprintf("it has %d points after its summary, more than %d", len(lines)-1, MostPoints)) + } + for i, l := range lines { + if i > 0 && len([]rune(l)) > PointLength { + problems = append(problems, fmt.Sprintf("its point %d is %d characters, more than %d", i, len([]rune(l)), PointLength)) + } + } + if len(problems) > 0 { + return errors.New("the explanation does not read at a glance: " + strings.Join(problems, "; ")) + } + return nil +} + +// The bounds of an ask (novox/hq ADR 0234 §8, ADR 0259 §4). +const ( + MostOptions = 4 + MostOpen = 3 + ApproveLasts = 24 * time.Hour + DestroyLasts = 10 * time.Minute + HeadlineLength = 60 + LabelLength = 24 +) + +var usableID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`) + +// UsableID says whether a name can be an ask's or an option's id: a key and a subject token both. +func UsableID(id string) bool { return usableID.MatchString(id) } + +// Highest is the highest level among the ask's options. +func Highest(a seat.Ask) string { + high := Acknowledge + for _, o := range a.Options { + if Rank(o.Level) > Rank(high) { + high = o.Level + } + } + return high +} + +// OptionOf is the ask's option of this id, or false. +func OptionOf(a seat.Ask, id string) (seat.Option, bool) { + for _, o := range a.Options { + if o.ID == id { + return o, true + } + } + return seat.Option{}, false +} + +// Check is what an ask is held to before anything is shown: every refusal, in words its asker can act on. +func Check(a seat.Ask, now time.Time) error { + var problems []string + say := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) } + if !UsableID(a.ID) { + say("its id %q is not letters, digits, - and _, at most 64", a.ID) + } + if strings.TrimSpace(a.Headline) == "" || len([]rune(a.Headline)) > HeadlineLength { + say("its headline is empty or longer than %d characters", HeadlineLength) + } + if strings.TrimSpace(a.Explanation) == "" { + say("it explains nothing") + } + if a.Who != Operator { + say("it is answered by %q, and only the operator answers today", a.Who) + } + if len(a.Options) == 0 || len(a.Options) > MostOptions { + say("it offers %d options, and an ask offers one to %d", len(a.Options), MostOptions) + } + seen := map[string]bool{} + for _, o := range a.Options { + switch { + case !UsableID(o.ID): + say("an option's id %q is not letters, digits, - and _", o.ID) + case seen[o.ID]: + say("the option %s is offered twice", o.ID) + } + seen[o.ID] = true + if strings.TrimSpace(o.Label) == "" || len([]rune(o.Label)) > LabelLength { + say("the option %s's label is empty or longer than %d characters", o.ID, LabelLength) + } + if strings.TrimSpace(o.Does) == "" { + say("the option %s does not say what it does", o.ID) + } + if Rank(o.Level) > Rank(Destroy) { + say("the option %s has the level %q, which is none of acknowledge, approve, destroy", o.ID, o.Level) + } + if o.Level != Acknowledge && !strings.HasPrefix(o.Binds, "sha256:") { + say("the option %s authorises and does not bind what it performs (its binds is not an ActDigest)", o.ID) + } + } + expires, err := Time(a.Expires) + if err != nil { + say("its expiry %q is not an RFC 3339 time", a.Expires) + } + if !expires.After(now) { + say("it expires before it is asked") + } + switch Highest(a) { + case Approve: + if expires.After(now.Add(ApproveLasts)) { + say("an ask that approves lasts at most %s", ApproveLasts) + } + case Destroy: + if expires.After(now.Add(DestroyLasts)) { + say("an ask that destroys lasts at most %s", DestroyLasts) + } + } + if Highest(a) != Acknowledge && strings.TrimSpace(a.OnExpiry) == "" { + say("it does not say what happens when nobody answers, and an ask that authorises never defaults") + } + if a.About != "" && strings.ContainsAny(a.About, " \n") { + say("what it is about is a key, without spaces") + } + if len(problems) > 0 { + return errors.New("the ask is refused: " + strings.Join(problems, "; ")) + } + return nil +} + +// Carried reads an ask a module keeps or hands on as the seat's JSON (a value of its own state that carries the ask it +// publishes, novox/hq ADR 0627 §4) and holds it to what every keeper of an ask owes, whoever reads it back: its id is the +// one the keeper gave it (id), and it offers at least one option. It checks nothing else of the ask: Check is what an +// ask is held to before it is shown, and an ask kept long ago is read back even when the bounds of Check moved since. +func Carried(raw []byte, id string) (seat.Ask, error) { + var a seat.Ask + if err := json.Unmarshal(raw, &a); err != nil { + return seat.Ask{}, fmt.Errorf("the value carries no operator-channel ask: %w", err) + } + if a.ID != id { + return seat.Ask{}, fmt.Errorf("the ask's id %q is not %q, the id it was kept under", a.ID, id) + } + if len(a.Options) == 0 { + return seat.Ask{}, fmt.Errorf("the ask %q offers no option", a.ID) + } + return a, nil +} + +// Says is the warrant in the words an asker records with its act: "the operator, via telegram (user id +// verified), chose Release". +func Says(w seat.Warrant) string { + if w.Outcome != OutcomeChosen || w.By == nil { + return fmt.Sprintf("no person chose: the ask %s %s", w.Ask, w.Outcome) + } + via := w.By.Kind + if w.By.Verified != "" { + via += " (" + w.By.Verified + ")" + } + return fmt.Sprintf("the %s, via %s, chose %s", w.By.Who, via, w.Label) +} + +// For checks a warrant against the ask its asker made: the same asker and ask, the same ask's digest (so the +// same words, options and binds), a choice, an option the ask offered, at that option's level, before the +// ask expired. An asker acts on nothing else, and then performs only what the option's Binds names. +func For(w seat.Warrant, asker string, a seat.Ask) (seat.Option, error) { + if w.Asker != asker || w.Ask != a.ID { + return seat.Option{}, fmt.Errorf("the warrant is for %s's ask %s, not %s's %s", w.Asker, w.Ask, asker, a.ID) + } + if w.Outcome != OutcomeChosen || w.By == nil || w.By.Who != a.Who { + return seat.Option{}, fmt.Errorf("the ask %s ended %s; no person chose", a.ID, w.Outcome) + } + if d := Digest(a); w.AskDigest != d { + return seat.Option{}, fmt.Errorf("the warrant answers an ask whose digest is %q, and the ask %s kept here is %s: "+ + "it was not the ask the person was shown", w.AskDigest, a.ID, d) + } + o, offered := OptionOf(a, w.Option) + if !offered { + return seat.Option{}, fmt.Errorf("the ask %s offered no option %s", a.ID, w.Option) + } + if w.Level != o.Level { + return seat.Option{}, fmt.Errorf("the option %s is %s, and the warrant says %s", o.ID, o.Level, w.Level) + } + // A choice made after the ask expired is no answer to it, whatever the router said. An ask that says no + // expiry, or a warrant that says no time, is no answer either: neither can be shown to be in time. + expires, err := Time(a.Expires) + if err != nil || expires.IsZero() { + return seat.Option{}, fmt.Errorf("the ask %s says no expiry, so no answer to it can be in time", a.ID) + } + at, err := Time(w.At) + if err != nil || at.IsZero() { + return seat.Option{}, fmt.Errorf("the warrant for the ask %s says no time it was given, so it cannot be shown to be in time", a.ID) + } + if at.After(expires) { + return seat.Option{}, fmt.Errorf("the warrant was given at %s, after the ask %s expired at %s", + at.UTC().Format(time.RFC3339), a.ID, expires.UTC().Format(time.RFC3339)) + } + return o, nil +} + +// Performs checks that the act an asker is about to perform is the one the chosen option bound when it +// asked: the act's digest equals the option's Binds. An acknowledge option that bound nothing passes. +func Performs(o seat.Option, act Act) error { + if o.Binds == "" && o.Level == Acknowledge { + return nil + } + d, err := ActDigest(act) + if err != nil { + return err + } + if d != o.Binds { + return fmt.Errorf("the option %s bound %s, and the act about to be performed is %s: nothing is done", o.ID, o.Binds, d) + } + return nil +} + +// EngineAsker is the asker name a node's engine asks the operator under (novox/hq ADR 0563 §3, which changes ADR +// 0259 §2 to admit a node's engine as an asker): `node-engine-`, the last token of the by-caller subjects +// granted to that node's engine account alone, so no engine acts on another node's warrant. +func EngineAsker(node string) string { return "node-engine-" + node } diff --git a/operator-and-conversation/go/clients/operatorchannelclient/ask_test.go b/operator-and-conversation/go/clients/operatorchannelclient/ask_test.go new file mode 100644 index 0000000..9d32152 --- /dev/null +++ b/operator-and-conversation/go/clients/operatorchannelclient/ask_test.go @@ -0,0 +1,388 @@ +package operatorchannelclient + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" +) + +var now = time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC) + +func held() seat.Ask { + return seat.Ask{ID: "d-1", Headline: "openrazer delivery held", Explanation: "Needs you: release it, or stop it.", + Who: Operator, Expires: FormatTime(now.Add(time.Hour)), OnExpiry: "the delivery stays held", About: "delivery.openrazer", + Options: []seat.Option{ + {ID: "release", Label: "Release", Does: "the delivery goes on", Level: Approve, Binds: bind("release", "openrazer")}, + {ID: "stop", Label: "Stop", Does: "the delivery ends", Level: Approve, Binds: bind("stop", "openrazer")}, + {ID: "silence", Label: "Silence for a week", Does: "nothing more is said for a week", Level: Acknowledge}, + }} +} + +func bind(verb, what string) string { + d, err := ActDigest(Act{"verb": verb, "delivery": what}) + if err != nil { + panic(err) + } + return d +} + +func TestASoundAskPasses(t *testing.T) { + if err := Check(held(), now); err != nil { + t.Fatal(err) + } +} + +func TestAnAskIsRefusedInWordsForEachFault(t *testing.T) { + for want, change := range map[string]func(*seat.Ask){ + "is not letters": func(a *seat.Ask) { a.ID = "a.b" }, + "headline is empty": func(a *seat.Ask) { a.Headline = "" }, + "explains nothing": func(a *seat.Ask) { a.Explanation = " " }, + "only the operator answers": func(a *seat.Ask) { a.Who = "agent" }, + "offers 0 options": func(a *seat.Ask) { a.Options = nil }, + "offers 5 options": func(a *seat.Ask) { a.Options = append(a.Options, a.Options[0], a.Options[1]) }, + "is offered twice": func(a *seat.Ask) { a.Options[1].ID = "release" }, + "does not say what it does": func(a *seat.Ask) { a.Options[0].Does = "" }, + "none of acknowledge": func(a *seat.Ask) { a.Options[0].Level = "admin" }, + "expires before it is asked": func(a *seat.Ask) { a.Expires = FormatTime(now) }, + "approves lasts at most": func(a *seat.Ask) { a.Expires = FormatTime(now.Add(25 * time.Hour)) }, + "destroys lasts at most": func(a *seat.Ask) { a.Options[0].Level = Destroy }, + "never defaults": func(a *seat.Ask) { a.OnExpiry = "" }, + "is a key, without spaces": func(a *seat.Ask) { a.About = "a b" }, + "does not bind what it": func(a *seat.Ask) { a.Options[0].Binds = "" }, + } { + a := held() + change(&a) + if err := Check(a, now); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("want a refusal saying %q, got %v", want, err) + } + } + ack := held() + ack.Options = ack.Options[2:] + ack.OnExpiry = "" + ack.Expires = FormatTime(now.Add(7 * 24 * time.Hour)) + if err := Check(ack, now); err != nil { + t.Errorf("an ask that only acknowledges needs no expiry words and may last a week: %v", err) + } + // Carried: an ask read back from a keeper's value is its own id's, and offers an option. + raw, _ := json.Marshal(held()) + if a, err := Carried(raw, "d-1"); err != nil || !reflect.DeepEqual(a, held()) { + t.Errorf("a carried sound ask reads back whole: %v %+v", err, a) + } + none := held() + none.Options = nil + noOptions, _ := json.Marshal(none) + for want, c := range map[string]struct { + raw []byte + id string + }{ + "carries no operator-channel ask": {[]byte("[1]"), "d-1"}, + `is not "d-2"`: {raw, "d-2"}, + "offers no option": {noOptions, "d-1"}, + } { + if _, err := Carried(c.raw, c.id); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("Carried: want a refusal saying %q, got %v", want, err) + } + } +} + +func TestAnAskerActsOnlyOnAWarrantForItsOwnAskAndOption(t *testing.T) { + w := seat.Warrant{Ask: "d-1", Asker: "an-asker", Outcome: OutcomeChosen, Option: "release", Label: "Release", + Level: Approve, By: &seat.Person{Who: Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}, + AskDigest: Digest(held()), At: FormatTime(now.Add(time.Minute))} + if o, err := For(w, "an-asker", held()); err != nil || o.ID != "release" { + t.Fatalf("a sound warrant was refused: %v", err) + } + if got := Says(w); got != "the operator, via telegram (user id verified), chose Release" { + t.Errorf("the warrant says %q", got) + } + for why, change := range map[string]func(*seat.Warrant){ + "another asker": func(w *seat.Warrant) { w.Asker = "mesh-controller" }, + "another ask": func(w *seat.Warrant) { w.Ask = "d-2" }, + "no choice": func(w *seat.Warrant) { w.Outcome = OutcomeExpired }, + "nobody": func(w *seat.Warrant) { w.By = nil }, + "not offered": func(w *seat.Warrant) { w.Option = "delete" }, + "a lower level": func(w *seat.Warrant) { w.Level = Acknowledge }, + "another role": func(w *seat.Warrant) { w.By = &seat.Person{Who: "agent"} }, + "no digest": func(w *seat.Warrant) { w.AskDigest = "" }, + "no time given": func(w *seat.Warrant) { w.At = "" }, + "after expiry": func(w *seat.Warrant) { w.At = FormatTime(now.Add(2 * time.Hour)) }, + "another ask's digest": func(w *seat.Warrant) { + other := held() + other.Options[0].Binds = bind("release", "another") + w.AskDigest = Digest(other) + }, + } { + c := w + by := *w.By + c.By = &by + change(&c) + if _, err := For(c, "an-asker", held()); err == nil { + t.Errorf("a warrant with %s was acted on", why) + } + } +} + +// A warrant answers one ask as it was shown, and authorises the act its option bound and no other +// (novox/hq ADR 0259 §6: the warrant is bound to the exact ask and its arguments). +func TestAWarrantAuthorisesOnlyTheActItsOptionBound(t *testing.T) { + a := held() + // The digest survives the trip through the bus: what the router decodes and digests is what was sent. + raw, err := json.Marshal(a) + if err != nil { + t.Fatal(err) + } + var decoded seat.Ask + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if Digest(decoded) != Digest(a) { + t.Fatalf("the ask's digest changed on the way: %s, %s", Digest(decoded), Digest(a)) + } + local := a + local.Expires = inZone(a.Expires, time.FixedZone("CEST", 2*3600)) + if Digest(local) != Digest(a) { + t.Error("the same instant in another zone digests differently") + } + // Changing anything the person was shown, or what an option binds, changes the digest. + for what, change := range map[string]func(*seat.Ask){ + "the headline": func(a *seat.Ask) { a.Headline += "!" }, + "the explanation": func(a *seat.Ask) { a.Explanation += " " }, + "a label": func(a *seat.Ask) { a.Options[0].Label = "Go" }, + "what it binds": func(a *seat.Ask) { a.Options[0].Binds = bind("release", "another") }, + "the expiry": func(a *seat.Ask) { a.Expires = shift(a.Expires, time.Second) }, + "what it is about": func(a *seat.Ask) { a.About = "delivery.other" }, + } { + c := held() + change(&c) + if Digest(c) == Digest(a) { + t.Errorf("changing %s leaves the digest", what) + } + } + o, _ := OptionOf(a, "release") + if err := Performs(o, Act{"verb": "release", "delivery": "openrazer"}); err != nil { + t.Errorf("the bound act was refused: %v", err) + } + if err := Performs(o, Act{"verb": "release", "delivery": "another"}); err == nil { + t.Error("an act the option did not bind was let through") + } + ack, _ := OptionOf(a, "silence") + if err := Performs(ack, Act{"anything": "x"}); err != nil { + t.Errorf("an acknowledgement that bound nothing: %v", err) + } +} + +// The digests are over named fields in a canonical encoding (the review of 2026-10-09): not over Go's +// encoding of a struct, so a field added to Ask or Option, a JSON tag renamed, or a map built in another +// order changes nothing it should not — and every field that is shown or bound is in it. +func TestDigestsAreCanonicalOverNamedFields(t *testing.T) { + // The digests are those mesh-sdk's asks gave before the move to the contract's types (ADR 0627 §1): a router and + // an asker of either build agree on every ask. + gold := held() + gold.Whole, gold.Details, gold.WholeDetails = "w", "d", "wd" + if Digest(held()) != "sha256:088e7e69e1783b78c1b7e8310e0a6d0504a88a37982b293bc1e667da4565bb0f" || + Digest(gold) != "sha256:997af29a80663c5eb7d9dd38fc029c4a15fd726220b25e3c7cdc54d5a60bbbae" { + t.Errorf("the digests changed with the move: %s, %s", Digest(held()), Digest(gold)) + } + a1, _ := ActDigest(Act{"verb": "release", "arg.delivery": "hq"}) + b := Act{} + b["arg.delivery"] = "hq" + b["verb"] = "release" + a2, _ := ActDigest(b) + if a1 != a2 { + t.Error("the order an act was built in changed its digest") + } + // A value cannot be read as two: "a"+"b=c" is not "a=b"+"c". + x, _ := ActDigest(Act{"a": "b\n1:c"}) + y, _ := ActDigest(Act{"a": "b", "c": ""}) + if x == y { + t.Error("two different acts encode the same") + } + if _, err := ActDigest(Act{}); err == nil { + t.Error("an empty act was digested") + } + // The encoding, by hand: any language reproduces it. + sum := sha256.Sum256([]byte("novox.act.v1\n4:verb\n7:release\n")) + if got, _ := ActDigest(Act{"verb": "release"}); got != "sha256:"+hex.EncodeToString(sum[:]) { + t.Errorf("the act's encoding is not the one written down: %s", got) + } + + // Every exported field of Ask and Option is in the digest: changing any one changes it. + base := held() + mutations := map[string]func(*seat.Ask){ + "ID": func(a *seat.Ask) { a.ID = "other" }, + "Headline": func(a *seat.Ask) { a.Headline += "!" }, + "Explanation": func(a *seat.Ask) { a.Explanation += "!" }, + "Who": func(a *seat.Ask) { a.Who = "someone" }, + "Expires": func(a *seat.Ask) { a.Expires = shift(a.Expires, time.Nanosecond) }, + "OnExpiry": func(a *seat.Ask) { a.OnExpiry += "!" }, + "About": func(a *seat.Ask) { a.About += "!" }, + "Urgent": func(a *seat.Ask) { a.Urgent = !a.Urgent }, + "Options": func(a *seat.Ask) { a.Options = a.Options[:1] }, + "Option.ID": func(a *seat.Ask) { a.Options = append([]seat.Option(nil), a.Options...); a.Options[0].ID = "x" }, + "Option.Label": func(a *seat.Ask) { + a.Options = append([]seat.Option(nil), a.Options...) + a.Options[0].Label += "!" + }, + "Option.Does": func(a *seat.Ask) { a.Options = append([]seat.Option(nil), a.Options...); a.Options[0].Does += "!" }, + "Option.Level": func(a *seat.Ask) { + a.Options = append([]seat.Option(nil), a.Options...) + a.Options[0].Level = Destroy + }, + "Option.Binds": func(a *seat.Ask) { + a.Options = append([]seat.Option(nil), a.Options...) + a.Options[0].Binds = "sha256:00" + }, + } + for name, mutate := range mutations { + c := base + mutate(&c) + if Digest(c) == Digest(base) { + t.Errorf("changing %s left the digest the same", name) + } + } + // Whole and Details (issue 383) are in the digest once given, and change it; an ask without either digests + // exactly as before they existed — the encoding by hand, so no build can drift from it unseen. + plain := seat.Ask{ID: "p", Headline: "h", Explanation: "e", Who: Operator, Expires: FormatTime(now), OnExpiry: "o", About: "a", + Options: []seat.Option{{ID: "ok", Label: "OK", Does: "d", Level: Acknowledge}}} + byHand := sha256.Sum256([]byte("novox.ask.v1\n1:p\n1:h\n1:e\n8:operator\n20:2026-10-08T14:00:00Z\n1:o\n1:a\n5:false\n1:1\n" + + "2:ok\n2:OK\n1:d\n11:acknowledge\n0:\n")) + if Digest(plain) != "sha256:"+hex.EncodeToString(byHand[:]) { + t.Errorf("an ask without Whole and Details does not digest as it did before them: %s", Digest(plain)) + } + withWhole, withDetails := plain, plain + withWhole.Whole = "e, whole" + withDetails.Details = "fingerprint 1234" + if Digest(withWhole) == Digest(plain) || Digest(withDetails) == Digest(plain) || Digest(withWhole) == Digest(withDetails) { + t.Error("Whole or Details left the digest the same") + } + both := withWhole + both.Details = "fingerprint 1234" + changed := both + changed.Whole += "!" + if Digest(changed) == Digest(both) { + t.Error("changing Whole beside Details left the digest the same") + } + // A field added to Ask or Option without being added to Digest fails here. + if n := reflect.TypeOf(seat.Ask{}).NumField(); n != 12 { + t.Errorf("Ask has %d fields, and Digest covers 12: add the new one to Digest and to this test", n) + } + if n := reflect.TypeOf(seat.Option{}).NumField(); n != 5 { + t.Errorf("Option has %d fields, and Digest covers 5: add the new one to Digest and to this test", n) + } + // The same instant in another zone is the same ask. + z := base + z.Expires = inZone(z.Expires, time.FixedZone("x", 3600)) + if Digest(z) != Digest(base) { + t.Error("the expiry's zone changed the digest") + } +} + +// An ask that says no expiry is answered by no warrant (the confirmation review of 2026-10-09): nothing could +// show the answer was in time. +func TestAWarrantForAnAskWithNoExpiryIsRefused(t *testing.T) { + a := held() + a.Expires = "" + w := seat.Warrant{Ask: "d-1", Asker: "an-asker", Outcome: OutcomeChosen, Option: "release", Label: "Release", + Level: Approve, By: &seat.Person{Who: Operator, Kind: "telegram", Identity: "42"}, AskDigest: Digest(a), + At: FormatTime(now.Add(time.Minute))} + if _, err := For(w, "an-asker", a); err == nil { + t.Error("a warrant for an ask with no expiry was taken") + } +} + +// An explanation that reads at a glance passes; a wall of text says each way it is one (novox/hq issue 639). +func TestAnExplanationReadsAtAGlance(t *testing.T) { + if err := Glance("Your agents' memory follows you to every machine. A file changed on two keeps both.\n\n• Only the memory folders are carried\n• A conflict reaches you as a warning"); err != nil { + t.Fatalf("a summary and two points: %v", err) + } + var wall strings.Builder + wall.WriteString("ADR 0343 was proposed by fix-367 and approved by the knowledge base's own review.") + for i := 1; i <= 13; i++ { + wall.WriteString("\n" + strings.Repeat("A clause lead. ", 3)) + } + err := Glance(wall.String()) + if err == nil || !strings.Contains(err.Error(), "13 points") { + t.Fatalf("thirteen clause lines pass as a glance: %v", err) + } + if err := Glance(strings.Repeat("word ", 70)); err == nil || !strings.Contains(err.Error(), "summary is 349") { + t.Fatalf("a long summary passes: %v", err) + } + if err := Glance("short.\n" + strings.Repeat("p", PointLength+1)); err == nil || !strings.Contains(err.Error(), "point 1") { + t.Fatalf("a long point passes: %v", err) + } + if err := Glance(" \n"); err == nil { + t.Fatal("an empty explanation passes") + } +} + +// WholeDetails is digested when given, and an ask without it digests as before it existed: a router and an asker +// of different builds still agree on every such ask. +func TestWholeDetailsAreDigestedOnlyWhenGiven(t *testing.T) { + a := seat.Ask{ID: "a", Headline: "h", Explanation: "e", Details: "d"} + before := Digest(a) + a.WholeDetails = "+ shares: media=/storage/media" + if Digest(a) == before { + t.Fatal("the whole details are not in the digest") + } + b := a + b.WholeDetails = "+ shares: media=/storage/other" + if Digest(a) == Digest(b) { + t.Fatal("two whole details digest the same") + } + a.WholeDetails = "" + if Digest(a) != before { + t.Fatal("an ask without whole details digests otherwise than before") + } +} + +// No two asks encode the same (the review of mesh-sdk #25): whole details given beside nothing else are not read as +// a Whole and Details that happen to spell the same values, nor as any other split of the three fields. +func TestWholeDetailsCollideWithNoOtherAsk(t *testing.T) { + base := seat.Ask{ID: "a", Headline: "h", Explanation: "e"} + shapes := []seat.Ask{} + for _, f := range []struct{ whole, details, wholeDetails string }{ + {"", "", "+ x: 1"}, + {"whole-details", "+ x: 1", ""}, + {"", "+ x: 1", ""}, + {"+ x: 1", "", ""}, + {"", "", "whole-details"}, + {"", "+ x: 1", "+ x: 1"}, + {"+ x: 1", "+ x: 1", ""}, + {"", "", ""}, + } { + a := base + a.Whole, a.Details, a.WholeDetails = f.whole, f.details, f.wholeDetails + shapes = append(shapes, a) + } + seen := map[string]int{} + for i, a := range shapes { + if j, ok := seen[Digest(a)]; ok { + t.Errorf("asks %d and %d digest the same: %+v / %+v", j, i, shapes[j], a) + } + seen[Digest(a)] = i + } +} + +// shift is an RFC 3339 time of the contract moved by d. +func shift(s string, d time.Duration) string { + t, err := Time(s) + if err != nil { + panic(err) + } + return t.Add(d).Format(time.RFC3339Nano) +} + +// inZone is an RFC 3339 time of the contract written in another zone: the same instant. +func inZone(s string, z *time.Location) string { + t, err := Time(s) + if err != nil { + panic(err) + } + return t.In(z).Format(time.RFC3339Nano) +} diff --git a/operator-and-conversation/go/clients/operatorchannelclient/bench.go b/operator-and-conversation/go/clients/operatorchannelclient/bench.go new file mode 100644 index 0000000..28fe858 --- /dev/null +++ b/operator-and-conversation/go/clients/operatorchannelclient/bench.go @@ -0,0 +1,46 @@ +package operatorchannelclient + +// The subjects and words of the two kinded benches a channel holds, `channel` (to show and say) and `intake` (to say +// what was chosen, and to ask a proof): the router and every channel share them (novox/hq ADR 0259 §3, ADR 0634 §1). +// The shapes carried are the seat's types (Message, Choice, LinkAsked, Failed, Standing, Code, ProofAnswer). + +// The benches. +const ( + // ChannelSeat is the kinded bench a channel holds to show and say: its accepts carry the kind. + ChannelSeat = "channel" + // IntakeSeat is the kinded bench a channel holds to say what was chosen: its events and proofs carry the kind. + IntakeSeat = "intake" +) + +// The work a channel takes, on ChannelSubject. +const ( + Show = "show" // a message offering answers + Edit = "edit" // a message shown before, replaced + Send = "send" // a message offering nothing +) + +// ChannelSubject is where the router sends a channel of a kind its work. +func ChannelSubject(verb, kind string) string { + return "mesh.seat." + ChannelSeat + ".accept." + verb + "." + kind +} + +// What a channel says, on IntakeSubject. +const ( + Chosen = "choice" // a button tapped + Link = "link" // somebody asked to be linked as the operator + FailedWhat = "failed" // a message it could not deliver + StandingWhat = "standing" // what it says of itself +) + +// IntakeSubject is where a channel of a kind says what arrived. +func IntakeSubject(what, kind string) string { + return "mesh.seat." + IntakeSeat + ".event." + what + "." + kind +} + +// CodeProof is the one proof verb: a code the operator typed, carried by request and reply, never kept. +const CodeProof = "code" + +// ProofSubject is where a channel of a kind asks a proof. +func ProofSubject(verb, kind string) string { + return "mesh.seat." + IntakeSeat + ".proof." + verb + "." + kind +} diff --git a/operator-and-conversation/go/clients/operatorchannelclient/bench_test.go b/operator-and-conversation/go/clients/operatorchannelclient/bench_test.go new file mode 100644 index 0000000..4cdc8fc --- /dev/null +++ b/operator-and-conversation/go/clients/operatorchannelclient/bench_test.go @@ -0,0 +1,68 @@ +package operatorchannelclient + +import ( + "bytes" + "encoding/json" + "testing" + + seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" +) + +// TestTheBenchShapesAreTheChannelsWire: each shape the benches channel and intake carry, as the wire the benches speak +// (written out here, the contract's own), reads into the contract's type with no field dropped and reads back as the same wire. +func TestTheBenchShapesAreTheChannelsWire(t *testing.T) { + sender := `{"identity":"42","display":"Op","authenticated":true}` + cases := []struct { + name string + wire string + into any + }{ + {"message", `{"handle":"h1","title":"t","body":"b","buttons":[{"label":"Approve","ticket":"tk"}],"note":"n","urgent":true,"silent":true,"reply":"r","to":"42","secret":true}`, &seat.Message{}}, + {"a send", `{"handle":"h2","title":"t","body":"b"}`, &seat.Message{}}, + {"choice", `{"id":"c1","ticket":"tk","handle":"h1","sender":` + sender + `,"at":"2026-11-05T12:00:00Z"}`, &seat.Choice{}}, + {"link", `{"id":"l1","sender":` + sender + `,"at":"2026-11-05T12:00:00Z"}`, &seat.LinkAsked{}}, + {"failed", `{"handle":"h1","why":"blocked","permanent":true,"at":"2026-11-05T12:00:00Z"}`, &seat.Failed{}}, + {"standing", `{"ready":true,"why":"w","edits-silently":true,"at":"2026-11-05T12:00:00Z"}`, &seat.Standing{}}, + {"code", `{"sender":` + sender + `,"code":"123456","purpose":"approve"}`, &seat.Code{}}, + {"proof answer", `{"accepted":true,"words":"ok"}`, &seat.ProofAnswer{}}, + } + for _, c := range cases { + dec := json.NewDecoder(bytes.NewReader([]byte(c.wire))) + dec.DisallowUnknownFields() + if err := dec.Decode(c.into); err != nil { + t.Errorf("%s: %v", c.name, err) + continue + } + back, _ := json.Marshal(c.into) + var got, want any + _ = json.Unmarshal(back, &got) + _ = json.Unmarshal([]byte(c.wire), &want) + if !jsonEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + } +} + +func jsonEqual(a, b any) bool { + x, _ := json.Marshal(a) + y, _ := json.Marshal(b) + return bytes.Equal(x, y) +} + +// TestTheBenchSubjectsAreTheChannelsOwn: the subjects and words, character for character. +func TestTheBenchSubjectsAreTheChannelsOwn(t *testing.T) { + for _, c := range [][2]string{ + {ChannelSubject(Show, "telegram"), "mesh.seat.channel.accept.show.telegram"}, + {ChannelSubject(Edit, "desk"), "mesh.seat.channel.accept.edit.desk"}, + {ChannelSubject(Send, "desk"), "mesh.seat.channel.accept.send.desk"}, + {IntakeSubject(Chosen, "telegram"), "mesh.seat.intake.event.choice.telegram"}, + {IntakeSubject(Link, "telegram"), "mesh.seat.intake.event.link.telegram"}, + {IntakeSubject(FailedWhat, "desk"), "mesh.seat.intake.event.failed.desk"}, + {IntakeSubject(StandingWhat, "desk"), "mesh.seat.intake.event.standing.desk"}, + {ProofSubject(CodeProof, "telegram"), "mesh.seat.intake.proof.code.telegram"}, + } { + if c[0] != c[1] { + t.Errorf("subject %q, want %q", c[0], c[1]) + } + } +} diff --git a/operator-and-conversation/go/clients/operatorchannelclient/client.go b/operator-and-conversation/go/clients/operatorchannelclient/client.go new file mode 100644 index 0000000..b809fe1 --- /dev/null +++ b/operator-and-conversation/go/clients/operatorchannelclient/client.go @@ -0,0 +1,197 @@ +package operatorchannelclient + +// Client is a module's asking of a person (novox/hq ADR 0259 §10): it publishes an ask under the module's own name, +// cancels it, reads its state from the router's record, and hands the module each warrant once — only one for an ask +// it made, for an option it offered, at that option's level, before it expired. The module then acts with its own +// grants. +// +// It keeps what it asked, and which warrants it acted on, in a state of the module's own (declared in its manifest, +// by default `asked`): the ask is written before it is published, and a warrant is marked acted on by compare-and-set +// before the module's handler runs, so a redelivery, or a second instance, acts once. + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "time" + + seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Client asks as one module. +type Client struct { + // Module is the asking module's name: the one the bus grants its asks under. + Module string + // State is the module's own state the asks are kept in. + State Store + // Now is the clock; nil is time.Now. + Now func() time.Time + // Publish, Record and Subscribe reach the bus; nil is the stdio SDK's, through the module's runtime. + Publish func(subject string, body any, id string) error + Record func(key string) (*stdio.StateEntry, error) + Subscribe func(pattern string, handle func(stdio.Envelope) error) error +} + +// Store is where the asks are kept: stdio.State(name) is one. +type Store interface { + Get(key string) (*stdio.StateEntry, error) + Create(key string, value any) (uint64, error) + Update(key string, value any, revision uint64) (uint64, error) +} + +// New is a client for the module the runtime serves, keeping its asks in the state named. +func New(state string) *Client { + return &Client{Module: os.Getenv("MESH_MODULE"), State: stdio.State(state)} +} + +func (c *Client) now() time.Time { + if c.Now != nil { + return c.Now() + } + return time.Now() +} + +// Kept is one ask as the module keeps it. +type Kept struct { + Ask seat.Ask `json:"ask"` + Asked time.Time `json:"asked"` + Acted bool `json:"acted,omitempty"` + Ended string `json:"ended,omitempty"` + Answer *seat.Warrant `json:"answer,omitempty"` +} + +func key(id string) string { return "ask." + id } + +// Ask checks an ask, keeps it, and publishes it under the module's own name. An ask with an id already kept +// is refused: a new question is a new id. +func (c *Client) Ask(a seat.Ask) error { + if err := Check(a, c.now()); err != nil { + return err + } + if _, err := c.State.Create(key(a.ID), Kept{Ask: a, Asked: c.now()}); err != nil { + if errors.Is(err, stdio.ErrChanged) { + return fmt.Errorf("an ask %s was already made: a new question is a new id", a.ID) + } + return err + } + return c.publish(seat.AcceptSubjectAsk(c.Module), a, a.ID) +} + +// Cancel takes an ask back: the router ends it and edits every copy. +func (c *Client) Cancel(id string) error { + return c.publish(seat.AcceptSubjectCancel(c.Module), map[string]string{"id": id}, "cancel."+id) +} + +func (c *Client) publish(subject string, body any, id string) error { + if c.Publish != nil { + return c.Publish(subject, body, id) + } + return stdio.SeatPublish(subject, body, id) +} + +// Where is an ask's state as the router keeps it: its state and its warrant, if any — read under the +// module's own name and no other's. +func (c *Client) Where(id string) (state string, w *seat.Warrant, err error) { + read := c.Record + if read == nil { + read = func(k string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", k) } + } + e, err := read(id) + if err != nil || e == nil { + return "", nil, err + } + var rec struct { + State string `json:"state"` + Warrant *seat.Warrant `json:"warrant"` + } + if err := json.Unmarshal(e.Value, &rec); err != nil { + return "", nil, fmt.Errorf("the router's record of %s cannot be read: %w", id, err) + } + return rec.State, rec.Warrant, nil +} + +// Handled is what came of a warrant: acted on, refused (and why), or already done. +type Handled string + +const ( + Acted Handled = "acted" + Refused Handled = "refused" + Done Handled = "already acted on" + Recorded Handled = "ended without a choice" +) + +// Take hands act a warrant once, after holding it to the ask it answers. act performs the option with the +// module's own grants; its error is said and the warrant stays acted on (a person's decision is not +// performed twice; the module says the failure in its own record). +func (c *Client) Take(w seat.Warrant, act func(seat.Option, seat.Warrant) error) (Handled, error) { + if w.Asker != c.Module { + return Refused, fmt.Errorf("a warrant for %s reached %s", w.Asker, c.Module) + } + e, err := c.State.Get(key(w.Ask)) + if err != nil { + return "", err + } + if e == nil { + return Refused, fmt.Errorf("%s holds no ask %s", c.Module, w.Ask) + } + var k Kept + if err := json.Unmarshal(e.Value, &k); err != nil { + return "", err + } + if k.Acted || k.Ended != "" { + return Done, nil + } + if w.Outcome != OutcomeChosen { + k.Ended = w.Outcome + k.Answer = &w + if _, err := c.State.Update(key(w.Ask), k, e.Revision); err != nil && !errors.Is(err, stdio.ErrChanged) { + return "", err + } + return Recorded, nil + } + o, err := For(w, c.Module, k.Ask) + if err != nil { + return Refused, err + } + // Marked before acting: of two deliveries, or two instances, only the one whose write stands acts. + k.Acted, k.Ended, k.Answer = true, OutcomeChosen, &w + if _, err := c.State.Update(key(w.Ask), k, e.Revision); err != nil { + if errors.Is(err, stdio.ErrChanged) { + return Done, nil + } + return "", err + } + return Acted, act(o, w) +} + +// Listen hands every warrant for this module to Take, with act. It asks the runtime for the module's +// events matching the router's `decided` subject for this module. +func (c *Client) Listen(act func(seat.Option, seat.Warrant) error, said func(Handled, seat.Warrant, error)) error { + subscribe := c.Subscribe + if subscribe == nil { + subscribe = stdio.Subscribe + } + return subscribe(seat.Seat+"."+seat.EventDecided+"."+c.Module, func(e stdio.Envelope) error { + if !strings.HasPrefix(e.Key, seat.Seat+"."+seat.EventDecided+".") { + return nil + } + var w seat.Warrant + if err := json.Unmarshal(e.Body, &w); err != nil { + if said != nil { + said(Refused, w, fmt.Errorf("a warrant could not be read: %w", err)) + } + return nil + } + h, err := c.Take(w, act) + if h == "" && err != nil { + return err // the state could not be reached: offered again + } + if said != nil { + said(h, w, err) + } + return nil + }) +} diff --git a/operator-and-conversation/go/clients/operatorchannelclient/client_test.go b/operator-and-conversation/go/clients/operatorchannelclient/client_test.go new file mode 100644 index 0000000..45c2b0a --- /dev/null +++ b/operator-and-conversation/go/clients/operatorchannelclient/client_test.go @@ -0,0 +1,141 @@ +package operatorchannelclient + +import ( + "encoding/json" + "strings" + "testing" + "time" + + seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/process" +) + +type mem struct { + vals map[string]json.RawMessage + revs map[string]uint64 + n uint64 +} + +func (m *mem) Get(k string) (*stdio.StateEntry, error) { + v, ok := m.vals[k] + if !ok { + return nil, nil + } + return &stdio.StateEntry{Key: k, Value: v, Revision: m.revs[k]}, nil +} +func (m *mem) Create(k string, v any) (uint64, error) { + if _, ok := m.vals[k]; ok { + return 0, stdio.ErrChanged + } + return m.put(k, v) +} +func (m *mem) Update(k string, v any, rev uint64) (uint64, error) { + if rev == 0 || m.revs[k] != rev { + return 0, stdio.ErrChanged + } + return m.put(k, v) +} +func (m *mem) put(k string, v any) (uint64, error) { + raw, _ := json.Marshal(v) + m.n++ + m.vals[k], m.revs[k] = raw, m.n + return m.n, nil +} + +func client() (*Client, map[string][]string) { + sent := map[string][]string{} + c := &Client{Module: "an-asker", State: &mem{vals: map[string]json.RawMessage{}, revs: map[string]uint64{}}, + Now: func() time.Time { return now }, + Publish: func(subject string, body any, id string) error { + sent[subject] = append(sent[subject], id) + return nil + }} + return c, sent +} + +// releaseHQ is the act the ask's one option binds. +var releaseHQ = map[string]string{"verb": "release", "delivery": "hq"} + +func askedHeld() seat.Ask { + binds, _ := ActDigest(releaseHQ) + return seat.Ask{ID: "d-1", Headline: "Delivery of hq held", Explanation: "Needs you: release it, or stop it.", + Who: Operator, Expires: FormatTime(now.Add(time.Hour)), OnExpiry: "the delivery stays held", + Options: []seat.Option{{ID: "release", Label: "Release", Does: "it goes on", Level: Approve, Binds: binds}}} +} + +func askedWarrant() seat.Warrant { + return seat.Warrant{Ask: "d-1", Asker: "an-asker", Outcome: OutcomeChosen, Option: "release", + Label: "Release", Level: Approve, At: FormatTime(now.Add(time.Minute)), AskDigest: Digest(askedHeld()), + By: &seat.Person{Who: Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}} +} + +func TestAnAskIsKeptThenPublishedUnderTheModulesOwnName(t *testing.T) { + var _ Store = process.State{} // a process connection's state keeps the asks (issue 1188 AC2) + c, sent := client() + if err := c.Ask(askedHeld()); err != nil { + t.Fatal(err) + } + if got := sent[seat.AcceptSubjectAsk("an-asker")]; len(got) != 1 || got[0] != "d-1" { + t.Fatalf("%v", sent) + } + if err := c.Ask(askedHeld()); err == nil || !strings.Contains(err.Error(), "already made") { + t.Errorf("the same id was asked twice: %v", err) + } + bad := askedHeld() + bad.Who = "agent" + if err := c.Ask(bad); err == nil { + t.Error("an ask the contract refuses was published") + } +} + +func TestAWarrantIsActedOnOnceAndOnlyForItsOwnAsk(t *testing.T) { + c, _ := client() + _ = c.Ask(askedHeld()) + acted := 0 + act := func(o seat.Option, _ seat.Warrant) error { + if err := Performs(o, releaseHQ); err != nil { + t.Errorf("the bound act: %v", err) + } + acted++ + return nil + } + if h, err := c.Take(askedWarrant(), act); h != Acted || err != nil || acted != 1 { + t.Fatalf("%s %v %d", h, err, acted) + } + if h, _ := c.Take(askedWarrant(), act); h != Done || acted != 1 { + t.Errorf("a redelivery was acted on: %s", h) + } + for why, change := range map[string]func(*seat.Warrant){ + "another asker": func(w *seat.Warrant) { w.Asker = "mesh-controller" }, + "an ask not made": func(w *seat.Warrant) { w.Ask = "d-9" }, + "another level": func(w *seat.Warrant) { w.Level = Acknowledge }, + "an option not offered": func(w *seat.Warrant) { w.Option = "delete" }, + "after its expiry": func(w *seat.Warrant) { w.At = FormatTime(now.Add(2 * time.Hour)) }, + "another ask's digest": func(w *seat.Warrant) { w.AskDigest = "sha256:00" }, + } { + c2, _ := client() + _ = c2.Ask(askedHeld()) + w := askedWarrant() + change(&w) + if h, _ := c2.Take(w, act); h == Acted { + t.Errorf("a warrant from %s was acted on", why) + } + } + if acted != 1 { + t.Errorf("acted %d times", acted) + } +} + +func TestAnAskEndedWithoutAChoiceIsRecordedAndNothingDone(t *testing.T) { + c, _ := client() + _ = c.Ask(askedHeld()) + w := askedWarrant() + w.Outcome, w.Option, w.By = OutcomeExpired, "", nil + if h, err := c.Take(w, func(seat.Option, seat.Warrant) error { t.Fatal("acted"); return nil }); h != Recorded || err != nil { + t.Fatalf("%s %v", h, err) + } + if h, _ := c.Take(askedWarrant(), func(seat.Option, seat.Warrant) error { t.Fatal("acted after it ended"); return nil }); h != Done { + t.Errorf("%s", h) + } +} diff --git a/operator-and-conversation/go/clients/operatorchannelclient/condition.go b/operator-and-conversation/go/clients/operatorchannelclient/condition.go new file mode 100644 index 0000000..0fa5c5a --- /dev/null +++ b/operator-and-conversation/go/clients/operatorchannelclient/condition.go @@ -0,0 +1,117 @@ +package operatorchannelclient + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + + seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" +) + +// ConditionSubject is where a module says a condition to the operator: the seat's by-caller condition accept +// (novox/hq ADR 0437 §9). The router reads the verb at the fifth part and the caller at the sixth, so the publisher is +// one token, the module's own name; it is refused when empty or when it holds a dot, a wildcard or whitespace. +func ConditionSubject(publisher string) (string, error) { + if publisher == "" { + return "", errors.New("operator-channel: the condition's publisher is empty") + } + if strings.ContainsAny(publisher, ".*> \t\r\n") || strings.ContainsFunc(publisher, func(r rune) bool { return r <= ' ' }) { + return "", fmt.Errorf("operator-channel: the condition's publisher %q is not one token: it holds a dot, a wildcard or whitespace", publisher) + } + return seat.AcceptSubjectCondition(publisher), nil +} + +// ConditionVersion is the version of the condition value this package writes and the only one it reads. +const ConditionVersion = 1 + +// ErrUnknownConditionVersion is what DecodeCondition returns for a value whose version is missing or not +// ConditionVersion. Test it with errors.Is. +var ErrUnknownConditionVersion = errors.New("operator-channel: unknown condition version") + +// The words a condition's explanation opens with (novox/hq ADR 0253 §1). +const ( + NothingToDo = "Nothing for you to do." + NeedsYou = "Needs you: " +) + +// The severities and states of a condition. +const ( + SeverityUrgent = "urgent" + SeverityWarning = "warning" + StateRaised = "raised" + StateCleared = "cleared" +) + +func checkCondition(c seat.Condition) error { + if c.Key == "" { + return errors.New("operator-channel: a condition has no key") + } + if c.Subject.Scope == "" || c.Subject.ID == "" { + return errors.New("operator-channel: a condition's subject needs its scope and id") + } + switch c.State { + case StateCleared: + return nil + case StateRaised: + default: + return fmt.Errorf("operator-channel: the condition's state %q is not raised or cleared", c.State) + } + switch c.Severity { + case SeverityUrgent, SeverityWarning: + default: + return fmt.Errorf("operator-channel: the condition's severity %q is not urgent or warning", c.Severity) + } + if c.Headline == "" { + return errors.New("operator-channel: a raised condition has no headline") + } + if n := len([]rune(c.Headline)); n > HeadlineLength { + return fmt.Errorf("operator-channel: the condition's headline is %d characters, more than %d", n, HeadlineLength) + } + verdict := NothingToDo + if c.Needs != "" { + verdict = NeedsYou + c.Needs + } + if !strings.HasPrefix(c.Explanation, verdict) { + return fmt.Errorf("operator-channel: the condition's explanation must open with %q", verdict) + } + return nil +} + +// EncodeCondition is c as JSON at ConditionVersion; it refuses what ADR 0253 and ADR 0437 §9 refuse. +func EncodeCondition(c seat.Condition) ([]byte, error) { + if err := checkCondition(c); err != nil { + return nil, err + } + c.V = ConditionVersion + return json.Marshal(c) +} + +// DecodeCondition reads a condition; it refuses an unknown version (ErrUnknownConditionVersion) and what +// EncodeCondition refuses. +func DecodeCondition(data []byte) (seat.Condition, error) { + var probe struct { + V *int `json:"v"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return seat.Condition{}, fmt.Errorf("operator-channel: the condition is not JSON: %w", err) + } + if probe.V == nil { + return seat.Condition{}, fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownConditionVersion, ConditionVersion) + } + if *probe.V != ConditionVersion { + return seat.Condition{}, fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownConditionVersion, *probe.V, ConditionVersion) + } + var c seat.Condition + if err := json.Unmarshal(data, &c); err != nil { + return seat.Condition{}, fmt.Errorf("operator-channel: the condition does not fit its shape: %w", err) + } + if err := checkCondition(c); err != nil { + return seat.Condition{}, err + } + return c, nil +} + +// SameCondition says whether two conditions are the same value. +func SameCondition(a, b seat.Condition) bool { return reflect.DeepEqual(a, b) } diff --git a/operator-and-conversation/go/clients/operatorchannelclient/condition_test.go b/operator-and-conversation/go/clients/operatorchannelclient/condition_test.go new file mode 100644 index 0000000..2450b24 --- /dev/null +++ b/operator-and-conversation/go/clients/operatorchannelclient/condition_test.go @@ -0,0 +1,145 @@ +package operatorchannelclient + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" +) + +func TestConditionSubject(t *testing.T) { // 1202/AC1 + s, err := ConditionSubject("an-asker-teller") + if err != nil || s != "mesh.seat.operator-channel.accept.condition.an-asker-teller" { + t.Fatalf("subject = %q, %v", s, err) + } + // The router splits an accept subject in six parts and reads the verb at 4 and the caller at 5. + parts := strings.Split(s, ".") + if len(parts) != 6 || parts[4] != "condition" || parts[5] != "an-asker-teller" { + t.Errorf("subject %q does not keep the router's shape: %v", s, parts) + } + for _, bad := range []string{"", "a.b", "a*", "a>", "a b", "a\tb", "a\nb"} { + if _, err := ConditionSubject(bad); err == nil { + t.Errorf("publisher %q accepted", bad) + } + } +} + +func raisedCondition() seat.Condition { + return seat.Condition{ + Key: "delivery.stuck.mesh-mail", Severity: "warning", + Subject: seat.ConditionSubject{Scope: "module", ID: "mesh-mail", Machine: "ace"}, State: "raised", + Summary: "plan 12 stopped", Headline: "Delivery of mesh-mail is stuck", + Needs: "approve the merge.", Explanation: "Needs you: approve the merge. The plan waits.", + Resolved: "The delivery of mesh-mail moved on.", + Actions: []seat.ConditionActionsItem{{Label: "Approve", Verb: "mesh-controller.assign", Machine: "ace", + Arguments: map[string]string{"why": ""}, Level: Approve}}, + } +} + +func clearedCondition() seat.Condition { + return seat.Condition{Key: "delivery.stuck.mesh-mail", Subject: seat.ConditionSubject{Scope: "module", ID: "mesh-mail"}, State: "cleared"} +} + +func TestConditionRoundTrip(t *testing.T) { // 1202/AC2 + for _, c := range []seat.Condition{raisedCondition(), clearedCondition()} { + data, err := EncodeCondition(c) + if err != nil { + t.Fatalf("%s: %v", c.State, err) + } + var probe map[string]any + if err := json.Unmarshal(data, &probe); err != nil || probe["v"] != float64(1) { + t.Fatalf("%s: %s does not carry v:1", c.State, data) + } + c.V = ConditionVersion + got, err := DecodeCondition(data) + if err != nil || !SameCondition(got, c) { + t.Errorf("%s round trip: %+v %v", c.State, got, err) + } + } + data, _ := EncodeCondition(raisedCondition()) + var probe map[string]any + _ = json.Unmarshal(data, &probe) + for _, name := range []string{"key", "severity", "subject", "summary", "headline", "needs", "explanation", "resolved", "actions", "state"} { + if _, ok := probe[name]; !ok { + t.Errorf("the JSON has no %q: %s", name, data) + } + } + subject := probe["subject"].(map[string]any) + for _, name := range []string{"scope", "id", "machine"} { + if _, ok := subject[name]; !ok { + t.Errorf("the subject has no %q: %s", name, data) + } + } + action := probe["actions"].([]any)[0].(map[string]any) + for _, name := range []string{"label", "verb", "machine", "arguments", "level"} { + if _, ok := action[name]; !ok { + t.Errorf("the action has no %q: %s", name, data) + } + } + for _, in := range []string{`{"key":"k"}`, `{"v":2,"key":"k"}`} { + if _, err := DecodeCondition([]byte(in)); !errors.Is(err, ErrUnknownConditionVersion) { + t.Errorf("decode of %s = %v, want ErrUnknownConditionVersion", in, err) + } + } +} + +func TestConditionVerdict(t *testing.T) { // 1202/AC3 + c := raisedCondition() + c.Needs, c.Explanation = "", "The plan waits." + _, err := EncodeCondition(c) + if err == nil || !strings.Contains(err.Error(), "Nothing for you to do.") { + t.Errorf("empty needs without its verdict: %v", err) + } + c.Explanation = "Nothing for you to do. The plan waits." + if _, err := EncodeCondition(c); err != nil { + t.Errorf("empty needs with its verdict refused: %v", err) + } + c = raisedCondition() + c.Explanation = "Nothing for you to do. The plan waits." + _, err = EncodeCondition(c) + if err == nil || !strings.Contains(err.Error(), "Needs you: approve the merge.") { + t.Errorf("needs set with the wrong verdict: %v", err) + } + c.Explanation = "Needs you: something else. The plan waits." + if _, err = EncodeCondition(c); err == nil { + t.Error("needs set with other needs in the verdict accepted") + } +} + +func TestConditionRefused(t *testing.T) { // 1202/AC4 + mutate := func(f func(*seat.Condition)) error { + c := raisedCondition() + f(&c) + _, err := EncodeCondition(c) + return err + } + for name, f := range map[string]func(*seat.Condition){ + "severity": func(c *seat.Condition) { c.Severity = "critical" }, + "state": func(c *seat.Condition) { c.State = "open" }, + "key": func(c *seat.Condition) { c.Key = "" }, + "headline": func(c *seat.Condition) { c.Headline = "" }, + "long headline": func(c *seat.Condition) { c.Headline = strings.Repeat("h", HeadlineLength+1) }, + } { + if err := mutate(f); err == nil { + t.Errorf("a condition with a bad %s accepted", name) + } + } + if err := mutate(func(c *seat.Condition) { c.Headline = strings.Repeat("h", HeadlineLength) }); err != nil { + t.Errorf("a headline of the greatest length refused: %v", err) + } + if _, err := EncodeCondition(clearedCondition()); err != nil { + t.Errorf("a cleared condition with only key, subject and state refused: %v", err) + } + c := clearedCondition() + c.Key = "" + if _, err := EncodeCondition(c); err == nil { + t.Error("a cleared condition without its key accepted") + } + c = clearedCondition() + c.State = "gone" + if _, err := EncodeCondition(c); err == nil { + t.Error("a condition with an unknown state accepted") + } +} diff --git a/operator-and-conversation/go/clients/operatorchannelclient/go.mod b/operator-and-conversation/go/clients/operatorchannelclient/go.mod new file mode 100644 index 0000000..0e7a39e --- /dev/null +++ b/operator-and-conversation/go/clients/operatorchannelclient/go.mod @@ -0,0 +1,17 @@ +module git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 +) + +require ( + github.com/klauspost/compress v1.20.1 // indirect + github.com/nats-io/nats.go v1.54.0 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect +) diff --git a/operator-and-conversation/go/clients/operatorchannelclient/go.sum b/operator-and-conversation/go/clients/operatorchannelclient/go.sum new file mode 100644 index 0000000..999068e --- /dev/null +++ b/operator-and-conversation/go/clients/operatorchannelclient/go.sum @@ -0,0 +1,24 @@ +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/operator-and-conversation/go/seats/operatorchannel/contract.go b/operator-and-conversation/go/seats/operatorchannel/contract.go new file mode 100644 index 0000000..8fb2a22 --- /dev/null +++ b/operator-and-conversation/go/seats/operatorchannel/contract.go @@ -0,0 +1,500 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package operatorchannel is the contract of the seat "operator-channel", defined whole (seats/operator-channel/seat.json): Asking a person and answering on a channel (ADR 0259): held by the router, the messenger module, whose manifest declares the seat; written from that seat entry (design 26). +package operatorchannel + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "time" +) + +// Seat is the seat's name. +const Seat = "operator-channel" + +const ( + VerbOpen = "open" + VerbHistory = "history" + VerbNotify = "notify" + VerbTell = "tell" + VerbAsks = "asks" +) + +const ( + AcceptAsk = "ask" + AcceptCancel = "cancel" + AcceptCondition = "condition" +) + +const ( + EventDecided = "decided" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbOpen, VerbHistory, VerbNotify, VerbTell, VerbAsks} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventDecided} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "operator-channel.decided" + FamilyMaxAgeDays = 7 +) + +// SubjectDecided is where decided is published for one caller. +func SubjectDecided(caller string) string { + return "mesh.seat.operator-channel.event.decided." + caller +} + +// AcceptSubjectAsk is where one caller sends ask. +func AcceptSubjectAsk(caller string) string { return "mesh.seat.operator-channel.accept.ask." + caller } + +// AcceptSubjectCancel is where one caller sends cancel. +func AcceptSubjectCancel(caller string) string { + return "mesh.seat.operator-channel.accept.cancel." + caller +} + +// AcceptSubjectCondition is where one caller sends condition. +func AcceptSubjectCondition(caller string) string { + return "mesh.seat.operator-channel.accept.condition." + caller +} + +// The holder's own events, listed beside the seat and published under the holder's subjects (ADR 0620 §2). +const ( + HolderEventRefused = "refused" + HolderEventOffered = "offered" + HolderEventClosed = "closed" +) + +// HolderSubjectRefused is where the holder publishes refused. +func HolderSubjectRefused(holder string) string { return "mesh.mod." + holder + ".event.refused" } + +// HolderSubjectOffered is where the holder publishes offered, kept in router.asks. +func HolderSubjectOffered(holder string) string { return "mesh.stream." + holder + ".event.offered" } + +// HolderSubjectClosed is where the holder publishes closed, kept in router.asks. +func HolderSubjectClosed(holder string) string { return "mesh.stream." + holder + ".event.closed" } + +const ( + StateAsks = "asks" +) + +// AsksByAskerIDKey is the key of one value of the state asks under the form ".". +func AsksByAskerIDKey(asker, id string) string { return asker + "." + id } + +// ParseAsksByAskerIDKey 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 ParseAsksByAskerIDKey(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 +} + +// DecodeAsksByAskerIDValue reads one value of the state asks under the form ".", refusing a field AsksByAskerIDValue does not know and any text after the value. +func DecodeAsksByAskerIDValue(raw []byte) (AsksByAskerIDValue, error) { + var v AsksByAskerIDValue + 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 +} + +// Ask is a type of the contract. +type Ask struct { + // what it is about: a newer ask about it replaces the older + About string `json:"about,omitempty"` + // its secondary words + Details string `json:"details,omitempty"` + // when it ends unanswered, RFC 3339 + Expires string `json:"expires"` + // what happened and what it means + Explanation string `json:"explanation"` + // a few plain words + Headline string `json:"headline"` + // the asker's own id + ID string `json:"id"` + // what the asker then does + OnExpiry string `json:"on-expiry"` + // the answers it offers + Options []Option `json:"options"` + // urgent + Urgent bool `json:"urgent,omitempty"` + // who may answer: operator + Who string `json:"who"` + // the explanation with its values whole + Whole string `json:"whole,omitempty"` + // the details whole + WholeDetails string `json:"whole-details,omitempty"` +} + +// Button is one answer a channel offers: its label and the router's one-time ticket for it. +type Button struct { + // the button's label + Label string `json:"label"` + // the router's one-time ticket for it + Ticket string `json:"ticket"` +} + +// Choice is a button chosen on a channel, said on the bench intake as the event `choice`. +type Choice struct { + // when, RFC 3339 + At time.Time `json:"at"` + // the message's handle + Handle string `json:"handle,omitempty"` + // the channel's own for this arrival, unique, so the router acts on it once + ID string `json:"id"` + Sender Sender `json:"sender"` + // the ticket of the button + Ticket string `json:"ticket"` +} + +// Code is a code a person typed on a channel, asked as the proof `code` on the bench intake by request and reply: never in an event, never kept. +type Code struct { + // the code + Code string `json:"code"` + // what it is for + Purpose string `json:"purpose"` + Sender Sender `json:"sender"` +} + +// ConditionActionsItem is a type of the contract. +type ConditionActionsItem struct { + Arguments map[string]string `json:"arguments,omitempty"` + // label + Label string `json:"label"` + // level + Level string `json:"level,omitempty"` + // machine + Machine string `json:"machine,omitempty"` + // verb + Verb string `json:"verb"` +} + +// ConditionSubject is a type of the contract. +type ConditionSubject struct { + // id + ID string `json:"id"` + // machine + Machine string `json:"machine,omitempty"` + // scope + Scope string `json:"scope"` +} + +// Condition is a type of the contract. +type Condition struct { + // the answers it offers + Actions []ConditionActionsItem `json:"actions,omitempty"` + // plain words + Explanation string `json:"explanation,omitempty"` + // plain words + Headline string `json:"headline,omitempty"` + // the condition's key + Key string `json:"key"` + // what it needs + Needs string `json:"needs,omitempty"` + // the line said when it is over + Resolved string `json:"resolved,omitempty"` + // urgent or warning + Severity string `json:"severity,omitempty"` + // raised or cleared + State string `json:"state"` + Subject ConditionSubject `json:"subject"` + // one line + Summary string `json:"summary,omitempty"` + // the form's version, 1 + V int64 `json:"v"` +} + +// Failed is a message a channel could not deliver, said on the bench intake as the event `failed`. +type Failed struct { + // when, RFC 3339 + At time.Time `json:"at"` + // the message's handle + Handle string `json:"handle"` + // trying again cannot help + Permanent bool `json:"permanent,omitempty"` + // why + Why string `json:"why"` +} + +// LinkAsked is somebody on a channel asking to be linked as the operator, said on the bench intake as the event `link`. +type LinkAsked struct { + // when, RFC 3339 + At time.Time `json:"at"` + // the channel's own for this arrival + ID string `json:"id"` + Sender Sender `json:"sender"` +} + +// Message is the work a channel takes on the bench channel: shown with buttons (show), shown again in place (edit) or said (send); the words are the router's, shown as given. +type Message struct { + // its body + Body string `json:"body"` + // the answers it offers (none for a send) + Buttons []Button `json:"buttons,omitempty"` + // the router's name for it, the same across a show and its edits + Handle string `json:"handle"` + // its secondary words after the body and the buttons: who asked and when, what each answer does, when it expires, the ask's details + Note string `json:"note,omitempty"` + // the choice or link request this answers, by its id: the channel shows it where that was made + Reply string `json:"reply,omitempty"` + // the words carry something shown once (a link's code): the channel keeps no copy + Secret bool `json:"secret,omitempty"` + // shown without notifying + Silent bool `json:"silent,omitempty"` + // its title + Title string `json:"title"` + // the account linked as the operator on this kind, where the channel sends what is not a reply; empty when none is linked + To string `json:"to,omitempty"` + // urgent + Urgent bool `json:"urgent,omitempty"` +} + +// Option is a type of the contract. +type Option struct { + // the digest of exactly what the asker performs when it is chosen + Binds string `json:"binds,omitempty"` + // what it does, in plain words + Does string `json:"does"` + // the option's id + ID string `json:"id"` + // the button's label + Label string `json:"label"` + // the proof its answer needs + Level string `json:"level"` +} + +// Person is a type of the contract. +type Person struct { + // the name shown + Display string `json:"display,omitempty"` + // the account + Identity string `json:"identity"` + // the channel kind + Kind string `json:"kind"` + // how the router knew + Verified string `json:"verified"` + // the role + Who string `json:"who"` +} + +// ProofAnswer is the router's answer to a proof. +type ProofAnswer struct { + // whether it was taken + Accepted bool `json:"accepted"` + // words to say to the person + Words string `json:"words"` +} + +// Sender is who a channel's service says sent something; the router alone judges whether that is the operator. +type Sender struct { + // whether the service authenticated it + Authenticated bool `json:"authenticated"` + // the name it shows + Display string `json:"display,omitempty"` + // the account + Identity string `json:"identity"` +} + +// Standing is what a channel says of itself at least every five minutes and whenever it changes, on the bench intake as the event `standing`. +type Standing struct { + // when, RFC 3339 + At time.Time `json:"at"` + // its edits notify nobody + EditsSilently bool `json:"edits-silently,omitempty"` + // it can send now + Ready bool `json:"ready"` + // why not + Why string `json:"why,omitempty"` +} + +// Warrant is a type of the contract. +type Warrant struct { + // what it was about + About string `json:"about,omitempty"` + // the ask + Ask string `json:"ask"` + // the digest of the ask as the router took it + AskDigest string `json:"ask-digest,omitempty"` + // its asker + Asker string `json:"asker"` + // when, RFC 3339 + At string `json:"at"` + By *Person `json:"by,omitempty"` + // the module it came through + Channel string `json:"channel,omitempty"` + // its label + Label string `json:"label,omitempty"` + // its level + Level string `json:"level,omitempty"` + // the option chosen + Option string `json:"option,omitempty"` + Outcome string `json:"outcome"` + // the proofs present + Proofs []string `json:"proofs,omitempty"` + // why it ended without a choice, or what refused it + Words string `json:"words,omitempty"` +} + +// The values of Warrant's outcome. +const ( + WarrantOutcomeChosen = "chosen" + WarrantOutcomeExpired = "expired" + WarrantOutcomeCancelled = "cancelled" + WarrantOutcomeReplaced = "replaced" + WarrantOutcomeRefused = "refused" +) + +// OpenInput is what open takes. +type OpenInput struct { +} + +// OpenAnswer is what open answers. +type OpenAnswer = map[string]any + +// HistoryInput is what history takes. +type HistoryInput struct { + // at most this many of each (default 50) + Limit int64 `json:"limit,omitempty"` +} + +// HistoryAnswer is what history answers. +type HistoryAnswer = map[string]any + +// NotifyInput is what notify takes. +type NotifyInput struct { + // it is over + Clear bool `json:"clear,omitempty"` + // one or two plain sentences + Explanation string `json:"explanation,omitempty"` + // plain words + Headline string `json:"headline,omitempty"` + // the message's key + Key string `json:"key,omitempty"` + // the line said when it is over + Resolved string `json:"resolved,omitempty"` + Severity string `json:"severity,omitempty"` + // what it is about + Subject string `json:"subject,omitempty"` + // one line + Summary string `json:"summary,omitempty"` +} + +// The values of NotifyInput's severity. +const ( + NotifyInputSeverityUrgent = "urgent" + NotifyInputSeverityWarning = "warning" +) + +// NotifyAnswer is what notify answers. +type NotifyAnswer = map[string]any + +// TellInput is what tell takes. +type TellInput struct { + // plain words + Headline string `json:"headline,omitempty"` + // one or two plain lines + Lines []string `json:"lines,omitempty"` + // what it is about + Subject string `json:"subject,omitempty"` +} + +// TellAnswer is what tell answers. +type TellAnswer = map[string]any + +// AsksInput is what asks takes. +type AsksInput struct { +} + +// AsksAnswer is what asks answers. +type AsksAnswer = map[string]any + +// AskAccept is the body of ask. +type AskAccept = Ask + +// CancelAccept is the body of cancel. +type CancelAccept struct { + // the ask's id + ID string `json:"id"` +} + +// ConditionAccept is the body of condition. +type ConditionAccept = Condition + +// DecidedEvent is the body of the event decided. +type DecidedEvent = Warrant + +// OfferedHolderEvent is the body of the holder's event offered. +type OfferedHolderEvent struct { + // its asker + Asker string `json:"asker"` + // the channels it was offered on + Channels []string `json:"channels,omitempty"` + // its headline + Headline string `json:"headline,omitempty"` + // the ask's id + ID string `json:"id"` +} + +// ClosedHolderEvent is the body of the holder's event closed. +type ClosedHolderEvent struct { + // its asker + Asker string `json:"asker"` + How string `json:"how"` + // the ask's id + ID string `json:"id"` +} + +// The values of ClosedHolderEvent's how. +const ( + ClosedHolderEventHowAnswered = "answered" + ClosedHolderEventHowExpired = "expired" + ClosedHolderEventHowCancelled = "cancelled" + ClosedHolderEventHowReplaced = "replaced" + ClosedHolderEventHowUnlinked = "unlinked" +) + +// AsksByAskerIDValue is one value of the state asks under the key .. +type AsksByAskerIDValue struct { + // how it ended, in words + Answer string `json:"answer,omitempty"` + // its asker + Asker string `json:"asker"` + // its asker has been told how it ended + AskerTold bool `json:"asker_told,omitempty"` + // when it ends unanswered, RFC 3339 + Expires string `json:"expires"` + // its headline + Headline string `json:"headline"` + // . + Key string `json:"key"` + // the channels it was offered on + OfferedOn []string `json:"offered_on,omitempty"` + // when it was opened, RFC 3339 + Opened string `json:"opened"` + // how it stands: open, chosen, expired, cancelled, replaced or refused + State string `json:"state"` +} diff --git a/operator-and-conversation/go/seats/operatorchannel/contract_test.go b/operator-and-conversation/go/seats/operatorchannel/contract_test.go new file mode 100644 index 0000000..440ac8a --- /dev/null +++ b/operator-and-conversation/go/seats/operatorchannel/contract_test.go @@ -0,0 +1,64 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package operatorchannel + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/operator-channel/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: "decided for its asker", subject: "mesh.seat.operator-channel.event.decided.mesh-delivery", given: "{\"ask\":\"a1\",\"ask-digest\":\"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"asker\":\"mesh-delivery\",\"at\":\"2026-11-04T12:00:00Z\",\"by\":{\"identity\":\"1\",\"kind\":\"telegram\",\"verified\":\"user id\",\"who\":\"operator\"},\"channel\":\"telegram\",\"label\":\"Release\",\"level\":\"approve\",\"option\":\"ok\",\"outcome\":\"chosen\"}", wire: "{\"ask\":\"a1\",\"ask-digest\":\"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"asker\":\"mesh-delivery\",\"at\":\"2026-11-04T12:00:00Z\",\"by\":{\"identity\":\"1\",\"kind\":\"telegram\",\"verified\":\"user id\",\"who\":\"operator\"},\"channel\":\"telegram\",\"label\":\"Release\",\"level\":\"approve\",\"option\":\"ok\",\"outcome\":\"chosen\"}", + decode: func(b []byte) (any, error) { var v DecidedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.operator-channel\\.event\\.decided\\.[A-Za-z0-9_-]+$")}, + {name: "refused under the holder's own subjects", subject: "mesh.mod.messenger.event.refused", 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\\.refused$")}, + {name: "offered on the router's restricted stream", subject: "mesh.stream.messenger.event.offered", given: "{\"asker\":\"mesh-delivery\",\"channels\":[\"telegram\"],\"headline\":\"release held\",\"id\":\"a1\"}", wire: "{\"asker\":\"mesh-delivery\",\"channels\":[\"telegram\"],\"headline\":\"release held\",\"id\":\"a1\"}", + decode: func(b []byte) (any, error) { var v OfferedHolderEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.stream\\.[a-z0-9-]+\\.event\\.offered$")}, + {name: "closed once per end", subject: "mesh.stream.messenger.event.closed", given: "{\"asker\":\"mesh-delivery\",\"how\":\"answered\",\"id\":\"a1\"}", wire: "{\"asker\":\"mesh-delivery\",\"how\":\"answered\",\"id\":\"a1\"}", + decode: func(b []byte) (any, error) { var v ClosedHolderEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.stream\\.[a-z0-9-]+\\.event\\.closed$")}, + {name: "an ask by its asker", subject: "mesh.seat.operator-channel.accept.ask.mesh-delivery", given: "{\"expires\":\"2026-11-05T12:00:00Z\",\"explanation\":\"A delivery waits on your word.\",\"headline\":\"release held\",\"id\":\"a1\",\"on-expiry\":\"the delivery stays held\",\"options\":[{\"binds\":\"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"does\":\"releases it\",\"id\":\"ok\",\"label\":\"Release\",\"level\":\"approve\"}],\"who\":\"operator\"}", wire: "{\"expires\":\"2026-11-05T12:00:00Z\",\"explanation\":\"A delivery waits on your word.\",\"headline\":\"release held\",\"id\":\"a1\",\"on-expiry\":\"the delivery stays held\",\"options\":[{\"binds\":\"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"does\":\"releases it\",\"id\":\"ok\",\"label\":\"Release\",\"level\":\"approve\"}],\"who\":\"operator\"}", + decode: func(b []byte) (any, error) { var v AskAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.operator-channel\\.accept\\.ask\\.[A-Za-z0-9_-]+$")}, + {name: "a cancel by its asker", subject: "mesh.seat.operator-channel.accept.cancel.mesh-delivery", given: "{\"id\":\"a1\"}", wire: "{\"id\":\"a1\"}", + decode: func(b []byte) (any, error) { var v CancelAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.operator-channel\\.accept\\.cancel\\.[A-Za-z0-9_-]+$")}, + {name: "a condition by its publisher", subject: "mesh.seat.operator-channel.accept.condition.mesh-watcher", given: "{\"key\":\"backup.ace.failed\",\"severity\":\"warning\",\"state\":\"raised\",\"subject\":{\"id\":\"ace\",\"scope\":\"node\"},\"v\":1}", wire: "{\"key\":\"backup.ace.failed\",\"severity\":\"warning\",\"state\":\"raised\",\"subject\":{\"id\":\"ace\",\"scope\":\"node\"},\"v\":1}", + decode: func(b []byte) (any, error) { var v ConditionAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.operator-channel\\.accept\\.condition\\.[A-Za-z0-9_-]+$")}, + {name: "an ask chosen on a channel, as the router's tool states it (captured)", subject: "", given: "{\"answer\":\"the operator, via telegram (user id verified), chose Approve\",\"asker\":\"mesh-knowledge\",\"asker_told\":true,\"expires\":\"2026-11-06T12:17:43Z\",\"headline\":\"Parts wait for the seat list before acting\",\"key\":\"mesh-knowledge.kb-decision-636-1bcf1a2c\",\"offered_on\":[\"telegram\"],\"opened\":\"2026-11-05T12:27:44Z\",\"state\":\"chosen\"}", wire: "{\"answer\":\"the operator, via telegram (user id verified), chose Approve\",\"asker\":\"mesh-knowledge\",\"asker_told\":true,\"expires\":\"2026-11-06T12:17:43Z\",\"headline\":\"Parts wait for the seat list before acting\",\"key\":\"mesh-knowledge.kb-decision-636-1bcf1a2c\",\"offered_on\":[\"telegram\"],\"opened\":\"2026-11-05T12:27:44Z\",\"state\":\"chosen\"}", + decode: func(b []byte) (any, error) { var v AsksByAskerIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "an ask its asker took back (captured)", subject: "", given: "{\"answer\":\"cancelled: its asker took it back\",\"asker\":\"mesh-delivery-teller\",\"asker_told\":true,\"expires\":\"2026-11-06T10:04:01Z\",\"headline\":\"Check failed - merge into main\",\"key\":\"mesh-delivery-teller.merge-feat_registry_milestone-3a038b9f8b4d-4-4da6ee3cd9d5\",\"offered_on\":[\"telegram\"],\"opened\":\"2026-11-05T10:14:04Z\",\"state\":\"cancelled\"}", wire: "{\"answer\":\"cancelled: its asker took it back\",\"asker\":\"mesh-delivery-teller\",\"asker_told\":true,\"expires\":\"2026-11-06T10:04:01Z\",\"headline\":\"Check failed - merge into main\",\"key\":\"mesh-delivery-teller.merge-feat_registry_milestone-3a038b9f8b4d-4-4da6ee3cd9d5\",\"offered_on\":[\"telegram\"],\"opened\":\"2026-11-05T10:14:04Z\",\"state\":\"cancelled\"}", + decode: func(b []byte) (any, error) { var v AsksByAskerIDValue; 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/operator-and-conversation/go/seats/operatorchannel/go.mod b/operator-and-conversation/go/seats/operatorchannel/go.mod new file mode 100644 index 0000000..691961a --- /dev/null +++ b/operator-and-conversation/go/seats/operatorchannel/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel + +go 1.26.0 diff --git a/operator-and-conversation/module.json b/operator-and-conversation/module.json new file mode 100644 index 0000000..2498f04 --- /dev/null +++ b/operator-and-conversation/module.json @@ -0,0 +1,38 @@ +{ + "module": "mesh-operator-and-conversation-contracts", + "version": "1", + "build": { + "artifacts": [ + { + "name": "go-operatorchannel", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/operatorchannel" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-operatorchannelclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/operatorchannelclient" + ], + "series": "0.1" + }, + { + "name": "ts-operatorchannel", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/operatorchannel" + ], + "series": "0.1", + "generated-from": "." + } + ] + }, + "resources": [] +} diff --git a/operator-and-conversation/seats/index.json b/operator-and-conversation/seats/index.json new file mode 100644 index 0000000..62a0f68 --- /dev/null +++ b/operator-and-conversation/seats/index.json @@ -0,0 +1,6 @@ +{ + "format": 1, + "seats": [ + "operator-channel" + ] +} diff --git a/operator-and-conversation/seats/operator-channel/fixtures/events.json b/operator-and-conversation/seats/operator-channel/fixtures/events.json new file mode 100644 index 0000000..efa6a5b --- /dev/null +++ b/operator-and-conversation/seats/operator-channel/fixtures/events.json @@ -0,0 +1,98 @@ +[ + { + "name": "decided for its asker", + "of": "event:decided", + "subject": "mesh.seat.operator-channel.event.decided.mesh-delivery", + "given": { + "ask": "a1", + "asker": "mesh-delivery", + "outcome": "chosen", + "option": "ok", + "label": "Release", + "level": "approve", + "by": { + "who": "operator", + "kind": "telegram", + "identity": "1", + "verified": "user id" + }, + "channel": "telegram", + "at": "2026-11-04T12:00:00Z", + "ask-digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + }, + { + "name": "refused under the holder's own subjects", + "of": "holder-event:refused", + "subject": "mesh.mod.messenger.event.refused", + "given": {} + }, + { + "name": "offered on the router's restricted stream", + "of": "holder-event:offered", + "subject": "mesh.stream.messenger.event.offered", + "given": { + "id": "a1", + "asker": "mesh-delivery", + "headline": "release held", + "channels": [ + "telegram" + ] + } + }, + { + "name": "closed once per end", + "of": "holder-event:closed", + "subject": "mesh.stream.messenger.event.closed", + "given": { + "id": "a1", + "asker": "mesh-delivery", + "how": "answered" + } + }, + { + "name": "an ask by its asker", + "of": "accept:ask", + "subject": "mesh.seat.operator-channel.accept.ask.mesh-delivery", + "given": { + "id": "a1", + "headline": "release held", + "explanation": "A delivery waits on your word.", + "options": [ + { + "id": "ok", + "label": "Release", + "does": "releases it", + "level": "approve", + "binds": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + } + ], + "who": "operator", + "expires": "2026-11-05T12:00:00Z", + "on-expiry": "the delivery stays held" + } + }, + { + "name": "a cancel by its asker", + "of": "accept:cancel", + "subject": "mesh.seat.operator-channel.accept.cancel.mesh-delivery", + "given": { + "id": "a1" + } + }, + { + "name": "a condition by its publisher", + "of": "accept:condition", + "subject": "mesh.seat.operator-channel.accept.condition.mesh-watcher", + "given": { + "v": 1, + "key": "backup.ace.failed", + "subject": { + "scope": "node", + "id": "ace" + }, + "state": "raised", + "severity": "warning" + } + } +] diff --git a/operator-and-conversation/seats/operator-channel/fixtures/states.json b/operator-and-conversation/seats/operator-channel/fixtures/states.json new file mode 100644 index 0000000..66bc5de --- /dev/null +++ b/operator-and-conversation/seats/operator-channel/fixtures/states.json @@ -0,0 +1,36 @@ +[ + { + "name": "an ask chosen on a channel, as the router's tool states it (captured)", + "of": "state:asks/.", + "given": { + "answer": "the operator, via telegram (user id verified), chose Approve", + "asker": "mesh-knowledge", + "asker_told": true, + "expires": "2026-11-06T12:17:43Z", + "headline": "Parts wait for the seat list before acting", + "key": "mesh-knowledge.kb-decision-636-1bcf1a2c", + "offered_on": [ + "telegram" + ], + "opened": "2026-11-05T12:27:44Z", + "state": "chosen" + } + }, + { + "name": "an ask its asker took back (captured)", + "of": "state:asks/.", + "given": { + "answer": "cancelled: its asker took it back", + "asker": "mesh-delivery-teller", + "asker_told": true, + "expires": "2026-11-06T10:04:01Z", + "headline": "Check failed - merge into main", + "key": "mesh-delivery-teller.merge-feat_registry_milestone-3a038b9f8b4d-4-4da6ee3cd9d5", + "offered_on": [ + "telegram" + ], + "opened": "2026-11-05T10:14:04Z", + "state": "cancelled" + } + } +] diff --git a/operator-and-conversation/seats/operator-channel/seat.json b/operator-and-conversation/seats/operator-channel/seat.json new file mode 100644 index 0000000..3f33532 --- /dev/null +++ b/operator-and-conversation/seats/operator-channel/seat.json @@ -0,0 +1,861 @@ +{ + "format": 1, + "seat": "operator-channel", + "scope": "mesh", + "grain": "whole", + "domain": "Operator and conversation", + "decision": "novox/hq ADR 0259", + "holder-declares": true, + "description": "Asking a person and answering on a channel (ADR 0259): held by the router, the messenger module, whose manifest declares the seat; written from that seat entry (design 26).", + "verbs": [ + { + "name": "open", + "description": "What is open now: every message the operator was sent about something still wrong, urgent first, oldest first.", + "input": { + "type": "object", + "properties": {}, + "description": "no arguments" + }, + "answer": { + "type": "object", + "description": "the open messages" + } + }, + { + "name": "history", + "description": "What was said to the operator lately, newest first, the refusals, and what waits to be sent on each channel.", + "input": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "at most this many of each (default 50)" + } + } + }, + "answer": { + "type": "object", + "description": "the sends, refusals and what waits" + } + }, + { + "name": "notify", + "description": "Tell the operator something, as a module that uses the seat: a key, urgent or warning, one line, and what it is about; clear says it is over.", + "input": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "the message's key" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "subject": { + "type": "string", + "description": "what it is about" + }, + "headline": { + "type": "string", + "description": "plain words" + }, + "explanation": { + "type": "string", + "description": "one or two plain sentences" + }, + "resolved": { + "type": "string", + "description": "the line said when it is over" + }, + "clear": { + "type": "boolean", + "description": "it is over" + } + } + }, + "answer": { + "type": "object", + "description": "what was sent" + } + }, + { + "name": "tell", + "description": "Tell the operator a plain notice that needs no answer and opens no condition (ADR 0380).", + "input": { + "type": "object", + "properties": { + "headline": { + "type": "string", + "description": "plain words" + }, + "lines": { + "type": "array", + "items": { + "type": "string" + }, + "description": "one or two plain lines" + }, + "subject": { + "type": "string", + "description": "what it is about" + } + } + }, + "answer": { + "type": "object", + "description": "what was sent" + } + }, + { + "name": "asks", + "description": "The asks the router holds: open first, then the last month's, each with how it ended.", + "input": { + "type": "object", + "properties": {}, + "description": "no arguments" + }, + "answer": { + "type": "object", + "description": "the asks" + } + } + ], + "accepts": [ + { + "name": "ask", + "by-caller": true, + "body": { + "$ref": "#/defs/Ask" + } + }, + { + "name": "cancel", + "by-caller": true, + "body": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the ask's id" + } + }, + "required": [ + "id" + ] + } + }, + { + "name": "condition", + "by-caller": true, + "body": { + "$ref": "#/defs/Condition" + } + } + ], + "events": [ + { + "name": "decided", + "by-caller": true, + "body": { + "$ref": "#/defs/Warrant" + } + } + ], + "family": { + "name": "operator-channel.decided", + "max-age-days": 7 + }, + "states": [ + { + "name": "asks", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "one ask the router holds and how it stands, as the router's tool states it", + "properties": { + "key": { + "type": "string", + "description": "." + }, + "asker": { + "type": "string", + "description": "its asker" + }, + "headline": { + "type": "string", + "description": "its headline" + }, + "state": { + "type": "string", + "description": "how it stands: open, chosen, expired, cancelled, replaced or refused" + }, + "opened": { + "type": "string", + "description": "when it was opened, RFC 3339" + }, + "expires": { + "type": "string", + "description": "when it ends unanswered, RFC 3339" + }, + "offered_on": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the channels it was offered on" + }, + "asker_told": { + "type": "boolean", + "description": "its asker has been told how it ended" + }, + "answer": { + "type": "string", + "description": "how it ended, in words" + } + }, + "required": [ + "key", + "asker", + "headline", + "state", + "opened", + "expires" + ] + }, + "description": "one ask the router holds and how it stands" + } + ], + "description": "the asks a module raised and the router holds, one key per asker and ask" + } + ], + "holder-events": [ + { + "name": "refused", + "subject": "mesh.mod..event.refused" + }, + { + "name": "offered", + "subject": "mesh.stream..event.offered", + "family": "router.asks", + "body": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the ask's id" + }, + "asker": { + "type": "string", + "description": "its asker" + }, + "headline": { + "type": "string", + "description": "its headline" + }, + "channels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the channels it was offered on" + } + }, + "required": [ + "id", + "asker" + ] + } + }, + { + "name": "closed", + "subject": "mesh.stream..event.closed", + "family": "router.asks", + "body": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the ask's id" + }, + "asker": { + "type": "string", + "description": "its asker" + }, + "how": { + "type": "string", + "enum": [ + "answered", + "expired", + "cancelled", + "replaced", + "unlinked" + ] + } + }, + "required": [ + "id", + "asker", + "how" + ] + } + } + ], + "defs": { + "Ask": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the asker's own id" + }, + "headline": { + "type": "string", + "description": "a few plain words" + }, + "explanation": { + "type": "string", + "description": "what happened and what it means" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/defs/Option" + }, + "description": "the answers it offers" + }, + "who": { + "type": "string", + "description": "who may answer: operator" + }, + "expires": { + "type": "string", + "description": "when it ends unanswered, RFC 3339" + }, + "on-expiry": { + "type": "string", + "description": "what the asker then does" + }, + "about": { + "type": "string", + "description": "what it is about: a newer ask about it replaces the older" + }, + "urgent": { + "type": "boolean", + "description": "urgent" + }, + "whole": { + "type": "string", + "description": "the explanation with its values whole" + }, + "details": { + "type": "string", + "description": "its secondary words" + }, + "whole-details": { + "type": "string", + "description": "the details whole" + } + }, + "required": [ + "id", + "headline", + "explanation", + "options", + "who", + "expires", + "on-expiry" + ] + }, + "Option": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the option's id" + }, + "label": { + "type": "string", + "description": "the button's label" + }, + "does": { + "type": "string", + "description": "what it does, in plain words" + }, + "level": { + "type": "string", + "description": "the proof its answer needs" + }, + "binds": { + "type": "string", + "description": "the digest of exactly what the asker performs when it is chosen" + } + }, + "required": [ + "id", + "label", + "does", + "level" + ] + }, + "Warrant": { + "type": "object", + "properties": { + "ask": { + "type": "string", + "description": "the ask" + }, + "asker": { + "type": "string", + "description": "its asker" + }, + "about": { + "type": "string", + "description": "what it was about" + }, + "outcome": { + "type": "string", + "enum": [ + "chosen", + "expired", + "cancelled", + "replaced", + "refused" + ] + }, + "option": { + "type": "string", + "description": "the option chosen" + }, + "label": { + "type": "string", + "description": "its label" + }, + "level": { + "type": "string", + "description": "its level" + }, + "by": { + "$ref": "#/defs/Person" + }, + "channel": { + "type": "string", + "description": "the module it came through" + }, + "proofs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the proofs present" + }, + "at": { + "type": "string", + "description": "when, RFC 3339" + }, + "ask-digest": { + "type": "string", + "description": "the digest of the ask as the router took it" + }, + "words": { + "type": "string", + "description": "why it ended without a choice, or what refused it" + } + }, + "required": [ + "ask", + "asker", + "outcome", + "at" + ] + }, + "Person": { + "type": "object", + "properties": { + "who": { + "type": "string", + "description": "the role" + }, + "kind": { + "type": "string", + "description": "the channel kind" + }, + "identity": { + "type": "string", + "description": "the account" + }, + "display": { + "type": "string", + "description": "the name shown" + }, + "verified": { + "type": "string", + "description": "how the router knew" + } + }, + "required": [ + "who", + "kind", + "identity", + "verified" + ] + }, + "Condition": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the form's version, 1" + }, + "key": { + "type": "string", + "description": "the condition's key" + }, + "severity": { + "type": "string", + "description": "urgent or warning" + }, + "subject": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "scope" + }, + "id": { + "type": "string", + "description": "id" + }, + "machine": { + "type": "string", + "description": "machine" + } + }, + "required": [ + "scope", + "id" + ] + }, + "state": { + "type": "string", + "description": "raised or cleared" + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string", + "description": "plain words" + }, + "needs": { + "type": "string", + "description": "what it needs" + }, + "explanation": { + "type": "string", + "description": "plain words" + }, + "resolved": { + "type": "string", + "description": "the line said when it is over" + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "label" + }, + "verb": { + "type": "string", + "description": "verb" + }, + "machine": { + "type": "string", + "description": "machine" + }, + "arguments": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "level": { + "type": "string", + "description": "level" + } + }, + "required": [ + "label", + "verb" + ] + }, + "description": "the answers it offers" + } + }, + "required": [ + "v", + "key", + "subject", + "state" + ] + }, + "Button": { + "type": "object", + "description": "one answer a channel offers: its label and the router's one-time ticket for it", + "properties": { + "label": { + "type": "string", + "description": "the button's label" + }, + "ticket": { + "type": "string", + "description": "the router's one-time ticket for it" + } + }, + "required": [ + "label", + "ticket" + ] + }, + "Message": { + "type": "object", + "description": "the work a channel takes on the bench channel: shown with buttons (show), shown again in place (edit) or said (send); the words are the router's, shown as given", + "properties": { + "handle": { + "type": "string", + "description": "the router's name for it, the same across a show and its edits" + }, + "title": { + "type": "string", + "description": "its title" + }, + "body": { + "type": "string", + "description": "its body" + }, + "buttons": { + "type": "array", + "items": { + "$ref": "#/defs/Button" + }, + "description": "the answers it offers (none for a send)" + }, + "note": { + "type": "string", + "description": "its secondary words after the body and the buttons: who asked and when, what each answer does, when it expires, the ask's details" + }, + "urgent": { + "type": "boolean", + "description": "urgent" + }, + "silent": { + "type": "boolean", + "description": "shown without notifying" + }, + "reply": { + "type": "string", + "description": "the choice or link request this answers, by its id: the channel shows it where that was made" + }, + "to": { + "type": "string", + "description": "the account linked as the operator on this kind, where the channel sends what is not a reply; empty when none is linked" + }, + "secret": { + "type": "boolean", + "description": "the words carry something shown once (a link's code): the channel keeps no copy" + } + }, + "required": [ + "handle", + "title", + "body" + ] + }, + "Sender": { + "type": "object", + "description": "who a channel's service says sent something; the router alone judges whether that is the operator", + "properties": { + "identity": { + "type": "string", + "description": "the account" + }, + "display": { + "type": "string", + "description": "the name it shows" + }, + "authenticated": { + "type": "boolean", + "description": "whether the service authenticated it" + } + }, + "required": [ + "identity", + "authenticated" + ] + }, + "Choice": { + "type": "object", + "description": "a button chosen on a channel, said on the bench intake as the event `choice`", + "properties": { + "id": { + "type": "string", + "description": "the channel's own for this arrival, unique, so the router acts on it once" + }, + "ticket": { + "type": "string", + "description": "the ticket of the button" + }, + "handle": { + "type": "string", + "description": "the message's handle" + }, + "sender": { + "$ref": "#/defs/Sender" + }, + "at": { + "type": "string", + "description": "when, RFC 3339", + "format": "date-time" + } + }, + "required": [ + "id", + "ticket", + "sender", + "at" + ] + }, + "LinkAsked": { + "type": "object", + "description": "somebody on a channel asking to be linked as the operator, said on the bench intake as the event `link`", + "properties": { + "id": { + "type": "string", + "description": "the channel's own for this arrival" + }, + "sender": { + "$ref": "#/defs/Sender" + }, + "at": { + "type": "string", + "description": "when, RFC 3339", + "format": "date-time" + } + }, + "required": [ + "id", + "sender", + "at" + ] + }, + "Failed": { + "type": "object", + "description": "a message a channel could not deliver, said on the bench intake as the event `failed`", + "properties": { + "handle": { + "type": "string", + "description": "the message's handle" + }, + "why": { + "type": "string", + "description": "why" + }, + "permanent": { + "type": "boolean", + "description": "trying again cannot help" + }, + "at": { + "type": "string", + "description": "when, RFC 3339", + "format": "date-time" + } + }, + "required": [ + "handle", + "why", + "at" + ] + }, + "Standing": { + "type": "object", + "description": "what a channel says of itself at least every five minutes and whenever it changes, on the bench intake as the event `standing`", + "properties": { + "ready": { + "type": "boolean", + "description": "it can send now" + }, + "why": { + "type": "string", + "description": "why not" + }, + "edits-silently": { + "type": "boolean", + "description": "its edits notify nobody" + }, + "at": { + "type": "string", + "description": "when, RFC 3339", + "format": "date-time" + } + }, + "required": [ + "ready", + "at" + ] + }, + "Code": { + "type": "object", + "description": "a code a person typed on a channel, asked as the proof `code` on the bench intake by request and reply: never in an event, never kept", + "properties": { + "sender": { + "$ref": "#/defs/Sender" + }, + "code": { + "type": "string", + "description": "the code" + }, + "purpose": { + "type": "string", + "description": "what it is for" + } + }, + "required": [ + "sender", + "code", + "purpose" + ] + }, + "ProofAnswer": { + "type": "object", + "description": "the router's answer to a proof", + "properties": { + "accepted": { + "type": "boolean", + "description": "whether it was taken" + }, + "words": { + "type": "string", + "description": "words to say to the person" + } + }, + "required": [ + "accepted", + "words" + ] + } + } +} diff --git a/operator-and-conversation/ts/seats/operatorchannel/package-lock.json b/operator-and-conversation/ts/seats/operatorchannel/package-lock.json new file mode 100644 index 0000000..3061cb7 --- /dev/null +++ b/operator-and-conversation/ts/seats/operatorchannel/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-operator-and-conversation-contracts-operator-channel", + "packages": { + "": { + "name": "@novox/mesh-operator-and-conversation-contracts-operator-channel", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/operator-and-conversation/ts/seats/operatorchannel/package.json b/operator-and-conversation/ts/seats/operatorchannel/package.json new file mode 100644 index 0000000..945f309 --- /dev/null +++ b/operator-and-conversation/ts/seats/operatorchannel/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat operator-channel, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-operator-and-conversation-contracts-operator-channel", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/operator-and-conversation/ts/seats/operatorchannel/src/index.ts b/operator-and-conversation/ts/seats/operatorchannel/src/index.ts new file mode 100644 index 0000000..1331987 --- /dev/null +++ b/operator-and-conversation/ts/seats/operatorchannel/src/index.ts @@ -0,0 +1,429 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "operator-channel", defined whole (seats/operator-channel/seat.json): Asking a person and answering on a channel (ADR 0259): held by the router, the messenger module, whose manifest declares the seat; written from that seat entry (design 26). + +export const seat = "operator-channel"; + +export const verbs = { + open: "open", + history: "history", + notify: "notify", + tell: "tell", + asks: "asks", +} as const; + +export const accepts = { + ask: "ask", + cancel: "cancel", + condition: "condition", +} as const; + +export const events = { + decided: "decided", +} as const; + +export const family = { name: "operator-channel.decided", maxAgeDays: 7 } as const; + +export function decidedSubject(caller: string): string { + return "mesh.seat.operator-channel.event.decided." + caller; +} + +export function askSubject(caller: string): string { + return "mesh.seat.operator-channel.accept.ask." + caller; +} + +export function cancelSubject(caller: string): string { + return "mesh.seat.operator-channel.accept.cancel." + caller; +} + +export function conditionSubject(caller: string): string { + return "mesh.seat.operator-channel.accept.condition." + caller; +} + +export const holderEvents = { + refused: "refused", + offered: "offered", + closed: "closed", +} as const; + +export function refusedHolderSubject(holder: string): string { + return "mesh.mod." + holder + ".event.refused"; +} + +export function offeredHolderSubject(holder: string): string { + return "mesh.stream." + holder + ".event.offered"; +} + +export function closedHolderSubject(holder: string): string { + return "mesh.stream." + holder + ".event.closed"; +} + +export const states = { + asks: "asks", +} as const; + +/** The key of one value of the state asks under the form ".". */ +export function asksByAskerIDKey(asker: string, id: string): string { + return asker + "." + id; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseAsksByAskerIDKey(_key: string): { asker: string; id: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const asker = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + if (_rest === "") return undefined; + const id = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { asker, id }; +} + +export interface Ask { + /** what it is about: a newer ask about it replaces the older */ + "about"?: string; + /** its secondary words */ + "details"?: string; + /** when it ends unanswered, RFC 3339 */ + "expires": string; + /** what happened and what it means */ + "explanation": string; + /** a few plain words */ + "headline": string; + /** the asker's own id */ + "id": string; + /** what the asker then does */ + "on-expiry": string; + /** the answers it offers */ + "options": Option[]; + /** urgent */ + "urgent"?: boolean; + /** who may answer: operator */ + "who": string; + /** the explanation with its values whole */ + "whole"?: string; + /** the details whole */ + "whole-details"?: string; +} + +/** one answer a channel offers: its label and the router's one-time ticket for it */ +export interface Button { + /** the button's label */ + "label": string; + /** the router's one-time ticket for it */ + "ticket": string; +} + +/** a button chosen on a channel, said on the bench intake as the event `choice` */ +export interface Choice { + /** when, RFC 3339 */ + "at": string; + /** the message's handle */ + "handle"?: string; + /** the channel's own for this arrival, unique, so the router acts on it once */ + "id": string; + "sender": Sender; + /** the ticket of the button */ + "ticket": string; +} + +/** a code a person typed on a channel, asked as the proof `code` on the bench intake by request and reply: never in an event, never kept */ +export interface Code { + /** the code */ + "code": string; + /** what it is for */ + "purpose": string; + "sender": Sender; +} + +export interface ConditionActionsItem { + "arguments"?: Record; + /** label */ + "label": string; + /** level */ + "level"?: string; + /** machine */ + "machine"?: string; + /** verb */ + "verb": string; +} + +export interface ConditionSubject { + /** id */ + "id": string; + /** machine */ + "machine"?: string; + /** scope */ + "scope": string; +} + +export interface Condition { + /** the answers it offers */ + "actions"?: ConditionActionsItem[]; + /** plain words */ + "explanation"?: string; + /** plain words */ + "headline"?: string; + /** the condition's key */ + "key": string; + /** what it needs */ + "needs"?: string; + /** the line said when it is over */ + "resolved"?: string; + /** urgent or warning */ + "severity"?: string; + /** raised or cleared */ + "state": string; + "subject": ConditionSubject; + /** one line */ + "summary"?: string; + /** the form's version, 1 */ + "v": number; +} + +/** a message a channel could not deliver, said on the bench intake as the event `failed` */ +export interface Failed { + /** when, RFC 3339 */ + "at": string; + /** the message's handle */ + "handle": string; + /** trying again cannot help */ + "permanent"?: boolean; + /** why */ + "why": string; +} + +/** somebody on a channel asking to be linked as the operator, said on the bench intake as the event `link` */ +export interface LinkAsked { + /** when, RFC 3339 */ + "at": string; + /** the channel's own for this arrival */ + "id": string; + "sender": Sender; +} + +/** the work a channel takes on the bench channel: shown with buttons (show), shown again in place (edit) or said (send); the words are the router's, shown as given */ +export interface Message { + /** its body */ + "body": string; + /** the answers it offers (none for a send) */ + "buttons"?: Button[]; + /** the router's name for it, the same across a show and its edits */ + "handle": string; + /** its secondary words after the body and the buttons: who asked and when, what each answer does, when it expires, the ask's details */ + "note"?: string; + /** the choice or link request this answers, by its id: the channel shows it where that was made */ + "reply"?: string; + /** the words carry something shown once (a link's code): the channel keeps no copy */ + "secret"?: boolean; + /** shown without notifying */ + "silent"?: boolean; + /** its title */ + "title": string; + /** the account linked as the operator on this kind, where the channel sends what is not a reply; empty when none is linked */ + "to"?: string; + /** urgent */ + "urgent"?: boolean; +} + +export interface Option { + /** the digest of exactly what the asker performs when it is chosen */ + "binds"?: string; + /** what it does, in plain words */ + "does": string; + /** the option's id */ + "id": string; + /** the button's label */ + "label": string; + /** the proof its answer needs */ + "level": string; +} + +export interface Person { + /** the name shown */ + "display"?: string; + /** the account */ + "identity": string; + /** the channel kind */ + "kind": string; + /** how the router knew */ + "verified": string; + /** the role */ + "who": string; +} + +/** the router's answer to a proof */ +export interface ProofAnswer { + /** whether it was taken */ + "accepted": boolean; + /** words to say to the person */ + "words": string; +} + +/** who a channel's service says sent something; the router alone judges whether that is the operator */ +export interface Sender { + /** whether the service authenticated it */ + "authenticated": boolean; + /** the name it shows */ + "display"?: string; + /** the account */ + "identity": string; +} + +/** what a channel says of itself at least every five minutes and whenever it changes, on the bench intake as the event `standing` */ +export interface Standing { + /** when, RFC 3339 */ + "at": string; + /** its edits notify nobody */ + "edits-silently"?: boolean; + /** it can send now */ + "ready": boolean; + /** why not */ + "why"?: string; +} + +export interface Warrant { + /** what it was about */ + "about"?: string; + /** the ask */ + "ask": string; + /** the digest of the ask as the router took it */ + "ask-digest"?: string; + /** its asker */ + "asker": string; + /** when, RFC 3339 */ + "at": string; + "by"?: Person; + /** the module it came through */ + "channel"?: string; + /** its label */ + "label"?: string; + /** its level */ + "level"?: string; + /** the option chosen */ + "option"?: string; + "outcome": "chosen" | "expired" | "cancelled" | "replaced" | "refused"; + /** the proofs present */ + "proofs"?: string[]; + /** why it ended without a choice, or what refused it */ + "words"?: string; +} + +/** is what open takes. */ +export interface OpenInput { +} + +/** is what open answers. */ +export type OpenAnswer = Record; + +/** is what history takes. */ +export interface HistoryInput { + /** at most this many of each (default 50) */ + "limit"?: number; +} + +/** is what history answers. */ +export type HistoryAnswer = Record; + +/** is what notify takes. */ +export interface NotifyInput { + /** it is over */ + "clear"?: boolean; + /** one or two plain sentences */ + "explanation"?: string; + /** plain words */ + "headline"?: string; + /** the message's key */ + "key"?: string; + /** the line said when it is over */ + "resolved"?: string; + "severity"?: "urgent" | "warning"; + /** what it is about */ + "subject"?: string; + /** one line */ + "summary"?: string; +} + +/** is what notify answers. */ +export type NotifyAnswer = Record; + +/** is what tell takes. */ +export interface TellInput { + /** plain words */ + "headline"?: string; + /** one or two plain lines */ + "lines"?: string[]; + /** what it is about */ + "subject"?: string; +} + +/** is what tell answers. */ +export type TellAnswer = Record; + +/** is what asks takes. */ +export interface AsksInput { +} + +/** is what asks answers. */ +export type AsksAnswer = Record; + +/** is the body of ask. */ +export type AskAccept = Ask; + +/** is the body of cancel. */ +export interface CancelAccept { + /** the ask's id */ + "id": string; +} + +/** is the body of condition. */ +export type ConditionAccept = Condition; + +/** is the body of the event decided. */ +export type DecidedEvent = Warrant; + +/** is the body of the holder's event offered. */ +export interface OfferedHolderEvent { + /** its asker */ + "asker": string; + /** the channels it was offered on */ + "channels"?: string[]; + /** its headline */ + "headline"?: string; + /** the ask's id */ + "id": string; +} + +/** is the body of the holder's event closed. */ +export interface ClosedHolderEvent { + /** its asker */ + "asker": string; + "how": "answered" | "expired" | "cancelled" | "replaced" | "unlinked"; + /** the ask's id */ + "id": string; +} + +/** is one value of the state asks under the key .. */ +export interface AsksByAskerIDValue { + /** how it ended, in words */ + "answer"?: string; + /** its asker */ + "asker": string; + /** its asker has been told how it ended */ + "asker_told"?: boolean; + /** when it ends unanswered, RFC 3339 */ + "expires": string; + /** its headline */ + "headline": string; + /** . */ + "key": string; + /** the channels it was offered on */ + "offered_on"?: string[]; + /** when it was opened, RFC 3339 */ + "opened": string; + /** how it stands: open, chosen, expired, cancelled, replaced or refused */ + "state": string; +} + diff --git a/operator-and-conversation/ts/seats/operatorchannel/tsconfig.json b/operator-and-conversation/ts/seats/operatorchannel/tsconfig.json new file mode 100644 index 0000000..60274c6 --- /dev/null +++ b/operator-and-conversation/ts/seats/operatorchannel/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/provisioning/go/clients/meshprovisionerclient/client.go b/provisioning/go/clients/meshprovisionerclient/client.go new file mode 100644 index 0000000..59845c4 --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/client.go @@ -0,0 +1,319 @@ +// Package meshprovisionerclient is the contract of the seat mesh-provisioner's records (novox/hq ADR 0570 clauses 1, 2 and 5; issue +// 1954): every provision one key of the state `provisions`, holding a reference and never a value, and every handed +// module's provisioning plan one key of the state `plans`. The module mesh-provisioner alone writes both; the vault, +// each node's provisioner client and every provider read them through `reads`. +package meshprovisionerclient + +import ( + "bytes" + "encoding/json" + "fmt" + "regexp" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The seat and its two states, by their local names. The module that holds the seat, and so the addresses and buckets of the +// states (`.`, `_`), is each reader's own, taken from its manifest's `reads` (novox/hq ADR 0625 §4). +const ( + Seat = "mesh-provisioner" + ProvisionsState = "provisions" + PlansState = "plans" +) + +// The states' buckets and `reads` addresses for the module holding the seat, which a reader names from its own manifest. +func ProvisionsBucketOf(holder string) string { return holder + "_" + ProvisionsState } + +// PlansBucketOf is the bucket of the state plans in the holder's name. +func PlansBucketOf(holder string) string { return holder + "_" + PlansState } + +// ProvisionsReadOf is the address a manifest's `reads` names the state provisions by. +func ProvisionsReadOf(holder string) string { return holder + "." + ProvisionsState } + +// PlansReadOf is the address a manifest's `reads` names the state plans by. +func PlansReadOf(holder string) string { return holder + "." + PlansState } + +// The states of a provision (clause 1). +const ( + // Asked is a provision its consumer needs that its provider has not answered yet, or whose dependencies are not + // all provided (clause 3). + Asked = "asked" + // Provided is a provision the provider said it made: set only from the provider's outcome (clause 3). + Provided = "provided" + // Waits is a provision only a person can give, naming what is awaited (clause 7). + Waits = "waits" + // Failed is a provision the provider could not make, with why. + Failed = "failed" + // Retiring is a provision no want needs, asked of the operator before its credential goes (clause 5). + Retiring = "retiring" +) + +// States is every state a provision may be in. +var States = []string{Asked, Provided, Waits, Failed, Retiring} + +// Version is the version of the records' layout. +const Version = 1 + +// token is one part of a key: what a bucket's key takes, no dot. +var token = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// Key is a provision's key: `..`, and `.` where the consumer keeps several values of +// one provision (ADR 0094). The same key the provider's own entry for that consumer has (provider.Entry.Key). +func Key(node, consumer, provision, local string) (string, error) { + parts := []string{node, consumer, provision} + if local != "" { + parts = append(parts, local) + } + for _, p := range parts { + if !token.MatchString(p) { + return "", fmt.Errorf("provisions: %q cannot be part of a provision's key", p) + } + } + return strings.Join(parts, "."), nil +} + +// ParseKey is the inverse of Key. +func ParseKey(key string) (node, consumer, provision, local string, err error) { + parts := strings.Split(key, ".") + if len(parts) < 3 || len(parts) > 4 { + return "", "", "", "", fmt.Errorf("provisions: %q is not ..[.]", key) + } + for _, p := range parts { + if !token.MatchString(p) { + return "", "", "", "", fmt.Errorf("provisions: %q is not ..[.]", key) + } + } + if len(parts) == 4 { + local = parts[3] + } + return parts[0], parts[1], parts[2], local, nil +} + +// PlanKey is a plan's key in `plans`: `.` (clause 2). +func PlanKey(node, module string) (string, error) { + if !token.MatchString(node) || !token.MatchString(module) { + return "", fmt.Errorf("provisions: %s.%s is not a plan's key, .", node, module) + } + return node + "." + module, nil +} + +// The forms of a provision's credential, as a plan's entry names them (catalogue.PlanProvision.CredentialForm): the +// catalogue's one list (catalogue.CredentialForms, issue 774), under the names this package has used. +const ( + FormPair = catalogue.CredentialFormPair + FormShared = catalogue.CredentialFormShared + FormIssued = catalogue.CredentialFormIssued + FormGiven = catalogue.CredentialFormGiven + // FormNone is an offer whose consumers present no credential (catalogue.CredentialFormNone): the vault mints nothing, + // and the provision depends on no credential. + FormNone = catalogue.CredentialFormNone +) + +// VaultName is the vault's name of a provision's credential in form, as its verb `own` names it and the provider asks +// it: `pair////[/]` for a login of the consumer's own (ADR 0158 +// §11), `provision//` for the provider's one shared value, and `provision////` for a holder's own issued or given value. Empty for a form with no +// credential. +func VaultName(form string, p Provision) string { + switch form { + case FormPair: + n := "pair/" + p.Provider.Node + "/" + p.Provision + "/" + p.Consumer.Node + "/" + p.Consumer.Module + if p.Local != "" { + n += "/" + p.Local + } + return n + case FormShared: + return "provision/" + p.Provider.Node + "/" + p.Provision + case FormIssued, FormGiven: + return "provision/" + p.Provider.Node + "/" + p.Provision + "/" + p.Consumer.Node + "/" + p.Consumer.Module + } + return "" +} + +// SecretProvision is the provision the seat mesh-vault delivers: the vault's own offer, and the one provision with no +// credential beside it (novox/hq ADR 0570 clauses 1, 6). The client names the provision, never the module that holds +// the seat (novox/hq ADR 0625 §4). +const SecretProvision = "secret" + +// CredentialKey is the key of a provision's credential, itself a provision whose provider is the vault (clause 6; the +// key form the lead settled on 2026-10-29): `..secret-[.]` for a value of the +// consumer's own (pair, issued, given), and `..secret-` for the provider's one +// shared value. False for a form with no vault value, and for a provision the vault itself provides. +func CredentialKey(form string, consumer, provider End, provision, local string) (string, bool) { + if provision == SecretProvision { + return "", false // the vault's own offer is the value: it has no credential beside it (the lead, 2026-10-29) + } + name := catalogue.CredentialProvisionPrefix + provision + switch form { + case FormPair, FormIssued, FormGiven: + k, err := Key(consumer.Node, consumer.Module, name, local) + return k, err == nil + case FormShared: + k, err := Key(provider.Node, provider.Module, name, "") + return k, err == nil + } + return "", false +} + +// DependsOn is what a provision waits for before its provider makes it (clause 3): its credential's key, where its form +// has a vault value, and the key of every provision its provider module itself requires on its own node, read from +// the provider's plan there (nil when the provider is not handed and has none). Sorted, none twice. +func DependsOn(consumer End, entry catalogue.PlanProvision, providerPlan *catalogue.Plan) []string { + seen := map[string]bool{} + var out []string + add := func(k string) { + if k != "" && !seen[k] { + seen[k] = true + out = append(out, k) + } + } + provider := End{Module: entry.ProviderModule, Node: entry.Provider} + if k, ok := CredentialKey(entry.CredentialForm, consumer, provider, entry.Provision, entry.Local); ok { + add(k) + } + if providerPlan != nil { + for _, own := range providerPlan.Provisions { + if k, err := Key(provider.Node, provider.Module, own.Provision, own.Local); err == nil { + add(k) + } + } + } + sort.Strings(out) + return out +} + +// End is one end of a provision: a module on a node. +type End struct { + Module string `json:"module"` + Node string `json:"node"` +} + +// Reference is a value the vault keeps, by its id and the generation of the value: never the value, sealed or not +// (ADR 0349 §7). The holder writes the generation the vault says in its own entry (clause 6). ID is the vault's name of +// the credential exactly as its verb `own` names it (VaultName): the consuming node's engine checks both the name and +// the generation of what it is handed. +type Reference struct { + ID string `json:"id"` + Generation int `json:"generation"` +} + +// Warrant is the operator's answer to a retiring provision's ask, as the holder writes it into the key (clause 5). +type Warrant struct { + Ask string `json:"ask"` + Option string `json:"option"` + By string `json:"by"` + At time.Time `json:"at"` +} + +// Provision is one key of `provisions`. +type Provision struct { + V int `json:"v"` + Provision string `json:"provision"` + Local string `json:"local,omitempty"` + Consumer End `json:"consumer"` + Provider End `json:"provider"` + // DependsOn are the keys of the provisions this one waits for (clause 3): a database user whose password is the + // vault's. + DependsOn []string `json:"depends-on,omitempty"` + State string `json:"state"` + // Why is the reason for waits, failed and retiring, and for asked when a dependency holds it. + Why string `json:"why,omitempty"` + Reference *Reference `json:"reference,omitempty"` + // Ask is a retiring provision's ask of the operator; Warrant the answer, once given. + Ask string `json:"ask,omitempty"` + Warrant *Warrant `json:"warrant,omitempty"` + // Plan is the plan entry the key was composed from, at the consumer's wanted build or its newest (issue 1126): the + // provider binds the consumer from it, so a requirement the newest build adds is provided before the want that + // waits for it moves, though the plan the seat keeps at the wanted build names it not yet. + Plan *catalogue.PlanProvision `json:"plan,omitempty"` + At time.Time `json:"at"` +} + +// Key is the provision's key. +func (p Provision) Key() (string, error) { + return Key(p.Consumer.Node, p.Consumer.Module, p.Provision, p.Local) +} + +func (p Provision) check() error { + if _, err := p.Key(); err != nil { + return err + } + if p.Provider.Module == "" || p.Provider.Node == "" { + return fmt.Errorf("provisions: %s names no provider", p.Provision) + } + known := false + for _, s := range States { + known = known || s == p.State + } + switch { + case !known: + return fmt.Errorf("provisions: a provision's state is one of %s, not %q", strings.Join(States, ", "), p.State) + case (p.State == Waits || p.State == Failed || p.State == Retiring) && strings.TrimSpace(p.Why) == "": + return fmt.Errorf("provisions: a %s provision says why", p.State) + case p.Warrant != nil && p.State != Retiring: + return fmt.Errorf("provisions: only a retiring provision carries the operator's warrant") + } + for _, d := range p.DependsOn { + if _, _, _, _, err := ParseKey(d); err != nil { + return err + } + } + return nil +} + +// Encode is p as JSON at Version; it refuses what a holder may not write. +func Encode(p Provision) ([]byte, error) { + p.V = Version + if err := p.check(); err != nil { + return nil, err + } + return json.Marshal(p) +} + +// Decode reads a key's value. **A field the layout does not name is read past and dropped** (issue 1126 review 1): a +// reader on an older mesh-sdk must not refuse a key a newer writer adds a field to. No value rides in a provision all +// the same (clause 1, ADR 0201 §13): Encode writes the layout alone, so what a reader does not know is never kept. +func Decode(data []byte) (Provision, error) { + var p Provision + dec := json.NewDecoder(bytes.NewReader(data)) + if err := dec.Decode(&p); err != nil { + return Provision{}, fmt.Errorf("provisions: a provision is not readable: %w", err) + } + if p.V != Version { + return Provision{}, fmt.Errorf("provisions: a provision of version %d, and this reads %d", p.V, Version) + } + return p, p.check() +} + +// Plan is one key of `plans`: a module's provisioning plan on a node, never a value (clause 2). +type Plan struct { + V int `json:"v"` + Node string `json:"node"` + Module string `json:"module"` + Plan catalogue.Plan `json:"plan"` +} + +// EncodePlan is p as JSON at Version. +func EncodePlan(p Plan) ([]byte, error) { + p.V = Version + if _, err := PlanKey(p.Node, p.Module); err != nil { + return nil, err + } + return json.Marshal(p) +} + +// DecodePlan reads a plan's key. +func DecodePlan(data []byte) (Plan, error) { + var p Plan + if err := json.Unmarshal(data, &p); err != nil { + return Plan{}, fmt.Errorf("provisions: a plan is not readable: %w", err) + } + if p.V != Version { + return Plan{}, fmt.Errorf("provisions: a plan of version %d, and this reads %d", p.V, Version) + } + _, err := PlanKey(p.Node, p.Module) + return p, err +} diff --git a/provisioning/go/clients/meshprovisionerclient/client_test.go b/provisioning/go/clients/meshprovisionerclient/client_test.go new file mode 100644 index 0000000..4e48937 --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/client_test.go @@ -0,0 +1,127 @@ +package meshprovisionerclient + +import ( + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// ADR 0570 clauses 1, 2 and 5 (issue 1954 L6-P2): a provision's key is its consumer's node and module, the provision and +// its local name, the provider's entry's key; its value round-trips, names its state and why, and refuses any field the +// layout does not name, so no value rides in it; a plan's key is its node and module. +func TestAProvisionIsAReferenceUnderItsConsumersKey(t *testing.T) { + at := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC) + db := Provision{Provision: "postgres-database", Consumer: End{Module: "keycloak", Node: "novox"}, + Provider: End{Module: "postgres", Node: "novox"}, DependsOn: []string{"novox.keycloak.secret.db"}, State: Asked, + Reference: &Reference{ID: "provision/postgres/postgres-database", Generation: 2}, At: at} + for _, c := range []struct { + name string + edit func(*Provision) + key string + refused string + }{ + {name: "asked, depending on the vault's", key: "novox.keycloak.postgres-database"}, + {name: "under a local name", edit: func(p *Provision) { p.Local = "replica" }, key: "novox.keycloak.postgres-database.replica"}, + {name: "retiring with the operator's warrant", edit: func(p *Provision) { + p.State, p.Why, p.Ask = Retiring, "keycloak left novox", "a-1" + p.Warrant = &Warrant{Ask: "a-1", Option: "yes", By: "the operator", At: at} + }, key: "novox.keycloak.postgres-database"}, + {name: "an unknown state", edit: func(p *Provision) { p.State = "done" }, refused: "one of"}, + {name: "failed without why", edit: func(p *Provision) { p.State = Failed }, refused: "says why"}, + {name: "a warrant on a provided one", edit: func(p *Provision) { p.State, p.Warrant = Provided, &Warrant{Ask: "a"} }, refused: "only a retiring"}, + {name: "no provider", edit: func(p *Provision) { p.Provider = End{} }, refused: "no provider"}, + {name: "a dotted consumer", edit: func(p *Provision) { p.Consumer.Module = "a.b" }, refused: "cannot be part"}, + {name: "a dependency that is no key", edit: func(p *Provision) { p.DependsOn = []string{"x"} }, refused: "is not"}, + } { + p := db + if c.edit != nil { + c.edit(&p) + } + raw, err := Encode(p) + if c.refused != "" { + if err == nil || !strings.Contains(err.Error(), c.refused) { + t.Errorf("%s: %v, want refused saying %q", c.name, err, c.refused) + } + continue + } + back, err := Decode(raw) + key, kerr := back.Key() + if err != nil || kerr != nil || key != c.key || back.V != Version || back.Reference.Generation != 2 { + t.Errorf("%s: read back as %+v under %q, %v %v", c.name, back, key, err, kerr) + } + if node, consumer, provision, local, err := ParseKey(key); err != nil || node != "novox" || consumer != "keycloak" || + provision != "postgres-database" || local != p.Local { + t.Errorf("%s: %s parses as %s %s %s %s, %v", c.name, key, node, consumer, provision, local, err) + } + } + // A field a later layout adds is read past, never kept (issue 1126 review 1: a reader on an older mesh-sdk must not + // refuse a key a newer writer adds a field to). A value still never rides in a provision: Encode writes the layout + // alone, so what a reader does not know is dropped, and encoding it again carries none of it. + later, err := Decode([]byte(`{"v":1,"provision":"p","consumer":{"module":"c","node":"n"},"provider":{"module":"p","node":"n"},` + + `"state":"provided","at":"2026-10-29T22:00:00Z","a-later-field":{"x":1},"password":"hunter2"}`)) + if err != nil || later.Provision != "p" || later.State != Provided { + t.Errorf("a provision with a field of a later layout: %+v, %v", later, err) + } + if again, _ := Encode(later); strings.Contains(string(again), "hunter2") || strings.Contains(string(again), "a-later-field") { + t.Errorf("a field the layout does not name was kept: %s", again) + } + plan := Plan{Node: "novox", Module: "restic", Plan: catalogue.Plan{}} + raw, err := EncodePlan(plan) + if err != nil { + t.Fatal(err) + } + if back, err := DecodePlan(raw); err != nil || back.Node != "novox" || back.Module != "restic" { + t.Errorf("a plan read back as %+v, %v", back, err) + } + if k, err := PlanKey("novox", "restic"); k != "novox.restic" || err != nil { + t.Errorf("a plan's key is %q, %v", k, err) + } + // A reference's id is the vault's name as its verb own names it, in each form (the host agent, 2026-10-29). + for form, want := range map[string]string{FormPair: "pair/novox/postgres-database/novox/keycloak", FormShared: "provision/novox/postgres-database", + FormIssued: "provision/novox/postgres-database/novox/keycloak", FormNone: ""} { + if got := VaultName(form, db); got != want { + t.Errorf("the %s name is %q", form, got) + } + } + if got := VaultName(FormPair, Provision{Provision: "p", Local: "r", Provider: End{Node: "a"}, Consumer: End{Node: "b", Module: "m"}}); got != "pair/a/p/b/m/r" { + t.Errorf("a local name's pair is %q", got) + } + // A provision waits for its credential, the vault's provision `secret-`, and for what its provider itself + // requires on its node (clause 3; the key form the lead settled on 2026-10-29). + keycloak, postgres := End{Module: "keycloak", Node: "novox"}, End{Module: "postgres", Node: "ace"} + providerPlan := &catalogue.Plan{Provisions: []catalogue.PlanProvision{{Provision: "backup-target", Provider: "ace", ProviderModule: "restic"}}} + for _, c := range []struct { + form string + plan *catalogue.Plan + want string + }{ + {FormPair, nil, "novox.keycloak.secret-postgres-database"}, + {FormShared, nil, "ace.postgres.secret-postgres-database"}, + {FormPair, providerPlan, "ace.postgres.backup-target,novox.keycloak.secret-postgres-database"}, + {"", nil, ""}, + // An offer with no credential (the lead, 2026-10-29): nothing minted, nothing to wait for. + {FormNone, nil, ""}, + } { + entry := catalogue.PlanProvision{Provision: "postgres-database", Provider: postgres.Node, ProviderModule: postgres.Module, + CredentialForm: c.form} + if got := strings.Join(DependsOn(keycloak, entry, c.plan), ","); got != c.want { + t.Errorf("%q depends on %q, want %q", c.form, got, c.want) + } + } + // The vault's own offer is the value: no secret-secret beside it. + // It is the seat's provision that has none, whatever module holds the seat (ADR 0625 §4). + for _, holder := range []string{"mesh-vault", "another-holder"} { + vaultEntry := catalogue.PlanProvision{Provision: SecretProvision, Provider: "novox", ProviderModule: holder, CredentialForm: FormPair} + if got := DependsOn(keycloak, vaultEntry, nil); len(got) != 0 { + t.Errorf("the secret provided by %s depends on %v", holder, got) + } + } + if ProvisionsBucketOf(testProvisioner) != "mesh-provisioner_provisions" || PlansReadOf(testProvisioner) != "mesh-provisioner.plans" { + t.Errorf("the states are %s and %s", ProvisionsBucketOf(testProvisioner), PlansReadOf(testProvisioner)) + } +} + +// testProvisioner is the module holding the seat in these tests: a reader names it from its own manifest's `reads`. +const testProvisioner = "mesh-provisioner" diff --git a/provisioning/go/clients/meshprovisionerclient/go.mod b/provisioning/go/clients/meshprovisionerclient/go.mod new file mode 100644 index 0000000..6ef2f66 --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/go.mod @@ -0,0 +1,14 @@ +module git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient + +go 1.26.0 + +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 + +require ( + github.com/klauspost/compress v1.20.1 // indirect + github.com/nats-io/nats.go v1.54.0 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect +) diff --git a/provisioning/go/clients/meshprovisionerclient/go.sum b/provisioning/go/clients/meshprovisionerclient/go.sum new file mode 100644 index 0000000..0daef11 --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/go.sum @@ -0,0 +1,12 @@ +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/provisioning/go/clients/meshprovisionerclient/provider/consumers.go b/provisioning/go/clients/meshprovisionerclient/provider/consumers.go new file mode 100644 index 0000000..326d0a9 --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/provider/consumers.go @@ -0,0 +1,72 @@ +package provider + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" +) + +// The provider's consumers' credentials, from the file its machine's provisioner client fills (novox/hq ADR 0373, +// issue 679). +// +// **The provider never asks the vault.** A provider in the tool runner has no account of its own, and the vault +// hands a secret only to a node's provisioner client or to a module under an account of its own (ADR 0349 clause +// 11, ADR 0370 clause 2). So the provider's template names `${consumers:}`, and its machine's client +// fills that file with one entry per consumer bound to it for that provision, each credential asked of the vault in +// the client's own name; the file is the provider's alone (mode 0600, owned). ConsumersFile is that file as the +// helper's Vault: it reads the file on each ask, so a consumer that binds or a credential the vault remakes reaches +// the provider at its next pass. + +// ConsumerCredential is one entry of the file: the consumer, the vault's name of its credential, and its value. +type ConsumerCredential struct { + // Consumer and Module are the consumer's machine and module, Local its local name for the provision, if any. + Consumer string `json:"consumer"` + Module string `json:"module"` + Local string `json:"local,omitempty"` + // Credential is the vault's name of the consumer's credential, PairName's. + Credential string `json:"credential"` + // Value is the credential itself. + Value string `json:"value"` +} + +// ConsumersFile answers a credential from the files the provider machine's client fills: one per provision whose +// template names `${consumers:}`, each a JSON list of ConsumerCredential. +type ConsumersFile struct { + Paths []string +} + +// Value is the credential named name, from the first file carrying it. A file not there yet is passed over; one not +// in the client's form is an error, never read as no consumers. +func (f ConsumersFile) Value(_ context.Context, name string) ([]byte, error) { + var missing []string + for _, at := range f.Paths { + raw, err := os.ReadFile(at) + if errors.Is(err, fs.ErrNotExist) { + missing = append(missing, at) + continue + } + if err != nil { + return nil, fmt.Errorf("the consumers' file %s: %w", at, err) + } + var entries []ConsumerCredential + if err := json.Unmarshal(raw, &entries); err != nil { + return nil, fmt.Errorf("the consumers' file %s is not the provisioner client's list of consumers", at) + } + for _, e := range entries { + if e.Credential == name { + if e.Value == "" { + return nil, fmt.Errorf("the consumers' file %s carries %s with no value", at, name) + } + return []byte(e.Value), nil + } + } + } + if len(missing) == len(f.Paths) { + return nil, fmt.Errorf("%s: no consumers' file is filled yet (%v)", name, f.Paths) + } + return nil, fmt.Errorf("%s is in no consumers' file: the vault holds none for it yet, or its machine's client "+ + "has not filled it", name) +} diff --git a/provisioning/go/clients/meshprovisionerclient/provider/harness/ctx_test.go b/provisioning/go/clients/meshprovisionerclient/provider/harness/ctx_test.go new file mode 100644 index 0000000..736ef25 --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/provider/harness/ctx_test.go @@ -0,0 +1,5 @@ +package harness + +import "context" + +var ctx = context.Background() diff --git a/provisioning/go/clients/meshprovisionerclient/provider/harness/early_test.go b/provisioning/go/clients/meshprovisionerclient/provider/harness/early_test.go new file mode 100644 index 0000000..4598603 --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/provider/harness/early_test.go @@ -0,0 +1,22 @@ +package harness + +// The moved path started before its runtime answers asks again until it does (issue 943). + +import ( + "context" + "errors" +) + +// earlyBus refuses its first watches as the runtime does before Serve runs, then answers as the bucket bus does. +type earlyBus struct { + *bucketBus + refuse int +} + +func (b *earlyBus) Watch(ctx context.Context, bucket string, handle func(string, []byte)) error { + if b.refuse > 0 { + b.refuse-- + return errors.New("before Serve: a bundle reaches the bus through the runtime that launched it") + } + return b.bucketBus.Watch(ctx, bucket, handle) +} diff --git a/provisioning/go/clients/meshprovisionerclient/provider/harness/grants_moved_test.go b/provisioning/go/clients/meshprovisionerclient/provider/harness/grants_moved_test.go new file mode 100644 index 0000000..d8206dd --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/provider/harness/grants_moved_test.go @@ -0,0 +1,124 @@ +package harness + +// postgres handed to the provisioner still serves the consumers the controller's grant file gives (issue 937): a +// consumer not moved has no plan naming postgres, only its grant, until R1 (issue 682). + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + "time" +) + +// granting gives the moved world a grant file, as the controller writes it for the consumers not moved. +func (w *movedWorld) granting(given ...map[string]any) { + if w.h.Receives == "" { + w.h.Receives = filepath.Join(w.t.TempDir(), "mesh.json") + } + dir := filepath.Dir(w.h.Receives) + for _, g := range given { + secret := filepath.Join(dir, g["as"].(string)+".secret") + if err := os.WriteFile(secret, []byte("pw-"+g["as"].(string)+"\n"), 0o600); err != nil { + w.t.Fatal(err) + } + g["secret"] = secret + } + if given == nil { + given = []map[string]any{} + } + raw, _ := json.Marshal(map[string]any{"requirement": ProvisionName, "given": given}) + if err := os.WriteFile(w.h.Receives, raw, 0o600); err != nil { + w.t.Fatal(err) + } +} + +// settled is enough passes and time for a retirement to be stable. +func (w *movedWorld) settled() { + for i := 0; i < 8; i++ { + w.pass() + } + w.now = w.now.Add(15 * time.Minute) + w.pass() + w.pass() +} + +// The logins the grant file gives are asked for: made as the grant path makes them, never offered for retirement, +// beside a moved consumer the helper makes from its plan. +func testAConsumerOnlyTheGrantFileGivesIsAskedForAndMade(t *testing.T) { + bound, logins := bindings(1) + w := newMovedWorld(t, bound) + w.a.inv.Active = []string{"mesh_ace_baserow", "mesh_ace_letta", logins[0]} + w.bus.bind("ace", "app1", nil) + w.granting( + map[string]any{"as": "mesh_ace_baserow", "node": "ace", "values": map[string]any{"name": "baserow"}}, + map[string]any{"as": "mesh_ace_letta", "node": "ace", + "values": map[string]any{"name": "letta", "extensions": []any{"vector"}}}, + ) + w.settled() + if len(w.a.removed) != 0 || w.h.r.waiting != nil { + t.Fatalf("retired %v, waiting %+v: the grant file's logins were counted unasked", w.a.removed, w.h.r.waiting) + } + made := map[string]Provision{} + for _, c := range w.a.created { + made[c.As] = c + } + letta := made["mesh_ace_letta"] + if letta.Password != "pw-mesh_ace_letta" || letta.Consumer != "ace" || + !reflect.DeepEqual(letta.Values, map[string]any{"name": "letta", "extensions": []any{"vector"}}) { + t.Fatalf("made %+v for letta, as its grant gives it", letta) + } + if _, ok := made["mesh_ace_baserow"]; !ok { + t.Fatalf("baserow not made: %+v", w.a.created) + } + if _, ok := made[logins[0]]; !ok { + t.Fatalf("the moved consumer not made: %+v", w.a.created) + } +} + +// A login both a plan and the grant file name is the plan's: made once, with the plan's credential. +func testALoginAPlanNamesIsNotMadeFromTheGrantFile(t *testing.T) { + bound, logins := bindings(1) + w := newMovedWorld(t, bound) + w.bus.bind("ace", "app1", nil) + w.granting(map[string]any{"as": logins[0], "node": "ace", "values": map[string]any{"name": "app1"}}) + w.pass() + w.pass() + for _, c := range w.a.created { + if c.As == logins[0] && c.Password != "pw-app1" { + t.Fatalf("made %s with the grant's password %q, not the plan's", c.As, c.Password) + } + } +} + +// An unreadable grant file is not nobody asking (issue 241): nothing is retired while it cannot be read. +func testAnUnreadableGrantFileRetiresNothing(t *testing.T) { + bound, logins := bindings(1) + w := newMovedWorld(t, bound) + w.a.inv.Active = []string{"mesh_ace_baserow", logins[0]} + w.bus.bind("ace", "app1", nil) + w.h.Receives = filepath.Join(t.TempDir(), "absent.json") + w.settled() + if len(w.a.removed) != 0 || w.h.r.waiting != nil { + t.Fatalf("retired %v, waiting %+v with the grant file unreadable", w.a.removed, w.h.r.waiting) + } +} + +// A login the grant file stops giving is not retired by the provider itself (novox/hq ADR 0570 clause 5): every +// retirement waits for the operator's yes on a provision's key. +func testALoginTheGrantFileStopsGivingIsRetired(t *testing.T) { + bound, _ := bindings(1) + w := newMovedWorld(t, bound) + w.bus.bind("ace", "app1", nil) + w.granting( + map[string]any{"as": "mesh_ace_baserow", "node": "ace", "values": map[string]any{"name": "baserow"}}, + map[string]any{"as": "mesh_ace_letta", "node": "ace", "values": map[string]any{"name": "letta"}}, + ) + w.pass() + w.granting(map[string]any{"as": "mesh_ace_baserow", "node": "ace", "values": map[string]any{"name": "baserow"}}) + w.settled() + if len(w.a.removed) != 0 || len(w.a.deleted) != 0 { + t.Fatalf("retired %v, deleted %v, by itself", w.a.removed, w.a.deleted) + } +} diff --git a/provisioning/go/clients/meshprovisionerclient/provider/harness/harness.go b/provisioning/go/clients/meshprovisionerclient/provider/harness/harness.go new file mode 100644 index 0000000..9e9dedc --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/provider/harness/harness.go @@ -0,0 +1,572 @@ +// Package harness is the reconcile loop and the retirement of a provider that keeps a resource per consumer; the +// module supplies an Adapter. +package harness + +// The reconcile loop every provider shares, as the TypeScript SDK's runProvisioner runs it +// (@novox/mesh-sdk/provisioner, 0.1.12), line for line in behaviour. It moved here from the postgres and keycloak +// providers, which each carried an identical copy (novox/hq ADR 0039: the loop is the SDK's, the adapter is the +// module's; issue 849). +// +// Read the contributions the mesh delivered; bring each consumer's resource into being through the +// adapter, under the login and password the mesh minted; retire what the mesh no longer asks for. +// **A provider creates the credential the mesh minted, and seals nothing (novox/hq ADR 0048).** +// +// **A provider that keeps failing a consumer says so on the bus (novox/hq ADR 0224).** A consumer +// whose create, check or secret has failed without one success in between for FailingAfter is +// announced as `provisioner.failing` — naming the consumer, its machine and the class of error — and +// again every SayAgainEvery while it lasts; the first success after that is `provisioner.recovered`. +// The controller keeps the newest per provider and consumer and `status` names it. On 2026-10-05 the +// identity provider failed every consumer 31,000 times in a day and said so only in its journal +// (novox/hq issue 179). +// +// **A consumer the mesh stops asking for is retired, not withdrawn, and deleted only by a person +// (novox/hq ADR 0230, the operator's model of 2026-10-06).** Retiring disables its access — reversibly — +// and marks its login and data "to delete" with when and why; nothing is deleted. Asked for again, the +// ordinary create re-enables it as it was. It is retired only once the same set has gone unasked in +// StablePasses consecutive passes and for StableFor, and a set larger than the bound waits for a +// person. retirement.go. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "strings" + "sync" + "time" +) + +// Provision is one consumer's resource to bring into being — everything the mesh derived and delivered. +type Provision struct { + // As is the login the mesh derived and gave the consumer to present. + As string + // Password is the one the mesh minted, read from the file the host unsealed. + Password string + // Values are what the consumer contributed (e.g. {"name": "letta", "extensions": ["vector"]}). + Values map[string]any + // Derived is what this provider's own definition derives for the consumer (novox/hq ADR 0201). + Derived map[string]any + // At is where the consumer is; Consumer is its node. + At string + Consumer string +} + +// Adapter is the per-service half. +type Adapter interface { + // Create brings the consumer into being under the mesh's login and password — and, for one that + // was retired, re-enables it as it was and clears its mark to delete. + Create(ctx context.Context, p Provision) error + // Retire disables the consumer's access, reversibly, and marks its login and data to delete with + // when and why. It deletes nothing (novox/hq ADR 0230). derived is what the mesh last derived, + // remembered here; nil for a consumer this process did not make. + Retire(ctx context.Context, as string, derived map[string]any, why string, at time.Time) error + // Holds says whether the backend still holds the consumer exactly as p says. Read-only. + Holds(ctx context.Context, p Provision) (bool, error) + // Inventory is what the backend holds that the mesh made: consumers active and retired, read from + // the backend itself so a restart forgets nothing. Read-only. + Inventory(ctx context.Context) (Inventory, error) + // Delete removes one retired consumer — its login and its data — for good. Only ever asked by a + // person, through `cleanup delete`; the harness has checked it is retired and not asked for. + Delete(ctx context.Context, r Retired) (freedBytes int64, err error) +} + +// Harness is the loop's settings and memory. +type Harness struct { + Resource string + Receives string + Adapter Adapter + Every time.Duration // 5s + VerifyEvery time.Duration // 60s + HoldsTimeout time.Duration // 30s + Log func(format string, args ...any) + Now func() time.Time + // Announce publishes one of the provider's standing events; nil announces nothing. Node is the + // machine this provider runs on, said in each. + Announce func(event string, body map[string]any) + Node string + // FailingAfter is how long a consumer fails without a success before it is announced (5m); + // SayAgainEvery is how often it is announced again while it lasts (15m), so a controller that + // missed the first hears the next, and a standing nobody repeats can be told from one that holds. + FailingAfter time.Duration + SayAgainEvery time.Duration + // StablePasses (5) is how many consecutive passes must see the same set no longer asked for, and + // StableFor (10m) how long it must have held since the first of them, before it is retired. RetireAtOnce (3) and RetireFraction (0.5) bound what is retired without a person: + // more consumers than RetireAtOnce, or — where more than one is held — a larger share of those held + // than RetireFraction, waits for `retire approve` (novox/hq ADR 0230). + StablePasses int + StableFor time.Duration + RetireAtOnce int + RetireFraction float64 + + // mu is held by a pass and by every tool a person asks, so the two never interleave. + mu sync.Mutex + verifiedAt time.Time + r retirement + applied map[string]appliedEntry + lost map[string]brake + waiting map[string]int + failing map[string]failure + trouble map[string]*standing + cleared map[string]bool + lastWarning string +} + +// standing is one consumer's unbroken run of failures: since when, how often, and the last error. +type standing struct { + node string + since time.Time + attempts int + class string + text string + saidAt time.Time +} + +// The events a provider's standing is announced as (novox/hq ADR 0224). The controller derives the +// permission to emit them for every module that receives contributions; no manifest lists them. +const ( + EventFailing = "provisioner.failing" + EventRecovered = "provisioner.recovered" +) + +// The classes of error a standing is announced with: what a person reading `status` needs to know +// before reading the journal. An adapter may say better (Classifier). +const ( + ClassCredentials = "credentials-rejected" + ClassUnreachable = "unreachable" + ClassSecret = "secret-unreadable" + ClassRefused = "refused" +) + +// Classifier is an adapter that can say what class an error of its own is. +type Classifier interface { + Class(err error) string +} + +type appliedEntry struct { + hash string + derived map[string]any + node string +} + +type brake struct { + times int + nextAt time.Time +} + +type failure struct { + text string + times int +} + +// The longest a consumer whose create keeps failing to satisfy holds waits between checks. +const maxBackoff = time.Hour + +// How many passes a secret may be unreadable before it stops being called a race (issue 225), and +// once said loudly, how often it is repeated. The same cadence quiets a create that keeps failing +// the same way. +const ( + patiently = 12 + loudlyEvery = 240 +) + +type contribution struct { + As string `json:"as"` + Secret string `json:"secret"` + Node string `json:"node"` + At string `json:"at"` + Values map[string]any `json:"values"` + Derived map[string]any `json:"derived"` +} + +func (h *Harness) init() { + if h.Every == 0 { + h.Every = 5 * time.Second + } + if h.VerifyEvery == 0 { + h.VerifyEvery = time.Minute + } + if h.HoldsTimeout == 0 { + h.HoldsTimeout = 30 * time.Second + } + if h.Now == nil { + h.Now = time.Now + } + if h.FailingAfter == 0 { + h.FailingAfter = 5 * time.Minute + } + if h.SayAgainEvery == 0 { + h.SayAgainEvery = 15 * time.Minute + } + if h.StablePasses == 0 { + h.StablePasses = 5 + } + if h.StableFor == 0 { + h.StableFor = StableFor + } + if h.RetireAtOnce == 0 { + h.RetireAtOnce = 3 + } + if h.RetireFraction == 0 { + h.RetireFraction = 0.5 + } + if h.Log == nil { + h.Log = func(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) } + } + if h.applied == nil { + h.applied = map[string]appliedEntry{} + h.lost = map[string]brake{} + h.waiting = map[string]int{} + h.failing = map[string]failure{} + h.trouble = map[string]*standing{} + h.cleared = map[string]bool{} + h.r.retired = map[string]retiredEntry{} + } +} + +// Run reconciles until ctx ends. One consumer's failure never stops the others'. +func (h *Harness) Run(ctx context.Context) { + h.init() + for { + h.Reconcile(ctx) + select { + case <-ctx.Done(): + return + case <-time.After(h.Every): + } + } +} + +func (h *Harness) say(format string, args ...any) { + h.Log("[provisioner:"+h.Resource+"] "+format, args...) +} + +func (h *Harness) warn(why string) { + if why == h.lastWarning { + return + } + if why != "" { + h.say("%s; nothing applied or removed until it can be read", why) + } else { + h.say("contributions file readable again") + } + h.lastWarning = why +} + +// readContributions answers the consumers asked for, or nil when the file says nothing usable. +// **Nothing read is not nobody asking** (novox/hq issue 241): only a file that was read can retire anything. +func (h *Harness) readContributions() []contribution { + raw, err := os.ReadFile(h.Receives) + if err != nil { + h.warn(fmt.Sprintf("contributions file unreadable (%s): %v", h.Receives, err)) + return nil + } + var doc struct { + Requirement string `json:"requirement"` + Given json.RawMessage `json:"given"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + h.warn(fmt.Sprintf("contributions file is not JSON (%s): %v", h.Receives, err)) + return nil + } + if doc.Requirement != "" && doc.Requirement != h.Resource { + h.warn(fmt.Sprintf("%s is for %s, not %s", h.Receives, doc.Requirement, h.Resource)) + return nil + } + var given []contribution + if len(doc.Given) == 0 || string(doc.Given) == "null" || json.Unmarshal(doc.Given, &given) != nil { + h.warn(fmt.Sprintf("%s has no given list", h.Receives)) + return nil + } + h.warn("") + out := []contribution{} + for _, g := range given { + // No `as` is not a credential grant: nothing to create for it. + if g.As != "" && g.Secret != "" { + out = append(out, g) + } + } + return out +} + +func hashOf(as, password string, values, derived map[string]any) string { + // Derived is in the hash: a provider that renames what it derives gave a different resource. + b, _ := json.Marshal([]any{as, password, orEmpty(values), orEmpty(derived)}) + return string(b) +} + +func orEmpty(m map[string]any) map[string]any { + if m == nil { + return map[string]any{} + } + return m +} + +// Reconcile is one pass. +func (h *Harness) Reconcile(ctx context.Context) { + h.mu.Lock() + defer h.mu.Unlock() + h.init() + given := h.readContributions() + if given == nil { + // Not a result: the passes that must agree before anything is retired start again. + h.r.key, h.r.count = "", 0 + return + } + want := map[string]bool{} + for _, g := range given { + want[g.As] = true + } + h.seed(ctx, want) + h.apply(ctx, given) + + // Retire what the mesh has stably stopped asking for — within the bound, or with a person. + h.retireUnasked(ctx, want) + h.r.lastWant = want + for as := range h.failing { + if !want[as] { + delete(h.failing, as) + } + } + // A consumer the mesh stopped asking for that this provider holds nothing for is no longer failed by + // anyone: said, so a standing the controller keeps for it is cleared rather than left naming a + // consumer that is gone. One still held is said recovered when it is retired. + for as := range h.trouble { + if _, held := h.applied[as]; !want[as] && !held { + h.recovered(as, "no longer asked for") + } + } +} + +// apply makes each consumer the grant file gives, and checks every VerifyEvery that the backend still holds it. The +// caller holds h.mu. One consumer's failure never stops the others'. +func (h *Harness) apply(ctx context.Context, given []contribution) { + verifying := h.Now().Sub(h.verifiedAt) >= h.VerifyEvery + if verifying { + h.verifiedAt = h.Now() + } + + for _, g := range given { + raw, err := os.ReadFile(g.Secret) + if err != nil { + // A secret the host has not written yet is a race on the first pass; past a minute it is + // a person's to look at, and said so (novox/hq issue 225). + n := h.waiting[g.As] + 1 + h.waiting[g.As] = n + if n <= patiently { + h.say("%s: secret not readable yet (%s): %v", g.As, g.Secret, err) + } else if n == patiently+1 || n%loudlyEvery == 0 { + h.say("%s: CANNOT READ the secret after %d attempts (%s): %v. This is not a race any more — "+ + "nothing has been provisioned for this consumer and nothing will be until somebody looks. "+ + "Check who owns the file and who this process runs as (novox/hq issue 225)", g.As, n, g.Secret, err) + } + h.failed(g.As, g.Node, ClassSecret, fmt.Sprintf("secret not readable (%s): %v", g.Secret, err)) + continue + } + delete(h.waiting, g.As) + password := strings.TrimSuffix(string(raw), "\n") + p := Provision{As: g.As, Password: password, Values: orEmpty(g.Values), Derived: orEmpty(g.Derived), At: g.At, Consumer: g.Node} + hash := hashOf(g.As, password, g.Values, g.Derived) + + reapplying := 0 + if was, ok := h.applied[g.As]; ok && was.hash == hash { + if !verifying { + continue + } + b, braked := h.lost[g.As] + if braked && h.Now().Before(b.nextAt) { + continue + } + hctx, cancel := context.WithTimeout(ctx, h.HoldsTimeout) + held, err := h.Adapter.Holds(hctx, p) + timedOut := errors.Is(hctx.Err(), context.DeadlineExceeded) + cancel() + if err != nil { + // Unable to ask is not evidence of loss. A backend that timed out will time out for + // the next consumer too, so the rest of this pass is not asked. + text := scrub(err, password) + h.say("%s: could not check the backend, will ask again: %s", g.As, text) + h.failed(g.As, g.Node, h.classOf(err, text), text) + if timedOut { + verifying = false + } + continue + } + if held { + delete(h.lost, g.As) + h.succeeded(g.As) + continue + } + reapplying = b.times + 1 + if reapplying == 1 { + h.say("%s: the backend no longer holds it; applying again", g.As) + } else { + h.say("%s: still not held after being applied again (%d times in a row) — create does not "+ + "produce what holds checks; applying again", g.As, reapplying) + } + } + if err := h.Adapter.Create(ctx, p); err != nil { + text := scrub(err, password) + f := h.failing[g.As] + if f.text != text { + f = failure{text: text} + } + f.times++ + h.failing[g.As] = f + // Said each time it changes, and while it stays the same, as rarely as a lost secret. + if f.times == 1 || f.times%loudlyEvery == 0 { + h.say("%s: create failed, will retry: %s", g.As, text) + } + h.failed(g.As, g.Node, h.classOf(err, text), text) + if reapplying > 0 { + h.lost[g.As] = brake{times: reapplying - 1} + } + continue + } + if f, was := h.failing[g.As]; was { + h.say("%s: created, after %d failed attempt(s)", g.As, f.times) + delete(h.failing, g.As) + } + h.succeeded(g.As) + h.applied[g.As] = appliedEntry{hash: hash, derived: p.Derived, node: g.Node} + h.reenabled(g.As, g.Node) + if reapplying == 0 { + delete(h.lost, g.As) + } else { + wait := h.VerifyEvery << (reapplying - 1) + if wait > maxBackoff || wait <= 0 { + wait = maxBackoff + } + h.lost[g.As] = brake{times: reapplying, nextAt: h.Now().Add(wait)} + if reapplying > 1 { + h.say("%s: next check in %s", g.As, wait.Round(time.Second)) + } + } + } +} + +// failed counts one more failure in a consumer's unbroken run, and announces the run once it has +// lasted FailingAfter — then again every SayAgainEvery while it lasts. +func (h *Harness) failed(as, node, class, text string) { + now := h.Now() + s := h.trouble[as] + if s == nil { + s = &standing{since: now} + h.trouble[as] = s + } + s.node, s.class, s.text = node, class, text + s.attempts++ + if now.Sub(s.since) < h.FailingAfter { + return + } + if !s.saidAt.IsZero() && now.Sub(s.saidAt) < h.SayAgainEvery { + return + } + first := s.saidAt.IsZero() + s.saidAt = now + if first { + h.say("%s: FAILING for %s (%d attempts, %s): %s. Announced as %s; `status` names it until it "+ + "succeeds (novox/hq ADR 0224)", as, now.Sub(s.since).Round(time.Second), s.attempts, class, text, EventFailing) + } + h.announce(EventFailing, map[string]any{ + "provider": h.Resource, "provider-node": h.Node, + "consumer": as, "node": node, + "class": class, "error": clip(text), + "since": s.since.UTC().Format(time.RFC3339), "attempts": s.attempts, + }) +} + +// succeeded ends a consumer's run of failures; one that was announced is announced recovered. +// +// **And the first success for a consumer since this process started is announced too**, failing or +// not: a provider that announced a failure and was restarted has forgotten it, and without this the +// controller would name the consumer failing for ever after it recovered unheard. +func (h *Harness) succeeded(as string) { + if h.trouble[as] == nil && !h.cleared[as] { + h.cleared[as] = true + h.announce(EventRecovered, map[string]any{ + "provider": h.Resource, "provider-node": h.Node, "consumer": as, "why": "first-success", + }) + return + } + h.cleared[as] = true + h.recovered(as, "") +} + +func (h *Harness) recovered(as, why string) { + s := h.trouble[as] + if s == nil { + return + } + delete(h.trouble, as) + if s.saidAt.IsZero() { + return // never announced, so there is nothing to take back + } + if why == "" { + h.say("%s: recovered after %s and %d failed attempt(s)", as, h.Now().Sub(s.since).Round(time.Second), s.attempts) + } + body := map[string]any{ + "provider": h.Resource, "provider-node": h.Node, "consumer": as, "node": s.node, + "since": s.since.UTC().Format(time.RFC3339), "attempts": s.attempts, + } + if why != "" { + body["why"] = why + } + h.announce(EventRecovered, body) +} + +func (h *Harness) announce(event string, body map[string]any) { + if h.Announce != nil { + h.Announce(event, body) + } +} + +// classOf is an error's class: the adapter's word when it has one, else read from the text. +func (h *Harness) classOf(err error, text string) string { + if c, ok := h.Adapter.(Classifier); ok { + if class := c.Class(err); class != "" { + return class + } + } + return ClassOf(text) +} + +// ClassOf reads an error's class from its text — the words the backends the mesh runs use. +func ClassOf(text string) string { + t := strings.ToLower(text) + for _, w := range []string{"invalid_grant", "invalid user credentials", "password authentication failed", + "authentication failed", "unauthorized", " 401"} { + if strings.Contains(t, w) { + return ClassCredentials + } + } + for _, w := range []string{"connection refused", "no such host", "i/o timeout", "deadline exceeded", + "connection reset", "network is unreachable", "no route to host", "eof"} { + if strings.Contains(t, w) { + return ClassUnreachable + } + } + return ClassRefused +} + +// clip keeps an announced error to what belongs in a status line. +func clip(text string) string { + const most = 300 + if len(text) <= most { + return text + } + return text[:most] + "…" +} + +// scrub is an error's text with the consumer's password removed, raw and URL-encoded. +func scrub(err error, password string) string { + text := err.Error() + if password == "" { + return text + } + for _, form := range []string{password, url.QueryEscape(password), url.PathEscape(password)} { + text = strings.ReplaceAll(text, form, "***") + } + return text +} diff --git a/provisioning/go/clients/meshprovisionerclient/provider/harness/harness_test.go b/provisioning/go/clients/meshprovisionerclient/provider/harness/harness_test.go new file mode 100644 index 0000000..d06976a --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/provider/harness/harness_test.go @@ -0,0 +1,154 @@ +package harness + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +type world struct { + t *testing.T + dir string + receives string + now time.Time + h *Harness + a *recorder + said []string +} + +func newWorld(t *testing.T) *world { + w := &world{t: t, dir: t.TempDir(), now: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC), a: &recorder{held: true}} + w.receives = filepath.Join(w.dir, "mesh.json") + w.h = &Harness{Resource: "postgres-database", Receives: w.receives, Adapter: w.a, + Now: func() time.Time { return w.now }, + Log: func(f string, a ...any) { w.said = append(w.said, f) }} + return w +} + +func (w *world) give(given ...map[string]any) { + for _, g := range given { + secret := filepath.Join(w.dir, g["as"].(string)+".secret") + if err := os.WriteFile(secret, []byte("pw-"+g["as"].(string)+"\n"), 0o600); err != nil { + w.t.Fatal(err) + } + g["secret"] = secret + } + if given == nil { + given = []map[string]any{} + } + raw, _ := json.Marshal(map[string]any{"requirement": "postgres-database", "given": given}) + if err := os.WriteFile(w.receives, raw, 0o600); err != nil { + w.t.Fatal(err) + } +} + +func testAConsumerIsCreatedOnceUnderTheMeshsLoginAndPassword(t *testing.T) { + w := newWorld(t) + w.give(map[string]any{"as": "mesh_ace_letta", "node": "ace", "values": map[string]any{"name": "letta"}}) + w.h.Reconcile(ctx) + w.h.Reconcile(ctx) + if len(w.a.created) != 1 { + t.Fatalf("created %d times", len(w.a.created)) + } + p := w.a.created[0] + if p.As != "mesh_ace_letta" || p.Password != "pw-mesh_ace_letta" || p.Consumer != "ace" { + t.Fatalf("%+v", p) + } +} + +func testAConsumerNoLongerAskedForIsRetiredOnceStable(t *testing.T) { + w := newWorld(t) + w.give(map[string]any{"as": "a"}, map[string]any{"as": "b"}) + w.h.Reconcile(ctx) + w.give(map[string]any{"as": "a"}) + w.settle() // five passes and ten minutes + if strings.Join(w.a.removed, ",") != "b" { + t.Fatal(w.a.removed) + } + // Only a file that says nobody asks retires the last one. + w.give() + w.settle() + if strings.Join(w.a.removed, ",") != "b,a" { + t.Fatal(w.a.removed) + } +} + +func testNothingReadIsNotNobodyAsking(t *testing.T) { + for name, content := range map[string]string{ + "unreadable": "", + "not JSON": "{", + "no given": `{"requirement": "postgres-database"}`, + "another": `{"requirement": "mssql-database", "given": []}`, + } { + t.Run(name, func(t *testing.T) { + w := newWorld(t) + w.give(map[string]any{"as": "a"}) + w.h.Reconcile(ctx) + if content == "" { + os.Remove(w.receives) + } else { + os.WriteFile(w.receives, []byte(content), 0o600) + } + w.h.Reconcile(ctx) + if len(w.a.removed) != 0 { + t.Fatalf("retired %v on a file it could not use", w.a.removed) + } + }) + } +} + +func testALostConsumerIsAppliedAgainAndBraked(t *testing.T) { + w := newWorld(t) + w.give(map[string]any{"as": "a"}) + w.h.Reconcile(ctx) + w.a.held = false + w.now = w.now.Add(2 * time.Minute) + w.h.Reconcile(ctx) + if len(w.a.created) != 2 { + t.Fatalf("created %d times", len(w.a.created)) + } + // Still not held a minute later: applied again, then braked for two minutes. + w.now = w.now.Add(61 * time.Second) + w.h.Reconcile(ctx) + w.now = w.now.Add(61 * time.Second) + w.h.Reconcile(ctx) + if len(w.a.created) != 3 { + t.Fatalf("not braked: created %d times", len(w.a.created)) + } +} + +func testAFailingCreateIsRetriedAndSaidOnce(t *testing.T) { + w := newWorld(t) + w.a.failing = &pgErr{"extension \"nope\" refused, password pw-a"} + w.give(map[string]any{"as": "a"}) + for i := 0; i < 5; i++ { + w.h.Reconcile(ctx) + } + n := 0 + for _, s := range w.said { + if strings.Contains(s, "create failed") { + n++ + } + } + if n != 1 { + t.Fatalf("said %d times", n) + } + w.a.failing = nil + w.h.Reconcile(ctx) + if len(w.a.created) != 1 { + t.Fatal("not retried") + } +} + +type pgErr struct{ s string } + +func (e *pgErr) Error() string { return e.s } + +func testScrubRemovesThePassword(t *testing.T) { + if got := scrub(&pgErr{"bad pw a/b c and a%2Fb+c"}, "a/b c"); strings.Contains(got, "a/b c") || strings.Contains(got, "a%2Fb+c") { + t.Fatal(got) + } +} diff --git a/provisioning/go/clients/meshprovisionerclient/provider/harness/moved.go b/provisioning/go/clients/meshprovisionerclient/provider/harness/moved.go new file mode 100644 index 0000000..1f2e90d --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/provider/harness/moved.go @@ -0,0 +1,314 @@ +package harness + +// A provider handed to the provisioner (novox/hq ADR 0373, ADR 0349 clause 12, issues 780 and 805): where its +// consumers file is set, it runs on the provider helper (package provider) instead of the harness reading the +// controller's grant files. Moved is that path, shared by every provider that keeps a resource per consumer; a module +// says only what differs, in a MovedSpec. +// +// The helper learns who binds the provider from the plans, composes each consumer's entry and reads its credential +// through ConsumersFile, the file this machine's provisioner client fills. It leaves the rest to the two hooks +// here: Make brings the consumer's resource into being exactly as the harness's adapter does, and Gone leaves +// retirement to the harness's guards (retirement.go): the resource is disabled and marked to delete, never removed. +// +// **A consumer not moved is still the grant file's** (issue 937, ADR 0349 clause 17), for a provider whose spec says +// WithGrants: only a moved consumer's plan names its provider, so the controller still gives every other consumer +// through the grant file (MESH_RECEIVES) until R1 (issue 682). Each login it gives is asked for, and one no plan +// names is made and checked by the harness's grant pass exactly as on the grant path; a login a plan names is the +// helper's alone. A grant file that cannot be read is not nobody asking (issue 241): nothing is retired until it is +// read again. + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + provisions "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider" +) + +// MovedSpec is what differs per provider on the moved path. +type MovedSpec struct { + // Module and Provision are the provider module's name and what it provides, as its manifest says. + Module, Provision string + // Provisioner is the module that holds the seat mesh-provisioner, as the provider's manifest's `reads` name it. + Provisioner string + // Offer is what it serves: its credential form and served values. + Offer provider.Offer + // Login is the identity a consumer is made under at the provider's end (postgres: the entry's User; keycloak: its + // As); an entry with none is not made. + Login func(e provider.Entry) string + // Values are the values the adapter is handed for a consumer; the entry's contribution when not set. + Values func(e provider.Entry) map[string]any + // Noun names what Login is in words ("login", "client id"), Made what is made ("login", "client"), and Backend + // the service in "no longer holds it" ("the backend", "Keycloak"). + Noun, Made, Backend string + // WithGrants makes the logins the grant file gives, that no plan names, asked for and made by the harness's grant + // pass (issue 937). + WithGrants bool +} + +// Moved is a provider on the provider helper, with the harness's own memory and retirement behind it: what is held, +// what is counted unasked, what waits for a person (retirement.go). The harness is never Run here; the helper's Make +// and Gone and the loop below drive the same state, so the guards are the harness's, not a copy. +type Moved struct { + h *Harness + spec MovedSpec + + // FirstRetry is the first wait before a watch asked too early is asked again (issue 943); a second when zero. + FirstRetry time.Duration + + mu sync.Mutex // guards plans and loaded + plans map[string][]byte + loaded bool + verified map[string]time.Time +} + +func newMoved(h *Harness, spec MovedSpec) *Moved { + h.mu.Lock() + h.init() + h.mu.Unlock() + return &Moved{h: h, spec: spec, plans: map[string][]byte{}, verified: map[string]time.Time{}} +} + +// Make brings one consumer's resource into being under its login and the vault's credential, as the harness does: +// at once when it is new or changed, and otherwise only when the backend no longer holds it, which is asked every +// VerifyEvery. +func (m *Moved) Make(ctx context.Context, c provider.Consumer) error { + h, e := m.h, c.Entry + as := m.spec.Login(e) + if as == "" { + return fmt.Errorf("%s has no %s to make", e.Key(), m.spec.Noun) + } + if len(c.Credential) == 0 { + return fmt.Errorf("%s has no credential to make its %s with", e.Key(), m.spec.Made) + } + h.mu.Lock() + defer h.mu.Unlock() + password := string(c.Credential) + values := orEmpty(e.Contributes) + if m.spec.Values != nil { + values = m.spec.Values(e) + } + p := Provision{As: as, Password: password, Values: values, Consumer: e.Consumer} + hash := hashOf(as, password, p.Values, nil) + if was, ok := h.applied[as]; ok && was.hash == hash { + if h.Now().Sub(m.verified[as]) < h.VerifyEvery { + return nil + } + m.verified[as] = h.Now() + hctx, cancel := context.WithTimeout(ctx, h.HoldsTimeout) + held, err := h.Adapter.Holds(hctx, p) + cancel() + if err != nil { + text := scrub(err, password) + h.failed(as, e.Consumer, h.classOf(err, text), text) + return errors.New(text) + } + if held { + h.succeeded(as) + return nil + } + h.say("%s: %s no longer holds it; applying again", as, m.spec.Backend) + } + if err := h.Adapter.Create(ctx, p); err != nil { + text := scrub(err, password) + h.failed(as, e.Consumer, h.classOf(err, text), text) + return errors.New(text) + } + h.succeeded(as) + h.applied[as] = appliedEntry{hash: hash, node: e.Consumer} + m.verified[as] = h.Now() + h.reenabled(as, e.Consumer) + return nil +} + +// Gone is the helper letting a consumer go, which it does only on a retiring provision the operator said yes to (novox/hq +// ADR 0570 clause 5): its login is retired at once, through the adapter, its resource and data kept and marked to +// delete, deleted only by a person's `cleanup delete`. A consumer another plan still binds under the same login loses +// nothing. +func (m *Moved) Gone(ctx context.Context, e provider.Entry) error { + h := m.h + as := m.spec.Login(e) + if as == "" { + return nil // refused: nothing was made for it + } + want, _ := m.asked(h.Node) + h.mu.Lock() + defer h.mu.Unlock() + if want[as] { + return nil // another holder of the same login still binds: nothing to retire + } + if _, held := h.applied[as]; !held { + return nil + } + if done := h.retire(ctx, []string{as}, "the operator approved its retirement ("+e.Key()+")", nil); len(done) != 1 { + return fmt.Errorf("%s was not retired; it is tried again at the next pass", as) + } + return nil +} + +// asked is the logins the seat's plans bind the provider for (novox/hq ADR 0570 clause 2), and whether they could all be +// read: a plan that cannot be read, or no read yet, is not an answer (novox/hq issue 241). +func (m *Moved) asked(node string) (map[string]bool, bool) { + m.mu.Lock() + defer m.mu.Unlock() + if !m.loaded || len(m.plans) == 0 { + return nil, false + } + want := map[string]bool{} + for key, raw := range m.plans { + pl, err := provisions.DecodePlan(raw) + if err != nil || pl.Node+"."+pl.Module != key { + return nil, false + } + for _, b := range pl.Plan.Provisions { + if b.Provider == node && b.ProviderModule == m.spec.Module && b.Provision == m.spec.Provision { + as := b.As + if as == "" { + as = provider.ConsumerIdentity(pl.Node, pl.Module) // a plan that names none + } + want[as] = true + } + } + } + return want, true +} + +// granted is what the grant file gives that no plan names (issue 937), and every login it gives; and whether it +// could be read. The caller holds h.mu. With no grant file named there is nothing given, and that is a read. +func (m *Moved) granted(planned map[string]bool) ([]contribution, map[string]bool, bool) { + h := m.h + if h.Receives == "" { + return nil, map[string]bool{}, true + } + given := h.readContributions() + if given == nil { + return nil, nil, false + } + var own []contribution + logins := map[string]bool{} + for _, g := range given { + logins[g.As] = true + if !planned[g.As] { + own = append(own, g) + } + } + return own, logins, true +} + +// retire is one pass over what the plans bind and the grant file gives now: the logins only the grant file gives are +// made and checked, as the grant path does. It retires nothing (ADR 0570 clause 5). +func (m *Moved) retire(ctx context.Context) { + h := m.h + want, ok := m.asked(h.Node) + h.mu.Lock() + defer h.mu.Unlock() + var given []contribution + if ok && m.spec.WithGrants { + var granted map[string]bool + given, granted, ok = m.granted(want) + for as := range granted { + want[as] = true + } + } + if !ok { + // Not a result: the passes that must agree before anything is retired start again. + h.r.key, h.r.count = "", 0 + return + } + h.seed(ctx, want) + h.apply(ctx, given) + // No login is retired for going unasked (novox/hq ADR 0570 clause 5): only Gone, on the operator's yes. + h.r.lastWant = want + for as := range h.failing { + if !want[as] { + delete(h.failing, as) + } + } + for as := range h.trouble { + if _, held := h.applied[as]; !want[as] && !held { + h.recovered(as, "no longer asked for") + } + } +} + +// watch keeps the plans as the bus holds them, from the first read on. +func (m *Moved) watch(ctx context.Context, bus provider.Bus) error { + err := bus.Watch(ctx, provisions.PlansBucketOf(m.spec.Provisioner), func(key string, value []byte) { + m.mu.Lock() + defer m.mu.Unlock() + if value == nil { + delete(m.plans, key) + } else { + m.plans[key] = append([]byte(nil), value...) + } + }) + if err == nil { + m.mu.Lock() + m.loaded = true + m.mu.Unlock() + } + return err +} + +// Run answers the consumers through the helper and retires through the harness's rules, until ctx ends. +// +// The bus is reached through the runtime that launched this bundle, which answers only once Serve runs, and Run starts +// beside Serve: a watch asked too early is asked again, after a wait that doubles from FirstRetry up to a minute, +// until it is answered or ctx ends, so a start that loses the race never leaves the consumers unanswered (issue 943). +func (m *Moved) Run(ctx context.Context, p *provider.Provider, bus provider.Bus) error { + wait := m.FirstRetry + if wait <= 0 { + wait = time.Second + } + for tries := 1; ; tries++ { + err := m.watch(ctx, bus) + if err == nil { + break + } + if tries == 4 || tries%20 == 0 { + m.h.say("the plans cannot be watched yet (%d tries), asking again: %v", tries, err) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(wait): + } + wait = min(wait*2, time.Minute) + } + go func() { + for { + m.retire(ctx) + select { + case <-ctx.Done(): + return + case <-time.After(m.h.Every): + } + } + }() + return p.Run(ctx) +} + +// NewMoved is a provider handed to the provisioner: the helper answering its consumers, and the harness (never run) +// whose retirement and tools it shares. consumersFile is where its machine's client fills its consumers. Run the +// result with Moved.Run, passing the provider and the bus returned with it. +func NewMoved(consumersFile string, h *Harness, spec MovedSpec) (*Moved, *provider.Provider, provider.Bus, error) { + if consumersFile == "" { + return nil, nil, nil, errors.New("no consumers' file") + } + run := newMoved(h, spec) + bus := provider.StateBus(spec.Module, spec.Provisioner) + return run, provider.New(provider.Options{ + Node: h.Node, + Module: spec.Module, Provisioner: spec.Provisioner, + Offers: map[string]provider.Offer{spec.Provision: spec.Offer}, + Bus: bus, + Vault: provider.ConsumersFile{Paths: []string{consumersFile}}, + Make: run.Make, + Gone: run.Gone, + Every: h.Every, + Logf: func(f string, a ...any) { h.say(f, a...) }, + }), bus, nil +} diff --git a/provisioning/go/clients/meshprovisionerclient/provider/harness/moved_test.go b/provisioning/go/clients/meshprovisionerclient/provider/harness/moved_test.go new file mode 100644 index 0000000..7bbc7a0 --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/provider/harness/moved_test.go @@ -0,0 +1,467 @@ +package harness + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "sync" + "testing" + "time" + + provisions "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider" + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +const ( + ProvisionName = "postgres-database" + servedPort = 5432 +) + +// postgresSpec is what a postgres-like provider says: a login per consumer, and the grant file's logins. +var postgresSpec = MovedSpec{Module: "postgres", Provisioner: testProvisioner, Provision: ProvisionName, + Offer: provider.Offer{Credential: provider.PerConsumer, Serves: map[string]any{"port": servedPort}}, + Login: func(e provider.Entry) string { return e.User }, + Noun: "login", Made: "login", Backend: "the backend", WithGrants: true} + +// bucketBus is the helper's bus over in-memory buckets. +type bucketBus struct { + mu sync.Mutex + buckets map[string]map[string][]byte +} + +func (b *bucketBus) Watch(_ context.Context, bucket string, handle func(string, []byte)) error { + b.mu.Lock() + defer b.mu.Unlock() + for k, v := range b.buckets[bucket] { + handle(k, v) + } + return nil +} + +func (b *bucketBus) Put(_ context.Context, bucket, key string, value []byte) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.buckets[bucket] == nil { + b.buckets[bucket] = map[string][]byte{} + } + b.buckets[bucket][key] = append([]byte(nil), value...) + return nil +} + +func (b *bucketBus) Delete(_ context.Context, bucket, key string) error { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.buckets[bucket], key) + return nil +} + +// putPlan is a consumer's plan on the seat mesh-provisioner, of these plan entries, each provision's key provided (novox/hq +// ADR 0570 clauses 1, 2): what the helper binds by. +func (b *bucketBus) putPlan(node, module string, entries ...map[string]any) { + raw, _ := json.Marshal(entries) + var ps []catalogue.PlanProvision + if err := json.Unmarshal(raw, &ps); err != nil { + panic(err) + } + for _, pp := range ps { + pr := provisions.Provision{Provision: pp.Provision, Local: pp.Local, Consumer: provisions.End{Module: module, Node: node}, + Provider: provisions.End{Module: pp.ProviderModule, Node: pp.Provider}, State: provisions.Provided} + key, _ := pr.Key() + value, err := provisions.Encode(pr) + if err != nil { + panic(err) + } + _ = b.Put(context.Background(), provisions.ProvisionsBucketOf(testProvisioner), key, value) + } + plan, err := provisions.EncodePlan(provisions.Plan{Node: node, Module: module, Plan: catalogue.Plan{Provisions: ps}}) + if err != nil { + panic(err) + } + _ = b.Put(context.Background(), provisions.PlansBucketOf(testProvisioner), node+"."+module, plan) +} + +// retireOn is a consumer's provision retiring with the operator's yes (ADR 0570 clause 5), its plan gone: the one way +// a provider lets it go. +func (b *bucketBus) retireOn(node, module, provides string) { + pr := provisions.Provision{Provision: ProvisionName, Consumer: provisions.End{Module: module, Node: node}, + Provider: provisions.End{Module: provides, Node: "novox"}, State: provisions.Retiring, Why: module + " left", + Warrant: &provisions.Warrant{Ask: "a-1", Option: "yes", By: "the operator"}} + key, _ := pr.Key() + value, _ := provisions.Encode(pr) + _ = b.Delete(context.Background(), provisions.PlansBucketOf(testProvisioner), node+"."+module) + _ = b.Put(context.Background(), provisions.ProvisionsBucketOf(testProvisioner), key, value) +} + +// bind is one consumer's assignment, whose plan names this machine's postgres as the provider and carries what the +// consumer contributes (issue 776). +func (b *bucketBus) bind(node, module string, contributes map[string]any) { + b.bindNamed("postgres", node, module, contributes, nil) +} + +// bindNamed is bind to a provider module, with the names the controller composed for the consumer (issue 792). +func (b *bucketBus) bindNamed(provides, node, module string, contributes map[string]any, names map[string]string) { + b.putPlan(node, module, map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": provides, + "contributes": contributes, "names": names}) +} + +// consumersFile writes the file the machine's client fills: one credential per consumer. +func consumersFile(t *testing.T, bound map[[2]string]string) string { + var list []provider.ConsumerCredential + for k, password := range bound { + list = append(list, provider.ConsumerCredential{Consumer: k[0], Module: k[1], + Credential: provider.PairName("novox", ProvisionName, k[0], k[1], ""), Value: password}) + } + raw, _ := json.Marshal(list) + at := filepath.Join(t.TempDir(), "consumers.json") + if err := os.WriteFile(at, raw, 0o600); err != nil { + t.Fatal(err) + } + return at +} + +// movedWorld is postgres on the helper over an in-memory bus, with a recorder for a server and a clock to move. +type movedWorld struct { + t *testing.T + a *recorder + bus *bucketBus + h *Harness + spec MovedSpec + run *Moved + p *provider.Provider + now time.Time + file string +} + +func newMovedWorld(t *testing.T, bound map[[2]string]string) *movedWorld { + return newMovedWorldOf(t, bound, postgresSpec) +} + +func newMovedWorldOf(t *testing.T, bound map[[2]string]string, spec MovedSpec) *movedWorld { + w := &movedWorld{t: t, spec: spec, a: &recorder{}, bus: &bucketBus{buckets: map[string]map[string][]byte{}}, + now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + w.file = consumersFile(t, bound) + w.h = &Harness{Resource: ProvisionName, Adapter: w.a, Node: "novox", Now: func() time.Time { return w.now }, + Log: func(string, ...any) {}} + var bus provider.Bus + w.run, w.p, bus, _ = NewMoved(w.file, w.h, spec) + _ = bus + if err := w.run.watch(ctx, w.bus); err != nil { + t.Fatal(err) + } + return w +} + +// pass is one helper pass and one retirement pass, five seconds after the last. +func (w *movedWorld) pass() []provider.Outcome { + w.now = w.now.Add(5 * time.Second) + // the plans as the bus holds them now, as the watch would hand them + w.run.plans = map[string][]byte{} + for k, v := range w.bus.buckets[provisions.PlansBucketOf(testProvisioner)] { + w.run.plans[k] = v + } + // the helper reads its buckets from the in-memory bus + w.p = provider.New(provider.Options{Provisioner: testProvisioner, Node: "novox", Module: w.spec.Module, Bus: w.bus, + Vault: provider.ConsumersFile{Paths: []string{w.file}}, + Offers: map[string]provider.Offer{ProvisionName: w.spec.Offer}, + Make: w.run.Make, Gone: w.run.Gone, Logf: func(string, ...any) {}}) + out, err := w.p.Pass(ctx) + if err != nil { + w.t.Fatal(err) + } + w.run.retire(ctx) + return out +} + +func bindings(n int) (map[[2]string]string, []string) { + bound := map[[2]string]string{} + var logins []string + for i := 1; i <= n; i++ { + module := fmt.Sprintf("app%d", i) + bound[[2]string{"ace", module}] = "pw-" + module + logins = append(logins, provider.ConsumerIdentity("ace", module)) + } + return bound, logins +} + +// The consumers' file makes the same calls on the adapter as the equivalent grant files do (the harness). +func testTheConsumersFileMakesWhatTheGrantFilesMake(t *testing.T) { + letta := provider.ConsumerIdentity("ace", "letta") + plain := provider.ConsumerIdentity("ace", "plain") + + // Today: the grant files, through the harness. + g := newWorld(t) + g.give( + map[string]any{"as": letta, "node": "ace", "values": map[string]any{"name": "letta", "extensions": []any{"vector"}}}, + map[string]any{"as": plain, "node": "ace", "values": map[string]any{"name": "plain"}}, + ) + g.h.Reconcile(ctx) + + // The consumers' file, through the provider helper. + w := newMovedWorld(t, map[[2]string]string{{"ace", "letta"}: "pw-" + letta, {"ace", "plain"}: "pw-" + plain}) + w.bus.bind("ace", "letta", map[string]any{"name": "letta", "extensions": []any{"vector"}}) + w.bus.bind("ace", "plain", map[string]any{"name": "plain"}) + for _, o := range w.pass() { + if o.Status != provider.Answered { + t.Fatalf("%+v", o) + } + } + byAs := map[string]Provision{} + for _, c := range w.a.created { + byAs[c.As] = c + } + if len(w.a.created) != 2 { + t.Fatalf("created %d, want 2", len(w.a.created)) + } + for _, want := range g.a.created { + c := byAs[want.As] + if c.As != want.As || c.Password != want.Password || c.Consumer != want.Consumer || + !reflect.DeepEqual(orEmpty(c.Values), orEmpty(want.Values)) { + t.Fatalf("made %+v, the grant files make %+v", c, want) + } + } + // Unchanged, and held: nothing is made again, within a minute or after it. + w.a.held = true + w.pass() + w.now = w.now.Add(2 * time.Minute) + w.pass() + if len(w.a.created) != 2 { + t.Fatalf("an unchanged pass created again: %d", len(w.a.created)) + } + // No longer held: made again. + w.a.held = false + w.now = w.now.Add(2 * time.Minute) + w.pass() + if len(w.a.created) != 4 { + t.Fatalf("not made again: %d", len(w.a.created)) + } +} + +// A consumer whose plan goes retires nothing by itself, however long (novox/hq ADR 0570 clause 5): its login is retired, +// never dropped, and its entry removed only once its provision is retiring with the operator's yes. +func testAConsumerMissingFromOneReadRetiresNothing(t *testing.T) { + bound, logins := bindings(2) + w := newMovedWorld(t, bound) + w.bus.bind("ace", "app1", nil) + w.bus.bind("ace", "app2", nil) + w.pass() + _ = w.bus.Delete(ctx, provisions.PlansBucketOf(testProvisioner), "ace.app2") + for i := 0; i < 8; i++ { + w.pass() + } + w.now = w.now.Add(30 * time.Minute) + w.pass() + if len(w.a.removed) != 0 || w.h.r.waiting != nil { + t.Fatalf("retired %v, waiting %v, with no warrant", w.a.removed, w.h.r.waiting) + } + if _, kept := w.bus.buckets[provider.BucketOf("postgres")]["ace.app2.postgres-database"]; !kept { + t.Fatal("the entry went with no warrant") + } + w.bus.retireOn("ace", "app2", "postgres") + w.pass() + if !reflect.DeepEqual(w.a.removed, []string{logins[1]}) || len(w.a.deleted) != 0 { + t.Fatalf("retired %v, deleted %v", w.a.removed, w.a.deleted) + } + if _, kept := w.bus.buckets[provider.BucketOf("postgres")]["ace.app2.postgres-database"]; kept { + t.Fatal("the entry stayed after the login was retired") + } +} + +// A plan list that was never read is not nobody asking. +func testNoPlansReadRetiresNothing(t *testing.T) { + bound, _ := bindings(1) + w := newMovedWorld(t, bound) + w.bus.bind("ace", "app1", nil) + w.pass() + w.bus.Delete(ctx, provisions.PlansBucketOf(testProvisioner), "ace.app1") + for i := 0; i < 8; i++ { + w.pass() + } + w.now = w.now.Add(20 * time.Minute) + w.pass() + if len(w.a.removed) != 0 { + t.Fatalf("retired %v with no plans read", w.a.removed) + } +} + +// A consumer whose credential is not in the file yet is waited for: nothing is made for it. +func testAConsumerWithoutACredentialIsWaitedFor(t *testing.T) { + w := newMovedWorld(t, map[[2]string]string{}) + w.bus.bind("ace", "letta", map[string]any{"name": "letta"}) + outcomes := w.pass() + if len(outcomes) != 1 || outcomes[0].Status != provider.Waiting || len(w.a.created) != 0 { + t.Fatalf("%+v, created %v", outcomes, w.a.created) + } +} + +// Where the file is not there, postgres takes the grant directory, as before; where it is, the helper. +// The grant path restarts itself when the consumers file is first filled. +// A consumer whose module has a slug keeps the login its plan names, the slug first (issue 788): the adapter makes it +// under that login, and the retirement counts it as asked for, so a pass retires nothing. +func testASluggedConsumerKeepsTheLoginItsPlanNames(t *testing.T) { + agent := provider.ConsumerIdentity("ace", "agent") + w := newMovedWorld(t, map[[2]string]string{{"ace", "claude-code"}: "pw-agent"}) + w.bus.putPlan("ace", "claude-code", map[string]any{"provision": ProvisionName, "provider": "novox", + "provider-module": "postgres", "as": agent}) + for _, o := range w.pass() { + if o.Status != provider.Answered { + t.Fatalf("%+v", o) + } + } + if len(w.a.created) != 1 || w.a.created[0].As != agent { + t.Fatalf("made %+v, want one under %s", w.a.created, agent) + } + for i := 0; i < 8; i++ { + w.pass() + } + w.now = w.now.Add(15 * time.Minute) + w.pass() + w.pass() + if len(w.a.removed) != 0 { + t.Fatalf("retired %v: the plan's login was counted unasked", w.a.removed) + } +} + +// A provider whose spec names the plan's `as` as the login and no grant file (the keycloak shape) makes the consumer +// under that name with the values its spec composes, and a grant file that cannot be read does not hold up its +// retirement pass. +func testASpecWithoutGrantsMakesUnderThePlansAsAndIgnoresTheGrantFile(t *testing.T) { + a := &recorder{} + bus := &bucketBus{buckets: map[string]map[string][]byte{}} + now := time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC) + h := &Harness{Resource: ProvisionName, Adapter: a, Node: "novox", Now: func() time.Time { return now }, + Receives: filepath.Join(t.TempDir(), "unreadable-or-missing.json"), Log: func(string, ...any) {}} + spec := MovedSpec{Module: "keycloak", Provisioner: testProvisioner, Provision: ProvisionName, + Offer: provider.Offer{Credential: provider.PerConsumer}, + Login: func(e provider.Entry) string { return e.As }, Noun: "client id", Made: "client", Backend: "Keycloak", + Values: func(e provider.Entry) map[string]any { return map[string]any{"label": e.Contributes["label"]} }} + file := consumersFile(t, map[[2]string]string{{"ace", "wiki"}: "pw-wiki"}) + run, _, _, err := NewMoved(file, h, spec) + if err != nil { + t.Fatal(err) + } + bus.putPlan("ace", "wiki", map[string]any{"provision": ProvisionName, "provider": "novox", + "provider-module": "keycloak", "as": "wiki-client", "contributes": map[string]any{"label": "Wiki"}}) + if err := run.watch(ctx, bus); err != nil { + t.Fatal(err) + } + p := provider.New(provider.Options{Provisioner: testProvisioner, Node: "novox", Module: "keycloak", Bus: bus, + Vault: provider.ConsumersFile{Paths: []string{file}}, + Offers: map[string]provider.Offer{ProvisionName: spec.Offer}, Make: run.Make, Gone: run.Gone, + Logf: func(string, ...any) {}}) + out, err := p.Pass(ctx) + if err != nil || len(out) != 1 || out[0].Status != provider.Answered { + t.Fatalf("%v %+v", err, out) + } + run.retire(ctx) + if len(a.created) != 1 || a.created[0].As != "wiki-client" || a.created[0].Password != "pw-wiki" || + a.created[0].Values["label"] != "Wiki" { + t.Fatalf("made %+v", a.created) + } +} + +const testIssuer = "https://id.example.org/realms/Novox" + +var ( + grafanaContributes = map[string]any{"label": "grafana", "endpoint": "web", "callback": "/login/generic_oauth"} + grafanaNames = map[string]string{"name": "grafana.example.org", "internal-name": "grafana.home.internal"} +) + +// keycloakSpec is what a keycloak-like provider says: the client id is the plan's `as`, the values are what the +// consumer contributes with the controller's names over them, and no grant file is consulted. +var keycloakSpec = MovedSpec{Module: "keycloak", Provisioner: testProvisioner, Provision: ProvisionName, + Offer: provider.Offer{Credential: provider.PerConsumer, Serves: map[string]any{"issuer": testIssuer, + "token-path": "/protocol/openid-connect/token"}}, + Login: func(e provider.Entry) string { return e.As }, + Values: func(e provider.Entry) map[string]any { + values := map[string]any{} + for k, v := range e.Contributes { + values[k] = v + } + for k, name := range e.Names { + values[k] = name + } + return values + }, + Noun: "client id", Made: "client", Backend: "Keycloak"} + +// The consumers' file makes the same call on the adapter as the equivalent grant file does (the harness), for a +// provider of the keycloak shape: the same client id, secret and values; and the entry tells the consumer what the +// provider serves. +func testTheConsumersFileMakesWhatTheGrantFileMakesForTheKeycloakShape(t *testing.T) { + grafana := provider.ConsumerIdentity("home", "grafana") + values := map[string]any{"name": grafanaNames["name"], "internal-name": grafanaNames["internal-name"]} + for k, v := range grafanaContributes { + values[k] = v + } + g := newWorld(t) + g.give(map[string]any{"as": grafana, "node": "home", "values": values}) + g.h.Reconcile(ctx) + + w := newMovedWorldOf(t, map[[2]string]string{{"home", "grafana"}: "pw-" + grafana}, keycloakSpec) + w.bus.bindNamed("keycloak", "home", "grafana", grafanaContributes, grafanaNames) + for _, o := range w.pass() { + if o.Status != provider.Answered { + t.Fatalf("%+v", o) + } + } + if len(w.a.created) != 1 || len(g.a.created) != 1 { + t.Fatalf("created %d through the helper, %d through the harness, want 1 each", len(w.a.created), len(g.a.created)) + } + c, want := w.a.created[0], g.a.created[0] + if c.As != want.As || c.Password != want.Password || c.Consumer != want.Consumer || + !reflect.DeepEqual(orEmpty(c.Values), orEmpty(want.Values)) { + t.Fatalf("made %+v, the grant file makes %+v", c, want) + } + var entry provider.Entry + if err := json.Unmarshal(w.bus.buckets[provider.BucketOf("keycloak")]["home.grafana."+ProvisionName], &entry); err != nil { + t.Fatal(err) + } + if entry.Values["issuer"] != testIssuer || entry.Values["token-path"] != "/protocol/openid-connect/token" { + t.Fatalf("served %v", entry.Values) + } + w.a.held = true + w.pass() + w.now = w.now.Add(2 * time.Minute) + w.pass() + if len(w.a.created) != 1 { + t.Fatalf("an unchanged pass created again: %d", len(w.a.created)) + } + w.a.held = false + w.now = w.now.Add(2 * time.Minute) + w.pass() + if len(w.a.created) != 2 { + t.Fatalf("not made again: %d", len(w.a.created)) + } +} + +// A consumer whose plan names its client id (a slugged module) keeps it, and the retirement counts it as asked for. +func testAConsumerNamedByItsPlanKeepsItsClientId(t *testing.T) { + agent := provider.ConsumerIdentity("home", "dashboard") + w := newMovedWorldOf(t, map[[2]string]string{{"home", "grafana-2"}: "pw"}, keycloakSpec) + w.bus.putPlan("home", "grafana-2", map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": "keycloak", + "as": agent, "contributes": grafanaContributes, "names": grafanaNames}) + for _, o := range w.pass() { + if o.Status != provider.Answered { + t.Fatalf("%+v", o) + } + } + if len(w.a.created) != 1 || w.a.created[0].As != agent { + t.Fatalf("made %+v, want one under %s", w.a.created, agent) + } + for i := 0; i < 8; i++ { + w.pass() + } + w.now = w.now.Add(15 * time.Minute) + w.pass() + w.pass() + if len(w.a.removed) != 0 { + t.Fatalf("retired %v: the plan's client id was counted unasked", w.a.removed) + } +} + +// testProvisioner is the module holding the seat mesh-provisioner in these tests. +const testProvisioner = "mesh-provisioner" diff --git a/provisioning/go/clients/meshprovisionerclient/provider/harness/retirement.go b/provisioning/go/clients/meshprovisionerclient/provider/harness/retirement.go new file mode 100644 index 0000000..180213a --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/provider/harness/retirement.go @@ -0,0 +1,626 @@ +package harness + +// What becomes of a consumer the mesh no longer asks for (novox/hq ADR 0230 — the operator's model, +// decided 2026-10-06; it replaces ADR 0229's withdrawal brake, which let one consumer go every hour). +// +// A consumer — a login, a client, a key, and the data behind it — is in one of three states: +// +// 1. ACTIVE. +// 2. RETIRED. The mesh stopped asking for it: its access is disabled, reversibly, and its login and +// data are marked "to delete" with when and why. Nothing is deleted. Asked for again, the ordinary +// create re-enables it at once, as it was. +// 3. DELETED, only through `cleanup delete`, a person's act, which this provider executes because it +// owns its backend. +// +// **Stable removals.** A consumer is retired only once the same set of consumers has gone unasked BOTH +// in StablePasses (5) consecutive passes that read the file AND for at least StableFor (10 minutes) +// since the first pass that saw it. Five passes alone are twenty-five seconds — shorter than a +// controller restart, a store reconnecting or a file half written. A pass that could not read the file +// is not a result and starts both again; so does a different set. Additions and changes are never +// delayed. +// +// **Too many is a person.** A stable set of more than RetireAtOnce (3), or — where more than one is +// held — of more than RetireFraction (half) of those held, retires nothing: it WAITS, said in the +// journal and announced `provisioner.retirement` `waiting` (again every SayAgainEvery), which the +// controller raises as an urgent condition, until `retire approve ` or `retire reject`. +// An unassignment the controller made itself is no exception: many changes at once means a person is +// at work, and a person confirms once. On 2026-10-04 one misread file withdrew seven consumers at once +// (issue 241); under this rule that is a question, not an act. +// +// **The backend remembers, not this process.** What is retired, since when and why is read from the +// backend's own mark (Adapter.Inventory), so a restart forgets nothing; a consumer the backend holds +// active that the mesh no longer asks for is retired by the same rules after a restart as before one. +// A consumer found disabled without the mark — withdrawn before this record — is ADOPTED as retired: +// marked, said, announced `adopted`, and its clock starts then. +// +// **A rejection lives as long as this process.** Rejected, the set is kept active and not asked about +// again while it stays the same set; a restart asks again — loudly, never silently. + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// EventRetirement is the one event a provider says about retirement, its `change` saying what +// happened. The controller derives the permission to emit it for every module that receives +// contributions, as it does the standing events (novox/hq ADR 0224, 0230). +const EventRetirement = "provisioner.retirement" + +// The changes EventRetirement carries. +const ( + ChangeWaiting = "waiting" // a stable set over the bound waits for a person + ChangeSettled = "settled" // a waiting or rejected set ended without being retired + ChangeApproved = "approved" // a person approved the waiting (or rejected) set + ChangeRejected = "rejected" // a person kept the waiting set active + ChangeRetired = "retired" // consumers disabled and marked to delete + ChangeReenabled = "reenabled" // a retired consumer asked for again, enabled as it was + ChangeDeleted = "deleted" // a retired consumer deleted, by a person + ChangeAdopted = "adopted" // found disabled without the mark, now retired on record +) + +// StableFor is the least time the same unasked set must hold before it is retired (novox/hq ADR 0230): +// longer than a controller restart, a store reconnecting or a file half written. +const StableFor = 10 * time.Minute + +// KindConsumer is a retired consumer. A backend may list other things set aside for deletion under a +// word of its own (postgres: a database renamed aside). +const KindConsumer = "consumer" + +// Retired is one thing a provider holds retired, as its backend's mark says. +type Retired struct { + Consumer string + // Node is the consumer's machine, where the mark records it. + Node string + // RetiredAt is when it was retired; zero for one found disabled without the mesh's mark. + RetiredAt time.Time + Why string + // SizeBytes is what deleting it would free; -1 when the backend cannot say. + SizeBytes int64 + Kind string +} + +// Inventory is what a backend holds that the mesh made. +type Inventory struct { + Active []string + Retired []Retired + // Sizes is what each active consumer keeps on the backend, in bytes, where the backend can say: what + // the controller tells an empty replacement of a consumer's data by (novox/hq ADR 0233). + Sizes map[string]int64 +} + +// retirement is the loop's memory of the sets it is counting, waiting on and was told to keep. +type retirement struct { + key string // the unasked set, joined + count int // consecutive passes that saw it + firstSeen time.Time + waiting *waitingSet + rejected *rejectedSet + retired map[string]retiredEntry + lastWant map[string]bool + seeded bool + seedSaid string + // noneSaid is whether this process has said that nothing waits: an earlier process may have left a set + // waiting that this one never knew (issue 937). + noneSaid bool +} + +type waitingSet struct { + set []string + since time.Time + saidAt time.Time + held int +} + +type rejectedSet struct { + set []string + by, why string + at time.Time +} + +type retiredEntry struct { + node string + at time.Time + why string +} + +// seed reads what the backend holds, once per process: an active consumer the mesh no longer asks +// for is held, so it is retired by the same rules as one this process made; one found disabled +// without the mark is adopted as retired. +func (h *Harness) seed(ctx context.Context, want map[string]bool) { + if h.r.seeded { + return + } + inv, err := h.Adapter.Inventory(ctx) + if err != nil { + if said := err.Error(); said != h.r.seedSaid { + h.say("cannot list what the backend holds (%v); a consumer the mesh stopped asking for while this "+ + "provider was down is not retired until it can", err) + h.r.seedSaid = said + } + return + } + h.r.seeded = true + for _, as := range inv.Active { + if _, held := h.applied[as]; !held && !want[as] { + // No hash: asked for again, it is applied again, which is harmless and marks it. + h.applied[as] = appliedEntry{} + h.say("%s: held by the backend and no longer asked for; retired once that holds for %d passes "+ + "and %s (novox/hq ADR 0230)", as, h.StablePasses, h.StableFor) + } + } + now := h.Now() + for _, r := range inv.Retired { + if r.Kind != "" && r.Kind != KindConsumer { + continue + } + if !r.RetiredAt.IsZero() { + h.r.retired[r.Consumer] = retiredEntry{node: r.Node, at: r.RetiredAt, why: r.Why} + continue + } + if want[r.Consumer] { + continue // asked for: this pass's create enables it + } + why := "found disabled without the mesh's mark — withdrawn before retirement was recorded " + + "(novox/hq ADR 0230); retired as found" + if err := h.Adapter.Retire(ctx, r.Consumer, nil, why, now); err != nil { + h.say("%s: found disabled and could not be marked retired, will try at the next start: %v", r.Consumer, err) + continue + } + h.r.retired[r.Consumer] = retiredEntry{node: r.Node, at: now, why: why} + h.say("%s: ADOPTED as retired — %s", r.Consumer, why) + h.announce(EventRetirement, h.retirementBody(ChangeAdopted, []map[string]any{ + {"consumer": r.Consumer, "node": r.Node, "retired_at": stamp(now), "why": why, "size_bytes": r.SizeBytes}, + }, map[string]any{"why": why})) + } +} + +// retireUnasked counts the passes that saw the same set no longer asked for and, once it is stable, +// retires it — or, past the bound, waits for a person. +func (h *Harness) retireUnasked(ctx context.Context, want map[string]bool) { + var unasked []string + for as := range h.applied { + if !want[as] { + unasked = append(unasked, as) + } + } + sort.Strings(unasked) + key := strings.Join(unasked, "\x00") + now := h.Now() + switch { + case len(unasked) == 0: + h.settle("every consumer held is asked for again") + h.r.key, h.r.count = "", 0 + return + case key != h.r.key: + if h.r.waiting != nil || h.r.rejected != nil { + h.settle("the set the mesh no longer asks for changed") + } + h.r.key, h.r.count, h.r.firstSeen = key, 1, now + h.say("no longer asked for: %s; retired once the same set holds for %d passes and %s", + strings.Join(unasked, ", "), h.StablePasses, h.StableFor) + default: + h.r.count++ + } + if h.r.rejected != nil || h.r.count < h.StablePasses || now.Sub(h.r.firstSeen) < h.StableFor { + return + } + if h.overTheBound(len(unasked), len(h.applied)) { + h.wait(unasked) + return + } + why := fmt.Sprintf("the mesh stopped asking for it: the same result in %d consecutive passes over %s, from %s", + h.r.count, now.Sub(h.r.firstSeen).Round(time.Second), stamp(h.r.firstSeen)) + h.retire(ctx, unasked, why, nil) +} + +// overTheBound says retiring n of the held consumers at once needs a person. +func (h *Harness) overTheBound(n, held int) bool { + if n == 0 { + return false + } + return n > h.RetireAtOnce || (held > 1 && float64(n) > h.RetireFraction*float64(held)) +} + +func (h *Harness) bound(held int) string { + return fmt.Sprintf("more than %d at once, or more than %.0f%% of the %d held", h.RetireAtOnce, + h.RetireFraction*100, held) +} + +// wait holds a stable set over the bound for a person, said once and announced again every +// SayAgainEvery so a controller that missed the first hears the next. +func (h *Harness) wait(set []string) { + now := h.Now() + w := h.r.waiting + if w == nil { + w = &waitingSet{set: set, since: now, held: len(h.applied)} + h.r.waiting = w + h.say("RETIREMENT WAITS FOR A PERSON: the mesh no longer asks for %d of the %d consumer(s) this provider "+ + "holds (%s), %s. Nothing is retired; each stays active until `retire approve %s ` or "+ + "`retire reject` (novox/hq ADR 0230)", len(set), w.held, strings.Join(set, ", "), h.bound(w.held), h.Node) + } else if now.Sub(w.saidAt) < h.SayAgainEvery { + return + } + w.saidAt = now + h.announce(EventRetirement, h.retirementBody(ChangeWaiting, h.named(set), map[string]any{ + "held": w.held, "bound": h.bound(w.held), "since": stamp(w.since), + })) +} + +// settle ends a waiting or rejected set that the mesh's own answer made moot. +func (h *Harness) settle(why string) { + var set []string + switch { + case h.r.waiting != nil: + set = h.r.waiting.set + case h.r.rejected != nil: + set = h.r.rejected.set + case h.r.noneSaid: + return + default: + // A restart forgets a set an earlier process waited on, and so would never say it ended: the + // controller's condition would ask a person to approve what nobody waits on any more (issue 937). + // The first time this process finds every consumer held asked for, it says so once, with no set. + h.r.noneSaid = true + h.say("nothing waits for a person: %s", why) + h.announce(EventRetirement, h.retirementBody(ChangeSettled, []map[string]any{}, map[string]any{"why": why})) + return + } + h.r.waiting, h.r.rejected = nil, nil + h.say("retirement of %s settled without retiring: %s", strings.Join(set, ", "), why) + h.announce(EventRetirement, h.retirementBody(ChangeSettled, h.named(set), map[string]any{"why": why})) +} + +// retire disables and marks each consumer of set; one that fails stays held and is tried again once +// its set is stable again. +func (h *Harness) retire(ctx context.Context, set []string, why string, extra map[string]any) []string { + now := h.Now() + held := len(h.applied) + var done []string + var said []map[string]any + for _, as := range set { + was, ok := h.applied[as] + if !ok { + continue + } + if err := h.Adapter.Retire(ctx, as, was.derived, why, now); err != nil { + h.say("%s: retiring failed, will try again: %v", as, err) + continue + } + delete(h.applied, as) + delete(h.lost, as) + delete(h.failing, as) + h.r.retired[as] = retiredEntry{node: was.node, at: now, why: why} + h.recovered(as, "retired") + h.say("%s: RETIRED — its access disabled and its data kept, marked to delete (%s). Asked for again "+ + "it is re-enabled as it was; it is deleted only by `cleanup delete` (novox/hq ADR 0230)", as, why) + done = append(done, as) + said = append(said, map[string]any{"consumer": as, "node": was.node, "retired_at": stamp(now), "why": why}) + } + h.r.key, h.r.count, h.r.waiting, h.r.rejected = "", 0, nil, nil + if len(done) > 0 { + body := map[string]any{"held": held, "why": why} + for k, v := range extra { + body[k] = v + } + h.announce(EventRetirement, h.retirementBody(ChangeRetired, said, body)) + } + return done +} + +// reenabled says a retired consumer was asked for again and its create enabled it. +func (h *Harness) reenabled(as, node string) { + e, was := h.r.retired[as] + if !was { + return + } + delete(h.r.retired, as) + h.say("%s: asked for again — re-enabled as it was, its mark to delete cleared (retired %s: %s)", as, + stamp(e.at), e.why) + h.announce(EventRetirement, h.retirementBody(ChangeReenabled, []map[string]any{ + {"consumer": as, "node": node, "retired_at": stamp(e.at), "why": e.why}, + }, nil)) +} + +// Approve retires exactly the set waiting (or the set rejected) — a person's confirmation. +func (h *Harness) Approve(ctx context.Context, consumers []string, why, by, via string) ([]string, error) { + h.mu.Lock() + defer h.mu.Unlock() + h.init() + if strings.TrimSpace(why) == "" { + return nil, errors.New("approve: say why") + } + set := sorted(consumers) + var held []string + switch { + case h.r.waiting != nil && same(set, h.r.waiting.set): + held = h.r.waiting.set + case h.r.rejected != nil && same(set, h.r.rejected.set): + held = h.r.rejected.set + default: + return nil, fmt.Errorf("approve: %s is not the set waiting here — %s", orNone(set), h.waitingWords()) + } + h.say("retirement of %s APPROVED by %s: %s", strings.Join(held, ", "), orSomebody(by), why) + h.announce(EventRetirement, h.retirementBody(ChangeApproved, h.named(held), + map[string]any{"why": why, "by": by, "via": via})) + reason := fmt.Sprintf("the mesh stopped asking for it; approved by %s: %s", orSomebody(by), why) + return h.retire(ctx, held, reason, map[string]any{"by": by, "via": via}), nil +} + +// Reject keeps the waiting set active; it is not asked about again while it stays the same set. +func (h *Harness) Reject(consumers []string, why, by, via string) ([]string, error) { + h.mu.Lock() + defer h.mu.Unlock() + h.init() + if strings.TrimSpace(why) == "" { + return nil, errors.New("reject: say why") + } + set := sorted(consumers) + if h.r.waiting == nil || !same(set, h.r.waiting.set) { + return nil, fmt.Errorf("reject: %s is not the set waiting here — %s", orNone(set), h.waitingWords()) + } + h.r.rejected = &rejectedSet{set: h.r.waiting.set, by: by, why: why, at: h.Now()} + h.r.waiting = nil + h.say("retirement of %s REJECTED by %s: %s. Kept active, and not asked about again while the mesh goes on "+ + "not asking for exactly these (until this provider restarts)", strings.Join(set, ", "), orSomebody(by), why) + h.announce(EventRetirement, h.retirementBody(ChangeRejected, h.named(set), + map[string]any{"why": why, "by": by, "via": via})) + return set, nil +} + +// DeleteRetired deletes one retired consumer, by a person's word: never an active one, never one the +// mesh asks for. +func (h *Harness) DeleteRetired(ctx context.Context, consumer, why, by, via string) (int64, error) { + h.mu.Lock() + defer h.mu.Unlock() + h.init() + if strings.TrimSpace(why) == "" { + return 0, errors.New("delete: say why") + } + if h.r.lastWant[consumer] { + return 0, fmt.Errorf("delete: the mesh asks for %s — it is not retired", consumer) + } + if _, held := h.applied[consumer]; held { + return 0, fmt.Errorf("delete: %s is active here — only a retired consumer is deleted", consumer) + } + inv, err := h.Adapter.Inventory(ctx) + if err != nil { + return 0, fmt.Errorf("delete: what the backend holds cannot be read, so nothing is deleted: %w", err) + } + var found *Retired + for i := range inv.Retired { + if inv.Retired[i].Consumer == consumer { + found = &inv.Retired[i] + } + } + if found == nil { + return 0, fmt.Errorf("delete: %s is not retired here — only a retired consumer is deleted", consumer) + } + freed, err := h.Adapter.Delete(ctx, *found) + if err != nil { + return 0, err + } + delete(h.r.retired, consumer) + h.say("%s: DELETED by %s: %s (retired %s: %s; %d bytes freed)", consumer, orSomebody(by), why, + stampOr(found.RetiredAt), found.Why, freed) + h.announce(EventRetirement, h.retirementBody(ChangeDeleted, []map[string]any{{ + "consumer": consumer, "node": found.Node, "retired_at": stampOr(found.RetiredAt), "why": found.Why, + "size_bytes": found.SizeBytes, "kind": kindOf(*found), + }}, map[string]any{"why": why, "by": by, "via": via, "freed_bytes": freed})) + return freed, nil +} + +// Retirement is what a person (and the controller's `cleanup list` and its probe) asks: what is +// held, waiting, rejected and retired here. +func (h *Harness) Retirement(ctx context.Context) (map[string]any, error) { + h.mu.Lock() + defer h.mu.Unlock() + h.init() + inv, err := h.Adapter.Inventory(ctx) + if err != nil { + return nil, err + } + var held []string + for as := range h.applied { + held = append(held, as) + } + sort.Strings(held) + retired := []map[string]any{} + for _, r := range inv.Retired { + retired = append(retired, map[string]any{"consumer": r.Consumer, "node": r.Node, + "retired_at": stampOr(r.RetiredAt), "why": r.Why, "size_bytes": r.SizeBytes, "kind": kindOf(r)}) + } + sizes := map[string]int64{} + for _, as := range held { + if size, ok := inv.Sizes[as]; ok && size >= 0 { + sizes[as] = size + } + } + out := map[string]any{"resource": h.Resource, "node": h.Node, "held": orEmptyList(held), "held_sizes": sizes, + "stable_passes": h.StablePasses, "stable_for_seconds": int(h.StableFor.Seconds()), "bound": h.bound(len(h.applied)), "waiting": nil, "rejected": nil, + "retired": retired} + if w := h.r.waiting; w != nil { + out["waiting"] = map[string]any{"consumers": h.named(w.set), "since": stamp(w.since), "held": w.held} + } + if r := h.r.rejected; r != nil { + out["rejected"] = map[string]any{"consumers": h.named(r.set), "by": r.by, "why": r.why, "at": stamp(r.at)} + } + return out, nil +} + +func (h *Harness) waitingWords() string { + switch { + case h.r.waiting != nil: + return "waiting: " + strings.Join(h.r.waiting.set, ", ") + case h.r.rejected != nil: + return "nothing waits; rejected and kept: " + strings.Join(h.r.rejected.set, ", ") + } + return "nothing waits for a person here" +} + +// named is a set with each consumer's machine, as the events and the tools say it. +func (h *Harness) named(set []string) []map[string]any { + out := make([]map[string]any, 0, len(set)) + for _, as := range set { + out = append(out, map[string]any{"consumer": as, "node": h.applied[as].node}) + } + return out +} + +func (h *Harness) retirementBody(change string, consumers []map[string]any, extra map[string]any) map[string]any { + body := map[string]any{"provider": h.Resource, "provider-node": h.Node, "change": change, + "consumers": consumers, "at": stamp(h.Now())} + for k, v := range extra { + body[k] = v + } + return body +} + +// RetirementTools are the four tools every provider serves for retirement, the same names in every +// module: a caller asks them on the provider's machine, with the operator's answer named in why and by. +func RetirementTools(h *Harness) []stdio.Tool { + if h == nil { + return nil + } + ask := func() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), 2*time.Minute) + } + who := map[string]any{ + "why": map[string]any{"type": "string", "description": "why — required, kept in the hand-act log"}, + "by": map[string]any{"type": "string", "description": "who decided"}, + "via": map[string]any{"type": "string", "description": "the part or session that asks, when not the caller itself"}, + } + withSet := map[string]any{"consumers": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": "the set, exactly as provisioner_retirement names it"}} + for k, v := range who { + withSet[k] = v + } + withOne := map[string]any{ + "consumer": map[string]any{"type": "string", "description": "the retired consumer to delete"}, + "confirm": map[string]any{"type": "string", "description": "the consumer's name again, to say this is meant"}, + } + for k, v := range who { + withOne[k] = v + } + return []stdio.Tool{ + {Name: "provisioner_retirement", + Description: "What this provider holds, what waits for a person to approve its retirement, what was rejected, " + + "and every retired consumer with when, why and its size (novox/hq ADR 0230).", + Run: func(map[string]any) (any, error) { + ctx, cancel := ask() + defer cancel() + return h.Retirement(ctx) + }}, + {Name: "provisioner_retire_approve", + Description: "Retire exactly the set waiting for a person (or the set rejected earlier): access disabled, data " + + "kept and marked to delete. It asks nobody: the operator's answer on the operator channel to the provisioner's " + + "ask (novox/hq ADR 0570 §5) is named in why and by, which the hand-act log keeps.", + Input: withSet, + Run: func(args map[string]any) (any, error) { + ctx, cancel := ask() + defer cancel() + done, err := h.Approve(ctx, strs(args["consumers"]), argString(args, "why"), argString(args, "by"), argString(args, "via")) + if err != nil { + return nil, err + } + return map[string]any{"retired": orEmptyList(done)}, nil + }}, + {Name: "provisioner_retire_reject", + Description: "Keep the set waiting for a person active. It asks nobody: the operator's answer on the operator channel " + + "(novox/hq ADR 0570 §5) is named in why and by, which the hand-act log keeps.", + Input: withSet, + Run: func(args map[string]any) (any, error) { + kept, err := h.Reject(strs(args["consumers"]), argString(args, "why"), argString(args, "by"), argString(args, "via")) + if err != nil { + return nil, err + } + return map[string]any{"kept": kept}, nil + }}, + {Name: "provisioner_delete", + Description: "Delete one RETIRED consumer — its login and its data — for good. Refused for anything active or " + + "asked for. It asks nobody: ask the operator on the operator channel first, and name that answer in why and " + + "by, which the hand-act log keeps (novox/hq ADR 0604 §8).", + Input: withOne, + Run: func(args map[string]any) (any, error) { + consumer := argString(args, "consumer") + if consumer == "" || argString(args, "confirm") != consumer { + return nil, errors.New("delete: name the consumer, and repeat it in confirm") + } + ctx, cancel := ask() + defer cancel() + freed, err := h.DeleteRetired(ctx, consumer, argString(args, "why"), argString(args, "by"), argString(args, "via")) + if err != nil { + return nil, err + } + return map[string]any{"deleted": consumer, "freed_bytes": freed}, nil + }}, + } +} + +func strs(v any) []string { + list, _ := v.([]any) + out := []string{} + for _, x := range list { + if s, ok := x.(string); ok && s != "" { + out = append(out, s) + } + } + return out +} + +func sorted(list []string) []string { + out := append([]string(nil), list...) + sort.Strings(out) + return out +} + +func same(a, b []string) bool { + return strings.Join(sorted(a), "\x00") == strings.Join(sorted(b), "\x00") +} + +func orNone(set []string) string { + if len(set) == 0 { + return "an empty set" + } + return strings.Join(set, ", ") +} + +func orSomebody(by string) string { + if by == "" { + return "a person" + } + return by +} + +func orEmptyList(list []string) []string { + if list == nil { + return []string{} + } + return list +} + +func kindOf(r Retired) string { + if r.Kind == "" { + return KindConsumer + } + return r.Kind +} + +func stamp(t time.Time) string { return t.UTC().Format(time.RFC3339) } + +func stampOr(t time.Time) string { + if t.IsZero() { + return "" + } + return stamp(t) +} + +func argString(args map[string]any, key string) string { + s, _ := args[key].(string) + return s +} diff --git a/provisioning/go/clients/meshprovisionerclient/provider/harness/retirement_test.go b/provisioning/go/clients/meshprovisionerclient/provider/harness/retirement_test.go new file mode 100644 index 0000000..4408e1f --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/provider/harness/retirement_test.go @@ -0,0 +1,575 @@ +package harness + +// Retirement (novox/hq ADR 0230), as the shared loop does it: a consumer the mesh stops asking for is +// retired only after the same result in five consecutive passes, too many at once wait for a person, +// asked for again it is re-enabled, and only a person deletes. The same file in every Go provider. + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" + "time" +) + +// recorder is a backend that remembers what it holds, active and retired, as a real one's mark does. +type recorder struct { + created []Provision + removed []string // retired, in order + deleted []string + held bool + failing error + holdsErr error + retErr error + inv Inventory + invErr error +} + +func (r *recorder) Create(_ context.Context, p Provision) error { + if r.failing != nil { + return r.failing + } + r.created = append(r.created, p) + r.inv.Retired = withoutRetired(r.inv.Retired, p.As) + if !listHas(r.inv.Active, p.As) { + r.inv.Active = append(r.inv.Active, p.As) + } + return nil +} + +func (r *recorder) Retire(_ context.Context, as string, _ map[string]any, why string, at time.Time) error { + if r.retErr != nil { + return r.retErr + } + r.removed = append(r.removed, as) + r.inv.Active = without(r.inv.Active, as) + r.inv.Retired = append(withoutRetired(r.inv.Retired, as), + Retired{Consumer: as, RetiredAt: at, Why: why, SizeBytes: 42, Kind: KindConsumer}) + return nil +} + +func (r *recorder) Holds(context.Context, Provision) (bool, error) { + if r.holdsErr != nil { + return false, r.holdsErr + } + return r.held, nil +} + +func (r *recorder) Inventory(context.Context) (Inventory, error) { return r.inv, r.invErr } + +func (r *recorder) Delete(_ context.Context, x Retired) (int64, error) { + r.deleted = append(r.deleted, x.Consumer) + r.inv.Retired = withoutRetired(r.inv.Retired, x.Consumer) + return x.SizeBytes, nil +} + +func listHas(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +func without(list []string, s string) []string { + var out []string + for _, x := range list { + if x != s { + out = append(out, x) + } + } + return out +} + +func withoutRetired(list []Retired, s string) []Retired { + var out []Retired + for _, x := range list { + if x.Consumer != s { + out = append(out, x) + } + } + return out +} + +// holding gives the provider n consumers c1…cn and applies them. +func holding(w *world, n int) []map[string]any { + var given []map[string]any + for i := 1; i <= n; i++ { + given = append(given, map[string]any{"as": fmt.Sprintf("c%d", i), "node": "anchor"}) + } + w.give(given...) + w.h.Reconcile(ctx) + return given +} + +// pass is one reconcile five seconds after the last. +func (w *world) pass() { + w.now = w.now.Add(5 * time.Second) + w.h.Reconcile(ctx) +} + +func retirements(said []announced) []string { + var out []string + for _, a := range said { + if a.event == EventRetirement { + out = append(out, a.body["change"].(string)) + } + } + return out +} + +func noneWaits(a announced) bool { + c, _ := a.body["consumers"].([]map[string]any) + return a.body["change"] == ChangeSettled && len(c) == 0 +} + +func lastRetirement(t *testing.T, said []announced) map[string]any { + t.Helper() + for i := len(said) - 1; i >= 0; i-- { + if said[i].event == EventRetirement { + return said[i].body + } + } + t.Fatal("nothing about retirement was announced") + return nil +} + +func namesOf(body map[string]any) string { + var out []string + for _, c := range body["consumers"].([]map[string]any) { + out = append(out, c["consumer"].(string)) + } + return strings.Join(out, ",") +} + +// settle passes every five seconds until the same answer has held for StableFor, and one pass more. +func (w *world) settle() { w.passes(StableFor + 5*time.Second) } + +func testATransientEmptyListForFourPassesRetiresNothing(t *testing.T) { + w, said := standingWorld(t) + given := holding(w, 1) + w.give() + for i := 0; i < 4; i++ { + w.pass() + } + w.give(given...) + w.pass() + if len(w.a.removed) != 0 || len(retirements(*said)) != 0 { + t.Fatalf("four passes retired %v and said %v", w.a.removed, retirements(*said)) + } +} + +// Five identical passes are twenty-five seconds — shorter than a controller restart. Both must hold: +// five passes AND ten minutes of the same answer (novox/hq ADR 0230). +func testFivePassesInTwentyFiveSecondsRetireNothingTenMinutesDo(t *testing.T) { + w, said := standingWorld(t) + holding(w, 1) + w.give() + for i := 0; i < 5; i++ { + w.pass() + } + if len(w.a.removed) != 0 || len(retirements(*said)) != 0 { + t.Fatalf("five passes in 25 s retired %v", w.a.removed) + } + w.passes(StableFor - 30*time.Second) + if len(w.a.removed) != 0 { + t.Fatalf("retired before the set held %s: %v", StableFor, w.a.removed) + } + w.passes(time.Minute) + if strings.Join(w.a.removed, ",") != "c1" { + t.Fatalf("the same set held %s and was not retired: %v", StableFor, w.a.removed) + } + // Ten minutes in one slow pass are not five passes. + w2 := newWorld(t) + holding(w2, 1) + w2.give() + w2.pass() + w2.now = w2.now.Add(StableFor) + w2.pass() + if len(w2.a.removed) != 0 { + t.Fatalf("two passes ten minutes apart retired %v", w2.a.removed) + } + w2.passes(15 * time.Second) + if len(w2.a.removed) != 1 { + t.Fatalf("five passes over ten minutes did not retire: %v", w2.a.removed) + } +} + +func testAStableSetIsRetiredAndAskedAgainReEnables(t *testing.T) { + w, said := standingWorld(t) + given := holding(w, 3) + w.give(given[:2]...) + w.settle() + if strings.Join(w.a.removed, ",") != "c3" { + t.Fatalf("retired %v", w.a.removed) + } + body := lastRetirement(t, *said) + if body["change"] != ChangeRetired || namesOf(body) != "c3" || body["held"] != 3 || body["provider-node"] != "anchor" || + !strings.Contains(body["why"].(string), "consecutive passes over 10m") { + t.Fatalf("%v", body) + } + if len(w.a.inv.Retired) != 1 || w.a.inv.Retired[0].Consumer != "c3" { + t.Fatalf("the backend does not hold it retired: %+v", w.a.inv) + } + // Asked for again: the ordinary create, on the first pass, and said. + created := len(w.a.created) + w.give(given...) + w.pass() + if len(w.a.created) != created+1 || w.a.created[created].As != "c3" { + t.Fatalf("not created again: %v", w.a.created) + } + if body := lastRetirement(t, *said); body["change"] != ChangeReenabled || namesOf(body) != "c3" { + t.Fatalf("%v", body) + } + if len(w.a.inv.Retired) != 0 { + t.Fatalf("still marked retired: %+v", w.a.inv.Retired) + } +} + +func testAnUnreadablePassStartsTheCountAgain(t *testing.T) { + w := newWorld(t) + holding(w, 1) + w.give() + w.passes(StableFor - time.Minute) + os.WriteFile(w.receives, []byte("{"), 0o600) + w.pass() + w.give() + w.passes(StableFor - time.Minute) + if len(w.a.removed) != 0 { + t.Fatalf("an unreadable pass counted: %v", w.a.removed) + } + w.passes(2 * time.Minute) + if len(w.a.removed) != 1 { + t.Fatalf("not retired after ten minutes of readable passes: %v", w.a.removed) + } +} + +func testADifferentSetStartsTheCountAgain(t *testing.T) { + w := newWorld(t) + given := holding(w, 5) + w.give(given[:4]...) + w.passes(StableFor - time.Minute) + w.give(given[:3]...) + w.passes(StableFor - time.Minute) + if len(w.a.removed) != 0 { + t.Fatalf("a changed set kept its count: %v", w.a.removed) + } + w.passes(2 * time.Minute) + if strings.Join(w.a.removed, ",") != "c4,c5" { + t.Fatalf("%v", w.a.removed) + } +} + +func testAdditionsAndChangesAreNeverDelayed(t *testing.T) { + w := newWorld(t) + holding(w, 1) + w.give(map[string]any{"as": "c1", "node": "anchor"}, map[string]any{"as": "c2"}) + w.pass() + if len(w.a.created) != 2 || w.a.created[1].As != "c2" { + t.Fatalf("an addition waited: %v", w.a.created) + } + w.give(map[string]any{"as": "c1", "node": "anchor", "values": map[string]any{"name": "rotated"}}, map[string]any{"as": "c2"}) + w.pass() + if len(w.a.created) != 3 || w.a.created[2].As != "c1" { + t.Fatalf("a change waited: %v", w.a.created) + } +} + +func testTooManyWaitsForAPersonAndApproveRetiresExactlyThatSet(t *testing.T) { + w, said := standingWorld(t) + holding(w, 4) + w.give() + w.passes(15 * time.Minute) + if len(w.a.removed) != 0 { + t.Fatalf("four of four were retired without a person: %v", w.a.removed) + } + if got := strings.Join(retirements(*said), ","); got != ChangeWaiting { + t.Fatalf("said %q, want one waiting", got) + } + body := lastRetirement(t, *said) + if namesOf(body) != "c1,c2,c3,c4" || body["held"] != 4 || body["bound"] == "" { + t.Fatalf("%v", body) + } + if !strings.Contains(strings.Join(w.said, "\n"), "RETIREMENT WAITS FOR A PERSON") { + t.Fatal("the wait was not said") + } + // Said again every quarter of an hour while it waits. + w.passes(11 * time.Minute) + if got := strings.Join(retirements(*said), ","); got != "waiting,waiting" { + t.Fatalf("%q", got) + } + + if _, err := w.h.Approve(ctx, []string{"c1", "c2"}, "tidy", "operator", "mesh-controller"); err == nil { + t.Fatal("approved a set that is not the one waiting") + } + if _, err := w.h.Approve(ctx, []string{"c4", "c3", "c2", "c1"}, "", "operator", ""); err == nil { + t.Fatal("approved without a why") + } + done, err := w.h.Approve(ctx, []string{"c4", "c3", "c2", "c1"}, "the old machine is gone", "operator", "mesh-controller") + if err != nil || strings.Join(done, ",") != "c1,c2,c3,c4" || strings.Join(w.a.removed, ",") != "c1,c2,c3,c4" { + t.Fatal(done, err, w.a.removed) + } + changes := retirements(*said) + if strings.Join(changes[len(changes)-2:], ",") != "approved,retired" { + t.Fatalf("%v", changes) + } + if b := lastRetirement(t, *said); b["by"] != "operator" || b["via"] != "mesh-controller" || + !strings.Contains(b["why"].(string), "the old machine is gone") { + t.Fatalf("%v", b) + } + // Nothing more waits. + w.passes(time.Minute) + if r, _ := w.h.Retirement(ctx); r["waiting"] != nil || len(r["retired"].([]map[string]any)) != 4 { + t.Fatalf("%v", r) + } +} + +func testRejectedIsKeptAndNotAskedAgainUntilTheSetChanges(t *testing.T) { + w, said := standingWorld(t) + given := holding(w, 4) + w.give() + w.settle() + if _, err := w.h.Reject([]string{"c1"}, "no", "operator", ""); err == nil { + t.Fatal("rejected a set that is not the one waiting") + } + kept, err := w.h.Reject([]string{"c1", "c2", "c3", "c4"}, "a person is moving them", "operator", "mesh-controller") + if err != nil || len(kept) != 4 { + t.Fatal(kept, err) + } + w.passes(time.Hour) + if len(w.a.removed) != 0 { + t.Fatalf("a rejected set was retired: %v", w.a.removed) + } + if got := strings.Join(retirements(*said), ","); got != "waiting,rejected" { + t.Fatalf("asked again after a rejection: %q", got) + } + r, _ := w.h.Retirement(ctx) + if r["rejected"] == nil || r["waiting"] != nil { + t.Fatalf("%v", r) + } + // One of them asked for again: a different answer, so the rejection is settled and counting + // starts over — three of four is over the bound again, and waits again. + w.give(given[0]) + w.pass() + if got := strings.Join(retirements(*said), ","); got != "waiting,rejected,settled" { + t.Fatalf("%q", got) + } + w.settle() + if got := strings.Join(retirements(*said), ","); got != "waiting,rejected,settled,waiting" { + t.Fatalf("%q", got) + } + // A rejected set can still be approved later. + w2, _ := standingWorld(t) + holding(w2, 4) + w2.give() + w2.settle() + w2.h.Reject([]string{"c1", "c2", "c3", "c4"}, "wait", "operator", "") + if done, err := w2.h.Approve(ctx, []string{"c1", "c2", "c3", "c4"}, "now", "operator", ""); err != nil || len(done) != 4 { + t.Fatal(done, err) + } +} + +func testAWaitingSetAskedForAgainSettles(t *testing.T) { + w, said := standingWorld(t) + given := holding(w, 4) + w.give() + w.settle() + w.give(given...) + w.pass() + if got := strings.Join(retirements(*said), ","); got != "waiting,settled" || len(w.a.removed) != 0 { + t.Fatalf("%q %v", got, w.a.removed) + } + if _, err := w.h.Approve(ctx, []string{"c1", "c2", "c3", "c4"}, "late", "operator", ""); err == nil { + t.Fatal("approved a set that no longer waits") + } +} + +func testDeleteRemovesOnlyThatRetiredConsumer(t *testing.T) { + w, said := standingWorld(t) + given := holding(w, 5) + w.give(given[:3]...) + w.settle() + if strings.Join(w.a.removed, ",") != "c4,c5" { + t.Fatalf("%v", w.a.removed) + } + if _, err := w.h.DeleteRetired(ctx, "c1", "tidy", "operator", ""); err == nil { + t.Fatal("deleted an active consumer") + } + if _, err := w.h.DeleteRetired(ctx, "c5", "", "operator", ""); err == nil { + t.Fatal("deleted without a why") + } + if _, err := w.h.DeleteRetired(ctx, "nobody", "tidy", "operator", ""); err == nil { + t.Fatal("deleted something not retired") + } + freed, err := w.h.DeleteRetired(ctx, "c5", "the experiment is over", "operator", "mesh-controller") + if err != nil || freed != 42 || strings.Join(w.a.deleted, ",") != "c5" { + t.Fatal(freed, err, w.a.deleted) + } + if b := lastRetirement(t, *said); b["change"] != ChangeDeleted || namesOf(b) != "c5" || b["freed_bytes"] != int64(42) { + t.Fatalf("%v", b) + } + r, _ := w.h.Retirement(ctx) + if left := r["retired"].([]map[string]any); len(left) != 1 || left[0]["consumer"] != "c4" { + t.Fatalf("%v", r) + } + // Retired and asked for again before anybody deleted it: refused, it is the mesh's again. + w.give(given[:4]...) + w.pass() + if _, err := w.h.DeleteRetired(ctx, "c4", "tidy", "operator", ""); err == nil { + t.Fatal("deleted a consumer the mesh asks for") + } +} + +func testTheBound(t *testing.T) { + h := &Harness{} + h.init() + for _, c := range []struct{ n, held int }{{1, 1}, {1, 2}, {1, 3}, {3, 7}, {3, 6}, {2, 5}} { + if h.overTheBound(c.n, c.held) { + t.Errorf("%d of %d waits for a person", c.n, c.held) + } + } + for _, c := range []struct{ n, held int }{{2, 2}, {2, 3}, {4, 7}, {4, 10}, {7, 7}} { + if !h.overTheBound(c.n, c.held) { + t.Errorf("%d of %d is retired without a person", c.n, c.held) + } + } +} + +func testARestartRetiresWhatTheBackendHoldsUnaskedAndAdoptsWhatItFindsDisabled(t *testing.T) { + w, said := standingWorld(t) + w.a.inv = Inventory{Active: []string{"kept", "orphan"}, Retired: []Retired{ + {Consumer: "locked-before", SizeBytes: 7, Kind: KindConsumer}, + {Consumer: "old_deleted_20261005", RetiredAt: time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC), Kind: "set-aside-database"}, + }} + w.give(map[string]any{"as": "kept"}) + w.h.Reconcile(ctx) + if b := lastRetirement(t, *said); b["change"] != ChangeAdopted || namesOf(b) != "locked-before" { + t.Fatalf("%v", b) + } + if strings.Join(w.a.removed, ",") != "locked-before" { + t.Fatalf("adopting did not mark it: %v", w.a.removed) + } + w.settle() + if strings.Join(w.a.removed, ",") != "locked-before,orphan" { + t.Fatalf("an orphan the backend holds was not retired: %v", w.a.removed) + } +} + +func testABackendThatCannotBeListedIsSaidAndAskedAgain(t *testing.T) { + w := newWorld(t) + w.a.invErr = errors.New("connection refused") + holding(w, 1) + if !strings.Contains(strings.Join(w.said, "\n"), "cannot list what the backend holds") { + t.Fatal(w.said) + } + w.a.invErr = nil + w.a.inv = Inventory{Active: []string{"c1", "orphan"}} + w.pass() + w.settle() + if strings.Join(w.a.removed, ",") != "orphan" { + t.Fatalf("%v", w.a.removed) + } +} + +func testTheToolsAnswer(t *testing.T) { + w, _ := standingWorld(t) + holding(w, 4) + w.give() + w.settle() + tools := map[string]func(map[string]any) (any, error){} + for _, tool := range RetirementTools(w.h) { + tools[tool.Name] = tool.Run + // L3: the delete, approve and reject name no removed controller verb, and say where the operator answers (ADR 0604 §8). + if tool.Name != "provisioner_retirement" && (strings.Contains(tool.Description, "controller") || !strings.Contains(tool.Description, "operator channel")) { + t.Errorf("%s says %q", tool.Name, tool.Description) + } + } + if len(tools) != 4 { + t.Fatalf("%d tools", len(tools)) + } + got, err := tools["provisioner_retirement"](nil) + if err != nil || got.(map[string]any)["waiting"] == nil { + t.Fatal(got, err) + } + set := []any{"c1", "c2", "c3", "c4"} + if _, err := tools["provisioner_retire_approve"](map[string]any{"consumers": set}); err == nil { + t.Fatal("approved without a why") + } + if _, err := tools["provisioner_retire_approve"](map[string]any{"consumers": set, "why": "gone", "by": "operator"}); err != nil { + t.Fatal(err) + } + if _, err := tools["provisioner_delete"](map[string]any{"consumer": "c1", "why": "gone"}); err == nil { + t.Fatal("deleted without confirm") + } + if _, err := tools["provisioner_delete"](map[string]any{"consumer": "c1", "confirm": "c1", "why": "gone"}); err != nil { + t.Fatal(err) + } + if strings.Join(w.a.deleted, ",") != "c1" { + t.Fatal(w.a.deleted) + } +} + +func testAFailingConsumerRetiredIsSaidRecovered(t *testing.T) { + w, said := standingWorld(t) + given := holding(w, 2) + w.a.held = false + w.a.failing = errors.New("boom") + w.passes(7 * time.Minute) + w.give(given[0]) + w.settle() + recovered := false + for _, a := range *said { + if a.event == EventRecovered && a.body["consumer"] == "c2" && a.body["why"] == "retired" { + recovered = true + } + } + if !recovered { + t.Fatalf("%v", *said) + } +} + +// everyWord is a world that keeps every word the provider announces. +func everyWord(t *testing.T) (*world, *[]announced) { + w := newWorld(t) + var said []announced + w.h.Announce = func(e string, b map[string]any) { said = append(said, announced{e, b}) } + w.h.Node = "anchor" + return w, &said +} + +// A restarted provider that finds every consumer it holds asked for says once that nothing waits, so the controller +// clears a waiting condition an earlier process raised and this one never knew (issue 937); it says it no more. +func testARestartedProviderSaysOnceThatNothingWaits(t *testing.T) { + w, said := everyWord(t) + holding(w, 2) + w.pass() + w.pass() + var words []announced + for _, a := range *said { + if a.event == EventRetirement { + words = append(words, a) + } + } + if len(words) != 1 || !noneWaits(words[0]) || words[0].body["provider-node"] != w.h.Node { + t.Fatalf("said %+v, want one settled with no set", words) + } +} + +// A provider whose own set waits does not say that nothing waits. +func testAProviderThatWaitsDoesNotSayNothingWaits(t *testing.T) { + w, said := everyWord(t) + w.a.inv.Active = []string{"c1", "c2", "c3", "c4", "c5"} + w.give(map[string]any{"as": "c1", "node": "anchor"}) + w.settle() + for _, a := range *said { + if a.event == EventRetirement && noneWaits(a) { + t.Fatalf("said nothing waits while %v waited", w.h.r.waiting) + } + } + if w.h.r.waiting == nil { + t.Fatal("nothing waited: the test does not hold the case") + } +} diff --git a/provisioning/go/clients/meshprovisionerclient/provider/harness/standing_test.go b/provisioning/go/clients/meshprovisionerclient/provider/harness/standing_test.go new file mode 100644 index 0000000..52fd137 --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/provider/harness/standing_test.go @@ -0,0 +1,193 @@ +package harness + +// A provider that keeps failing a consumer says so on the bus (novox/hq ADR 0224): not on the first +// failure, which may be a restart; after FailingAfter of failures with no success between; again +// every SayAgainEvery while it lasts; and recovered on the first success, or when the consumer goes. + +import ( + "errors" + "os" + "strings" + "testing" + "time" +) + +type announced struct { + event string + body map[string]any +} + +func standingWorld(t *testing.T) (*world, *[]announced) { + w := newWorld(t) + var said []announced + w.h.Announce = func(e string, b map[string]any) { + // The first success since start, and the first word that nothing waits (issue 937), are their own + // tests'; every other test reads past them. + if c, _ := b["consumers"].([]map[string]any); b["why"] != "first-success" && + !(e == EventRetirement && b["change"] == ChangeSettled && len(c) == 0) { + said = append(said, announced{e, b}) + } + } + w.h.Node = "anchor" + return w, &said +} + +// passes reconciles every five seconds for d, as Run would. +func (w *world) passes(d time.Duration) { + for end := w.now.Add(d); w.now.Before(end); w.now = w.now.Add(5 * time.Second) { + w.h.Reconcile(ctx) + } +} + +func events(said []announced) string { + var out []string + for _, a := range said { + out = append(out, a.event) + } + return strings.Join(out, ",") +} + +func testAConsumerFailedForMinutesIsAnnouncedNamingItAndTheClass(t *testing.T) { + w, said := standingWorld(t) + w.a.failing = errors.New(`token request failed: 401 {"error":"invalid_grant","error_description":"Invalid user credentials"}`) + w.give(map[string]any{"as": "mesh_home_grafana", "node": "home-server"}) + + w.passes(4 * time.Minute) + if len(*said) != 0 { + t.Fatalf("announced before FailingAfter: %v", events(*said)) + } + w.passes(2 * time.Minute) + if events(*said) != EventFailing { + t.Fatalf("want one %s, got %q", EventFailing, events(*said)) + } + b := (*said)[0].body + if b["consumer"] != "mesh_home_grafana" || b["node"] != "home-server" || b["class"] != ClassCredentials || + b["provider"] != "postgres-database" || b["provider-node"] != "anchor" || b["attempts"].(int) < 60 { + t.Fatalf("%v", b) + } + + // Said again while it lasts, not every pass. + w.passes(14 * time.Minute) + if events(*said) != EventFailing { + t.Fatalf("repeated too soon: %q", events(*said)) + } + w.passes(2 * time.Minute) + if events(*said) != EventFailing+","+EventFailing { + t.Fatalf("not repeated: %q", events(*said)) + } + + // The first success takes it back. + w.a.failing = nil + w.passes(5 * time.Second) + if events(*said) != EventFailing+","+EventFailing+","+EventRecovered { + t.Fatalf("no recovery: %q", events(*said)) + } + if (*said)[2].body["consumer"] != "mesh_home_grafana" { + t.Fatal((*said)[2].body) + } +} + +func testOneSuccessBetweenFailuresStartsTheRunAgain(t *testing.T) { + w, said := standingWorld(t) + w.a.failing = errors.New("connection refused") + w.give(map[string]any{"as": "a"}) + w.passes(4 * time.Minute) + w.a.failing = nil + w.passes(5 * time.Second) + w.give(map[string]any{"as": "a", "values": map[string]any{"name": "changed"}}) + w.a.failing = errors.New("connection refused") + w.passes(4 * time.Minute) + if len(*said) != 0 { + t.Fatalf("two runs of four minutes are not one of eight: %q", events(*said)) + } +} + +// The check that failed for a day on 2026-10-05: clients already made, every minute's check refused +// at the token. A check that cannot be asked is a failure too. +func testACheckThatKeepsFailingIsAFailureToo(t *testing.T) { + w, said := standingWorld(t) + w.give(map[string]any{"as": "a"}) + w.h.Reconcile(ctx) + w.a.holdsErr = errors.New("401 invalid_grant") + w.passes(7 * time.Minute) + if events(*said) != EventFailing || (*said)[0].body["class"] != ClassCredentials { + t.Fatalf("%q %v", events(*said), *said) + } + w.a.holdsErr = nil + w.passes(time.Minute + 5*time.Second) + if events(*said) != EventFailing+","+EventRecovered { + t.Fatalf("%q", events(*said)) + } +} + +func testAnUnreadableSecretIsAnnouncedAsSuch(t *testing.T) { + w, said := standingWorld(t) + w.give(map[string]any{"as": "a"}) + os.Remove(w.dir + "/a.secret") + w.passes(6 * time.Minute) + if events(*said) != EventFailing || (*said)[0].body["class"] != ClassSecret { + t.Fatalf("%q %v", events(*said), *said) + } +} + +func testAConsumerNoLongerAskedForIsNoLongerFailing(t *testing.T) { + w, said := standingWorld(t) + w.a.failing = errors.New("boom") + w.give(map[string]any{"as": "a"}, map[string]any{"as": "b"}) + w.passes(6 * time.Minute) + if events(*said) != EventFailing+","+EventFailing { + t.Fatalf("%q", events(*said)) + } + w.give(map[string]any{"as": "a"}) + w.passes(5 * time.Second) + last := (*said)[len(*said)-1] + if last.event != EventRecovered || last.body["consumer"] != "b" || last.body["why"] != "no longer asked for" { + t.Fatalf("%v", *said) + } +} + +func testAnErrorIsClassedByItsWords(t *testing.T) { + for text, want := range map[string]string{ + `Keycloak token request failed: 401 {"error":"invalid_grant"}`: ClassCredentials, + `FATAL: password authentication failed for user "postgres"`: ClassCredentials, + `dial tcp 127.0.0.1:5432: connect: connection refused`: ClassUnreachable, + `context deadline exceeded`: ClassUnreachable, + `extension "nope" is not available`: ClassRefused, + } { + if got := ClassOf(text); got != want { + t.Errorf("%s: %s, want %s", text, got, want) + } + } +} + +func testAnAdapterThatClassesItsOwnErrorsIsBelieved(t *testing.T) { + w, said := standingWorld(t) + w.h.Adapter = classing{w.a} + w.a.failing = errors.New("anything") + w.give(map[string]any{"as": "a"}) + w.passes(6 * time.Minute) + if (*said)[0].body["class"] != "its-own" { + t.Fatal((*said)[0].body) + } +} + +type classing struct{ *recorder } + +func (classing) Class(error) string { return "its-own" } + +// A provider restarted after announcing a failure has forgotten it; its first success for each +// consumer is announced, so the controller clears what it kept rather than naming it for ever. +func testTheFirstSuccessSinceStartIsAnnouncedOnce(t *testing.T) { + w := newWorld(t) + var said []announced + w.h.Announce = func(e string, b map[string]any) { + if e != EventRetirement { + said = append(said, announced{e, b}) + } + } + w.give(map[string]any{"as": "a"}) + w.passes(3 * time.Minute) + if events(said) != EventRecovered || said[0].body["why"] != "first-success" || said[0].body["consumer"] != "a" { + t.Fatalf("%v", said) + } +} diff --git a/provisioning/go/clients/meshprovisionerclient/provider/harness/tables_test.go b/provisioning/go/clients/meshprovisionerclient/provider/harness/tables_test.go new file mode 100644 index 0000000..78af4fd --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/provider/harness/tables_test.go @@ -0,0 +1,114 @@ +package harness + +import ( + "context" + "testing" + "time" + + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider" +) + +// TestHarnessRuns groups the cases below, each one a subtest. +func TestHarnessRuns(t *testing.T) { + // TestHarness holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). + t.Run("Harness", func(t *testing.T) { + t.Run("AConsumerIsCreatedOnceUnderTheMeshsLoginAndPassword", testAConsumerIsCreatedOnceUnderTheMeshsLoginAndPassword) + t.Run("AConsumerNoLongerAskedForIsRetiredOnceStable", testAConsumerNoLongerAskedForIsRetiredOnceStable) + t.Run("NothingReadIsNotNobodyAsking", testNothingReadIsNotNobodyAsking) + t.Run("ALostConsumerIsAppliedAgainAndBraked", testALostConsumerIsAppliedAgainAndBraked) + t.Run("AFailingCreateIsRetriedAndSaidOnce", testAFailingCreateIsRetriedAndSaidOnce) + t.Run("ScrubRemovesThePassword", testScrubRemovesThePassword) + }) + + t.Run("AMovedPathStartedTooEarlyAsksAgainUntilTheRuntimeAnswers", func(t *testing.T) { + w := newMovedWorld(t, map[[2]string]string{}) + w.run.FirstRetry = time.Millisecond + w.run.loaded = false + bus := &earlyBus{bucketBus: w.bus, refuse: 3} + p := provider.New(provider.Options{Provisioner: testProvisioner, Node: "novox", Module: "postgres", Bus: w.bus, + Vault: provider.ConsumersFile{Paths: []string{w.file}}, Make: w.run.Make, Gone: w.run.Gone, + Every: time.Hour, Logf: func(string, ...any) {}}) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- w.run.Run(ctx, p, bus) }() + deadline := time.After(time.Second) + for { + w.run.mu.Lock() + loaded := w.run.loaded + w.run.mu.Unlock() + if loaded { + break + } + select { + case err := <-done: + cancel() + t.Fatalf("Run stopped before the runtime answered: %v", err) + case <-deadline: + cancel() + t.Fatal("the plans were never watched") + case <-time.After(time.Millisecond): + } + } + cancel() + <-done + if bus.refuse != 0 { + t.Fatalf("%d refusals left", bus.refuse) + } + }) + + // TestStanding holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). + t.Run("Standing", func(t *testing.T) { + t.Run("AConsumerFailedForMinutesIsAnnouncedNamingItAndTheClass", testAConsumerFailedForMinutesIsAnnouncedNamingItAndTheClass) + t.Run("OneSuccessBetweenFailuresStartsTheRunAgain", testOneSuccessBetweenFailuresStartsTheRunAgain) + t.Run("ACheckThatKeepsFailingIsAFailureToo", testACheckThatKeepsFailingIsAFailureToo) + t.Run("AnUnreadableSecretIsAnnouncedAsSuch", testAnUnreadableSecretIsAnnouncedAsSuch) + t.Run("AConsumerNoLongerAskedForIsNoLongerFailing", testAConsumerNoLongerAskedForIsNoLongerFailing) + t.Run("AnErrorIsClassedByItsWords", testAnErrorIsClassedByItsWords) + t.Run("AnAdapterThatClassesItsOwnErrorsIsBelieved", testAnAdapterThatClassesItsOwnErrorsIsBelieved) + t.Run("TheFirstSuccessSinceStartIsAnnouncedOnce", testTheFirstSuccessSinceStartIsAnnouncedOnce) + }) +} + +// TestHarnessMoves groups the cases below, each one a subtest. +func TestHarnessMoves(t *testing.T) { + // TestMoved holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). + t.Run("Moved", func(t *testing.T) { + t.Run("TheConsumersFileMakesWhatTheGrantFilesMake", testTheConsumersFileMakesWhatTheGrantFilesMake) + t.Run("AConsumerMissingFromOneReadRetiresNothing", testAConsumerMissingFromOneReadRetiresNothing) + t.Run("NoPlansReadRetiresNothing", testNoPlansReadRetiresNothing) + t.Run("AConsumerWithoutACredentialIsWaitedFor", testAConsumerWithoutACredentialIsWaitedFor) + t.Run("ASluggedConsumerKeepsTheLoginItsPlanNames", testASluggedConsumerKeepsTheLoginItsPlanNames) + t.Run("ASpecWithoutGrantsMakesUnderThePlansAsAndIgnoresTheGrantFile", testASpecWithoutGrantsMakesUnderThePlansAsAndIgnoresTheGrantFile) + t.Run("TheConsumersFileMakesWhatTheGrantFileMakesForTheKeycloakShape", testTheConsumersFileMakesWhatTheGrantFileMakesForTheKeycloakShape) + t.Run("AConsumerNamedByItsPlanKeepsItsClientId", testAConsumerNamedByItsPlanKeepsItsClientId) + }) + + // TestGrantsMoved holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). + t.Run("GrantsMoved", func(t *testing.T) { + t.Run("AConsumerOnlyTheGrantFileGivesIsAskedForAndMade", testAConsumerOnlyTheGrantFileGivesIsAskedForAndMade) + t.Run("ALoginAPlanNamesIsNotMadeFromTheGrantFile", testALoginAPlanNamesIsNotMadeFromTheGrantFile) + t.Run("AnUnreadableGrantFileRetiresNothing", testAnUnreadableGrantFileRetiresNothing) + t.Run("ALoginTheGrantFileStopsGivingIsRetired", testALoginTheGrantFileStopsGivingIsRetired) + }) + + // TestRetirement holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). + t.Run("Retirement", func(t *testing.T) { + t.Run("ATransientEmptyListForFourPassesRetiresNothing", testATransientEmptyListForFourPassesRetiresNothing) + t.Run("FivePassesInTwentyFiveSecondsRetireNothingTenMinutesDo", testFivePassesInTwentyFiveSecondsRetireNothingTenMinutesDo) + t.Run("AStableSetIsRetiredAndAskedAgainReEnables", testAStableSetIsRetiredAndAskedAgainReEnables) + t.Run("AnUnreadablePassStartsTheCountAgain", testAnUnreadablePassStartsTheCountAgain) + t.Run("ADifferentSetStartsTheCountAgain", testADifferentSetStartsTheCountAgain) + t.Run("AdditionsAndChangesAreNeverDelayed", testAdditionsAndChangesAreNeverDelayed) + t.Run("TooManyWaitsForAPersonAndApproveRetiresExactlyThatSet", testTooManyWaitsForAPersonAndApproveRetiresExactlyThatSet) + t.Run("RejectedIsKeptAndNotAskedAgainUntilTheSetChanges", testRejectedIsKeptAndNotAskedAgainUntilTheSetChanges) + t.Run("AWaitingSetAskedForAgainSettles", testAWaitingSetAskedForAgainSettles) + t.Run("DeleteRemovesOnlyThatRetiredConsumer", testDeleteRemovesOnlyThatRetiredConsumer) + t.Run("TheBound", testTheBound) + t.Run("ARestartRetiresWhatTheBackendHoldsUnaskedAndAdoptsWhatItFindsDisabled", testARestartRetiresWhatTheBackendHoldsUnaskedAndAdoptsWhatItFindsDisabled) + t.Run("ABackendThatCannotBeListedIsSaidAndAskedAgain", testABackendThatCannotBeListedIsSaidAndAskedAgain) + t.Run("TheToolsAnswer", testTheToolsAnswer) + t.Run("AFailingConsumerRetiredIsSaidRecovered", testAFailingConsumerRetiredIsSaidRecovered) + t.Run("ARestartedProviderSaysOnceThatNothingWaits", testARestartedProviderSaysOnceThatNothingWaits) + t.Run("AProviderThatWaitsDoesNotSayNothingWaits", testAProviderThatWaitsDoesNotSayNothingWaits) + }) +} diff --git a/provisioning/go/clients/meshprovisionerclient/provider/provider.go b/provisioning/go/clients/meshprovisionerclient/provider/provider.go new file mode 100644 index 0000000..0e393c1 --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/provider/provider.go @@ -0,0 +1,866 @@ +// Package provider is a providing module's half of a binding: it answers the provider's consumers itself, over the +// bus (novox/hq ADR 0349 clause 12, issue 678): the provider module, not the controller, makes each bound +// consumer's entry. +// +// **Who binds the provider.** Each node's provisioner client records the needs of the modules handed to it from +// their plans, and the controller names in each plan, per required provision and local name, the provider that +// resolution chose: `provisioning.provisions` in `.` of the assignments mirror (issue 760). A recorded +// need names no provider (issue 677's note 7), so the plan's entry is where a bind is seen, as the vault sees it +// (mesh-vault pair.go). A consumer binds this provider when an entry names this machine and this module, and +// unbinds when the entry, or the assignment, goes. A consumer still filled the old way has no plan and is not seen +// here; the controller answers it as before (ADR 0349 clause 17). +// +// **One entry per consumer, in the provider's own bucket.** For each bind the helper composes the consumer's +// served values from the provider's offer (its config already composed into it, ADR 0202 §7) and the consumer's +// identity (`${consumer:as}`, `${consumer:as:dns}`, ADR 0202 §1), and writes one entry, `.. +// [.]` of `_bound`: the one statement both ends read. It never carries a +// secret. It names the credential the vault keeps for the pair, which the consumer's client asks the vault for. +// +// **The credential is the vault's** (ADR 0349 clause 8). The helper mints nothing and reads nothing of the +// provider's own secrets: it reads the value from the vault and hands it to the provider's adapter, which makes the +// login its software keeps. An offer whose software holds one credential (ADR 0158) is the vault's one value per +// provider and provision, `provision//`, with the provider's one user name; an offer with +// a login per consumer (ADR 0048, ADR 0158 §11, the default) is each consumer's own value, +// `pair////[/]`, with the consumer's identity as its login (issue +// 677's notes 12 and 13). A credential the vault does not hold yet is waited for: nothing is made and no entry is +// written, so a consumer is never told of a login its provider has not made. A provider in the tool runner never asks +// the vault itself: its Vault is ConsumersFile, the file its machine's provisioner client fills (ADR 0373). +// +// **A multi-holder consumer gets no derived value** (ADR 0202 §10 as ADR 0349 clause 12 changes it): a consumer +// bound under a local name keeps several holders of the provision, each with its own login, while what the +// provider derives names one; its entry refuses it, naming both ends, and its client reports it. +// +// The helper speaks to the bus and the vault through the two interfaces below, so a provider wires it to its +// runtime and a test to a stand-in. Standard library only. +package provider + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "regexp" + "sort" + "strings" + "sync" + "time" + + provisions "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// BucketOf is a providing module's own bucket of entries, one per consumer: `_bound`, the module's state +// `bound`. +func BucketOf(module string) string { return module + "_bound" } + +// Bus is what the helper needs of the bus: a key-value watch and the provider's writes to its own bucket. +type Bus interface { + // Watch hands handle every current key and value of bucket, then each change as it comes, a removed key with a + // nil value; it returns once the current values are handed, and the watch lasts until ctx ends. + Watch(ctx context.Context, bucket string, handle func(key string, value []byte)) error + Put(ctx context.Context, bucket, key string, value []byte) error + Delete(ctx context.Context, bucket, key string) error +} + +// ConditionalBus is a Bus that can also delete a key only while it still holds what the caller read. The helper uses +// it when the Bus offers it, so a pass that lets a consumer go never removes an entry another instance of the +// provider wrote after the pass read it; a Bus without it deletes unconditionally. DeleteIf returns an error that +// is stdio.ErrChanged when the key holds something else now. +type ConditionalBus interface { + Bus + DeleteIf(ctx context.Context, bucket, key string, held []byte) error +} + +// Vault is how the provider reads a credential the vault keeps, by its name, opened. +type Vault interface { + Value(ctx context.Context, name string) ([]byte, error) +} + +// CredentialForm is which credential an offer's consumers present. +type CredentialForm int + +const ( + // PerConsumer is a login of each consumer's own (ADR 0048, ADR 0158 §11): the default. + PerConsumer CredentialForm = iota + // Shared is the one credential the provider's software holds, shared with every consumer (ADR 0158). + Shared + // NoCredential is an offer whose consumers present none. + NoCredential +) + +// Offer is what the provider serves one provision with. +type Offer struct { + // Serves are the values served to each consumer, the provider's config already composed into them; a string + // may name the consumer's identity, `${consumer:as}` or `${consumer:as:dns}`. + Serves map[string]any + // Credential is the form of the credential its consumers present. A consumer's plan entry that names a + // credential form of its own wins over it (the plan's `credential-form`). + Credential CredentialForm + // User is the provider's one user name, for a Shared credential. + User string +} + +// Entry is one consumer's entry in the provider's bucket: the one statement both ends read. Never a secret. +type Entry struct { + Provider string `json:"provider"` + ProviderModule string `json:"provider-module"` + Provision string `json:"provision"` + Consumer string `json:"consumer"` + Module string `json:"module"` + Local string `json:"local,omitempty"` + // As is the consumer's identity, the mesh's (ADR 0202 §1). + As string `json:"as,omitempty"` + // User is the login the consumer presents: its identity, or the provider's one user name for a shared credential. + User string `json:"user,omitempty"` + // Values are the served values, composed for this consumer: what `${bound::}` reads. + Values map[string]any `json:"values,omitempty"` + // VaultName is the name under which the vault keeps this consumer's credential, which the consumer's client asks + // the vault for: a name, never the credential. Its key is not "credential" because the runtime's state guard + // refuses any key ending in that word, whatever the value (issue 945). + VaultName string `json:"vault-name,omitempty"` + // Refused, when set, is why the consumer is not served, naming both ends. + Refused string `json:"refused,omitempty"` + // State is the provider's outcome for the consumer, which the holder of mesh-provisioner reads to set the + // provision's key (novox/hq ADR 0570 clause 3): provided, failed with Why, or waits with what is awaited. Empty from + // a provider before it. + State string `json:"state,omitempty"` + Why string `json:"why,omitempty"` + // Contributes is what the consumer's module tells its provider about this provision (a database's name, its + // extensions), from its plan's entry (issue 776). Handed to Make and Gone; never written to the bucket. + Contributes map[string]any `json:"-"` + // At and From are where the consumer's plan says the provider is and which machine it is (issue 788): the values + // today's binding file gives that consumer. Written into Values as `at` and `from`; never alone to the bucket. + At, From string `json:"-"` + // Names are the names the controller composed for this consumer on this provision (issue 792): its own name, its + // internal name, and one of each per local name it contributes under, keyed as its binding file names them. + // Written into Values over anything the provider serves under the key; never alone to the bucket. + Names map[string]string `json:"-"` + // CredentialForm and Name are from the consumer's plan entry (issue 825): the form of the credential the + // controller chose for this bind (`shared`, `pair`, `issued` or `given`) and the holder's name. When set, they + // win over the offer's form. Never written to the bucket. + CredentialForm, Name string `json:"-"` +} + +// Key is the entry's key in the provider's bucket: `..`, and `.` for a bind under a +// local name. +func (e Entry) Key() string { + k := e.Consumer + "." + e.Module + "." + e.Provision + if e.Local != "" { + k += "." + e.Local + } + return k +} + +// Consumer is what the provider's adapter makes: the consumer's entry and the credential the vault keeps for it. +type Consumer struct { + Entry Entry + Credential []byte +} + +// Options are the provider's. +type Options struct { + // Node and Module are this provider: the machine it runs on and the module serving. + Node, Module string + // Provisioner is the module that holds the seat mesh-provisioner, whose states plans and provisions the provider reads: its + // own manifest's `reads` name it (`.plans`); the client names no holder (ADR 0625 §4). + Provisioner string + // Offers are the provisions it serves, by name. + Offers map[string]Offer + Bus Bus + Vault Vault + // Make brings a consumer into being in the provider's software under the vault's credential. Asked on every + // pass for every consumer answered, so it must leave one that is already as asked alone; a rotated credential + // reaches the software this way. + Make func(ctx context.Context, c Consumer) error + // Gone, when set, hears that a consumer no longer binds the provider, before its entry is removed; an error + // keeps the entry, and it is heard again on the next pass. Retiring what was kept for it is the provider's + // (ADR 0230). + Gone func(ctx context.Context, e Entry) error + // Identity is the consumer's identity for an entry whose plan names none; ConsumerIdentity of the node and the + // module when not set. + Identity func(node, module string) string + // Every is how often a pass runs without a change watched; a minute when not set. + Every time.Duration + // Logf says what a pass did that differs from the last; the standard log when not set. + Logf func(format string, args ...any) +} + +// Provider answers its consumers. +type Provider struct { + opts Options + + mu sync.Mutex + entries map[string][]byte + // plans and provisions are the seat mesh-provisioner's states (ADR 0570 clauses 1, 2), by key. + plans map[string][]byte + provisions map[string][]byte + said map[string]string + poke chan struct{} +} + +// New is a provider answering its consumers as opts say. +func New(opts Options) *Provider { + if opts.Identity == nil { + opts.Identity = ConsumerIdentity + } + if opts.Every <= 0 { + opts.Every = time.Minute + } + if opts.Logf == nil { + opts.Logf = func(f string, a ...any) { fmt.Printf("[provider] "+f+"\n", a...) } + } + return &Provider{opts: opts, entries: map[string][]byte{}, + plans: map[string][]byte{}, provisions: map[string][]byte{}, said: map[string]string{}, poke: make(chan struct{}, 1)} +} + +// ErrNotReady is what a Make returns, wrapped or alone, when the provider cannot make a consumer's provision yet but +// will without anyone's hand (its keys not read since a restart): the entry waits and keeps serving what it served. +// Any other error is a failure, and its entry serves nothing. +var ErrNotReady = errors.New("not ready yet") + +// What a pass did for one bind. +const ( + Answered = "answered" + Refused = "refused" + Waiting = "waiting" + Failed = "failed" + Removed = "removed" + // Superseded is an entry no bind asks for any more that another instance of the provider wrote after this pass + // read it: it is left standing. + Superseded = "superseded" +) + +// Outcome is what a pass did for one consumer's entry. +type Outcome struct { + Key string `json:"key"` + Status string `json:"status"` + Why string `json:"why,omitempty"` +} + +// Run watches the binds and the provider's own entries, and passes once at start, on every change and every +// Every, until ctx ends. +func (p *Provider) Run(ctx context.Context) error { + if p.opts.Provisioner == "" { + return errors.New("the provider names no module holding the seat mesh-provisioner (Options.Provisioner), so it reads no plans") + } + if err := p.watch(ctx, BucketOf(p.opts.Module), p.entries); err != nil { + return fmt.Errorf("watching %s: %w", BucketOf(p.opts.Module), err) + } + for _, bucket := range []string{provisions.PlansBucketOf(p.opts.Provisioner), provisions.ProvisionsBucketOf(p.opts.Provisioner)} { + into := p.plans + if bucket == provisions.ProvisionsBucketOf(p.opts.Provisioner) { + into = p.provisions + } + if err := p.watch(ctx, bucket, into); err != nil { + return fmt.Errorf("watching %s: %w", bucket, err) + } + } + tick := time.NewTicker(p.opts.Every) + defer tick.Stop() + for { + p.mu.Lock() + in := Inputs{Entries: clone(p.entries), Plans: clone(p.plans), Provisions: clone(p.provisions)} + p.mu.Unlock() + if _, err := p.passIn(ctx, in); err != nil { + p.say("pass", err.Error()) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-p.poke: + case <-tick.C: + } + } +} + +func (p *Provider) watch(ctx context.Context, bucket string, into map[string][]byte) error { + return p.opts.Bus.Watch(ctx, bucket, func(key string, value []byte) { + p.mu.Lock() + if value == nil { + delete(into, key) + } else { + into[key] = append([]byte(nil), value...) + } + p.mu.Unlock() + if bucket != BucketOf(p.opts.Module) { + select { + case p.poke <- struct{}{}: + default: + } + } + }) +} + +// Pass reads the binds and the provider's entries from the bus and answers once: each bind of this provider gets +// its entry, and each entry of this provider no bind asks for is removed. An assignment that cannot be read, or that +// is not the one its key names, is skipped and said (an outcome of status Failed under the assignment's key, and a +// line of the log), and the entries of its consumer are left as they are, since a bind missed would read as an +// unbind; the pass goes on for the rest. Run passes by itself; Pass is for a provider that passes when it chooses. +// +// Over StateBus each call to Pass leaves two watches open for the life of the process, silenced once the call has +// ended (see StateBus): a provider that passes often should use Run, which watches once. +func (p *Provider) Pass(ctx context.Context) ([]Outcome, error) { + watchCtx, cancel := context.WithCancel(ctx) + defer cancel() + var mu sync.Mutex + read := func(bucket string) (map[string][]byte, error) { + got := map[string][]byte{} + err := p.opts.Bus.Watch(watchCtx, bucket, func(key string, value []byte) { + mu.Lock() + defer mu.Unlock() + if value == nil { + delete(got, key) + } else { + got[key] = append([]byte(nil), value...) + } + }) + mu.Lock() + defer mu.Unlock() + return clone(got), err + } + entries, err := read(BucketOf(p.opts.Module)) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", BucketOf(p.opts.Module), err) + } + plans, err := read(provisions.PlansBucketOf(p.opts.Provisioner)) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", provisions.PlansBucketOf(p.opts.Provisioner), err) + } + provs, err := read(provisions.ProvisionsBucketOf(p.opts.Provisioner)) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", provisions.ProvisionsBucketOf(p.opts.Provisioner), err) + } + return p.passIn(ctx, Inputs{Entries: entries, Plans: plans, Provisions: provs}) +} + +// Inputs are what one pass reads: the provider's own entries, and the seat mesh-provisioner's plans and provisions +// (novox/hq ADR 0570 clauses 1, 2), each by key. The controller's assignments are read no more (clause 2). +type Inputs struct { + Entries, Plans, Provisions map[string][]byte +} + +func (p *Provider) passIn(ctx context.Context, in Inputs) ([]Outcome, error) { + entries := in.Entries + var binds []Entry + var out []Outcome + unknown := map[string]bool{} // consumers whose assignment was skipped: their entries are not judged + // Every consumer is bound by its plan and its provision keys (ADR 0570 clauses 2, 3), and an entry no plan names is + // never let go by the provider itself (clause 5): only a retiring key with the operator's yes retires it. + seat := map[string]bool{} + gated := map[string]provisions.Provision{} + for _, key := range sortedKeys(in.Plans) { + plan, err := provisions.DecodePlan(in.Plans[key]) + if err != nil || plan.Node+"."+plan.Module != key { + o := Outcome{Key: key, Status: Failed, Why: fmt.Sprintf("the plan %s is not readable, so who it binds is not "+ + "known; it is skipped, and its entries are left as they are", key)} + out = append(out, o) + p.say(key, o.Status+" "+o.Why) + unknown[key], seat[key] = true, true + continue + } + seat[key] = true + for _, b := range plan.Plan.Provisions { + if b.Provider != p.opts.Node || b.ProviderModule != p.opts.Module { + continue + } + e := Entry{Provider: p.opts.Node, ProviderModule: p.opts.Module, Provision: b.Provision, Consumer: plan.Node, + Module: plan.Module, Local: b.Local, Contributes: b.Contributes, At: b.At, From: b.From, As: b.As, + Names: b.Names, CredentialForm: b.CredentialForm, Name: b.Name} + binds = append(binds, e) + } + } + for _, key := range sortedKeys(in.Provisions) { + if pr, err := provisions.Decode(in.Provisions[key]); err == nil && pr.Provider.Node == p.opts.Node && + pr.Provider.Module == p.opts.Module { + gated[key] = pr + } + } + // **A key no plan names is bound from the entry it carries** (issue 1126): the seat keys a consumer's provisions + // from its wanted and its newest builds, and keeps the plan at its wanted build alone, so a requirement the newest + // build adds has a key before any plan names it, and the want that waits for it waits on this provider. The plan + // wins where both say it. A key with no entry is said, never left without a word. + planned := map[string]bool{} + for _, b := range binds { + planned[b.Key()] = true + } + for _, key := range sortedKeys(gated) { + pr := gated[key] + if planned[key] || pr.State == provisions.Retiring || unknown[consumerOf(key)] { + continue + } + if pr.Plan == nil { + o := Outcome{Key: key, Status: Waiting, Why: "asked, but no plan names it and its key carries no plan entry, " + + "so whom it binds is not known; mesh-provisioner composes the entry"} + out = append(out, o) + p.say(key, o.Status+" "+o.Why) + continue + } + b := *pr.Plan + binds = append(binds, Entry{Provider: p.opts.Node, ProviderModule: p.opts.Module, Provision: pr.Provision, + Consumer: pr.Consumer.Node, Module: pr.Consumer.Module, Local: pr.Local, Contributes: b.Contributes, At: b.At, + From: b.From, As: b.As, Names: b.Names, CredentialForm: b.CredentialForm, Name: b.Name}) + } + want := map[string]bool{} + for _, b := range binds { + key := b.Key() + want[key] = true + if why, ok := p.ready(key, gated, in.Provisions); !ok { + o := Outcome{Key: key, Status: Waiting, Why: why} + out = append(out, o) + p.say(key, o.Status+" "+o.Why) + continue + } + o := p.answer(ctx, b, entries[key]) + out = append(out, o) + p.say(key, o.Status+" "+o.Why) + } + // ADR 0570 clause 5: a retiring key the operator said yes to is let go; one asked and not answered, or declined, + // keeps its entry. + for _, key := range sortedKeys(gated) { + if pr := gated[key]; pr.State == provisions.Retiring && pr.Warrant != nil && pr.Warrant.Option == "yes" { + want[key] = false + if _, held := entries[key]; held { + out = append(out, p.letGo(ctx, key, entries[key])...) + } + } + } + return out, nil +} + +// consumerOf is the `.` an entry's key starts with: its first two parts. +func consumerOf(key string) string { + parts := strings.SplitN(key, ".", 3) + if len(parts) < 3 { + return key + } + return parts[0] + "." + parts[1] +} + +// remove deletes an entry this instance read as held, and only while it is still as read when the Bus can say so. +func (p *Provider) remove(ctx context.Context, key string, held []byte) error { + if c, ok := p.opts.Bus.(ConditionalBus); ok { + return c.DeleteIf(ctx, BucketOf(p.opts.Module), key, held) + } + return p.opts.Bus.Delete(ctx, BucketOf(p.opts.Module), key) +} + +// answer makes one bind's entry and writes it when it differs from what the bucket holds. +func (p *Provider) answer(ctx context.Context, e Entry, have []byte) Outcome { + key := e.Key() + offer, offered := p.opts.Offers[e.Provision] + switch { + case !offered: + e.Refused = fmt.Sprintf("%s on %s is bound to %s on %s for %s, which %s does not serve", e.Module, + e.Consumer, e.ProviderModule, e.Provider, e.Provision, e.ProviderModule) + case e.Local != "" && derives(offer.Serves): + e.Refused = fmt.Sprintf("%s on %s keeps several holders of %s (this one is %q), and %s on %s derives %s for "+ + "each consumer from its login: each holder has a login of its own and a consumer is told one value per "+ + "requirement, so the two ends would name different things (novox/hq ADR 0202 §10, ADR 0349 clause 12)", + e.Module, e.Consumer, e.Provision, e.Local, e.ProviderModule, e.Provider, + strings.Join(derivedKeys(offer.Serves), ", ")) + } + if e.Refused != "" { + e.State, e.Why = provisions.Failed, e.Refused + return p.write(ctx, e, have, Outcome{Key: key, Status: Refused, Why: e.Refused}) + } + + // The plan's identity is the controller's, slug first; Identity only answers for an entry without one. + if e.As == "" { + e.As = p.opts.Identity(e.Consumer, e.Module) + } + values, err := ServedTo(offer.Serves, e.As) + if err != nil { + e.Refused = fmt.Sprintf("%s on %s serving %s to %s on %s: %v", e.ProviderModule, e.Provider, e.Provision, + e.Module, e.Consumer, err) + e.As = "" + e.State, e.Why = provisions.Failed, e.Refused + return p.write(ctx, e, have, Outcome{Key: key, Status: Refused, Why: e.Refused}) + } + if e.At != "" || e.From != "" { + if values == nil { + values = map[string]any{} + } + if e.At != "" { + values["at"] = e.At + } + if e.From != "" { + values["from"] = e.From + } + } + if len(e.Names) > 0 { + if values == nil { + values = map[string]any{} + } + for key, name := range e.Names { + values[key] = name + } + } + e.Values = values + var credential []byte + // The plan's form wins; the offer's is the default. + // An offer that declares no credential has none whatever the plan says: the controller writes "pair" for it. + form := e.CredentialForm + if offer.Credential == NoCredential { + form = "" + } else if form == "" { + form = map[CredentialForm]string{Shared: "shared", PerConsumer: "pair"}[offer.Credential] + } + switch form { + case "shared": + e.User = offer.User + e.VaultName = SharedName(e.Provider, e.Provision) + case "pair": + e.User = e.As + e.VaultName = PairName(e.Provider, e.Provision, e.Consumer, e.Module, e.Local) + case "issued", "given": + // A per-holder value, as mesh-vault names it (perholder.go). + e.User = e.As + e.VaultName = HolderName(e.Provider, e.Provision, e.Consumer, e.Module) + } + if e.VaultName != "" { + credential, err = p.opts.Vault.Value(ctx, e.VaultName) + if err != nil { + return Outcome{Key: key, Status: Waiting, Why: fmt.Sprintf("the vault's %s: %v", e.VaultName, err)} + } + } + if p.opts.Make != nil { + if err := p.opts.Make(ctx, Consumer{Entry: e, Credential: credential}); errors.Is(err, ErrNotReady) { + // **Not ready is no failure** (the regression of 2026-10-30: gitea after a restart, its keys not read yet, + // stopped serving every consumer): the entry says waits with why, and keeps what the last entry served, so a + // consumer goes on with it until the provider can make it again. + why := "the provider is not ready to make it yet: " + redact(err.Error(), credential) + waiting := e + var prev Entry + if len(have) > 0 && json.Unmarshal(have, &prev) == nil && prev.State == provisions.Provided { + waiting.Values, waiting.VaultName, waiting.User = prev.Values, prev.VaultName, prev.User + } else { + waiting.Values, waiting.VaultName, waiting.User = nil, "", "" + } + waiting.State, waiting.Why = provisions.Waits, why + p.write(ctx, waiting, have, Outcome{Key: key, Status: Waiting, Why: why}) + return Outcome{Key: key, Status: Waiting, Why: why} + } else if err != nil { + why := "the provider could not make it: " + redact(err.Error(), credential) + // Said in its entry, which the holder of mesh-provisioner reads (ADR 0570 clause 3), with nothing served. + failed := e + failed.Values, failed.VaultName, failed.User, failed.State, failed.Why = nil, "", "", provisions.Failed, why + p.write(ctx, failed, have, Outcome{Key: key, Status: Failed, Why: why}) + return Outcome{Key: key, Status: Failed, Why: why} + } + } + e.State = provisions.Provided + return p.write(ctx, e, have, Outcome{Key: key, Status: Answered}) +} + +// redact takes a credential out of text in the forms it can come back in: as it is, in base64 (standard and URL, +// padded and not) and as a JSON string says it (escapes included). +func redact(text string, credential []byte) string { + if len(credential) == 0 { + return text + } + quoted, _ := json.Marshal(string(credential)) + forms := []string{string(credential), base64.StdEncoding.EncodeToString(credential), + base64.RawStdEncoding.EncodeToString(credential), base64.URLEncoding.EncodeToString(credential), + base64.RawURLEncoding.EncodeToString(credential), string(quoted[1 : len(quoted)-1])} + sort.SliceStable(forms, func(i, j int) bool { return len(forms[i]) > len(forms[j]) }) + for _, f := range forms { + text = strings.ReplaceAll(text, f, "") + } + return text +} + +func (p *Provider) write(ctx context.Context, e Entry, have []byte, o Outcome) Outcome { + body, err := json.Marshal(e) + if err != nil { + return Outcome{Key: o.Key, Status: Failed, Why: err.Error()} + } + if sameJSON(have, body) { + return o + } + if err := p.opts.Bus.Put(ctx, BucketOf(p.opts.Module), o.Key, body); err != nil { + return Outcome{Key: o.Key, Status: Failed, Why: "its entry could not be written: " + err.Error()} + } + return o +} + +// say logs what a key's pass said when it differs from the last. +func (p *Provider) say(key, what string) { + p.mu.Lock() + same := p.said[key] == what + p.said[key] = what + p.mu.Unlock() + if !same { + p.opts.Logf("%s: %s", key, strings.TrimSpace(what)) + } +} + +// SharedName is the vault's one value per provider and provision (ADR 0158, issue 677). +func SharedName(provider, provision string) string { + return provisions.VaultName(provisions.FormShared, provisions.Provision{Provision: provision, Provider: provisions.End{Node: provider}}) +} + +// HolderName is the vault's value of one holder's own credential, issued or given for it (mesh-vault perholder.go). +func HolderName(provider, provision, node, module string) string { + return provisions.VaultName(provisions.FormIssued, provisions.Provision{Provision: provision, + Provider: provisions.End{Node: provider}, Consumer: provisions.End{Node: node, Module: module}}) +} + +// PairName is the vault's value of one consumer's own login (ADR 0048, ADR 0158 §11, issue 677's note 12). +func PairName(provider, provision, node, module, local string) string { + return provisions.VaultName(provisions.FormPair, provisions.Provision{Provision: provision, Local: local, + Provider: provisions.End{Node: provider}, Consumer: provisions.End{Node: node, Module: module}}) +} + +// identityUnusable is every character a consumer's identity does not hold (mesh-controller internal/catalogue +// identity.go, the one derivation). +var identityUnusable = regexp.MustCompile(`[^a-z0-9_]+`) + +// ConsumerIdentity is what one module on one machine is called wherever it authenticates: `mesh__`, +// lower case, anything but letters, digits and `_` written `_` (mesh-controller ConsumerIdentity). module is the +// identity source: the module's slug when it declares one, otherwise its name (ADR 0049). +func ConsumerIdentity(node, module string) string { + clean := func(s string) string { + return strings.Trim(identityUnusable.ReplaceAllString(strings.ToLower(s), "_"), "_") + } + return "mesh_" + clean(node) + "_" + clean(module) +} + +// consumerFact is `${consumer:}` or `${consumer::}`. +var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`) + +// ServedTo fills an offer's served values for one consumer whose identity is as (ADR 0202 §1): `${consumer:as}` as +// it is, `${consumer:as:dns}` with `-` for `_`. Only strings carry placeholders; any other fact or alphabet is +// refused, never left standing. +func ServedTo(serves map[string]any, as string) (map[string]any, error) { + if serves == nil { + return nil, nil + } + out := make(map[string]any, len(serves)) + for _, key := range sortedKeys(serves) { + text, ok := serves[key].(string) + if !ok || !strings.Contains(text, "${consumer:") { + out[key] = serves[key] + continue + } + var failed error + out[key] = consumerFact.ReplaceAllStringFunc(text, func(match string) string { + parts := consumerFact.FindStringSubmatch(match) + switch { + case parts[1] == "as" && parts[2] == "": + return as + case parts[1] == "as" && parts[2] == "dns": + return strings.ReplaceAll(as, "_", "-") + } + if failed == nil { + failed = fmt.Errorf("the value served as %q says %s, and the mesh states only ${consumer:as} and "+ + "${consumer:as:dns}", key, match) + } + return match + }) + if failed != nil { + return nil, failed + } + } + return out, nil +} + +func derives(serves map[string]any) bool { return len(derivedKeys(serves)) > 0 } + +func derivedKeys(serves map[string]any) []string { + var out []string + for _, k := range sortedKeys(serves) { + if text, ok := serves[k].(string); ok && strings.Contains(text, "${consumer:") { + out = append(out, k) + } + } + return out +} + +func clone(m map[string][]byte) map[string][]byte { + out := make(map[string][]byte, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +func sameJSON(a, b []byte) bool { + if a == nil || b == nil { + return false + } + var x, y any + if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil { + return false + } + ja, _ := json.Marshal(x) + jb, _ := json.Marshal(y) + return string(ja) == string(jb) +} + +func sortedKeys[V any](m map[string]V) []string { + if len(m) == 0 { + return nil + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// StateBus is the Bus of a provider that runs as a module bundle over the runtime's stdio: the provider's own bucket, +// BucketOf(module), is the module's state `bound`, and the provisioner holder's plans and provisions are the +// states it reads, `.plans` and `.provisions` (each declared in the module's manifest). +// Any other bucket is refused. +// +// A stdio state watch lasts as long as the process and cannot be ended (stdio.KeptState.Watch), so Watch honours the +// Bus contract by silencing instead: once ctx has ended the handler is never called again, though the runtime keeps +// sending changes that are dropped. A Pass, which watches twice per call, is therefore correct, only the dropped +// changes of its two spent watches remain. +func StateBus(module, provisioner string) Bus { + return stateBus{module: module, provisioner: provisioner} +} + +type stateBus struct{ module, provisioner string } + +func (b stateBus) state(bucket string) (stdio.KeptState, error) { + switch bucket { + case BucketOf(b.module): + return stdio.State("bound"), nil + case provisions.PlansBucketOf(b.provisioner): + return stdio.State(provisions.PlansReadOf(b.provisioner)), nil + case provisions.ProvisionsBucketOf(b.provisioner): + return stdio.State(provisions.ProvisionsReadOf(b.provisioner)), nil + } + return stdio.KeptState{}, fmt.Errorf("%s is not a bucket this provider keeps or reads", bucket) +} + +func (b stateBus) Watch(ctx context.Context, bucket string, handle func(key string, value []byte)) error { + s, err := b.state(bucket) + if err != nil { + return err + } + return s.Watch("", func(ch stdio.StateChange) error { + if ctx.Err() != nil { + return nil + } + if ch.Op == "delete" { + handle(ch.Key, nil) + } else { + handle(ch.Key, ch.Value) + } + return nil + }) +} + +func (b stateBus) Put(_ context.Context, bucket, key string, value []byte) error { + s, err := b.state(bucket) + if err != nil { + return err + } + _, err = s.Put(key, json.RawMessage(value)) + return err +} + +// DeleteIf removes the key only while it still holds what the caller read; it reads the key first, so another +// writer's change in that short while can still be lost. +func (b stateBus) DeleteIf(_ context.Context, bucket, key string, held []byte) error { + s, err := b.state(bucket) + if err != nil { + return err + } + cur, err := s.Get(key) + if err != nil { + return err + } + if cur == nil { + return nil + } + if !sameJSON(cur.Value, held) { + return fmt.Errorf("%s is not as it was read: %w", key, stdio.ErrChanged) + } + return s.Delete(key) +} + +func (b stateBus) Delete(_ context.Context, bucket, key string) error { + s, err := b.state(bucket) + if err != nil { + return err + } + return s.Delete(key) +} + +// Standing is whether an assignment's value is a standing assignment: false when `ended` is set (the module was +// taken off its node, novox/hq ADR 0408 clause 4) or `state` is not empty (a pending one). Such a value is read +// exactly as a removed key. Every other field, known or not, changes nothing (issue 1098). +func Standing(value []byte) bool { + var v struct { + Ended json.RawMessage `json:"ended"` + State json.RawMessage `json:"state"` + } + if json.Unmarshal(value, &v) != nil { + return true // not decided here: the parse that follows refuses it + } + return !fieldSet(v.Ended) && !fieldSet(v.State) +} + +// fieldSet is whether a field carries something: not absent, null or empty. +func fieldSet(f json.RawMessage) bool { + s := strings.TrimSpace(string(f)) + return s != "" && s != "null" && s != `""` +} + +// letGo lets a consumer's entry go: the provider hears it gone (Gone, which retires what it kept for the consumer), and +// the entry is removed while it is still as read. +func (p *Provider) letGo(ctx context.Context, key string, held []byte) []Outcome { + var e Entry + if err := json.Unmarshal(held, &e); err != nil || e.Provider != p.opts.Node || e.ProviderModule != p.opts.Module { + return nil // another machine's instance of this provider wrote it, or nothing of this helper's + } + said := func(o Outcome) []Outcome { + p.say(key, o.Status+" "+o.Why) + return []Outcome{o} + } + if p.opts.Gone != nil { + if err := p.opts.Gone(ctx, e); err != nil { + return said(Outcome{Key: key, Status: Failed, Why: "no longer bound, and the provider could not let it go: " + err.Error()}) + } + } + if err := p.remove(ctx, key, held); err != nil { + if errors.Is(err, stdio.ErrChanged) { + return said(Outcome{Key: key, Status: Superseded, Why: "another instance of the provider wrote its entry " + + "since this pass read it, so it stands"}) + } + return said(Outcome{Key: key, Status: Failed, Why: "no longer bound, and its entry could not be removed: " + err.Error()}) + } + p.say(key, Removed) + p.mu.Lock() + delete(p.said, key) + p.mu.Unlock() + return []Outcome{{Key: key, Status: Removed}} +} + +// ready says whether the provision under key may be made now (novox/hq ADR 0570 clause 3): its key is on the seat, it is +// asked or provided, and every provision it depends on is provided. Otherwise why it waits, naming the key or the +// dependency. +func (p *Provider) ready(key string, mine map[string]provisions.Provision, all map[string][]byte) (string, bool) { + pr, ok := mine[key] + if !ok { + return "mesh-provisioner holds no key " + key + " naming this provider yet", false + } + if pr.State != provisions.Asked && pr.State != provisions.Provided { + return "its key is " + pr.State + ": " + pr.Why, false + } + for _, d := range pr.DependsOn { + dep, err := provisions.Decode(all[d]) + if err != nil { + return "it depends on " + d + ", which mesh-provisioner holds no readable key of", false + } + if dep.State != provisions.Provided { + return "it depends on " + d + ", which is " + dep.State, false + } + } + return "", true +} diff --git a/provisioning/go/clients/meshprovisionerclient/provider/provider_test.go b/provisioning/go/clients/meshprovisionerclient/provider/provider_test.go new file mode 100644 index 0000000..5a6365e --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/provider/provider_test.go @@ -0,0 +1,306 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "slices" + "strings" + "sync" + "testing" + "time" + + provisions "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// standIn is a stand-in bus: buckets of keys and values, and watches that hand the current values first and then +// every change, as the bus's key-value watch does. +type standIn struct { + mu sync.Mutex + buckets map[string]map[string][]byte + watchers map[string][]func(string, []byte) +} + +func newStandIn() *standIn { + return &standIn{buckets: map[string]map[string][]byte{}, watchers: map[string][]func(string, []byte){}} +} + +func (s *standIn) Watch(ctx context.Context, bucket string, handle func(key string, value []byte)) error { + s.mu.Lock() + current := map[string][]byte{} + for k, v := range s.buckets[bucket] { + current[k] = v + } + s.watchers[bucket] = append(s.watchers[bucket], handle) + s.mu.Unlock() + for _, k := range sortedKeys(current) { + handle(k, current[k]) + } + return nil +} + +func (s *standIn) Put(_ context.Context, bucket, key string, value []byte) error { + s.mu.Lock() + if s.buckets[bucket] == nil { + s.buckets[bucket] = map[string][]byte{} + } + s.buckets[bucket][key] = append([]byte(nil), value...) + ws := slices.Clone(s.watchers[bucket]) + s.mu.Unlock() + for _, w := range ws { + w(key, value) + } + return nil +} + +func (s *standIn) Delete(_ context.Context, bucket, key string) error { + s.mu.Lock() + delete(s.buckets[bucket], key) + ws := slices.Clone(s.watchers[bucket]) + s.mu.Unlock() + for _, w := range ws { + w(key, nil) + } + return nil +} + +// DeleteIf removes the key only while it still holds what the caller read. +func (s *standIn) DeleteIf(ctx context.Context, bucket, key string, held []byte) error { + s.mu.Lock() + cur := s.buckets[bucket][key] + s.mu.Unlock() + if !bytes.Equal(cur, held) { + return fmt.Errorf("%s: %w", key, stdio.ErrChanged) + } + return s.Delete(ctx, bucket, key) +} + +func (s *standIn) get(bucket, key string) []byte { + s.mu.Lock() + defer s.mu.Unlock() + return s.buckets[bucket][key] +} + +func (s *standIn) keys(bucket string) []string { + s.mu.Lock() + defer s.mu.Unlock() + return sortedKeys(s.buckets[bucket]) +} + +// everything is every value the bus holds, for a test that no secret is in a bucket. +func (s *standIn) everything() []byte { + s.mu.Lock() + defer s.mu.Unlock() + var all []byte + for _, b := range s.buckets { + for k, v := range b { + all = append(all, k...) + all = append(all, v...) + } + } + return all +} + +// vault is a stand-in vault: a value per name, or nothing yet. +type vault struct { + mu sync.Mutex + values map[string]string + asked []string +} + +func (v *vault) Value(_ context.Context, name string) ([]byte, error) { + v.mu.Lock() + defer v.mu.Unlock() + v.asked = append(v.asked, name) + val, ok := v.values[name] + if !ok { + return nil, fmt.Errorf("the vault holds no %s yet", name) + } + return []byte(val), nil +} + +// maker records what the provider's adapter was asked to make, with the credential it was handed. +type maker struct { + mu sync.Mutex + made map[string]string + // contributed is what each consumer's entry carried of its plan's contribution. + contributed map[string]map[string]any + // as is the identity each consumer's entry was made under. + as map[string]string + gone []string + fail error + calls int +} + +func (m *maker) Make(_ context.Context, c Consumer) error { + m.mu.Lock() + defer m.mu.Unlock() + m.calls++ + if m.fail != nil { + return m.fail + } + if m.made == nil { + m.made = map[string]string{} + } + m.made[c.Entry.Key()] = string(c.Credential) + if m.contributed == nil { + m.contributed = map[string]map[string]any{} + } + m.contributed[c.Entry.Key()] = c.Entry.Contributes + if m.as == nil { + m.as = map[string]string{} + } + m.as[c.Entry.Key()] = c.Entry.As + return nil +} + +func (m *maker) Gone(_ context.Context, e Entry) error { + m.mu.Lock() + defer m.mu.Unlock() + m.gone = append(m.gone, e.Key()) + return nil +} + +// assign puts a consumer's plan with its provisions on the seat mesh-provisioner, each provision's key provided, as its +// holder writes them (novox/hq ADR 0570 clauses 1, 2): what a provider binds by. +func assign(t *testing.T, bus *standIn, node, module string, binds ...map[string]string) { + t.Helper() + var ps []catalogue.PlanProvision + for _, b := range binds { + raw, _ := json.Marshal(b) + var pp catalogue.PlanProvision + if err := json.Unmarshal(raw, &pp); err != nil { + t.Fatal(err) + } + ps = append(ps, pp) + } + planned(t, bus, node, module, ps...) +} + +// planned puts a consumer's plan of these provisions on the seat, each provision's key provided. +func planned(t *testing.T, bus *standIn, node, module string, ps ...catalogue.PlanProvision) { + t.Helper() + for _, pp := range ps { + pr := provisions.Provision{Provision: pp.Provision, Local: pp.Local, Consumer: provisions.End{Module: module, Node: node}, + Provider: provisions.End{Module: pp.ProviderModule, Node: pp.Provider}, State: provisions.Provided} + key, err := pr.Key() + if err != nil { + t.Fatal(err) + } + value, err := provisions.Encode(pr) + if err != nil { + t.Fatal(err) + } + _ = bus.Put(context.Background(), provisions.ProvisionsBucketOf(testProvisioner), key, value) + } + raw, err := provisions.EncodePlan(provisions.Plan{Node: node, Module: module, Plan: catalogue.Plan{Provisions: ps}}) + if err != nil { + t.Fatal(err) + } + if err := bus.Put(context.Background(), provisions.PlansBucketOf(testProvisioner), node+"."+module, raw); err != nil { + t.Fatal(err) + } +} + +// retire marks a consumer's provision retiring, with the operator's yes (novox/hq ADR 0570 clause 5): the one way a +// provider lets a consumer go. +func retire(t *testing.T, bus *standIn, node, module, provision, provider, providerModule string) { + t.Helper() + pr := provisions.Provision{Provision: provision, Consumer: provisions.End{Module: module, Node: node}, + Provider: provisions.End{Module: providerModule, Node: provider}, State: provisions.Retiring, Why: module + " left " + node, + Warrant: &provisions.Warrant{Ask: "a-1", Option: "yes", By: "the operator"}} + key, _ := pr.Key() + raw, err := provisions.Encode(pr) + if err != nil { + t.Fatal(err) + } + _ = bus.Delete(context.Background(), provisions.PlansBucketOf(testProvisioner), node+"."+module) + _ = bus.Put(context.Background(), provisions.ProvisionsBucketOf(testProvisioner), key, raw) +} + +func bind(provision, local, provider, providerModule string) map[string]string { + b := map[string]string{"provision": provision, "provider": provider, "provider-module": providerModule} + if local != "" { + b["local"] = local + } + return b +} + +func entryOf(t *testing.T, bus *standIn, key string) Entry { + t.Helper() + raw := bus.get(BucketOf("postgres"), key) + if raw == nil { + t.Fatalf("no entry %s in %s; it holds %v", key, BucketOf("postgres"), bus.keys(BucketOf("postgres"))) + } + var e Entry + if err := json.Unmarshal(raw, &e); err != nil { + t.Fatalf("entry %s: %v", key, err) + } + return e +} + +// postgres is a provider of postgres-database on novox with a login per consumer (ADR 0048, ADR 0158 §11), its +// served values derived from each consumer's identity (ADR 0202 §1). +func postgres(bus *standIn, v *vault, m *maker) *Provider { + return New(Options{Provisioner: testProvisioner, + Node: "novox", Module: "postgres", Bus: bus, Vault: v, Make: m.Make, Gone: m.Gone, + Offers: map[string]Offer{ + "postgres-database": {Credential: PerConsumer, Serves: map[string]any{ + "host": "postgres.novox.mesh", "port": 5432, "database": "${consumer:as}"}}, + }, + Logf: func(string, ...any) {}, + }) +} + +func waitFor(t *testing.T, ok func() bool) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for !ok() { + if time.Now().After(deadline) { + t.Fatal("not within 3s") + } + time.Sleep(5 * time.Millisecond) + } +} + +// refusedByTheStateGuard mirrors the key rule of the runtime's state guard (mesh-tools node-tools internal/bus +// state.go): a JSON key whose lowercased name, with `-`, `_` and spaces taken out, ends in one of these is refused. +func refusedByTheStateGuard(key string) bool { + k := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(key)) + for _, end := range []string{"password", "passwd", "secret", "token", "credential", "credentials", + "authorization", "apikey", "privatekey", "accesskey", "cookie"} { + if strings.HasSuffix(k, end) { + return true + } + } + return false +} + +func keysOf(v any, into *[]string) { + switch x := v.(type) { + case map[string]any: + for k, c := range x { + *into = append(*into, k) + keysOf(c, into) + } + case []any: + for _, c := range x { + keysOf(c, into) + } + } +} + +// testProvisioner is the module holding the seat mesh-provisioner in these tests (a reader names it from its manifest). +const testProvisioner = "mesh-provisioner" + +// A provider that does not say which module holds the seat mesh-provisioner reads no plans, and says so at its start: the +// client names no holder, so a reader that forgot to give it is loud (ADR 0625 §4). +func TestAProviderWithoutTheHoldersNameDoesNotRun(t *testing.T) { + p := New(Options{Node: "novox", Module: "postgres", Bus: &standIn{}, Logf: func(string, ...any) {}}) + if err := p.Run(context.Background()); err == nil || !strings.Contains(err.Error(), "mesh-provisioner") { + t.Errorf("Run = %v", err) + } +} diff --git a/provisioning/go/clients/meshprovisionerclient/provider/statebus_test.go b/provisioning/go/clients/meshprovisionerclient/provider/statebus_test.go new file mode 100644 index 0000000..9475411 --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/provider/statebus_test.go @@ -0,0 +1,54 @@ +package provider + +import ( + "bufio" + "encoding/json" + "io" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// runtime drives a bundle over pipes as the runtime does: it reads what the bundle says and sends what the runtime would. +type runtime struct { + t *testing.T + lines *bufio.Scanner + in io.Writer +} + +func (r *runtime) next() map[string]any { + done := make(chan map[string]any, 1) + go func() { + if r.lines.Scan() { + var m map[string]any + _ = json.Unmarshal(r.lines.Bytes(), &m) + done <- m + } + }() + select { + case m := <-done: + return m + case <-time.After(3 * time.Second): + r.t.Fatal("the bundle said nothing") + return nil + } +} + +func (r *runtime) send(m map[string]any) { + b, _ := json.Marshal(m) + _, _ = r.in.Write(append(b, '\n')) +} + +func (r *runtime) call(id int, tool string, args map[string]any) { + r.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": map[string]any{"name": tool, "arguments": args}}) +} + +func serve(t *testing.T, tools []stdio.Tool) *runtime { + t.Setenv(stdio.ServedAs, "keeper") + inR, inW := io.Pipe() + outR, outW := io.Pipe() + go func() { _ = stdio.ServeOn("", tools, inR, outW) }() + t.Cleanup(func() { _ = inW.Close() }) + return &runtime{t: t, lines: bufio.NewScanner(outR), in: inW} +} diff --git a/provisioning/go/clients/meshprovisionerclient/provider/tables_test.go b/provisioning/go/clients/meshprovisionerclient/provider/tables_test.go new file mode 100644 index 0000000..98f94f5 --- /dev/null +++ b/provisioning/go/clients/meshprovisionerclient/provider/tables_test.go @@ -0,0 +1,909 @@ +package provider + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + "time" + + provisions "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// TestEntries groups the cases below, each one a subtest. +func TestEntries(t *testing.T) { + // A provider answers each consumer that binds it with an entry of its own in its own bucket: composed from its + // served values and the consumer's identity, naming the credential the vault keeps for that consumer, and never + // the credential itself; its adapter is handed each consumer's own value read from the vault. + t.Run("EachConsumerGetsAnEntryOfItsOwnAndTheVaultsValue", func(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{ + "pair/novox/postgres-database/ace/letta": "letta-secret-1", + "pair/novox/postgres-database/g14/umami": "umami-secret-2", + }} + assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) + assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres")) + p := postgres(bus, v, m) + out, err := p.Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + if got := bus.keys(BucketOf("postgres")); !slices.Equal(got, []string{"ace.letta.postgres-database", + "g14.umami.postgres-database"}) { + t.Fatalf("entries %v", got) + } + e := entryOf(t, bus, "ace.letta.postgres-database") + if e.Provider != "novox" || e.ProviderModule != "postgres" || e.Consumer != "ace" || e.Module != "letta" || + e.As != "mesh_ace_letta" || e.User != "mesh_ace_letta" || + e.VaultName != "pair/novox/postgres-database/ace/letta" || e.Refused != "" { + t.Fatalf("entry %+v", e) + } + if e.Values["database"] != "mesh_ace_letta" || e.Values["host"] != "postgres.novox.mesh" || + e.Values["port"] != float64(5432) { + t.Fatalf("values %v", e.Values) + } + if m.made["ace.letta.postgres-database"] != "letta-secret-1" || + m.made["g14.umami.postgres-database"] != "umami-secret-2" { + t.Fatalf("made %v", m.made) + } + if bytes.Contains(bus.everything(), []byte("secret-")) { + t.Fatal("a credential is in a bucket") + } + for _, o := range out { + if o.Status != Answered { + t.Fatalf("outcome %+v", o) + } + } + }) + + // A provider whose offer shares one credential (ADR 0158) names the vault's one value for the provision, and its + // one user name, to every consumer. + t.Run("ASharedCredentialIsTheVaultsOneValueAndTheProvidersOneUser", func(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"provision/novox/sonarr-api": "one-value"}} + assign(t, bus, "ace", "overseerr", bind("sonarr-api", "", "novox", "sonarr")) + assign(t, bus, "g14", "homepage", bind("sonarr-api", "", "novox", "sonarr")) + p := New(Options{Provisioner: testProvisioner, Node: "novox", Module: "sonarr", Bus: bus, Vault: v, Make: m.Make, Logf: func(string, ...any) {}, + Offers: map[string]Offer{"sonarr-api": {Credential: Shared, User: "sonarr", + Serves: map[string]any{"url": "http://sonarr.novox.mesh:8989"}}}}) + if _, err := p.Pass(context.Background()); err != nil { + t.Fatal(err) + } + for _, key := range []string{"ace.overseerr.sonarr-api", "g14.homepage.sonarr-api"} { + var e Entry + if err := json.Unmarshal(bus.get(BucketOf("sonarr"), key), &e); err != nil { + t.Fatalf("%s: %v", key, err) + } + if e.VaultName != "provision/novox/sonarr-api" || e.User != "sonarr" || e.Values["url"] == nil { + t.Fatalf("%s: %+v", key, e) + } + if m.made[key] != "one-value" { + t.Fatalf("%s made with %q", key, m.made[key]) + } + } + if bytes.Contains(bus.everything(), []byte("one-value")) { + t.Fatal("the shared credential is in a bucket") + } + }) + + // A consumer that keeps several holders of one provision is served no derived value: its entry refuses it, naming + // both ends, so its client can report it (ADR 0349 clause 12, ADR 0202 §10); nothing is made for it. + t.Run("AMultiHolderConsumerIsRefusedADerivedValueNamingBothEnds", func(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta/archive": "x-secret"}} + assign(t, bus, "ace", "letta", bind("postgres-database", "archive", "novox", "postgres")) + out, err := postgres(bus, v, m).Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + e := entryOf(t, bus, "ace.letta.postgres-database.archive") + for _, says := range []string{"letta on ace", "postgres on novox", "postgres-database", "archive"} { + if !strings.Contains(e.Refused, says) { + t.Fatalf("the refusal does not name %q: %s", says, e.Refused) + } + } + if e.Values != nil || e.VaultName != "" { + t.Fatalf("a refused entry carries values: %+v", e) + } + if m.calls != 0 { + t.Fatal("the adapter made a refused consumer") + } + if len(out) != 1 || out[0].Status != Refused { + t.Fatalf("outcomes %+v", out) + } + }) + + // A consumer bound under a local name is served when the offer derives nothing from it: its credential is its own + // pair row under that local name. + t.Run("ALocalNameIsServedWhenNothingIsDerived", func(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"pair/novox/redis-cache/ace/letta/queue": "q-secret"}} + assign(t, bus, "ace", "letta", bind("redis-cache", "queue", "novox", "redis")) + p := New(Options{Provisioner: testProvisioner, Node: "novox", Module: "redis", Bus: bus, Vault: v, Make: m.Make, Logf: func(string, ...any) {}, + Offers: map[string]Offer{"redis-cache": {Credential: PerConsumer, Serves: map[string]any{"port": 6379}}}}) + if _, err := p.Pass(context.Background()); err != nil { + t.Fatal(err) + } + var e Entry + if err := json.Unmarshal(bus.get(BucketOf("redis"), "ace.letta.redis-cache.queue"), &e); err != nil { + t.Fatal(err) + } + if e.Refused != "" || e.VaultName != "pair/novox/redis-cache/ace/letta/queue" || e.Local != "queue" { + t.Fatalf("entry %+v", e) + } + if m.made["ace.letta.redis-cache.queue"] != "q-secret" { + t.Fatalf("made %v", m.made) + } + }) + + // A consumer's plan entry may carry what its module contributes to the provider (issue 776): the helper hands it to + // the provider's adapter with the consumer, and does not write it to the bucket. + t.Run("AConsumersContributionIsHandedToTheProviderPerConsumer", func(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{ + "pair/novox/postgres-database/ace/nextcloud": "a", "pair/novox/postgres-database/g14/umami": "b"}} + var pp catalogue.PlanProvision + if err := json.Unmarshal([]byte(`{"provision":"postgres-database","provider":"novox","provider-module":"postgres",`+ + `"contributes":{"name":"nextcloud","extensions":["pg_trgm"]}}`), &pp); err != nil { + t.Fatal(err) + } + planned(t, bus, "ace", "nextcloud", pp) + assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres")) + if _, err := postgres(bus, v, m).Pass(context.Background()); err != nil { + t.Fatal(err) + } + want := map[string]any{"name": "nextcloud", "extensions": []any{"pg_trgm"}} + if got := m.contributed["ace.nextcloud.postgres-database"]; !reflect.DeepEqual(got, want) { + t.Fatalf("the adapter was handed the contribution %v, want %v", got, want) + } + if got := m.contributed["g14.umami.postgres-database"]; got != nil { + t.Fatalf("a consumer contributing nothing was handed %v", got) + } + if bytes.Contains(bus.get(BucketOf("postgres"), "ace.nextcloud.postgres-database"), []byte("pg_trgm")) { + t.Fatal("a contribution is in the provider's bucket") + } + }) + + // A consumer's plan entry carries where its provider is, which machine, and who the consumer is (issue 788): the + // entry's values carry `at` and `from` as the binding file gives them, and its `as` is the plan's, so a module with a + // slug keeps the login it has today. An entry of a plan without them is as it was. + t.Run("AConsumersEntryCarriesAtFromAndTheAsOfItsPlan", func(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{ + "pair/novox/postgres-database/ace/claude-code": "a", "pair/novox/postgres-database/g14/umami": "b"}} + var pp catalogue.PlanProvision + if err := json.Unmarshal([]byte(`{"provision":"postgres-database","provider":"novox","provider-module":"postgres",`+ + `"at":"10.7.0.1","from":"novox","as":"mesh_ace_agent"}`), &pp); err != nil { + t.Fatal(err) + } + planned(t, bus, "ace", "claude-code", pp) + assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres")) + if _, err := postgres(bus, v, m).Pass(context.Background()); err != nil { + t.Fatal(err) + } + var e Entry + if err := json.Unmarshal(bus.get(BucketOf("postgres"), "ace.claude-code.postgres-database"), &e); err != nil { + t.Fatal(err) + } + if e.Values["at"] != "10.7.0.1" || e.Values["from"] != "novox" || e.Values["port"] == nil { + t.Fatalf("the entry's values are %v", e.Values) + } + if e.As != "mesh_ace_agent" || e.User != "mesh_ace_agent" || m.as["ace.claude-code.postgres-database"] != "mesh_ace_agent" { + t.Fatalf("the entry is %+v, made as %q", e, m.as["ace.claude-code.postgres-database"]) + } + var plain Entry + if err := json.Unmarshal(bus.get(BucketOf("postgres"), "g14.umami.postgres-database"), &plain); err != nil { + t.Fatal(err) + } + if _, has := plain.Values["at"]; has || plain.As != "mesh_g14_umami" { + t.Fatalf("an entry of a plan without them changed: %+v", plain) + } + }) + + // A consumer's plan entry may carry the names the controller composed for it (issue 792): each is copied into the + // entry's values under its key, over anything the provider serves under it. An entry without them is as it was. + t.Run("AConsumersEntryCarriesTheNamesOfItsPlan", func(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{ + "pair/novox/postgres-database/ace/gitea": "a", "pair/novox/postgres-database/g14/umami": "b"}} + var pp catalogue.PlanProvision + if err := json.Unmarshal([]byte(`{"provision":"postgres-database","provider":"novox","provider-module":"postgres",`+ + `"names":{"name":"git.example.org","internal-name":"git.mesh","name-web":"web.example.org"}}`), &pp); err != nil { + t.Fatal(err) + } + planned(t, bus, "ace", "gitea", pp) + assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres")) + if _, err := postgres(bus, v, m).Pass(context.Background()); err != nil { + t.Fatal(err) + } + var e Entry + if err := json.Unmarshal(bus.get(BucketOf("postgres"), "ace.gitea.postgres-database"), &e); err != nil { + t.Fatal(err) + } + if e.Values["name"] != "git.example.org" || e.Values["internal-name"] != "git.mesh" || + e.Values["name-web"] != "web.example.org" || e.Values["port"] == nil { + t.Fatalf("the entry's values are %v", e.Values) + } + var plain Entry + if err := json.Unmarshal(bus.get(BucketOf("postgres"), "g14.umami.postgres-database"), &plain); err != nil { + t.Fatal(err) + } + if _, has := plain.Values["name"]; has { + t.Fatalf("an entry of a plan without names changed: %+v", plain) + } + }) + + // The plan's credential form and holder name win over the offer's; the offer's form is the default (issue 825, + // finding 4). + t.Run("ThePlansCredentialFormWinsOverTheOffers", func(t *testing.T) { + run := func(offer CredentialForm, plan map[string]string) Entry { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "p", + "provision/novox/postgres-database": "s", "provision/novox/postgres-database/ace/letta": "h"}} + b := bind("postgres-database", "", "novox", "postgres") + for k, val := range plan { + b[k] = val + } + assign(t, bus, "ace", "letta", b) + p := New(Options{Provisioner: testProvisioner, Node: "novox", Module: "postgres", Bus: bus, Vault: v, Make: m.Make, Logf: func(string, ...any) {}, + Offers: map[string]Offer{"postgres-database": {Credential: offer, User: "shared-user"}}}) + out, err := p.Pass(context.Background()) + if err != nil || len(out) != 1 || out[0].Status != Answered { + t.Fatalf("%v %+v", err, out) + } + return entryOf(t, bus, "ace.letta.postgres-database") + } + if e := run(PerConsumer, map[string]string{"credential-form": "shared"}); e.VaultName != + "provision/novox/postgres-database" || e.User != "shared-user" { + t.Fatalf("shared over pair: %+v", e) + } + if e := run(Shared, map[string]string{"credential-form": "pair"}); e.VaultName != + "pair/novox/postgres-database/ace/letta" || e.User != "mesh_ace_letta" { + t.Fatalf("pair over shared: %+v", e) + } + for _, form := range []string{"issued", "given"} { + if e := run(PerConsumer, map[string]string{"credential-form": form, "name": "main"}); e.VaultName != + "provision/novox/postgres-database/ace/letta" || e.User != "mesh_ace_letta" { + t.Fatalf("%s holder: %+v", form, e) + } + } + if e := run(Shared, nil); e.VaultName != "provision/novox/postgres-database" { + t.Fatalf("the offer's form is the default: %+v", e) + } + { + // An offer that declares no credential is answered with none whatever form the plan says (the controller writes + // "pair" for such an offer): no vault name, and the Vault is never asked, so a provider with no Vault works (issue 825, + // finding 4; issue 923). + bus := newStandIn() + b := bind("route", "", "novox", "route-proxy") + b["credential-form"] = "pair" + assign(t, bus, "ace", "letta", b) + p := New(Options{Provisioner: testProvisioner, Node: "novox", Module: "route-proxy", Bus: bus, Logf: func(string, ...any) {}, + Offers: map[string]Offer{"route": {Credential: NoCredential}}}) + out, err := p.Pass(context.Background()) + if err != nil || len(out) != 1 || out[0].Status != Answered { + t.Fatalf("%v %+v", err, out) + } + var e Entry + if err := json.Unmarshal(bus.get(BucketOf("route-proxy"), "ace.letta.route"), &e); err != nil || e.VaultName != "" || + e.User != "" { + t.Fatalf("%v %+v", err, e) + } + } + }) + + // Issue 1126 review 1 (mcp-endpoint asked on ace and shanks, 2026-11-02 11:56): a consumer's key composed from its newest + // build carries the plan entry it was composed from, though its wanted build's plan names no such provision; the + // provider binds it from the key and provides it, so the want that waits for it moves. A key of this provider that + // carries no entry and no plan names is said, never bound silently or left without a word. + t.Run("AKeyNoPlanNamesIsBoundFromTheEntryItCarries", func(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "letta-secret-1"}} + // letta's wanted plan names nothing of postgres; its key, from its newest build, carries the entry. + planned(t, bus, "ace", "letta") + entry := catalogue.PlanProvision{Provision: "postgres-database", Provider: "novox", ProviderModule: "postgres", As: "mesh_ace_letta"} + pr := provisions.Provision{Provision: "postgres-database", Consumer: provisions.End{Module: "letta", Node: "ace"}, + Provider: provisions.End{Module: "postgres", Node: "novox"}, State: provisions.Asked, Plan: &entry} + raw, err := provisions.Encode(pr) + if err != nil { + t.Fatal(err) + } + _ = bus.Put(context.Background(), provisions.ProvisionsBucketOf(testProvisioner), "ace.letta.postgres-database", raw) + // And umami's key carries none: asked, but no plan names it. + bare := provisions.Provision{Provision: "postgres-database", Consumer: provisions.End{Module: "umami", Node: "g14"}, + Provider: provisions.End{Module: "postgres", Node: "novox"}, State: provisions.Asked} + raw, _ = provisions.Encode(bare) + _ = bus.Put(context.Background(), provisions.ProvisionsBucketOf(testProvisioner), "g14.umami.postgres-database", raw) + + out, err := postgres(bus, v, m).Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + if e := entryOf(t, bus, "ace.letta.postgres-database"); e.Consumer != "ace" || e.Module != "letta" || e.Refused != "" { + t.Errorf("the entry %+v", e) + } + said := map[string]Outcome{} + for _, o := range out { + said[o.Key] = o + } + if said["ace.letta.postgres-database"].Status != Answered { + t.Errorf("letta: %+v", said["ace.letta.postgres-database"]) + } + if o := said["g14.umami.postgres-database"]; o.Status != Waiting || !strings.Contains(o.Why, "no plan names it") { + t.Errorf("umami: %+v", o) + } + if bus.get(BucketOf("postgres"), "g14.umami.postgres-database") != nil { + t.Error("a key with no entry and no plan was bound") + } + }) +} + +// TestBinds groups the cases below, each one a subtest. +func TestBinds(t *testing.T) { + // A consumer that unbinds loses its entry and the adapter hears it is gone; an entry another machine's instance of + // the same provider wrote is not this one's to remove. + t.Run("AnUnbindRemovesTheEntryAndLeavesAnotherProvidersAlone", func(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s1"}} + assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) + other, _ := json.Marshal(Entry{Provider: "shanks", ProviderModule: "postgres", Provision: "postgres-database", + Consumer: "g14", Module: "gitea"}) + _ = bus.Put(context.Background(), BucketOf("postgres"), "g14.gitea.postgres-database", other) + p := postgres(bus, v, m) + if _, err := p.Pass(context.Background()); err != nil { + t.Fatal(err) + } + retire(t, bus, "ace", "letta", "postgres-database", "novox", "postgres") + out, err := p.Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + if got := bus.keys(BucketOf("postgres")); !slices.Equal(got, []string{"g14.gitea.postgres-database"}) { + t.Fatalf("entries left %v", got) + } + if !slices.Equal(m.gone, []string{"ace.letta.postgres-database"}) { + t.Fatalf("gone %v", m.gone) + } + if len(out) != 1 || out[0].Status != Removed { + t.Fatalf("outcomes %+v", out) + } + }) + + // Only the binds naming this provider, on this machine, are its own; a bind naming it for a provision it does not + // offer is refused, naming both ends. + t.Run("OnlyBindsNamingThisProviderAreAnswered", func(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s"}} + assign(t, bus, "ace", "letta", bind("postgres-database", "", "shanks", "postgres"), + bind("mongodb-database", "", "novox", "postgres")) + assign(t, bus, "ace", "other", bind("postgres-database", "", "novox", "mssql")) + out, err := postgres(bus, v, m).Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + if got := bus.keys(BucketOf("postgres")); !slices.Equal(got, []string{"ace.letta.mongodb-database"}) { + t.Fatalf("entries %v", got) + } + e := entryOf(t, bus, "ace.letta.mongodb-database") + if !strings.Contains(e.Refused, "postgres on novox") || !strings.Contains(e.Refused, "letta on ace") { + t.Fatalf("refusal %q", e.Refused) + } + if len(out) != 1 || m.calls != 0 { + t.Fatalf("outcomes %+v, %d made", out, m.calls) + } + }) + + // A plan that cannot be read is skipped and said, by its key and why; the rest of the pass goes on (issue 825, + // finding 3). Its consumer's own entry is left alone, since a bind missed would read as an unbind. + t.Run("AnUnreadableAssignmentIsSkippedAndSaidAndTheRestGoesOn", func(t *testing.T) { + for name, bad := range map[string]string{"not json": "{not json", + "another key": `{"v":1,"node":"g14","module":"umami","plan":{"templates":{"source":"","digest":""},"files":null}}`} { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s", + "pair/novox/postgres-database/g14/umami": "u"}} + assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) + p := postgres(bus, v, m) + if _, err := p.Pass(context.Background()); err != nil { + t.Fatal(err) + } + _ = bus.Put(context.Background(), provisions.PlansBucketOf(testProvisioner), "ace.letta", []byte(bad)) + assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres")) + var logged []string + p.opts.Logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) } + out, err := p.Pass(context.Background()) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if bus.get(BucketOf("postgres"), "g14.umami.postgres-database") == nil { + t.Fatalf("%s: the good consumer was not answered", name) + } + if bus.get(BucketOf("postgres"), "ace.letta.postgres-database") == nil || len(m.gone) != 0 { + t.Fatalf("%s: the entry of the unreadable assignment's consumer was removed", name) + } + said := false + for _, o := range out { + if o.Key == "ace.letta" && o.Status == Failed && o.Why != "" { + said = true + } + } + if !said || !strings.Contains(strings.Join(logged, "\n"), "ace.letta") { + t.Fatalf("%s: the skipped assignment is not said: %+v, %q", name, out, logged) + } + } + }) + + // Run watches the binds on the stand-in bus: a consumer that binds is answered, and one that unbinds is removed, + // without a pass asked by hand. + t.Run("RunAnswersBindsAndUnbindsAsTheyAreWatched", func(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s"}} + p := postgres(bus, v, m) + p.opts.Every = time.Hour + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- p.Run(ctx) }() + assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) + waitFor(t, func() bool { return bus.get(BucketOf("postgres"), "ace.letta.postgres-database") != nil }) + retire(t, bus, "ace", "letta", "postgres-database", "novox", "postgres") + waitFor(t, func() bool { return bus.get(BucketOf("postgres"), "ace.letta.postgres-database") == nil }) + cancel() + if err := <-done; err != nil && !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + }) + + // An instance deletes only an entry it wrote, and only while the entry is as it read it: one that another instance of + // the provider wrote after the consumer moved stands (issue 825, finding 5). + t.Run("AnEntryAnotherInstanceWroteAfterTheMoveStands", func(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s"}} + assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) + a := postgres(bus, v, m) + if _, err := a.Pass(context.Background()); err != nil { + t.Fatal(err) + } + // The consumer moves to the instance on shanks: A's pass reads its own entry and the unbind, and while it lets + // the consumer go, B writes the consumer's entry. + retire(t, bus, "ace", "letta", "postgres-database", "novox", "postgres") + theirs, _ := json.Marshal(Entry{Provider: "shanks", ProviderModule: "postgres", Provision: "postgres-database", + Consumer: "ace", Module: "letta", VaultName: "pair/shanks/postgres-database/ace/letta"}) + a.opts.Gone = func(ctx context.Context, e Entry) error { + return bus.Put(ctx, BucketOf("postgres"), e.Key(), theirs) + } + out, err := a.Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + if got := bus.get(BucketOf("postgres"), "ace.letta.postgres-database"); !bytes.Equal(got, theirs) { + t.Fatalf("the other instance's entry was removed: %s", got) + } + for _, o := range out { + if o.Status == Removed { + t.Fatalf("outcomes %+v", out) + } + } + }) +} + +// TestCredentialsAndKeys groups the cases below, each one a subtest. +func TestCredentialsAndKeys(t *testing.T) { + // A bind whose credential the vault does not hold yet is waited for: nothing is made and no entry is written, so a + // consumer is never told of a login its provider has not made; once the vault holds it, it is answered. + t.Run("ACredentialTheVaultDoesNotHoldYetIsWaitedFor", func(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{}} + assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) + p := postgres(bus, v, m) + out, err := p.Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(out) != 1 || out[0].Status != Waiting || !strings.Contains(out[0].Why, "pair/novox/postgres-database/ace/letta") { + t.Fatalf("outcomes %+v", out) + } + if bus.keys(BucketOf("postgres")) != nil || m.calls != 0 { + t.Fatal("a consumer was answered before its credential was there") + } + v.values["pair/novox/postgres-database/ace/letta"] = "late" + if _, err := p.Pass(context.Background()); err != nil { + t.Fatal(err) + } + if entryOf(t, bus, "ace.letta.postgres-database").Refused != "" || m.made["ace.letta.postgres-database"] != "late" { + t.Fatal("not answered once the vault held the value") + } + }) + + // An adapter that fails is said with the credential taken out of its words, and its entry says it failed, serving + // nothing (novox/hq ADR 0570 clause 3: the provider's outcome is its entry, which the holder of mesh-provisioner reads). + t.Run("AFailedMakeIsSaidWithoutTheCredential", func(t *testing.T) { + bus, m := newStandIn(), &maker{fail: errors.New(`password "hunter2-secret" rejected`)} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "hunter2-secret"}} + assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) + var logged []string + p := postgres(bus, v, m) + p.opts.Logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) } + out, err := p.Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(out) != 1 || out[0].Status != Failed || strings.Contains(out[0].Why, "hunter2") { + t.Fatalf("outcomes %+v", out) + } + if strings.Contains(strings.Join(logged, "\n"), "hunter2") || len(logged) == 0 { + t.Fatalf("logged %q", logged) + } + var e Entry + raw := bus.get(BucketOf("postgres"), "ace.letta.postgres-database") + if err := json.Unmarshal(raw, &e); err != nil || e.State != "failed" || e.Values != nil || e.VaultName != "" || + strings.Contains(string(raw), "hunter2") { + t.Fatalf("the failed entry is %s", raw) + } + + // A provider not ready yet (gitea after a restart, its keys not read; the regression of 2026-10-30) is no failure: + // a first entry waits serving nothing, and once made, a later not ready keeps serving what it served. + m.fail = fmt.Errorf("keys not read yet: %w", ErrNotReady) + _ = bus.Delete(context.Background(), BucketOf("postgres"), "ace.letta.postgres-database") + if out, _ := p.Pass(context.Background()); len(out) != 1 || out[0].Status != Waiting { + t.Fatalf("not ready at first: %+v", out) + } + raw = bus.get(BucketOf("postgres"), "ace.letta.postgres-database") + if err := json.Unmarshal(raw, &e); err != nil || e.State != "waits" || !strings.Contains(e.Why, "keys not read yet") || e.Values != nil { + t.Fatalf("the first entry not ready is %s", raw) + } + m.fail = nil + if out, _ := p.Pass(context.Background()); len(out) != 1 || out[0].Status != Answered { + t.Fatalf("made: %+v", out) + } + var made Entry + _ = json.Unmarshal(bus.get(BucketOf("postgres"), "ace.letta.postgres-database"), &made) + m.fail = ErrNotReady + if out, _ := p.Pass(context.Background()); len(out) != 1 || out[0].Status != Waiting { + t.Fatalf("not ready after a restart: %+v", out) + } + raw = bus.get(BucketOf("postgres"), "ace.letta.postgres-database") + if err := json.Unmarshal(raw, &e); err != nil || e.State != "waits" || e.VaultName != made.VaultName || e.User != made.User || + fmt.Sprint(e.Values) != fmt.Sprint(made.Values) || len(made.Values) == 0 { + t.Fatalf("not ready after it served, the entry is %s, it served %+v", raw, made) + } + }) + + // A failed Make is said with the credential taken out in the forms it can come back in: as it is, in base64 (standard + // and URL, with and without padding) and as a JSON string says it (issue 825, finding 6). + t.Run("AFailedMakeIsSaidWithoutTheCredentialInAnyOfItsForms", func(t *testing.T) { + cred := "pa/ss+w\"rd?>>x" + b := []byte(cred) + jsonQuoted, _ := json.Marshal(cred) + forms := map[string]string{ + "raw": cred, "std": base64.StdEncoding.EncodeToString(b), "std unpadded": base64.RawStdEncoding.EncodeToString(b), + "url": base64.URLEncoding.EncodeToString(b), "url unpadded": base64.RawURLEncoding.EncodeToString(b), + "json": string(jsonQuoted[1 : len(jsonQuoted)-1]), + } + if forms["std"] == forms["url"] || forms["json"] == forms["raw"] { + t.Fatal("the test credential does not tell the forms apart") + } + for name, form := range forms { + bus, m := newStandIn(), &maker{fail: errors.New("login failed for " + form + " at the server")} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": cred}} + assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) + out, err := postgres(bus, v, m).Pass(context.Background()) + if err != nil || len(out) != 1 { + t.Fatalf("%v %+v", err, out) + } + if strings.Contains(out[0].Why, form) || !strings.Contains(out[0].Why, "") { + t.Fatalf("%s form: %q", name, out[0].Why) + } + } + }) + + // The consumer's identity is the mesh's (ADR 0202 §1): mesh__, lower case, `_` for anything else, + // and its DNS form writes `-` for `_`. + t.Run("TheIdentityIsTheMeshsAndItsDNSForm", func(t *testing.T) { + if got := ConsumerIdentity("Home.Server", "my-app"); got != "mesh_home_server_my_app" { + t.Fatal(got) + } + got, err := ServedTo(map[string]any{"bucket": "b-${consumer:as:dns}", "n": 3}, "mesh_ace_letta") + if err != nil || got["bucket"] != "b-mesh-ace-letta" || got["n"] != 3 { + t.Fatalf("%v %v", got, err) + } + if _, err := ServedTo(map[string]any{"x": "${consumer:node}"}, "mesh_ace_letta"); err == nil { + t.Fatal("a fact the mesh does not state was filled") + } + }) + + // Every entry the helper writes passes the state guard's key rule (issue 945), and names where the vault keeps the + // credential under `vault-name`, for a login per consumer and for a shared one. + t.Run("AnEntryHasNoKeyTheStateGuardRefuses", func(t *testing.T) { + for name, form := range map[string]CredentialForm{"pair": PerConsumer, "shared": Shared} { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s1", + "provision/novox/postgres-database": "s2"}} + assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) + p := New(Options{Provisioner: testProvisioner, Node: "novox", Module: "postgres", Bus: bus, Vault: v, Make: m.Make, Logf: func(string, ...any) {}, + Offers: map[string]Offer{"postgres-database": {Credential: form, User: "shared-user"}}}) + if _, err := p.Pass(context.Background()); err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(bus.get(BucketOf("postgres"), "ace.letta.postgres-database"), &doc); err != nil { + t.Fatal(err) + } + var keys []string + keysOf(doc, &keys) + for _, k := range keys { + if refusedByTheStateGuard(k) { + t.Fatalf("%s: the entry carries the key %q, which the state guard refuses", name, k) + } + } + want := map[string]string{"pair": "pair/novox/postgres-database/ace/letta", + "shared": "provision/novox/postgres-database"}[name] + if doc["vault-name"] != want { + t.Fatalf("%s: vault-name is %v, want %s", name, doc["vault-name"], want) + } + } + }) + + // novox/hq ADR 0570 clauses 3 and 5 (issue 1954 L6-P3, P4): a consumer the seat mesh-provisioner plans is bound by its + // plan and its provision key, never by its assignment. Its entry is made only once its key is asked and every provision + // it depends on is provided, and says provided; a plan gone retires nothing by itself; a retiring key is let go only on + // the operator's yes. + t.Run("TheSeatsKeysSayWhenAProviderActsAndWhenItLetsGo", func(t *testing.T) { + ctx := context.Background() + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s1"}} + // The same consumer's assignment names another provider: the seat's plan wins. + assign(t, bus, "ace", "letta", bind("postgres-database", "", "shanks", "postgres")) + plan, _ := provisions.EncodePlan(provisions.Plan{Node: "ace", Module: "letta", Plan: catalogue.Plan{ + Provisions: []catalogue.PlanProvision{{Provision: "postgres-database", Provider: "novox", ProviderModule: "postgres"}}}}) + _ = bus.Put(ctx, provisions.PlansBucketOf(testProvisioner), "ace.letta", plan) + key := "ace.letta.postgres-database" + put := func(k string, state string, depends []string, warrant *provisions.Warrant) { + pr := provisions.Provision{Provision: "postgres-database", Consumer: provisions.End{Module: "letta", Node: "ace"}, + Provider: provisions.End{Module: "postgres", Node: "novox"}, DependsOn: depends, State: state, Warrant: warrant} + if k != key { + _, _, pr.Provision, _, _ = provisions.ParseKey(k) + pr.Provider = provisions.End{Module: "mesh-vault", Node: "novox"} + } + if state != provisions.Asked && state != provisions.Provided { + pr.Why = "said" + } + raw, err := provisions.Encode(pr) + if err != nil { + t.Fatal(err) + } + _ = bus.Put(ctx, provisions.ProvisionsBucketOf(testProvisioner), k, raw) + } + p := postgres(bus, v, m) + steps := []struct { + name string + before func() + status string + says string + entry bool + }{ + {name: "no key yet", status: Waiting, says: "holds no key"}, + {name: "a dependency asked", before: func() { + put("ace.letta.secret", provisions.Asked, nil, nil) + put(key, provisions.Asked, []string{"ace.letta.secret"}, nil) + }, status: Waiting, says: "depends on ace.letta.secret, which is asked"}, + {name: "the dependency provided", before: func() { put("ace.letta.secret", provisions.Provided, nil, nil) }, + status: Answered, entry: true}, + {name: "its plan gone: nothing let go by itself", before: func() { _ = bus.Delete(ctx, provisions.PlansBucketOf(testProvisioner), "ace.letta") }, + entry: true}, + {name: "retiring, not yet answered", before: func() { put(key, provisions.Retiring, nil, nil) }, entry: true}, + {name: "retiring, the operator's yes", before: func() { + put(key, provisions.Retiring, nil, &provisions.Warrant{Ask: "a-1", Option: "yes", By: "the operator"}) + }, status: Removed}, + } + for _, s := range steps { + if s.before != nil { + s.before() + } + out, err := p.Pass(ctx) + if err != nil { + t.Fatalf("%s: %v", s.name, err) + } + var mine []Outcome + for _, o := range out { + if o.Key == key { + mine = append(mine, o) + } + } + if s.status != "" && (len(mine) != 1 || mine[0].Status != s.status || !strings.Contains(mine[0].Why, s.says)) { + t.Errorf("%s: %+v", s.name, mine) + } + if held := bus.get(BucketOf("postgres"), key) != nil; held != s.entry { + t.Errorf("%s: an entry held %v", s.name, held) + } + if s.entry && entryOf(t, bus, key).State != provisions.Provided { + t.Errorf("%s: the entry does not say provided", s.name) + } + } + if !slices.Equal(m.gone, []string{key}) || m.made[key] != "s1" { + t.Errorf("gone %v, made %v: let go once, on the warrant, and made from the vault's value", m.gone, m.made) + } + }) +} + +// TestConsumersFileAndStateBus groups the cases below, each one a subtest. +func TestConsumersFileAndStateBus(t *testing.T) { + // The provider reads its consumers' credentials only from the file its machine's provisioner client fills (novox/hq + // ADR 0373 clause 3, issue 679): one entry per consumer, naming the consumer and the vault's name for its credential, + // and carrying the value. The helper is wired to it as its Vault and asks nothing of the vault itself. + t.Run("TheConsumersFileAnswersEachConsumersCredential", func(t *testing.T) { + dir := t.TempDir() + at := filepath.Join(dir, "consumers.json") + file := `[{"consumer":"ace","module":"letta","credential":"pair/novox/postgres-database/ace/letta","value":"s3cret"}, + {"consumer":"g14","module":"gitea","local":"cache","credential":"pair/novox/postgres-database/g14/gitea/cache","value":"other"}]` + if err := os.WriteFile(at, []byte(file), 0o600); err != nil { + t.Fatal(err) + } + v := ConsumersFile{Paths: []string{filepath.Join(dir, "absent.json"), at}} + got, err := v.Value(context.Background(), PairName("novox", "postgres-database", "ace", "letta", "")) + if err != nil || string(got) != "s3cret" { + t.Fatalf("ace's letta: %q, %v; want s3cret", got, err) + } + got, err = v.Value(context.Background(), PairName("novox", "postgres-database", "g14", "gitea", "cache")) + if err != nil || string(got) != "other" { + t.Fatalf("g14's gitea under cache: %q, %v; want other", got, err) + } + // A consumer the file does not carry is waited for, and never told of a login its provider has not made. + _, err = v.Value(context.Background(), PairName("novox", "postgres-database", "shanks", "photos", "")) + if err == nil || !strings.Contains(err.Error(), "pair/novox/postgres-database/shanks/photos") { + t.Fatalf("a consumer the file does not carry: %v", err) + } + if strings.Contains(err.Error(), "s3cret") || strings.Contains(err.Error(), "other") { + t.Fatalf("an error names a credential: %v", err) + } + + // Wired as the helper's Vault, the file is what makes each consumer's login. + bus := newStandIn() + assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) + var made []string + p := New(Options{Provisioner: testProvisioner, Node: "novox", Module: "postgres", Bus: bus, Vault: v, + Offers: map[string]Offer{"postgres-database": {Serves: map[string]any{"port": 5432}}}, + Make: func(_ context.Context, c Consumer) error { + made = append(made, c.Entry.User+"="+string(c.Credential)) + return nil + }, Logf: func(string, ...any) {}}) + if _, err := p.Pass(context.Background()); err != nil { + t.Fatal(err) + } + if len(made) != 1 || made[0] != "mesh_ace_letta=s3cret" { + t.Fatalf("made %v, want ace's letta under the file's credential", made) + } + }) + + // A file that is not the client's form is said, never read as no consumers. + t.Run("AConsumersFileNotInItsFormIsRefused", func(t *testing.T) { + at := filepath.Join(t.TempDir(), "consumers.json") + if err := os.WriteFile(at, []byte(`{"not": "a list"}`), 0o600); err != nil { + t.Fatal(err) + } + _, err := ConsumersFile{Paths: []string{at}}.Value(context.Background(), "pair/novox/p/ace/m") + if err == nil || !strings.Contains(err.Error(), at) { + t.Fatalf("a file not in the client's form: %v", err) + } + }) + + t.Run("StandingIsFalseOnlyForAnEndedOrPendingValue", func(t *testing.T) { + for raw, want := range map[string]bool{ + `{"node":"a","module":"b"}`: true, + `{"node":"a","module":"b","ended":"","state":"","x":{"y":1}}`: true, + `{"node":"a","module":"b","ended":null}`: true, + `{"node":"a","module":"b","build":"b1","commit":"c","rollout":"r","since":"t","artifact":{"name":"n"}}`: true, + `{"node":"a","module":"b","ended":"2026-10-18T01:00:00Z"}`: false, + `{"node":"a","module":"b","state":"pending"}`: false, + } { + if got := Standing([]byte(raw)); got != want { + t.Errorf("%s: %v, want %v", raw, got, want) + } + } + }) + + // The bus of a moved provider maps its own bucket to the state `bound` and the seat's plans to the state + // `mesh-provisioner.plans` (novox/hq ADR 0570 clause 2), writes only the own bucket's state, and refuses any other + // bucket, the controller's assignments among them. + t.Run("StateBusMapsTheProvidersBucketsToTheRuntimesStates", func(t *testing.T) { + bus := StateBus("postgres", testProvisioner) + rt := serve(t, []stdio.Tool{ + {Name: "watch", Run: func(a map[string]any) (any, error) { + return nil, bus.Watch(context.Background(), a["bucket"].(string), func(string, []byte) {}) + }}, + {Name: "put", Run: func(map[string]any) (any, error) { + return "put", bus.Put(context.Background(), BucketOf("postgres"), "k", []byte(`{"a":1}`)) + }}, + {Name: "delete", Run: func(map[string]any) (any, error) { + return "deleted", bus.Delete(context.Background(), BucketOf("postgres"), "k") + }}, + }) + for i, c := range []struct{ bucket, state string }{ + {BucketOf("postgres"), "bound"}, + {provisions.PlansBucketOf(testProvisioner), "mesh-provisioner.plans"}, + } { + rt.call(i+1, "watch", map[string]any{"bucket": c.bucket}) + w := rt.next() + if w["method"] != "mesh/state.watch" || w["params"].(map[string]any)["state"] != c.state { + t.Fatalf("bucket %s asked %v, want the state %s", c.bucket, w, c.state) + } + rt.send(map[string]any{"jsonrpc": "2.0", "id": w["id"], "result": map[string]any{}}) + rt.next() + } + rt.call(3, "put", nil) + p := rt.next() + if p["method"] != "mesh/state.put" || p["params"].(map[string]any)["state"] != "bound" || p["params"].(map[string]any)["key"] != "k" { + t.Fatalf("put asked %v", p) + } + rt.send(map[string]any{"jsonrpc": "2.0", "id": p["id"], "result": map[string]any{"revision": 1}}) + rt.next() + rt.call(4, "delete", nil) + d := rt.next() + if d["method"] != "mesh/state.delete" || d["params"].(map[string]any)["state"] != "bound" { + t.Fatalf("delete asked %v", d) + } + rt.send(map[string]any{"jsonrpc": "2.0", "id": d["id"], "result": map[string]any{}}) + rt.next() + + for _, bucket := range []string{"other_bound", "elsewhere", "mesh-controller_assignments"} { + if err := bus.Watch(context.Background(), bucket, func(string, []byte) {}); err == nil || !strings.Contains(err.Error(), "not a bucket") { + t.Fatalf("a watch of %s gave %v", bucket, err) + } + if err := bus.Put(context.Background(), bucket, "k", nil); err == nil { + t.Fatalf("a put to %s was allowed", bucket) + } + if err := bus.Delete(context.Background(), bucket, "k"); err == nil { + t.Fatalf("a delete in %s was allowed", bucket) + } + } + }) + + // A watch whose ctx has ended calls its handler no more, though the runtime keeps sending changes: the Bus contract + // says a watch lasts until ctx ends, and a stdio state watch lasts as long as the process. + t.Run("StateBusWatchCallsItsHandlerNoMoreOnceItsContextEnded", func(t *testing.T) { + bus := StateBus("postgres", testProvisioner) + ctx, cancel := context.WithCancel(context.Background()) + handled := make(chan string, 8) + rt := serve(t, []stdio.Tool{ + {Name: "start", Run: func(map[string]any) (any, error) { + return "started", bus.Watch(ctx, provisions.PlansBucketOf(testProvisioner), func(key string, value []byte) { + handled <- key + "=" + string(value) + }) + }}, + {Name: "stop", Run: func(map[string]any) (any, error) { cancel(); return "stopped", nil }}, + }) + change := func(id int, watch any, key, op string) { + rt.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": "mesh/state", "params": map[string]any{"watch": watch, + "change": map[string]any{"state": "mesh-controller.assignments", "key": key, "op": op, "value": "v"}}}) + if r := rt.next(); r["id"] != float64(id) || r["error"] != nil { + t.Fatalf("a change was answered %v", r) + } + } + rt.call(1, "start", nil) + w := rt.next() + rt.send(map[string]any{"jsonrpc": "2.0", "id": w["id"], "result": map[string]any{}}) + rt.next() + + change(50, w["id"], "before", "put") + if got := <-handled; got != `before="v"` { + t.Fatalf("handled %q", got) + } + rt.call(2, "stop", nil) + rt.next() + change(51, w["id"], "after", "put") + change(52, w["id"], "after", "delete") + select { + case got := <-handled: + t.Fatalf("the handler was called after its context ended: %q", got) + case <-time.After(100 * time.Millisecond): + } + }) +} diff --git a/provisioning/go/seats/meshprovisioner/contract.go b/provisioning/go/seats/meshprovisioner/contract.go new file mode 100644 index 0000000..7e2325e --- /dev/null +++ b/provisioning/go/seats/meshprovisioner/contract.go @@ -0,0 +1,324 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package meshprovisioner is the contract of the seat "mesh-provisioner", defined whole (seats/mesh-provisioner/seat.json): The provisioner (ADR 0349): which domain answers which kind of placeholder, what each module needs, what is applied where. +package meshprovisioner + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "time" +) + +// Seat is the seat's name. +const Seat = "mesh-provisioner" + +const ( + VerbProvisioning = "provisioning" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbProvisioning} +} + +const ( + StatePlans = "plans" + StateProvisions = "provisions" +) + +// PlansByNodeModuleKey is the key of one value of the state plans under the form ".". +func PlansByNodeModuleKey(node, module string) string { return node + "." + module } + +// ParsePlansByNodeModuleKey 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 ParsePlansByNodeModuleKey(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 +} + +// DecodePlansByNodeModuleValue reads one value of the state plans under the form ".", refusing a field PlansByNodeModuleValue does not know and any text after the value. +func DecodePlansByNodeModuleValue(raw []byte) (PlansByNodeModuleValue, error) { + var v PlansByNodeModuleValue + 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 +} + +// ProvisionsByNodeConsumerProvisionKey is the key of one value of the state provisions under the form "..". +func ProvisionsByNodeConsumerProvisionKey(node, consumer, provision string) string { + return node + "." + consumer + "." + provision +} + +// ParseProvisionsByNodeConsumerProvisionKey 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 ParseProvisionsByNodeConsumerProvisionKey(key string) (string, string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", "", false + } + rest = after0 + v2, after2, found2 := strings.Cut(rest, ".") + if !found2 || v2 == "" { + return "", "", "", false + } + rest = after2 + if rest == "" { + return "", "", "", false + } + v4 := rest + rest = "" + if rest != "" { + return "", "", "", false + } + return v0, v2, v4, true +} + +// DecodeProvisionsByNodeConsumerProvisionValue reads one value of the state provisions under the form "..", refusing a field ProvisionsByNodeConsumerProvisionValue does not know and any text after the value. +func DecodeProvisionsByNodeConsumerProvisionValue(raw []byte) (ProvisionsByNodeConsumerProvisionValue, error) { + var v ProvisionsByNodeConsumerProvisionValue + 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 +} + +// ProvisionsByNodeConsumerProvisionLocalKey is the key of one value of the state provisions under the form "...". +func ProvisionsByNodeConsumerProvisionLocalKey(node, consumer, provision, local string) string { + return node + "." + consumer + "." + provision + "." + local +} + +// ParseProvisionsByNodeConsumerProvisionLocalKey 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 ParseProvisionsByNodeConsumerProvisionLocalKey(key string) (string, string, string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", "", "", false + } + rest = after0 + v2, after2, found2 := strings.Cut(rest, ".") + if !found2 || v2 == "" { + return "", "", "", "", false + } + rest = after2 + v4, after4, found4 := strings.Cut(rest, ".") + if !found4 || v4 == "" { + return "", "", "", "", false + } + rest = after4 + if rest == "" { + return "", "", "", "", false + } + v6 := rest + rest = "" + if rest != "" { + return "", "", "", "", false + } + return v0, v2, v4, v6, true +} + +// DecodeProvisionsByNodeConsumerProvisionLocalValue reads one value of the state provisions under the form "...", refusing a field ProvisionsByNodeConsumerProvisionLocalValue does not know and any text after the value. +func DecodeProvisionsByNodeConsumerProvisionLocalValue(raw []byte) (ProvisionsByNodeConsumerProvisionLocalValue, error) { + var v ProvisionsByNodeConsumerProvisionLocalValue + 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 +} + +// ProvisioningInput is what provisioning takes. +type ProvisioningInput struct { + // for collect: a collection + Collection string `json:"collection,omitempty"` + // a module: for collect, a contributor + Module string `json:"module,omitempty"` + // a node + Node string `json:"node,omitempty"` + // registry (when not given), needs, applied or collect + What string `json:"what,omitempty"` +} + +// ProvisioningAnswer is what provisioning answers. +type ProvisioningAnswer = map[string]any + +// PlansByNodeModuleValue is one value of the state plans under the key .. +type PlansByNodeModuleValue struct { + Module string `json:"module"` + Node string `json:"node"` + // the module's plan + Plan map[string]any `json:"plan"` + V int64 `json:"v,omitempty"` +} + +// ProvisionsByNodeConsumerProvisionValueConsumer is a type of the contract. +type ProvisionsByNodeConsumerProvisionValueConsumer struct { + Module string `json:"module,omitempty"` + Node string `json:"node,omitempty"` +} + +// ProvisionsByNodeConsumerProvisionValuePlan is the plan's provision: the entry the key was composed from, at the consumer's wanted build or its newest (issue 1126). +type ProvisionsByNodeConsumerProvisionValuePlan struct { + As string `json:"as,omitempty"` + At string `json:"at,omitempty"` + // the provider's own contract's bytes, carried unread + Contributed map[string]any `json:"contributed,omitempty"` + Contributes map[string]any `json:"contributes,omitempty"` + CredentialForm any `json:"credential-form,omitempty"` + CredentialTool string `json:"credential-tool,omitempty"` + From string `json:"from,omitempty"` + Local string `json:"local,omitempty"` + Name string `json:"name,omitempty"` + Names map[string]string `json:"names,omitempty"` + Path string `json:"path,omitempty"` + Provider string `json:"provider"` + ProviderModule string `json:"provider-module"` + Provision string `json:"provision"` +} + +// ProvisionsByNodeConsumerProvisionValueProvider is a type of the contract. +type ProvisionsByNodeConsumerProvisionValueProvider struct { + Module string `json:"module,omitempty"` + Node string `json:"node,omitempty"` +} + +// ProvisionsByNodeConsumerProvisionValueReference is a type of the contract. +type ProvisionsByNodeConsumerProvisionValueReference struct { + Generation int64 `json:"generation,omitempty"` + ID string `json:"id,omitempty"` +} + +// ProvisionsByNodeConsumerProvisionValueWarrant is a type of the contract. +type ProvisionsByNodeConsumerProvisionValueWarrant struct { + Ask string `json:"ask,omitempty"` + At *time.Time `json:"at,omitempty"` + By string `json:"by,omitempty"` + Option string `json:"option,omitempty"` +} + +// ProvisionsByNodeConsumerProvisionValue is one value of the state provisions under the key ... +type ProvisionsByNodeConsumerProvisionValue struct { + Ask string `json:"ask,omitempty"` + At time.Time `json:"at"` + Consumer ProvisionsByNodeConsumerProvisionValueConsumer `json:"consumer"` + DependsOn []string `json:"depends-on,omitempty"` + Local string `json:"local,omitempty"` + // the plan's provision: the entry the key was composed from, at the consumer's wanted build or its newest (issue 1126) + Plan *ProvisionsByNodeConsumerProvisionValuePlan `json:"plan,omitempty"` + Provider ProvisionsByNodeConsumerProvisionValueProvider `json:"provider"` + Provision string `json:"provision"` + Reference *ProvisionsByNodeConsumerProvisionValueReference `json:"reference,omitempty"` + State string `json:"state"` + V int64 `json:"v,omitempty"` + Warrant *ProvisionsByNodeConsumerProvisionValueWarrant `json:"warrant,omitempty"` + Why string `json:"why,omitempty"` +} + +// The values of ProvisionsByNodeConsumerProvisionValue's state. +const ( + ProvisionsByNodeConsumerProvisionValueStateAsked = "asked" + ProvisionsByNodeConsumerProvisionValueStateProvided = "provided" + ProvisionsByNodeConsumerProvisionValueStateWaits = "waits" + ProvisionsByNodeConsumerProvisionValueStateFailed = "failed" + ProvisionsByNodeConsumerProvisionValueStateRetiring = "retiring" +) + +// ProvisionsByNodeConsumerProvisionLocalValueConsumer is a type of the contract. +type ProvisionsByNodeConsumerProvisionLocalValueConsumer struct { + Module string `json:"module,omitempty"` + Node string `json:"node,omitempty"` +} + +// ProvisionsByNodeConsumerProvisionLocalValuePlan is the plan's provision: the entry the key was composed from, at the consumer's wanted build or its newest (issue 1126). +type ProvisionsByNodeConsumerProvisionLocalValuePlan struct { + As string `json:"as,omitempty"` + At string `json:"at,omitempty"` + // the provider's own contract's bytes, carried unread + Contributed map[string]any `json:"contributed,omitempty"` + Contributes map[string]any `json:"contributes,omitempty"` + CredentialForm any `json:"credential-form,omitempty"` + CredentialTool string `json:"credential-tool,omitempty"` + From string `json:"from,omitempty"` + Local string `json:"local,omitempty"` + Name string `json:"name,omitempty"` + Names map[string]string `json:"names,omitempty"` + Path string `json:"path,omitempty"` + Provider string `json:"provider"` + ProviderModule string `json:"provider-module"` + Provision string `json:"provision"` +} + +// ProvisionsByNodeConsumerProvisionLocalValueProvider is a type of the contract. +type ProvisionsByNodeConsumerProvisionLocalValueProvider struct { + Module string `json:"module,omitempty"` + Node string `json:"node,omitempty"` +} + +// ProvisionsByNodeConsumerProvisionLocalValueReference is a type of the contract. +type ProvisionsByNodeConsumerProvisionLocalValueReference struct { + Generation int64 `json:"generation,omitempty"` + ID string `json:"id,omitempty"` +} + +// ProvisionsByNodeConsumerProvisionLocalValueWarrant is a type of the contract. +type ProvisionsByNodeConsumerProvisionLocalValueWarrant struct { + Ask string `json:"ask,omitempty"` + At *time.Time `json:"at,omitempty"` + By string `json:"by,omitempty"` + Option string `json:"option,omitempty"` +} + +// ProvisionsByNodeConsumerProvisionLocalValue is one value of the state provisions under the key .... +type ProvisionsByNodeConsumerProvisionLocalValue struct { + Ask string `json:"ask,omitempty"` + At time.Time `json:"at"` + Consumer ProvisionsByNodeConsumerProvisionLocalValueConsumer `json:"consumer"` + DependsOn []string `json:"depends-on,omitempty"` + Local string `json:"local,omitempty"` + // the plan's provision: the entry the key was composed from, at the consumer's wanted build or its newest (issue 1126) + Plan *ProvisionsByNodeConsumerProvisionLocalValuePlan `json:"plan,omitempty"` + Provider ProvisionsByNodeConsumerProvisionLocalValueProvider `json:"provider"` + Provision string `json:"provision"` + Reference *ProvisionsByNodeConsumerProvisionLocalValueReference `json:"reference,omitempty"` + State string `json:"state"` + V int64 `json:"v,omitempty"` + Warrant *ProvisionsByNodeConsumerProvisionLocalValueWarrant `json:"warrant,omitempty"` + Why string `json:"why,omitempty"` +} + +// The values of ProvisionsByNodeConsumerProvisionLocalValue's state. +const ( + ProvisionsByNodeConsumerProvisionLocalValueStateAsked = "asked" + ProvisionsByNodeConsumerProvisionLocalValueStateProvided = "provided" + ProvisionsByNodeConsumerProvisionLocalValueStateWaits = "waits" + ProvisionsByNodeConsumerProvisionLocalValueStateFailed = "failed" + ProvisionsByNodeConsumerProvisionLocalValueStateRetiring = "retiring" +) diff --git a/provisioning/go/seats/meshprovisioner/contract_test.go b/provisioning/go/seats/meshprovisioner/contract_test.go new file mode 100644 index 0000000..78917dc --- /dev/null +++ b/provisioning/go/seats/meshprovisioner/contract_test.go @@ -0,0 +1,66 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package meshprovisioner + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/mesh-provisioner/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 database provision provided to a consumer under a local name", subject: "", given: "{\"at\":\"2026-11-04T12:00:00Z\",\"consumer\":{\"module\":\"keycloak\",\"node\":\"ace\"},\"depends-on\":[\"novox.postgres.secret-postgres-database\"],\"local\":\"main\",\"plan\":{\"credential-form\":\"pair\",\"local\":\"main\",\"names\":{\"name-main\":\"keycloak.example.internal\"},\"provider\":\"novox\",\"provider-module\":\"postgres\",\"provision\":\"postgres-database\"},\"provider\":{\"module\":\"postgres\",\"node\":\"novox\"},\"provision\":\"postgres-database\",\"reference\":{\"generation\":2,\"id\":\"pair/novox/postgres-database/ace/keycloak/main\"},\"state\":\"provided\",\"v\":1}", wire: "{\"at\":\"2026-11-04T12:00:00Z\",\"consumer\":{\"module\":\"keycloak\",\"node\":\"ace\"},\"depends-on\":[\"novox.postgres.secret-postgres-database\"],\"local\":\"main\",\"plan\":{\"credential-form\":\"pair\",\"local\":\"main\",\"names\":{\"name-main\":\"keycloak.example.internal\"},\"provider\":\"novox\",\"provider-module\":\"postgres\",\"provision\":\"postgres-database\"},\"provider\":{\"module\":\"postgres\",\"node\":\"novox\"},\"provision\":\"postgres-database\",\"reference\":{\"generation\":2,\"id\":\"pair/novox/postgres-database/ace/keycloak/main\"},\"state\":\"provided\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v ProvisionsByNodeConsumerProvisionLocalValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a provision that waits for the operator", subject: "", given: "{\"at\":\"2026-11-04T12:00:00Z\",\"consumer\":{\"module\":\"bazarr\",\"node\":\"ace\"},\"provider\":{\"module\":\"mesh-vault\",\"node\":\"novox\"},\"provision\":\"api-token\",\"state\":\"waits\",\"v\":1,\"why\":\"the token is the operator's to give\"}", wire: "{\"at\":\"2026-11-04T12:00:00Z\",\"consumer\":{\"module\":\"bazarr\",\"node\":\"ace\"},\"provider\":{\"module\":\"mesh-vault\",\"node\":\"novox\"},\"provision\":\"api-token\",\"state\":\"waits\",\"v\":1,\"why\":\"the token is the operator's to give\"}", + decode: func(b []byte) (any, error) { + var v ProvisionsByNodeConsumerProvisionValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a retiring provision with the operator's warrant", subject: "", given: "{\"ask\":\"a7\",\"at\":\"2026-11-04T12:00:00Z\",\"consumer\":{\"module\":\"old-app\",\"node\":\"ace\"},\"provider\":{\"module\":\"postgres\",\"node\":\"novox\"},\"provision\":\"postgres-database\",\"state\":\"retiring\",\"v\":1,\"warrant\":{\"ask\":\"a7\",\"at\":\"2026-11-04T13:00:00Z\",\"by\":\"operator\",\"option\":\"retire\"},\"why\":\"no want needs it\"}", wire: "{\"ask\":\"a7\",\"at\":\"2026-11-04T12:00:00Z\",\"consumer\":{\"module\":\"old-app\",\"node\":\"ace\"},\"provider\":{\"module\":\"postgres\",\"node\":\"novox\"},\"provision\":\"postgres-database\",\"state\":\"retiring\",\"v\":1,\"warrant\":{\"ask\":\"a7\",\"at\":\"2026-11-04T13:00:00Z\",\"by\":\"operator\",\"option\":\"retire\"},\"why\":\"no want needs it\"}", + decode: func(b []byte) (any, error) { + var v ProvisionsByNodeConsumerProvisionValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a module's plan on a node", subject: "", given: "{\"module\":\"keycloak\",\"node\":\"ace\",\"plan\":{\"provisions\":[{\"credential-form\":\"pair\",\"provider\":\"novox\",\"provider-module\":\"postgres\",\"provision\":\"postgres-database\"}]},\"v\":1}", wire: "{\"module\":\"keycloak\",\"node\":\"ace\",\"plan\":{\"provisions\":[{\"credential-form\":\"pair\",\"provider\":\"novox\",\"provider-module\":\"postgres\",\"provision\":\"postgres-database\"}]},\"v\":1}", + decode: func(b []byte) (any, error) { var v PlansByNodeModuleValue; 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/provisioning/go/seats/meshprovisioner/go.mod b/provisioning/go/seats/meshprovisioner/go.mod new file mode 100644 index 0000000..8604b33 --- /dev/null +++ b/provisioning/go/seats/meshprovisioner/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/provisioning/go/seats/meshprovisioner + +go 1.26.0 diff --git a/provisioning/module.json b/provisioning/module.json new file mode 100644 index 0000000..f5796f5 --- /dev/null +++ b/provisioning/module.json @@ -0,0 +1,38 @@ +{ + "module": "mesh-provisioning-contracts", + "version": "1", + "build": { + "artifacts": [ + { + "name": "go-meshprovisioner", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshprovisioner" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshprovisionerclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/meshprovisionerclient" + ], + "series": "0.1" + }, + { + "name": "ts-meshprovisioner", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshprovisioner" + ], + "series": "0.1", + "generated-from": "." + } + ] + }, + "resources": [] +} diff --git a/provisioning/seats/index.json b/provisioning/seats/index.json new file mode 100644 index 0000000..185b64d --- /dev/null +++ b/provisioning/seats/index.json @@ -0,0 +1,9 @@ +{ + "format": 1, + "seats": [ + "mesh-provisioner", + "node-mounts", + "node-nfs-server", + "node-provisioner" + ] +} diff --git a/provisioning/seats/mesh-provisioner/fixtures/states.json b/provisioning/seats/mesh-provisioner/fixtures/states.json new file mode 100644 index 0000000..1f499cd --- /dev/null +++ b/provisioning/seats/mesh-provisioner/fixtures/states.json @@ -0,0 +1,56 @@ +[ + { + "name": "a database provision provided to a consumer under a local name", + "of": "state:provisions/...", + "given": { + "v": 1, + "provision": "postgres-database", + "local": "main", + "consumer": {"module": "keycloak", "node": "ace"}, + "provider": {"module": "postgres", "node": "novox"}, + "depends-on": ["novox.postgres.secret-postgres-database"], + "state": "provided", + "reference": {"id": "pair/novox/postgres-database/ace/keycloak/main", "generation": 2}, + "plan": {"provision": "postgres-database", "local": "main", "provider": "novox", "provider-module": "postgres", "credential-form": "pair", "names": {"name-main": "keycloak.example.internal"}}, + "at": "2026-11-04T12:00:00Z" + } + }, + { + "name": "a provision that waits for the operator", + "of": "state:provisions/..", + "given": { + "v": 1, + "provision": "api-token", + "consumer": {"module": "bazarr", "node": "ace"}, + "provider": {"module": "mesh-vault", "node": "novox"}, + "state": "waits", + "why": "the token is the operator's to give", + "at": "2026-11-04T12:00:00Z" + } + }, + { + "name": "a retiring provision with the operator's warrant", + "of": "state:provisions/..", + "given": { + "v": 1, + "provision": "postgres-database", + "consumer": {"module": "old-app", "node": "ace"}, + "provider": {"module": "postgres", "node": "novox"}, + "state": "retiring", + "why": "no want needs it", + "ask": "a7", + "warrant": {"ask": "a7", "option": "retire", "by": "operator", "at": "2026-11-04T13:00:00Z"}, + "at": "2026-11-04T12:00:00Z" + } + }, + { + "name": "a module's plan on a node", + "of": "state:plans/.", + "given": { + "v": 1, + "node": "ace", + "module": "keycloak", + "plan": {"provisions": [{"provision": "postgres-database", "provider": "novox", "provider-module": "postgres", "credential-form": "pair"}]} + } + } +] diff --git a/provisioning/seats/mesh-provisioner/seat.json b/provisioning/seats/mesh-provisioner/seat.json new file mode 100644 index 0000000..ad9b169 --- /dev/null +++ b/provisioning/seats/mesh-provisioner/seat.json @@ -0,0 +1,419 @@ +{ + "format": 1, + "seat": "mesh-provisioner", + "scope": "mesh", + "grain": "whole", + "domain": "Provisioning", + "decision": "novox/hq ADR 0349, ADR 0634, ADR 0635", + "description": "The provisioner (ADR 0349): which domain answers which kind of placeholder, what each module needs, what is applied where.", + "verbs": [ + { + "name": "provisioning", + "description": "Read what the provisioner keeps: the registry (which domain answers which kind of placeholder), the needs each module on each node recorded, what is applied where, or the items of every collection, narrowed by node and module. Reads only.", + "optional": true, + "input": { + "properties": { + "collection": { + "description": "for collect: a collection", + "type": "string" + }, + "module": { + "description": "a module: for collect, a contributor", + "type": "string" + }, + "node": { + "description": "a node", + "type": "string" + }, + "what": { + "description": "registry (when not given), needs, applied or collect", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the registry, the needs, what is applied, or the collections' items" + } + } + ], + "states": [ + { + "name": "plans", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "what a module needs on a node", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "module": { + "type": "string" + }, + "plan": { + "type": "object", + "description": "the module's plan" + } + }, + "required": [ + "node", + "module", + "plan" + ] + }, + "description": "one module's plan on one node" + } + ], + "description": "the needs recorded for each module on each node" + }, + { + "name": "provisions", + "template": "state", + "key-forms": [ + { + "key": "..", + "value": { + "type": "object", + "description": "one provision between a consumer and a provider", + "properties": { + "v": { + "type": "integer" + }, + "provision": { + "type": "string" + }, + "local": { + "type": "string" + }, + "consumer": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "node": { + "type": "string" + } + } + }, + "provider": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "node": { + "type": "string" + } + } + }, + "depends-on": { + "type": "array", + "items": { + "type": "string" + } + }, + "state": { + "type": "string", + "enum": [ + "asked", + "provided", + "waits", + "failed", + "retiring" + ] + }, + "why": { + "type": "string" + }, + "reference": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "generation": { + "type": "integer" + } + } + }, + "ask": { + "type": "string" + }, + "warrant": { + "type": "object", + "properties": { + "ask": { + "type": "string" + }, + "option": { + "type": "string" + }, + "by": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + } + } + }, + "plan": { + "type": "object", + "description": "the plan's provision: the entry the key was composed from, at the consumer's wanted build or its newest (issue 1126)", + "properties": { + "name": { + "type": "string" + }, + "provision": { + "type": "string" + }, + "local": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "provider-module": { + "type": "string" + }, + "credential-form": { + "enum": [ + "pair", + "shared", + "issued", + "given", + "none" + ] + }, + "credential-tool": { + "type": "string" + }, + "path": { + "type": "string" + }, + "contributes": { + "type": "object" + }, + "at": { + "type": "string" + }, + "from": { + "type": "string" + }, + "as": { + "type": "string" + }, + "names": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "contributed": { + "type": "object", + "description": "the provider's own contract's bytes, carried unread" + } + }, + "required": [ + "provision", + "provider", + "provider-module" + ], + "additionalProperties": false + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "provision", + "consumer", + "provider", + "state", + "at" + ] + }, + "description": "a provision with no local name" + }, + { + "key": "...", + "value": { + "type": "object", + "description": "one provision between a consumer and a provider", + "properties": { + "v": { + "type": "integer" + }, + "provision": { + "type": "string" + }, + "local": { + "type": "string" + }, + "consumer": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "node": { + "type": "string" + } + } + }, + "provider": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "node": { + "type": "string" + } + } + }, + "depends-on": { + "type": "array", + "items": { + "type": "string" + } + }, + "state": { + "type": "string", + "enum": [ + "asked", + "provided", + "waits", + "failed", + "retiring" + ] + }, + "why": { + "type": "string" + }, + "reference": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "generation": { + "type": "integer" + } + } + }, + "ask": { + "type": "string" + }, + "warrant": { + "type": "object", + "properties": { + "ask": { + "type": "string" + }, + "option": { + "type": "string" + }, + "by": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + } + } + }, + "plan": { + "type": "object", + "description": "the plan's provision: the entry the key was composed from, at the consumer's wanted build or its newest (issue 1126)", + "properties": { + "name": { + "type": "string" + }, + "provision": { + "type": "string" + }, + "local": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "provider-module": { + "type": "string" + }, + "credential-form": { + "enum": [ + "pair", + "shared", + "issued", + "given", + "none" + ] + }, + "credential-tool": { + "type": "string" + }, + "path": { + "type": "string" + }, + "contributes": { + "type": "object" + }, + "at": { + "type": "string" + }, + "from": { + "type": "string" + }, + "as": { + "type": "string" + }, + "names": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "contributed": { + "type": "object", + "description": "the provider's own contract's bytes, carried unread" + } + }, + "required": [ + "provision", + "provider", + "provider-module" + ], + "additionalProperties": false + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "provision", + "consumer", + "provider", + "state", + "at" + ] + }, + "description": "a provision of a local name" + } + ], + "description": "every provision between a consumer and a provider, and how it stands" + } + ] +} diff --git a/provisioning/seats/node-mounts/seat.json b/provisioning/seats/node-mounts/seat.json new file mode 100644 index 0000000..eadb518 --- /dev/null +++ b/provisioning/seats/node-mounts/seat.json @@ -0,0 +1,112 @@ +{ + "format": 1, + "seat": "node-mounts", + "scope": "node", + "grain": "seat-list", + "domain": "Provisioning", + "decision": "novox/hq ADR 0263", + "verbs": [ + { + "name": "list", + "description": "Every mount point on this machine: its fstab line, the mesh's mount and automount units for it, its state (armed, mounted, absent, unreachable, failed) and since when, and which settings asked for it. A password in a mount's options is never shown.", + "replaces": [ + "cat /etc/fstab", + "findmnt", + "systemctl list-units --type=mount" + ], + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "test", + "description": "Whether one share's or occasional source's server answers from this machine now, without touching its mount point.", + "replaces": [ + "showmount -e", + "ping" + ], + "input": { + "properties": { + "name": { + "description": "the share or source, by its name", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + { + "name": "mount", + "description": "Mount one share or occasional source now, rather than at its first access.", + "replaces": [ + "mount" + ], + "input": { + "properties": { + "name": { + "description": "the share or source, by its name", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + { + "name": "unmount", + "description": "Release one share or occasional source now; its automount stays armed, so the next access mounts it again.", + "replaces": [ + "umount" + ], + "input": { + "properties": { + "name": { + "description": "the share or source, by its name", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + { + "name": "adopt", + "description": "Take over a mount a person wrote by hand: comment out the /etc/fstab line for a mount point the mesh's own units now serve, keeping a copy of the file — only when the mesh's automount for it is armed. Without confirm, says what it would do and changes nothing.", + "replaces": [ + "sed -i /etc/fstab" + ], + "input": { + "properties": { + "confirm": { + "description": "\"true\": change it (needs why); anything else is a dry run", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "mountpoint": { + "description": "the mount point whose fstab line is taken over", + "type": "string" + }, + "why": { + "description": "why, for the record", + "type": "string" + } + }, + "required": [ + "mountpoint" + ], + "type": "object" + } + } + ] +} diff --git a/provisioning/seats/node-nfs-server/seat.json b/provisioning/seats/node-nfs-server/seat.json new file mode 100644 index 0000000..664dd3d --- /dev/null +++ b/provisioning/seats/node-nfs-server/seat.json @@ -0,0 +1,100 @@ +{ + "format": 1, + "seat": "node-nfs-server", + "scope": "node", + "grain": "seat-list", + "domain": "Provisioning", + "decision": "novox/hq ADR 0263", + "verbs": [ + { + "name": "exports", + "description": "Every share this machine exports: its name, its path, read-write or read-only, the owner every client is mapped to (uid and gid), each node it is exported to with that node's private address and access (only the nodes the server grants it to and that ask for it), what is granted and not asked for or asked for and not granted, and whether the kernel holds it now. Also the exports found that are not the mesh's: a dataset's sharenfs property, a line in /etc/exports.", + "replaces": [ + "exportfs -v", + "cat /etc/exports", + "zfs get sharenfs" + ], + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "clients", + "description": "Which machines have mounted which share now, as the NFS server knows its clients.", + "replaces": [ + "ss -tn sport = :2049", + "cat /proc/fs/nfsd/clients/*/info" + ], + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "test", + "description": "Whether this machine exports one share for the mesh now, and whether its NFS service is up; with an address, also whether the share is exported to that address: a node's own private address, when the server grants it the share and the node asks for it. What a machine mounting the share asks before it mounts.", + "replaces": [ + "showmount -e" + ], + "input": { + "properties": { + "address": { + "description": "a node's private address, to ask whether the share is exported to it (optional)", + "type": "string" + }, + "share": { + "description": "the share, by its name", + "type": "string" + } + }, + "required": [ + "share" + ], + "type": "object" + } + }, + { + "name": "reload", + "description": "Have the kernel read this machine's export files again now (exportfs -ra) and answer the shares as it then holds them. The module's own process writes its export file; this is for after a change made outside it. Changes no shared path.", + "replaces": [ + "exportfs -ra" + ], + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "adopt", + "description": "Take over an export a person made by hand: clear a dataset's sharenfs property for a path the mesh's own export now serves — only when that export is live. Without confirm, says what it would do and changes nothing.", + "replaces": [ + "zfs set sharenfs=off" + ], + "input": { + "properties": { + "confirm": { + "description": "\"true\": change it (needs why); anything else is a dry run", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "path": { + "description": "the shared path whose hand-made export is taken over", + "type": "string" + }, + "why": { + "description": "why, for the record", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + } + ] +} diff --git a/provisioning/seats/node-provisioner/seat.json b/provisioning/seats/node-provisioner/seat.json new file mode 100644 index 0000000..3510f6f --- /dev/null +++ b/provisioning/seats/node-provisioner/seat.json @@ -0,0 +1,8 @@ +{ + "format": 1, + "seat": "node-provisioner", + "scope": "node", + "grain": "seat-list", + "domain": "Provisioning", + "decision": "novox/hq ADR 0349" +} diff --git a/provisioning/ts/seats/meshprovisioner/package-lock.json b/provisioning/ts/seats/meshprovisioner/package-lock.json new file mode 100644 index 0000000..fe0cee0 --- /dev/null +++ b/provisioning/ts/seats/meshprovisioner/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-provisioning-contracts-mesh-provisioner", + "packages": { + "": { + "name": "@novox/mesh-provisioning-contracts-mesh-provisioner", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/provisioning/ts/seats/meshprovisioner/package.json b/provisioning/ts/seats/meshprovisioner/package.json new file mode 100644 index 0000000..022c75d --- /dev/null +++ b/provisioning/ts/seats/meshprovisioner/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-provisioner, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-provisioning-contracts-mesh-provisioner", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/provisioning/ts/seats/meshprovisioner/src/index.ts b/provisioning/ts/seats/meshprovisioner/src/index.ts new file mode 100644 index 0000000..23c0e47 --- /dev/null +++ b/provisioning/ts/seats/meshprovisioner/src/index.ts @@ -0,0 +1,226 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "mesh-provisioner", defined whole (seats/mesh-provisioner/seat.json): The provisioner (ADR 0349): which domain answers which kind of placeholder, what each module needs, what is applied where. + +export const seat = "mesh-provisioner"; + +export const verbs = { + provisioning: "provisioning", +} as const; + +export const states = { + plans: "plans", + provisions: "provisions", +} as const; + +/** The key of one value of the state plans under the form ".". */ +export function plansByNodeModuleKey(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 parsePlansByNodeModuleKey(_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 provisions under the form "..". */ +export function provisionsByNodeConsumerProvisionKey(node: string, consumer: string, provision: string): string { + return node + "." + consumer + "." + provision; +} + +/** The parts of a key of the form "..", or undefined for a key of another form. */ +export function parseProvisionsByNodeConsumerProvisionKey(_key: string): { node: string; consumer: string; provision: 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); + const _at2 = _rest.indexOf("."); + if (_at2 <= 0) return undefined; + const consumer = _rest.slice(0, _at2); + _rest = _rest.slice(_at2 + 1); + if (_rest === "") return undefined; + const provision = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node, consumer, provision }; +} + +/** The key of one value of the state provisions under the form "...". */ +export function provisionsByNodeConsumerProvisionLocalKey(node: string, consumer: string, provision: string, local: string): string { + return node + "." + consumer + "." + provision + "." + local; +} + +/** The parts of a key of the form "...", or undefined for a key of another form. */ +export function parseProvisionsByNodeConsumerProvisionLocalKey(_key: string): { node: string; consumer: string; provision: string; local: 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); + const _at2 = _rest.indexOf("."); + if (_at2 <= 0) return undefined; + const consumer = _rest.slice(0, _at2); + _rest = _rest.slice(_at2 + 1); + const _at4 = _rest.indexOf("."); + if (_at4 <= 0) return undefined; + const provision = _rest.slice(0, _at4); + _rest = _rest.slice(_at4 + 1); + if (_rest === "") return undefined; + const local = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node, consumer, provision, local }; +} + +/** is what provisioning takes. */ +export interface ProvisioningInput { + /** for collect: a collection */ + "collection"?: string; + /** a module: for collect, a contributor */ + "module"?: string; + /** a node */ + "node"?: string; + /** registry (when not given), needs, applied or collect */ + "what"?: string; +} + +/** is what provisioning answers. */ +export type ProvisioningAnswer = Record; + +/** is one value of the state plans under the key .. */ +export interface PlansByNodeModuleValue { + "module": string; + "node": string; + /** the module's plan */ + "plan": Record; + "v"?: number; +} + +export interface ProvisionsByNodeConsumerProvisionValueConsumer { + "module"?: string; + "node"?: string; +} + +/** the plan's provision: the entry the key was composed from, at the consumer's wanted build or its newest (issue 1126) */ +export interface ProvisionsByNodeConsumerProvisionValuePlan { + "as"?: string; + "at"?: string; + /** the provider's own contract's bytes, carried unread */ + "contributed"?: Record; + "contributes"?: Record; + "credential-form"?: unknown; + "credential-tool"?: string; + "from"?: string; + "local"?: string; + "name"?: string; + "names"?: Record; + "path"?: string; + "provider": string; + "provider-module": string; + "provision": string; +} + +export interface ProvisionsByNodeConsumerProvisionValueProvider { + "module"?: string; + "node"?: string; +} + +export interface ProvisionsByNodeConsumerProvisionValueReference { + "generation"?: number; + "id"?: string; +} + +export interface ProvisionsByNodeConsumerProvisionValueWarrant { + "ask"?: string; + "at"?: string; + "by"?: string; + "option"?: string; +} + +/** is one value of the state provisions under the key ... */ +export interface ProvisionsByNodeConsumerProvisionValue { + "ask"?: string; + "at": string; + "consumer": ProvisionsByNodeConsumerProvisionValueConsumer; + "depends-on"?: string[]; + "local"?: string; + /** the plan's provision: the entry the key was composed from, at the consumer's wanted build or its newest (issue 1126) */ + "plan"?: ProvisionsByNodeConsumerProvisionValuePlan; + "provider": ProvisionsByNodeConsumerProvisionValueProvider; + "provision": string; + "reference"?: ProvisionsByNodeConsumerProvisionValueReference; + "state": "asked" | "provided" | "waits" | "failed" | "retiring"; + "v"?: number; + "warrant"?: ProvisionsByNodeConsumerProvisionValueWarrant; + "why"?: string; +} + +export interface ProvisionsByNodeConsumerProvisionLocalValueConsumer { + "module"?: string; + "node"?: string; +} + +/** the plan's provision: the entry the key was composed from, at the consumer's wanted build or its newest (issue 1126) */ +export interface ProvisionsByNodeConsumerProvisionLocalValuePlan { + "as"?: string; + "at"?: string; + /** the provider's own contract's bytes, carried unread */ + "contributed"?: Record; + "contributes"?: Record; + "credential-form"?: unknown; + "credential-tool"?: string; + "from"?: string; + "local"?: string; + "name"?: string; + "names"?: Record; + "path"?: string; + "provider": string; + "provider-module": string; + "provision": string; +} + +export interface ProvisionsByNodeConsumerProvisionLocalValueProvider { + "module"?: string; + "node"?: string; +} + +export interface ProvisionsByNodeConsumerProvisionLocalValueReference { + "generation"?: number; + "id"?: string; +} + +export interface ProvisionsByNodeConsumerProvisionLocalValueWarrant { + "ask"?: string; + "at"?: string; + "by"?: string; + "option"?: string; +} + +/** is one value of the state provisions under the key .... */ +export interface ProvisionsByNodeConsumerProvisionLocalValue { + "ask"?: string; + "at": string; + "consumer": ProvisionsByNodeConsumerProvisionLocalValueConsumer; + "depends-on"?: string[]; + "local"?: string; + /** the plan's provision: the entry the key was composed from, at the consumer's wanted build or its newest (issue 1126) */ + "plan"?: ProvisionsByNodeConsumerProvisionLocalValuePlan; + "provider": ProvisionsByNodeConsumerProvisionLocalValueProvider; + "provision": string; + "reference"?: ProvisionsByNodeConsumerProvisionLocalValueReference; + "state": "asked" | "provided" | "waits" | "failed" | "retiring"; + "v"?: number; + "warrant"?: ProvisionsByNodeConsumerProvisionLocalValueWarrant; + "why"?: string; +} + diff --git a/provisioning/ts/seats/meshprovisioner/tsconfig.json b/provisioning/ts/seats/meshprovisioner/tsconfig.json new file mode 100644 index 0000000..60274c6 --- /dev/null +++ b/provisioning/ts/seats/meshprovisioner/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 new file mode 100644 index 0000000..1b83c4b --- /dev/null +++ b/seats/index.json @@ -0,0 +1,31 @@ +{ + "format": 1, + "seats": [ + "mesh-store", + "mesh-broker", + "mesh-config", + "node-login-shell", + "node-power", + "node-hotkeys", + "node-message-bus", + "node-backup", + "node-reads", + "node-files", + "node-engine", + "node-login-manager", + "node-display-server", + "node-display-session", + "node-terminal-emulator", + "node-launcher", + "node-notifier", + "node-lock-screen", + "node-clipboard", + "node-bar", + "node-compositor", + "mesh-module-assigner", + "node-service-manager", + "node-package-manager", + "node-container-runtime", + "node-environment" + ] +} diff --git a/seats/mesh-broker/fixtures/events.json b/seats/mesh-broker/fixtures/events.json new file mode 100644 index 0000000..374448d --- /dev/null +++ b/seats/mesh-broker/fixtures/events.json @@ -0,0 +1,213 @@ +[ + { + "name": "created on the seat's subject", + "of": "event:created", + "subject": "mesh.seat.mesh-broker.event.created", + "given": { + "module": "mesh-knowledge", + "local-name": "ledger", + "storage-type": "ledger", + "template": "ledger", + "bus-name": "mesh-knowledge_ledger", + "asked": { + "max-mib": 64 + }, + "held": { + "max-mib": 64 + }, + "fill": { + "count": 10, + "bytes": 2048, + "cap": 67108864 + } + } + }, + { + "name": "refused on the seat's subject", + "of": "event:refused", + "subject": "mesh.seat.mesh-broker.event.refused", + "given": { + "module": "mesh-knowledge", + "local-name": "ledger", + "storage-type": "ledger", + "template": "ledger", + "bus-name": "mesh-knowledge_ledger", + "asked": { + "max-mib": 64 + }, + "held": { + "max-mib": 64 + }, + "fill": { + "count": 10, + "bytes": 2048, + "cap": 67108864 + }, + "why": "the bus refused it" + } + }, + { + "name": "drifted on the seat's subject", + "of": "event:drifted", + "subject": "mesh.seat.mesh-broker.event.drifted", + "given": { + "module": "mesh-knowledge", + "local-name": "ledger", + "storage-type": "ledger", + "template": "ledger", + "bus-name": "mesh-knowledge_ledger", + "asked": { + "max-mib": 64 + }, + "held": { + "max-mib": 64 + }, + "fill": { + "count": 10, + "bytes": 2048, + "cap": 67108864 + }, + "why": "the bus refused it" + } + }, + { + "name": "drift-cleared on the seat's subject", + "of": "event:drift-cleared", + "subject": "mesh.seat.mesh-broker.event.drift-cleared", + "given": { + "module": "mesh-knowledge", + "local-name": "ledger", + "storage-type": "ledger", + "template": "ledger", + "bus-name": "mesh-knowledge_ledger", + "asked": { + "max-mib": 64 + }, + "held": { + "max-mib": 64 + }, + "fill": { + "count": 10, + "bytes": 2048, + "cap": 67108864 + } + } + }, + { + "name": "full on the seat's subject", + "of": "event:full", + "subject": "mesh.seat.mesh-broker.event.full", + "given": { + "module": "mesh-knowledge", + "local-name": "ledger", + "storage-type": "ledger", + "template": "ledger", + "bus-name": "mesh-knowledge_ledger", + "asked": { + "max-mib": 64 + }, + "held": { + "max-mib": 64 + }, + "fill": { + "count": 10, + "bytes": 2048, + "cap": 67108864 + } + } + }, + { + "name": "full-cleared on the seat's subject", + "of": "event:full-cleared", + "subject": "mesh.seat.mesh-broker.event.full-cleared", + "given": { + "module": "mesh-knowledge", + "local-name": "ledger", + "storage-type": "ledger", + "template": "ledger", + "bus-name": "mesh-knowledge_ledger", + "asked": { + "max-mib": 64 + }, + "held": { + "max-mib": 64 + }, + "fill": { + "count": 10, + "bytes": 2048, + "cap": 67108864 + } + } + }, + { + "name": "orphaned on the seat's subject", + "of": "event:orphaned", + "subject": "mesh.seat.mesh-broker.event.orphaned", + "given": { + "module": "mesh-knowledge", + "local-name": "ledger", + "storage-type": "ledger", + "template": "ledger", + "bus-name": "mesh-knowledge_ledger", + "asked": { + "max-mib": 64 + }, + "held": { + "max-mib": 64 + }, + "fill": { + "count": 10, + "bytes": 2048, + "cap": 67108864 + } + } + }, + { + "name": "recreated on the seat's subject", + "of": "event:recreated", + "subject": "mesh.seat.mesh-broker.event.recreated", + "given": { + "module": "mesh-knowledge", + "local-name": "ledger", + "storage-type": "ledger", + "template": "ledger", + "bus-name": "mesh-knowledge_ledger", + "asked": { + "max-mib": 64 + }, + "held": { + "max-mib": 64 + }, + "fill": { + "count": 10, + "bytes": 2048, + "cap": 67108864 + }, + "why": "the bus refused it" + } + }, + { + "name": "dropped on the seat's subject", + "of": "event:dropped", + "subject": "mesh.seat.mesh-broker.event.dropped", + "given": { + "module": "mesh-knowledge", + "local-name": "ledger", + "storage-type": "ledger", + "template": "ledger", + "bus-name": "mesh-knowledge_ledger", + "asked": { + "max-mib": 64 + }, + "held": { + "max-mib": 64 + }, + "fill": { + "count": 10, + "bytes": 2048, + "cap": 67108864 + }, + "why": "the bus refused it" + } + } +] diff --git a/seats/mesh-broker/fixtures/verbs.json b/seats/mesh-broker/fixtures/verbs.json new file mode 100644 index 0000000..a1d5c41 --- /dev/null +++ b/seats/mesh-broker/fixtures/verbs.json @@ -0,0 +1,51 @@ +[ + { + "name": "create one entry", + "of": "verb:create.input", + "given": { + "module": "mesh-knowledge", + "local-name": "ledger", + "storage-type": "ledger", + "template": "ledger", + "options": { + "max-mib": 64 + } + } + }, + { + "name": "drop on the operator's warrant", + "of": "verb:drop.input", + "given": { + "module": "mesh-knowledge", + "local-name": "old", + "warrant": "w-1", + "why": "the operator approved the drop" + } + }, + { + "name": "list answers every entry", + "of": "verb:list.answer", + "given": { + "entries": [ + { + "module": "mesh-knowledge", + "local-name": "ledger", + "storage-type": "ledger", + "template": "ledger", + "options": { + "max-mib": 64 + }, + "bus-name": "mesh-knowledge_ledger", + "settings": { + "max-mib": 64 + }, + "fill": { + "count": 10, + "bytes": 2048, + "cap": 67108864 + } + } + ] + } + } +] diff --git a/seats/mesh-broker/seat.json b/seats/mesh-broker/seat.json new file mode 100644 index 0000000..6fc5d4e --- /dev/null +++ b/seats/mesh-broker/seat.json @@ -0,0 +1,570 @@ +{ + "format": 1, + "seat": "mesh-broker", + "scope": "mesh", + "grain": "whole", + "domain": "Core", + "decision": "novox/hq ADR 0079", + "delivers": "mesh-bus", + "description": "The mesh's bus, and the bus state modules ask of it in requires (design 25 § 12). create, apply, recreate and drop are granted to the bus module's own credential alone.", + "verbs": [ + { + "name": "list", + "description": "Every entry the bus module created, its template, its settings and its fill against its cap.", + "callers": "any caller whose role grants it", + "input": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "one module (optional)" + } + } + }, + "answer": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/defs/Held" + }, + "description": "every entry" + } + }, + "required": [ + "entries" + ] + }, + "optional": true + }, + { + "name": "show", + "description": "One entry, its template, settings and fill.", + "callers": "any caller whose role grants it", + "input": { + "$ref": "#/defs/Named" + }, + "answer": { + "$ref": "#/defs/Held" + }, + "optional": true + }, + { + "name": "check", + "description": "Compare what the bus holds with what was asked, entry by entry; a difference that only grows is said as such.", + "callers": "any caller whose role grants it", + "input": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "one module (optional)" + } + } + }, + "answer": { + "type": "object", + "properties": { + "differences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "local-name": { + "type": "string", + "description": "the entry" + }, + "asked": { + "type": "object", + "description": "asked" + }, + "held": { + "type": "object", + "description": "held" + }, + "grows": { + "type": "boolean", + "description": "the difference only grows: apply may act on it" + } + }, + "required": [ + "module", + "local-name" + ] + }, + "description": "every difference" + } + }, + "required": [ + "differences" + ] + }, + "optional": true + }, + { + "name": "orphans", + "description": "Entries the bus holds that no manifest asks for; never one whose manifest the judge refused or outgrew, nor one of the foundation's old sections.", + "callers": "any caller whose role grants it", + "input": { + "type": "object", + "properties": {}, + "description": "no arguments" + }, + "answer": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/defs/Held" + }, + "description": "every orphan" + } + }, + "required": [ + "entries" + ] + }, + "optional": true + }, + { + "name": "create", + "description": "Create one entry now.", + "callers": "the bus module's own process only", + "input": { + "$ref": "#/defs/Entry" + }, + "answer": { + "$ref": "#/defs/Held" + }, + "optional": true + }, + { + "name": "apply", + "description": "Apply a difference that only grows: a larger cap, a longer age.", + "callers": "the bus module's own process only", + "input": { + "$ref": "#/defs/Named" + }, + "answer": { + "$ref": "#/defs/Held" + }, + "optional": true + }, + { + "name": "recreate", + "description": "Rebuild an entry whose settings differ in a way that cannot grow, from its data where the storage type allows, after a stream snapshot.", + "callers": "the bus module's process, on the operator's warrant", + "input": { + "$ref": "#/defs/Warranted" + }, + "answer": { + "$ref": "#/defs/Held" + }, + "optional": true + }, + { + "name": "drop", + "description": "Remove one orphan, after a stream snapshot.", + "callers": "the bus module's process, on the operator's warrant", + "input": { + "$ref": "#/defs/Warranted" + }, + "answer": { + "type": "object", + "properties": { + "dropped": { + "type": "boolean", + "description": "it is gone" + }, + "snapshot": { + "type": "string", + "description": "the snapshot taken first" + } + }, + "required": [ + "dropped" + ] + }, + "optional": true + } + ], + "events": [ + { + "name": "created", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "refused", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "drifted", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "drift-cleared", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "full", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "full-cleared", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "orphaned", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "recreated", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "dropped", + "body": { + "$ref": "#/defs/Said" + } + } + ], + "family": { + "name": "mesh-broker.events", + "max-age-days": 7 + }, + "provisions": [ + { + "name": "bus-state", + "form": { + "$ref": "#/defs/Binding" + } + } + ], + "defs": { + "Entry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module asking it" + }, + "local-name": { + "type": "string", + "description": "its local name in the module's manifest" + }, + "storage-type": { + "type": "string", + "enum": [ + "stream", + "key-value", + "object-store", + "ledger" + ], + "description": "how the bus keeps it" + }, + "template": { + "type": "string", + "enum": [ + "state", + "events", + "work-queue", + "log", + "objects", + "ledger", + "" + ], + "description": "its template; empty for a configuration of its own" + }, + "options": { + "type": "object", + "description": "the template's options the module set, within their bounds" + }, + "configuration": { + "type": "object", + "description": "its own configuration, where no template fits" + } + }, + "required": [ + "module", + "local-name", + "storage-type" + ], + "description": "one piece of bus state as a manifest asks it" + }, + "Fill": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "description": "messages, keys or objects held" + }, + "bytes": { + "type": "integer", + "description": "bytes held" + }, + "cap": { + "type": "integer", + "description": "its cap in bytes" + } + }, + "required": [ + "count", + "bytes", + "cap" + ] + }, + "Held": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module asking it" + }, + "local-name": { + "type": "string", + "description": "its local name in the module's manifest" + }, + "storage-type": { + "type": "string", + "enum": [ + "stream", + "key-value", + "object-store", + "ledger" + ], + "description": "how the bus keeps it" + }, + "template": { + "type": "string", + "enum": [ + "state", + "events", + "work-queue", + "log", + "objects", + "ledger", + "" + ], + "description": "its template; empty for a configuration of its own" + }, + "options": { + "type": "object", + "description": "the template's options the module set, within their bounds" + }, + "configuration": { + "type": "object", + "description": "its own configuration, where no template fits" + }, + "bus-name": { + "type": "string", + "description": "its name on the bus, _" + }, + "settings": { + "type": "object", + "description": "the settings the bus holds" + }, + "fill": { + "$ref": "#/defs/Fill" + }, + "state": { + "type": "string", + "enum": [ + "held", + "refused", + "waiting", + "drifted", + "full", + "orphaned" + ], + "description": "the entry's state: held as asked, refused, waiting to be made, drifted from what is asked, full, or orphaned (no manifest asks it)" + }, + "why": { + "type": "string", + "description": "why it is in its state, in plain words; empty when held as asked" + } + }, + "required": [ + "module", + "local-name", + "storage-type", + "bus-name" + ], + "description": "one entry as the bus holds it" + }, + "Said": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module asking it" + }, + "local-name": { + "type": "string", + "description": "its local name in the module's manifest" + }, + "storage-type": { + "type": "string", + "enum": [ + "stream", + "key-value", + "object-store", + "ledger" + ], + "description": "how the bus keeps it" + }, + "template": { + "type": "string", + "enum": [ + "state", + "events", + "work-queue", + "log", + "objects", + "ledger", + "" + ], + "description": "its template; empty for a configuration of its own" + }, + "options": { + "type": "object", + "description": "the template's options the module set, within their bounds" + }, + "configuration": { + "type": "object", + "description": "its own configuration, where no template fits" + }, + "bus-name": { + "type": "string", + "description": "its name on the bus" + }, + "asked": { + "type": "object", + "description": "the settings asked" + }, + "held": { + "type": "object", + "description": "the settings held" + }, + "why": { + "type": "string", + "description": "why: a refusal's cause, a drift's difference, a drop's or recreate's reason" + }, + "fill": { + "$ref": "#/defs/Fill" + }, + "snapshot": { + "type": "string", + "description": "the stream snapshot taken before a recreate or drop" + }, + "binding": { + "$ref": "#/defs/Binding" + }, + "revision": { + "type": "integer", + "description": "the indexer's revision of the verdict the bus module acted on (ADR 0622 §2), so a reader orders what it hears" + } + }, + "required": [ + "module", + "local-name", + "bus-name" + ], + "description": "the body of every event of the seat" + }, + "Named": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "local-name": { + "type": "string", + "description": "the entry's local name" + } + }, + "required": [ + "module", + "local-name" + ] + }, + "Warranted": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "local-name": { + "type": "string", + "description": "the entry's local name" + }, + "warrant": { + "type": "string", + "description": "the operator's warrant, as the operator channel decided it (ADR 0259 §6)" + }, + "why": { + "type": "string", + "description": "why, kept in the record" + } + }, + "required": [ + "module", + "local-name", + "warrant", + "why" + ] + }, + "Binding": { + "type": "object", + "properties": { + "bus-name": { + "type": "string", + "description": "the entry's name on the bus" + }, + "storage-type": { + "type": "string", + "description": "how the bus keeps it" + }, + "template": { + "type": "string", + "description": "its template" + }, + "subjects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "its subjects, for a stream" + } + }, + "required": [ + "bus-name", + "storage-type" + ], + "description": "the binding a module receives in its membership for one entry (ADR 0595 §1, §7)" + } + } +} diff --git a/seats/mesh-config/fixtures/events.json b/seats/mesh-config/fixtures/events.json new file mode 100644 index 0000000..4cbc324 --- /dev/null +++ b/seats/mesh-config/fixtures/events.json @@ -0,0 +1,44 @@ +[ + { + "name": "config-changed, unchanged", + "of": "holder-event:config-changed", + "subject": "mesh.mod.mesh-config.event.config-changed", + "given": { + "key": "x" + } + }, + { + "name": "renamed", + "of": "holder-event:renamed", + "subject": "mesh.stream.mesh-config.event.renamed", + "given": { + "module": "messenger", + "from": "old-key", + "to": "new-key", + "at": "2026-11-04T12:00:00Z" + } + }, + { + "name": "retiring", + "of": "holder-event:retiring", + "subject": "mesh.stream.mesh-config.event.retiring", + "given": { + "module": "messenger", + "key": "old-key", + "kind": "value", + "since": "2026-11-04T12:00:00Z", + "ask": "a1" + } + }, + { + "name": "retired", + "of": "holder-event:retired", + "subject": "mesh.stream.mesh-config.event.retired", + "given": { + "module": "messenger", + "name": "old-key", + "node": "ace", + "ask": "a1" + } + } +] diff --git a/seats/mesh-config/fixtures/states.json b/seats/mesh-config/fixtures/states.json new file mode 100644 index 0000000..edc2be3 --- /dev/null +++ b/seats/mesh-config/fixtures/states.json @@ -0,0 +1,45 @@ +[ + { + "name": "a module's value that falls back to a predefined key (captured)", + "of": "state:resolved/..", + "given": { + "node": "g14", + "module": "dunst", + "key": "font-size", + "predefined": "font-size", + "source": "module", + "value": 13 + } + }, + { + "name": "a module's own value (captured)", + "of": "state:resolved/..", + "given": { + "node": "g14", + "module": "dunst", + "key": "width", + "source": "module", + "value": 500 + } + }, + { + "name": "a held value, as mesh-config writes it (the holder's own test values)", + "of": "state:kept/.....", + "why": "no tool reads the state kept; the values are the holder's test (modules/mesh-config pulls_test.go): key novox.mesh-catalog.434.ace.n8n.media-gid", + "given": { + "pull": "novox/mesh-catalog#434", + "head": "abc1234def", + "declaration": "sha256:3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b", + "node": "ace", + "module": "n8n", + "key": "media-gid", + "value": 2000, + "by": "the operator", + "via": "telegram", + "why": "the group the media share is owned by", + "approved": "operator@telegram", + "proposal": "p-1", + "at": "2026-11-05T12:30:00Z" + } + } +] diff --git a/seats/mesh-config/fixtures/verbs.json b/seats/mesh-config/fixtures/verbs.json new file mode 100644 index 0000000..aab449b --- /dev/null +++ b/seats/mesh-config/fixtures/verbs.json @@ -0,0 +1,19 @@ +[ + { + "name": "renames answers the open windows", + "of": "verb:renames.answer", + "given": { + "renames": [ + { + "module": "messenger", + "from": "old-key", + "to": "new-key", + "at": "2026-11-04T12:00:00Z", + "open": [ + "g14" + ] + } + ] + } + } +] diff --git a/seats/mesh-config/seat.json b/seats/mesh-config/seat.json new file mode 100644 index 0000000..9a555b1 --- /dev/null +++ b/seats/mesh-config/seat.json @@ -0,0 +1,346 @@ +{ + "format": 1, + "seat": "mesh-config", + "scope": "mesh", + "grain": "whole", + "domain": "Config", + "decision": "novox/hq ADR 0348, ADR 0565", + "verbs": [ + { + "name": "config", + "description": "Set, clear, list or read the history of config values: mesh config or node config for a predefined key, or a module's own value on one node, each set with why, validated, and naming each module on each node whose value moves.", + "optional": true, + "input": { + "properties": { + "action": { + "description": "list (when not given), set, clear or history", + "type": "string" + }, + "by": { + "description": "who gave the value at the controller's terminal", + "type": "string" + }, + "key": { + "description": "the config key", + "type": "string" + }, + "limit": { + "description": "for history: at most this many, newest", + "type": "number" + }, + "module": { + "description": "a module: its own value, with node", + "type": "string" + }, + "node": { + "description": "a node", + "type": "string" + }, + "value": { + "description": "for set: the value, of the key's type: a number, a string, a boolean, or for a trusted key an object or a list" + }, + "why": { + "description": "for set and clear: why", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the values asked, or the one set or cleared, or its history" + } + }, + { + "name": "renames", + "optional": true, + "description": "The open renames of config values (design 32 *A config value renamed*): per rename the module, the old and new name, when it moved, and the nodes still holding the old name open.", + "input": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "one module (optional)" + } + } + }, + "callers": "any caller whose role grants it; the checker asks it", + "answer": { + "type": "object", + "properties": { + "renames": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "from": { + "type": "string", + "description": "the old name" + }, + "to": { + "type": "string", + "description": "the new name" + }, + "at": { + "type": "string", + "description": "when it moved, RFC 3339" + }, + "open": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the nodes still holding the old name open" + } + }, + "required": [ + "module", + "from", + "to" + ] + }, + "description": "every open rename" + } + }, + "required": [ + "renames" + ] + } + } + ], + "description": "The mesh's config values: read and set one, and the renames still open. mesh-config's own events stay under its own subjects (ADR 0620 §2).", + "holder-events": [ + { + "name": "config-changed", + "subject": "mesh.mod..event.config-changed" + }, + { + "name": "renamed", + "subject": "mesh.stream..event.renamed", + "family": "config.events", + "body": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "from": { + "type": "string", + "description": "the old name" + }, + "to": { + "type": "string", + "description": "the new name" + }, + "at": { + "type": "string", + "description": "when, RFC 3339" + } + }, + "required": [ + "module", + "from", + "to", + "at" + ] + } + }, + { + "name": "retiring", + "subject": "mesh.stream..event.retiring", + "family": "config.events", + "body": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "key": { + "type": "string", + "description": "the value's or secret's name" + }, + "kind": { + "type": "string", + "enum": [ + "value", + "secret" + ] + }, + "since": { + "type": "string", + "description": "since when, RFC 3339" + }, + "ask": { + "type": "string", + "description": "the ask's id" + } + }, + "required": [ + "module", + "key", + "kind", + "since" + ] + } + }, + { + "name": "retired", + "subject": "mesh.stream..event.retired", + "family": "config.events", + "body": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "name": { + "type": "string", + "description": "the name" + }, + "node": { + "type": "string", + "description": "the node" + }, + "ask": { + "type": "string", + "description": "the ask's id" + } + }, + "required": [ + "module", + "name" + ] + } + } + ], + "states": [ + { + "name": "resolved", + "template": "state", + "key-forms": [ + { + "key": "..", + "value": { + "type": "object", + "description": "the value a module on a node takes for a config key, and where it came from", + "properties": { + "node": { + "type": "string" + }, + "module": { + "type": "string" + }, + "key": { + "type": "string" + }, + "value": {}, + "source": { + "type": "string" + }, + "predefined": { + "type": "string", + "description": "the predefined key the value falls back to" + }, + "required": { + "type": "boolean", + "description": "an unset value is the operator's own to set" + }, + "trusted": { + "type": "boolean" + } + }, + "required": [ + "node", + "module", + "key", + "source" + ] + }, + "description": "one resolved value" + } + ], + "description": "the value each module on each node takes for each config key, built from the sources alone" + }, + { + "name": "kept", + "template": "state", + "key-forms": [ + { + "key": ".....", + "value": { + "type": "object", + "description": "an approved held value, kept apart from the values mesh-config resolves, laid only over the composition of its own pull request at its head (ADR 0565 clause 5)", + "properties": { + "pull": { + "type": "string", + "description": "the pull request, owner/repository#number" + }, + "head": { + "type": "string", + "description": "the head the declaration was read at" + }, + "declaration": { + "type": "string", + "description": "the digest of what the declaration holds the value to; the value stays only while it is the same" + }, + "node": { + "type": "string" + }, + "module": { + "type": "string" + }, + "key": { + "type": "string", + "description": "the config key" + }, + "value": { + "description": "the value held, as the operator approved it" + }, + "by": { + "type": "string" + }, + "via": { + "type": "string", + "description": "where the value was proposed from" + }, + "why": { + "type": "string" + }, + "approved": { + "type": "string", + "description": "who approved it, as the proposal kept it" + }, + "proposal": { + "type": "string", + "description": "the proposal's id" + }, + "at": { + "type": "string", + "format": "date-time", + "description": "when it was kept" + } + }, + "required": [ + "pull", + "head", + "declaration", + "node", + "module", + "key", + "value" + ] + }, + "description": "one held value: the pull request (owner, repository and number, none holding a dot), the node, the module and the config key, which may hold dots" + } + ], + "description": "the approved held values of open pull requests, read by the checker and the assigner's composition check" + } + ] +} diff --git a/seats/mesh-module-assigner/fixtures/events.json b/seats/mesh-module-assigner/fixtures/events.json new file mode 100644 index 0000000..7525d5a --- /dev/null +++ b/seats/mesh-module-assigner/fixtures/events.json @@ -0,0 +1,29 @@ +[ + { + "name": "a proposal says assignments-proposed", + "of": "event:assignments-proposed", + "subject": "mesh.seat.mesh-module-assigner.event.assignments-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/seats/mesh-module-assigner/fixtures/states.json b/seats/mesh-module-assigner/fixtures/states.json new file mode 100644 index 0000000..04e940b --- /dev/null +++ b/seats/mesh-module-assigner/fixtures/states.json @@ -0,0 +1,131 @@ +[ + { + "name": "a node's key", + "of": "state:assignments/.node", + "given": { + "v": 1, + "node": "ace", + "decided": { + "address": "10.0.0.2" + }, + "modules": [ + { + "module": "plex", + "digest": "0123456789abcdef" + } + ] + } + }, + { + "name": "a node that came with a tunnel", + "of": "state:assignments/.node", + "given": { + "v": 1, + "node": "ace", + "decided": { + "tunnel": { + "interface": "wg0", + "port": 51820, + "public-key": "k1", + "peers": [ + { + "public-key": "k2", + "address": "10.77.0.9/32" + } + ] + } + }, + "modules": [] + } + }, + { + "name": "a module's key", + "of": "state:assignments/.", + "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/seats/mesh-module-assigner/seat.json b/seats/mesh-module-assigner/seat.json new file mode 100644 index 0000000..1eb0669 --- /dev/null +++ b/seats/mesh-module-assigner/seat.json @@ -0,0 +1,996 @@ +{ + "format": 1, + "seat": "mesh-module-assigner", + "scope": "mesh", + "grain": "whole", + "domain": "Core", + "decision": "novox/hq ADR 0567, ADR 0634, ADR 0635", + "description": "The seat that places modules on nodes (ADR 0567): the state of assignments and the proposals that change it.", + "verbs": [ + { + "name": "assignments", + "description": "Read the assignments: 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 assignments-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": "assignments as read: nodes, keys with their revisions, history, seat holders, the state's fill" + } + }, + { + "name": "proposals", + "description": "Every open proposal of assignments: 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 assignments, 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 assignments-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": "assignments-proposed", + "body": { + "$ref": "#/defs/AssignmentsEvent" + } + }, + { + "name": "assignments-withdrawn", + "body": { + "$ref": "#/defs/AssignmentsEvent" + } + }, + { + "name": "assignments-judged", + "body": { + "$ref": "#/defs/AssignmentsEvent" + } + }, + { + "name": "assignments-changed", + "body": { + "$ref": "#/defs/AssignmentsEvent" + } + } + ], + "family": { + "name": "mesh-module-assigner.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "assignments", + "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": "assignments, 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" + }, + "assignments": { + "$ref": "#/defs/Assignments", + "description": "the node's assignments 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 assignments" + } + ], + "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 assignments" + }, + "has-mesh-wide": { + "type": "boolean" + }, + "layer": { + "type": "object", + "description": "this node's settings layer of the module's assignments" + }, + "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": { + "$ref": "#/defs/FoundTunnel", + "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" + } + } + }, + "FoundTunnel": { + "type": "object", + "description": "a tunnel found on the machine when it was adopted: everything but its private key", + "properties": { + "interface": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "config": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "mtu": { + "type": "integer" + }, + "address": { + "type": "string" + }, + "range": { + "type": "string" + }, + "public-key": { + "type": "string" + }, + "peers": { + "type": "array", + "items": { + "$ref": "#/defs/FoundTunnelPeer" + } + } + }, + "required": [ + "interface" + ] + }, + "FoundTunnelPeer": { + "type": "object", + "description": "one peer of a found tunnel", + "properties": { + "public-key": { + "type": "string" + }, + "address": { + "type": "string" + } + }, + "required": [ + "public-key" + ] + }, + "Assignments": { + "type": "object", + "description": "one node's assignments 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/Assignments", + "description": "the node's whole assignments, 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" + ] + }, + "AssignmentsEvent": { + "type": "object", + "description": "what each assignments event carries", + "properties": { + "proposal": { + "$ref": "#/defs/Proposal" + }, + "nodes": { + "type": "array", + "items": { + "type": "object" + }, + "description": "for assignments-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 assignments-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/seats/mesh-store/seat.json b/seats/mesh-store/seat.json new file mode 100644 index 0000000..fe1c4dd --- /dev/null +++ b/seats/mesh-store/seat.json @@ -0,0 +1,46 @@ +{ + "format": 1, + "seat": "mesh-store", + "scope": "mesh", + "grain": "seat-list", + "domain": "Module", + "decision": "novox/hq ADR 0079", + "delivers": "postgres-database", + "verbs": [ + { + "name": "databases", + "description": "Every database the store holds, with its on-disk size.", + "replaces": [ + "psql -l" + ], + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "query", + "description": "One read-only statement against one database the store holds.", + "replaces": [ + "psql" + ], + "input": { + "properties": { + "database": { + "description": "the database to query", + "type": "string" + }, + "sql": { + "description": "the read-only statement", + "type": "string" + } + }, + "required": [ + "database", + "sql" + ], + "type": "object" + } + } + ] +} diff --git a/seats/node-backup/seat.json b/seats/node-backup/seat.json new file mode 100644 index 0000000..f5c59d2 --- /dev/null +++ b/seats/node-backup/seat.json @@ -0,0 +1,81 @@ +{ + "format": 1, + "seat": "node-backup", + "scope": "node", + "grain": "seat-list", + "domain": "Data", + "decision": "novox/hq ADR 0214", + "verbs": [ + { + "name": "backed-up", + "description": "What this machine backs up: each module, what it declared, its last good night, how many restore points are kept and the repository's size.", + "replaces": [ + "restic snapshots" + ], + "input": { + "properties": { + "module": { + "description": "one module (optional)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "now", + "description": "Take a backup now, of one module or of every module on this machine — before a migration, a retirement or anything else that could go wrong.", + "replaces": [ + "restic backup" + ], + "input": { + "properties": { + "module": { + "description": "one module (optional)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "restore", + "description": "Restore one module's data from a restore point BESIDE the live data, never over it: each directory as \u003cpath\u003e.restored-\u003cdate\u003e. Swapping it in is a person's act. Lists the restore points when none is named.", + "replaces": [ + "restic restore" + ], + "input": { + "properties": { + "module": { + "description": "the module", + "type": "string" + }, + "path": { + "description": "one of the module's directories (all of them when omitted)", + "type": "string" + }, + "snapshot": { + "description": "the restore point (from `backed-up`; the newest when omitted)", + "type": "string" + } + }, + "required": [ + "module" + ], + "type": "object" + } + } + ], + "receives": [ + { + "kind": "backup", + "comment": "#", + "dirs": true + }, + { + "kind": "data", + "comment": "#", + "dirs": true + } + ] +} diff --git a/seats/node-bar/seat.json b/seats/node-bar/seat.json new file mode 100644 index 0000000..92249d1 --- /dev/null +++ b/seats/node-bar/seat.json @@ -0,0 +1,76 @@ +{ + "format": 1, + "seat": "node-bar", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0208", + "receives": [ + { + "kind": "block", + "comment": "#", + "offered": true, + "shape": { + "places": [ + { + "field": "bar", + "values": [ + "bottom", + "top" + ] + }, + { + "field": "place", + "values": [ + "resources", + "status" + ] + } + ], + "order": "order", + "examples": [ + { + "bar": "bottom", + "place": "status", + "shows": "battery" + }, + { + "bar": "bottom", + "options": { + "device": "BAT0" + }, + "place": "status", + "shows": "battery" + }, + { + "bar": "bottom", + "options": { + "command": "echo \"a line\"", + "interval": 5 + }, + "place": "status", + "shows": "command" + }, + { + "bar": "bottom", + "options": { + "state": "power.draw" + }, + "place": "status", + "shows": "state" + }, + { + "bar": "bottom", + "options": { + "key": "a-machine", + "state": "power.draw" + }, + "place": "status", + "shows": "state" + } + ], + "text": true + } + } + ] +} diff --git a/seats/node-clipboard/seat.json b/seats/node-clipboard/seat.json new file mode 100644 index 0000000..88dde07 --- /dev/null +++ b/seats/node-clipboard/seat.json @@ -0,0 +1,39 @@ +{ + "format": 1, + "seat": "node-clipboard", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0208", + "verbs": [ + { + "name": "history", + "description": "What the clipboard held lately, newest first.", + "input": { + "properties": { + "limit": { + "description": "how many (optional, default 20)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "copy", + "description": "Put text on the operator's clipboard.", + "input": { + "properties": { + "text": { + "description": "the text", + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + } + } + ] +} diff --git a/seats/node-compositor/seat.json b/seats/node-compositor/seat.json new file mode 100644 index 0000000..f126432 --- /dev/null +++ b/seats/node-compositor/seat.json @@ -0,0 +1,8 @@ +{ + "format": 1, + "seat": "node-compositor", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0208" +} diff --git a/seats/node-container-runtime/seat.json b/seats/node-container-runtime/seat.json new file mode 100644 index 0000000..8346a78 --- /dev/null +++ b/seats/node-container-runtime/seat.json @@ -0,0 +1,8 @@ +{ + "format": 1, + "seat": "node-container-runtime", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0166, ADR 0207" +} diff --git a/seats/node-display-server/seat.json b/seats/node-display-server/seat.json new file mode 100644 index 0000000..20c587d --- /dev/null +++ b/seats/node-display-server/seat.json @@ -0,0 +1,43 @@ +{ + "format": 1, + "seat": "node-display-server", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0208", + "verbs": [ + { + "name": "displays", + "description": "The monitors connected now, each with its identity, its modes and where it is placed; and the layout profile in force, if one matches.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "layout", + "description": "The monitor layout profiles, keyed by the connected monitors' identities: list them, save the current arrangement under a name, or apply one.", + "input": { + "properties": { + "action": { + "description": "list, save or apply", + "enum": [ + "list", + "save", + "apply" + ], + "type": "string" + }, + "name": { + "description": "the profile to save or apply (save and apply only)", + "type": "string" + } + }, + "required": [ + "action" + ], + "type": "object" + } + } + ] +} diff --git a/seats/node-display-session/seat.json b/seats/node-display-session/seat.json new file mode 100644 index 0000000..a815db1 --- /dev/null +++ b/seats/node-display-session/seat.json @@ -0,0 +1,45 @@ +{ + "format": 1, + "seat": "node-display-session", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0208", + "verbs": [ + { + "name": "reload", + "description": "Reload the session's configuration in place, keeping its windows.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "workspaces", + "description": "The session's workspaces: each one's name, output, and whether it is visible or focused.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "windows", + "description": "The session's windows: each one's title, class, workspace and whether it has focus; narrowed to one workspace when named.", + "input": { + "properties": { + "workspace": { + "description": "one workspace (optional)", + "type": "string" + } + }, + "type": "object" + } + } + ], + "receives": [ + { + "kind": "config", + "comment": "#" + } + ] +} diff --git a/seats/node-engine/fixtures/events.json b/seats/node-engine/fixtures/events.json new file mode 100644 index 0000000..eb2c8d7 --- /dev/null +++ b/seats/node-engine/fixtures/events.json @@ -0,0 +1,69 @@ +[ + { + "name": "asked on the node's subject", + "of": "event:asked", + "subject": "mesh.seat.node-engine.event.asked.ace", + "given": { + "act": "hand-over", + "subject": "/srv/media", + "caller": "mesh-tools", + "ask": "hand-over-1", + "state": "asked", + "node": "ace" + } + }, + { + "name": "done on the node's subject", + "of": "event:done", + "subject": "mesh.seat.node-engine.event.done.ace", + "given": { + "act": "hand-over", + "subject": "/srv/media", + "caller": "mesh-tools", + "ask": "hand-over-1", + "state": "done", + "said": "handed over: owner media, group media, mode 2775", + "node": "ace" + } + }, + { + "name": "refused on the node's subject", + "of": "event:refused", + "subject": "mesh.seat.node-engine.event.refused.ace", + "given": { + "act": "access-act", + "subject": "library:music", + "caller": "mesh-tools", + "ask": "access-act-1", + "state": "refused", + "said": "the operator said no", + "node": "ace" + } + }, + { + "name": "manifest-reads-changed when the engine started", + "of": "event:manifest-reads-changed", + "subject": "mesh.seat.node-engine.event.manifest-reads-changed.ace", + "given": { + "v": 1, + "node": "ace", + "manifest-reads": [ + "form:seat-reference", + "word:series" + ], + "at": "2026-11-04T10:00:00Z", + "why": "started" + } + }, + { + "name": "manifest-reads-changed when the node returned, from an engine that states none", + "of": "event:manifest-reads-changed", + "subject": "mesh.seat.node-engine.event.manifest-reads-changed.g14", + "given": { + "v": 1, + "node": "g14", + "at": "2026-11-04T10:05:00Z", + "why": "returned" + } + } +] diff --git a/seats/node-engine/fixtures/states.json b/seats/node-engine/fixtures/states.json new file mode 100644 index 0000000..35631e7 --- /dev/null +++ b/seats/node-engine/fixtures/states.json @@ -0,0 +1,519 @@ +[ + { + "name": "the finding of a seat list unread, as the engine writes it", + "of": "state:findings/", + "given": { + "v": 1, + "key": "machine.ace.seat-list-unread", + "kind": "seat-list-unread", + "subject": { + "scope": "machine", + "id": "ace", + "machine": "ace" + }, + "severity": "warning", + "summary": "the seat list has not been read for five minutes", + "source": "node-engine.seat-list", + "observer": "mesh-host", + "at": "2026-11-05T14:00:00Z" + } + }, + { + "name": "a node's packages, present and absent (captured from ace, shortened)", + "of": "state:packages/", + "given": { + "node": "ace", + "packages": [ + { + "absent": false, + "modules": [ + "avahi" + ], + "package": "avahi" + }, + { + "absent": false, + "modules": [ + "mesh-build-go", + "mesh-build-node" + ], + "package": "bubblewrap" + }, + { + "absent": true, + "modules": [ + "time-sync" + ], + "package": "ntp" + } + ] + } + }, + { + "name": "buildah composed on ace (resources captured from the declaration of 2026-10-24; the fingerprint stands in for the indexer's)", + "of": "state:composed/..", + "given": { + "v": 1, + "node": "ace", + "module": "buildah", + "fp": "src2:62756c6c6462756c6c6462756c6c6462756c6c6462756c6c6462756c6c646275", + "resources": [ + { + "id": "buildah.buildah", + "module": "buildah", + "package": "buildah", + "type": "package" + } + ] + } + }, + { + "name": "node-tools composed with a filled file (captured; the fingerprint stands in)", + "of": "state:composed/..", + "given": { + "v": 1, + "node": "ace", + "module": "node-tools", + "fp": "src2:6e6f64652d6e6f64652d6e6f64652d6e6f64652d6e6f64652d6e6f64652d6e6f", + "resources": [ + { + "id": "node-tools.mesh-state", + "mode": "0755", + "module": "node-tools", + "path": "/var/lib/mesh/node-tools", + "type": "directory" + }, + { + "id": "node-tools.interpreter", + "module": "node-tools", + "package": "nodejs", + "type": "package" + }, + { + "digest": "sha256:ef790876783d817903b337b8418b1eb21fb49d34128a390bbf7c3d08a4b2be8a", + "env": { + "MESH_AGENT_ACCOUNT": "ace", + "MESH_AGENT_HOME": "/home/ace", + "MESH_BROKER_FILE": "/var/lib/mesh/node-tools/broker", + "MESH_OPERATOR_ACCOUNT": "ace", + "MESH_OPERATOR_HOME": "/home/ace", + "MESH_TOOL_ENV": "{\"baserow\":{\"MESH_BASEROW_CONFIG_FILE\":\"/var/lib/mesh/baserow/config.json\",\"MESH_BASEROW_URL\":\"http://127.0.0.1:20011\"},\"bazarr\":{\"MESH_BAZARR_CONFIG_DIR\":\"/var/lib/bazarr/config\",\"MESH_BAZARR_CONFIG_FILE\":\"/var/lib/bazarr/config.json\",\"MESH_BAZARR_CONTAINER\":\"bazarr\",\"MESH_BAZARR_URL\":\"http://127.0.0.1:20020\"},\"bookshelf\":{\"MESH_BOOKSHELF_CONFIG_DIR\":\"/var/lib/bookshelf/config\",\"MESH_BOOKSHELF_URL\":\"http://127.0.0.1:20021\"},\"claude-code\":{\"MESH_CLAUDE_CODE_FACTS\":\"/var/lib/claude-code/facts.json\",\"MESH_CLAUDE_CODE_SETTINGS\":\"/var/lib/claude-code/settings.json\",\"MESH_CLAUDE_CODE_STATE\":\"/var/lib/claude-code\"},\"grafana\":{\"MESH_GRAFANA_CONFIG_FILE\":\"/var/lib/mesh/grafana/config.json\",\"MESH_GRAFANA_URL\":\"http://127.0.0.1:20012\"},\"home-assistant\":{\"MESH_HOMEASSISTANT_CONFIG_FILE\":\"/var/lib/mesh/home-assistant/config.json\",\"MESH_HOMEASSISTANT_TOKEN_FILE\":\"/var/lib/mesh/home-assistant/token\",\"MESH_HOMEASSISTANT_URL\":\"http://127.0.0.1:8123\"},\"icecast\":{\"MESH_ICECAST_ADMIN_PASSWORD_FILE\":\"/var/lib/icecast/admin.secret\",\"MESH_ICECAST_CONFIG_FILE\":\"/var/lib/mesh/icecast/config.json\",\"MESH_ICECAST_URL\":\"http://127.0.0.1:20005\"},\"influxdb\":{\"MESH_INFLUXDB_CONFIG_FILE\":\"/var/lib/mesh/influxdb/config.json\",\"MESH_INFLUXDB_TOKEN_FILE\":\"/var/lib/influxdb/admin-token.secret\",\"MESH_INFLUXDB_URL\":\"http://127.0.0.1:8086\",\"MESH_RECEIVES\":\"/var/lib/influxdb/grants/mesh.json\"},\"jackett\":{\"MESH_JACKETT_API_KEY_FILE\":\"/var/lib/mesh/jackett/api-key\",\"MESH_JACKETT_CONFIG_DIR\":\"/var/lib/jackett/config\",\"MESH_JACKETT_CONFIG_FILE\":\"/var/lib/jackett/config.json\",\"MESH_JACKETT_URL\":\"http://127.0.0.1:20004\"},\"kometa\":{\"MESH_KOMETA_CONFIG_DIR\":\"/var/lib/kometa/config\",\"MESH_KOMETA_CONFIG_FILE\":\"/var/lib/kometa/config.yml\"},\"letta\":{\"MESH_LETTA_CONFIG_FILE\":\"/var/lib/mesh/letta/config.json\",\"MESH_LETTA_URL\":\"http://127.0.0.1:20002\"},\"lidarr\":{\"MESH_LIDARR_API_KEY_FILE\":\"/var/lib/mesh/lidarr/api-key\",\"MESH_LIDARR_CONFIG_DIR\":\"/var/lib/lidarr/config\",\"MESH_LIDARR_RECYCLE_BIN\":\"/recycle/\",\"MESH_LIDARR_RECYCLE_DAYS\":\"7\",\"MESH_LIDARR_URL\":\"http://127.0.0.1:20022\",\"MESH_PLEX_BIND_FILE\":\"/var/lib/lidarr/plex-api.json\",\"MESH_PLEX_TOKEN_FILE\":\"/var/lib/lidarr/plex-api.secret\"},\"media-importer\":{\"MESH_MEDIA_IMPORTER_SETTINGS\":\"/var/lib/media-importer/settings.json\",\"MESH_MEDIA_IMPORTER_STATE\":\"/var/lib/media-importer\",\"MESH_MEDIA_LIBRARY\":\"/storage/media/poc\"},\"mesh-files\":{\"MESH_FILES_STATE\":\"/var/lib/mesh-files\",\"MESH_OWNED_PATHS\":\"{\\\"/etc/NetworkManager/conf.d/50-mesh.conf\\\":\\\"networkmanager (its own tools)\\\",\\\"/etc/claude-code\\\":\\\"claude-code (its own tools)\\\",\\\"/etc/claude-code/agent-account\\\":\\\"claude-code (its own tools)\\\",\\\"/etc/dnsmasq.conf\\\":\\\"dnsmasq (its own tools)\\\",\\\"/etc/docker/daemon.json\\\":\\\"docker (its own tools)\\\",\\\"/etc/fail2ban/action.d\\\":\\\"fail2ban (its own tools)\\\",\\\"/etc/fail2ban/action.d/iptables-allports-dualchain.conf\\\":\\\"fail2ban (its own tools)\\\",\\\"/etc/fail2ban/filter.d\\\":\\\"fail2ban (its own tools)\\\",\\\"/etc/fail2ban/jail.d\\\":\\\"fail2ban (its own tools)\\\",\\\"/etc/fail2ban/jail.d/recidive.conf\\\":\\\"fail2ban (its own tools)\\\",\\\"/etc/fail2ban/jail.d/sshd.conf\\\":\\\"fail2ban (its own tools)\\\",\\\"/etc/fail2ban/jail.local\\\":\\\"fail2ban (its own tools)\\\",\\\"/etc/fail2ban/mesh-own-addresses.json\\\":\\\"fail2ban (its own tools)\\\",\\\"/etc/hostname\\\":\\\"hostname (node-hostname)\\\",\\\"/etc/hosts\\\":\\\"hostname (node-hostname)\\\",\\\"/etc/locale.conf\\\":\\\"localization (its own tools)\\\",\\\"/etc/logrotate.conf\\\":\\\"logrotate (its own tools)\\\",\\\"/etc/logrotate.d/fail2ban\\\":\\\"fail2ban (its own tools)\\\",\\\"/etc/mesh-power\\\":\\\"power (its own tools)\\\",\\\"/etc/mesh-power/moments\\\":\\\"power (its own tools)\\\",\\\"/etc/mesh-power/moments/after-boot\\\":\\\"power (its own tools)\\\",\\\"/etc/mesh-power/moments/after-wake\\\":\\\"power (its own tools)\\\",\\\"/etc/mesh-power/moments/before-shutdown\\\":\\\"power (its own tools)\\\",\\\"/etc/mesh-power/moments/before-sleep\\\":\\\"power (its own tools)\\\",\\\"/etc/mesh-power/moments/on-battery\\\":\\\"power (its own tools)\\\",\\\"/etc/mesh-power/moments/on-mains\\\":\\\"power (its own tools)\\\",\\\"/etc/mesh-resolver/nodes.conf\\\":\\\"dnsmasq (its own tools)\\\",\\\"/etc/mesh-resolver/zones.conf\\\":\\\"dnsmasq (its own tools)\\\",\\\"/etc/nfs-server\\\":\\\"nfs-server (its own tools)\\\",\\\"/etc/nfs-server/machines\\\":\\\"nfs-server (its own tools)\\\",\\\"/etc/nfs-server/shares.conf\\\":\\\"nfs-server (its own tools)\\\",\\\"/etc/nfs.conf.d/50-mesh.conf\\\":\\\"nfs-server (its own tools)\\\",\\\"/etc/pacman.conf\\\":\\\"pacman (its own tools)\\\",\\\"/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook\\\":\\\"kernel (its own tools)\\\",\\\"/etc/resolv.conf\\\":\\\"networkmanager (node-uplink)\\\",\\\"/etc/ssh/sshd_config.d/10-mesh.conf\\\":\\\"sshd (its own tools)\\\",\\\"/etc/sudoers.d/10-mesh-operator\\\":\\\"sudo (its own tools)\\\",\\\"/etc/systemd/logind.conf.d\\\":\\\"power (its own tools)\\\",\\\"/etc/systemd/logind.conf.d/power.conf\\\":\\\"power (its own tools)\\\",\\\"/etc/systemd/system/dnsmasq.service.d\\\":\\\"dnsmasq (its own tools)\\\",\\\"/etc/systemd/system/dnsmasq.service.d/mesh.conf\\\":\\\"dnsmasq (its own tools)\\\",\\\"/etc/systemd/system/docker-prune.service\\\":\\\"docker (its own tools)\\\",\\\"/etc/systemd/system/docker-prune.timer\\\":\\\"docker (its own tools)\\\",\\\"/etc/systemd/system/hibernate.target.d\\\":\\\"power (its own tools)\\\",\\\"/etc/systemd/system/hibernate.target.d/mesh-power.conf\\\":\\\"power (its own tools)\\\",\\\"/etc/systemd/system/hybrid-sleep.target.d\\\":\\\"power (its own tools)\\\",\\\"/etc/systemd/system/hybrid-sleep.target.d/mesh-power.conf\\\":\\\"power (its own tools)\\\",\\\"/etc/systemd/system/mesh-ca-trust.service\\\":\\\"ca-trust (its own tools)\\\",\\\"/etc/systemd/system/mesh-filter.service\\\":\\\"nftables (its own tools)\\\",\\\"/etc/systemd/system/mesh-kernel-boot-entries.service\\\":\\\"kernel (its own tools)\\\",\\\"/etc/systemd/system/mesh-kernel-huge-pages.service\\\":\\\"kernel (its own tools)\\\",\\\"/etc/systemd/system/mesh-power-after-boot.service\\\":\\\"power (its own tools)\\\",\\\"/etc/systemd/system/mesh-power-after-wake.service\\\":\\\"power (its own tools)\\\",\\\"/etc/systemd/system/mesh-power-before-shutdown.service\\\":\\\"power (its own tools)\\\",\\\"/etc/systemd/system/mesh-power-before-sleep.service\\\":\\\"power (its own tools)\\\",\\\"/etc/systemd/system/mesh-power-supply.service\\\":\\\"power (its own tools)\\\",\\\"/etc/systemd/system/nftables.service.d/mesh.conf\\\":\\\"nftables (its own tools)\\\",\\\"/etc/systemd/system/sleep.target.d\\\":\\\"power (its own tools)\\\",\\\"/etc/systemd/system/sleep.target.d/mesh-power.conf\\\":\\\"power (its own tools)\\\",\\\"/etc/systemd/system/suspend-then-hibernate.target.d\\\":\\\"power (its own tools)\\\",\\\"/etc/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf\\\":\\\"power (its own tools)\\\",\\\"/etc/systemd/system/suspend.target.d\\\":\\\"power (its own tools)\\\",\\\"/etc/systemd/system/suspend.target.d/mesh-power.conf\\\":\\\"power (its own tools)\\\",\\\"/etc/systemd/timesyncd.conf.d/50-mesh.conf\\\":\\\"time-sync (its own tools)\\\",\\\"/etc/udev/rules.d/90-mesh-power.rules\\\":\\\"power (its own tools)\\\",\\\"/etc/vconsole.conf\\\":\\\"localization (its own tools)\\\",\\\"/etc/xdg/reflector/reflector.conf\\\":\\\"pacman (its own tools)\\\",\\\"/run/mesh-power\\\":\\\"power (its own tools)\\\",\\\"/run/nfs-server\\\":\\\"nfs-server (its own tools)\\\",\\\"/usr/lib/nox-mesh-host/launch\\\":\\\"mesh-host (its own tools)\\\",\\\"/usr/lib/nox-mesh-host/versions/4e121b6e88f1\\\":\\\"mesh-host (its own tools)\\\",\\\"/usr/local/bin\\\":\\\"mesh-cli (its own tools)\\\",\\\"/usr/local/bin/claude-agent\\\":\\\"claude-code (its own tools)\\\",\\\"/usr/local/lib/mesh-power\\\":\\\"power (its own tools)\\\",\\\"/var/lib/dnsmasq-watch\\\":\\\"dnsmasq (its own tools)\\\",\\\"/var/lib/mesh-fail2ban\\\":\\\"fail2ban (its own tools)\\\",\\\"/var/lib/mesh-fail2ban/own-addresses\\\":\\\"fail2ban (its own tools)\\\",\\\"/var/lib/mesh-store\\\":\\\"postgres (its own tools)\\\",\\\"/var/lib/nfs-server\\\":\\\"nfs-server (its own tools)\\\",\\\"/var/lib/nfs-server/from-bus\\\":\\\"nfs-server (its own tools)\\\",\\\"/var/lib/postgres\\\":\\\"postgres (its own tools)\\\",\\\"/var/lib/route-proxy/acme\\\":\\\"route-proxy (its own tools)\\\",\\\"/var/lib/route-proxy/ca\\\":\\\"route-proxy (its own tools)\\\",\\\"/var/lib/route-proxy/routes\\\":\\\"route-proxy (its own tools)\\\",\\\"/var/lib/systemd\\\":\\\"systemd (its own tools)\\\",\\\"/var/log/fail2ban.log\\\":\\\"fail2ban (its own tools)\\\",\\\"/var/run/fail2ban\\\":\\\"fail2ban (its own tools)\\\"}\",\"MESH_SECRET_PLACES\":\"[\\\"/var/lib/baserow\\\",\\\"/var/lib/bazarr/radarr-api.secret\\\",\\\"/var/lib/bazarr/sonarr-api.secret\\\",\\\"/var/lib/bookshelf/jackett-api.secret\\\",\\\"/var/lib/bookshelf/nzbget-api.secret\\\",\\\"/var/lib/bookshelf/qbittorrent-api.secret\\\",\\\"/var/lib/car-hunter\\\",\\\"/var/lib/claude-code/gitea-read.secret\\\",\\\"/var/lib/home-assistant/lidarr-api.secret\\\",\\\"/var/lib/home-assistant/mqtt-topic.secret\\\",\\\"/var/lib/home-assistant/radarr-api.secret\\\",\\\"/var/lib/home-assistant/sonarr-api.secret\\\",\\\"/var/lib/icecast/admin.secret\\\",\\\"/var/lib/icecast/relay.secret\\\",\\\"/var/lib/icecast/source.secret\\\",\\\"/var/lib/influxdb\\\",\\\"/var/lib/kometa\\\",\\\"/var/lib/letta\\\",\\\"/var/lib/lidarr/jackett-api.secret\\\",\\\"/var/lib/lidarr/nzbget-api.secret\\\",\\\"/var/lib/lidarr/plex-api.secret\\\",\\\"/var/lib/lidarr/qbittorrent-api.secret\\\",\\\"/var/lib/mesh-delivery-assigner\\\",\\\"/var/lib/mesh-provisioner-client\\\",\\\"/var/lib/mesh-reads\\\",\\\"/var/lib/mesh/dnsmasq\\\",\\\"/var/lib/mesh/grafana\\\",\\\"/var/lib/mesh/home-assistant\\\",\\\"/var/lib/mesh/jackett\\\",\\\"/var/lib/mesh/lidarr\\\",\\\"/var/lib/mesh/mosquitto\\\",\\\"/var/lib/mesh/node-tools\\\",\\\"/var/lib/mesh/nodered\\\",\\\"/var/lib/mesh/nzbget\\\",\\\"/var/lib/mesh/ombi\\\",\\\"/var/lib/mesh/pacman\\\",\\\"/var/lib/mesh/postgres\\\",\\\"/var/lib/mesh/qbittorrent\\\",\\\"/var/lib/mesh/radarr\\\",\\\"/var/lib/mesh/route-proxy\\\",\\\"/var/lib/mesh/searxng\\\",\\\"/var/lib/mesh/sonarr\\\",\\\"/var/lib/mesh/unifi\\\",\\\"/var/lib/mosquitto/grants\\\",\\\"/var/lib/mssql\\\",\\\"/var/lib/n8n/database.secret\\\",\\\"/var/lib/nodered/mqtt-topic.secret\\\",\\\"/var/lib/nzbgeek\\\",\\\"/var/lib/ombi/lidarr-api.secret\\\",\\\"/var/lib/ombi/plex-api.secret\\\",\\\"/var/lib/ombi/radarr-api.secret\\\",\\\"/var/lib/ombi/sonarr-api.secret\\\",\\\"/var/lib/radarr/jackett-api.secret\\\",\\\"/var/lib/radarr/nzbget-api.secret\\\",\\\"/var/lib/radarr/plex-api.secret\\\",\\\"/var/lib/radarr/qbittorrent-api.secret\\\",\\\"/var/lib/redis/default.secret\\\",\\\"/var/lib/redis/grants\\\",\\\"/var/lib/restic\\\",\\\"/var/lib/sonarr/jackett-api.secret\\\",\\\"/var/lib/sonarr/nzbget-api.secret\\\",\\\"/var/lib/sonarr/plex-api.secret\\\",\\\"/var/lib/sonarr/qbittorrent-api.secret\\\",\\\"/var/lib/supabase\\\",\\\"/var/lib/tautulli/plex-api.secret\\\",\\\"/var/lib/txt-game/database.secret\\\"]\"},\"mosquitto\":{\"MESH_MQTT_CTRL_CONTAINER\":\"mosquitto\",\"MESH_PROVISION_ADMIN_USER\":\"mesh-admin\",\"MESH_PROVISION_MQTT\":\"127.0.0.1:1883\",\"MESH_PROVISION_PASSWORD_FILE\":\"/var/lib/mesh/mosquitto/admin\",\"MESH_RECEIVES\":\"/var/lib/mosquitto/grants/mesh.json\"},\"mssql\":{\"MESH_MSSQL_READER_PASSWORD_FILE\":\"/var/lib/mssql/reader.secret\",\"MESH_PROVISION_MSSQL\":\"mssql://sa@127.0.0.1:4848/master\",\"MESH_PROVISION_PASSWORD_FILE\":\"/var/lib/mssql/sa.secret\",\"MESH_RECEIVES\":\"/var/lib/mssql/grants/mesh.json\"},\"nodered\":{\"MESH_NODERED_CONFIG_FILE\":\"/var/lib/mesh/nodered/config.json\",\"MESH_NODERED_URL\":\"http://127.0.0.1:20006\"},\"nzbgeek\":{\"MESH_NZBGEEK_KEY_FILE\":\"/var/lib/nzbgeek/api-key.secret\",\"MESH_NZBGEEK_SETTINGS\":\"/var/lib/nzbgeek/settings.json\"},\"nzbget\":{\"MESH_NZBGET_CONFIG_DIR\":\"/var/lib/nzbget/config\",\"MESH_NZBGET_CONFIG_FILE\":\"/var/lib/nzbget/config.json\",\"MESH_NZBGET_PASSWORD_FILE\":\"/var/lib/mesh/nzbget/password\",\"MESH_NZBGET_SECRETS_DIR\":\"/var/lib/mesh/nzbget\",\"MESH_NZBGET_STATE\":\"/var/lib/nzbget\",\"MESH_NZBGET_URL\":\"http://127.0.0.1:20017\"},\"ombi\":{\"MESH_OMBI_API_KEY_FILE\":\"/var/lib/mesh/ombi/api-key\",\"MESH_OMBI_CONFIG_FILE\":\"/var/lib/mesh/ombi/config.json\",\"MESH_OMBI_URL\":\"http://127.0.0.1:20024\"},\"pacman\":{\"PACMAN_KEEPER_CONFIG\":\"/var/lib/mesh/pacman/keeper.json\"},\"plex\":{\"MESH_PLEX_CONFIG_DIR\":\"/mnt/plex/config\",\"MESH_PLEX_URL\":\"http://127.0.0.1:32400\"},\"postgres\":{\"MESH_CONSUMERS_FILE\":\"/var/lib/mesh/postgres/consumers.json\",\"MESH_POSTGRES_READER_PASSWORD_FILE\":\"/var/lib/mesh/postgres/reader.secret\",\"MESH_PROVISION_PASSWORD_FILE\":\"/var/lib/mesh/postgres/superuser.secret\",\"MESH_PROVISION_POSTGRES\":\"postgres://postgres@127.0.0.1:5434/postgres?sslmode=disable\",\"MESH_RECEIVES\":\"/var/lib/mesh/postgres/grants/mesh.json\"},\"qbittorrent\":{\"MESH_QBITTORRENT_CONFIG_DIR\":\"/var/lib/qbittorrent/config\",\"MESH_QBITTORRENT_CONFIG_FILE\":\"/var/lib/qbittorrent/config.json\",\"MESH_QBITTORRENT_PASSWORD_FILE\":\"/var/lib/mesh/qbittorrent/password\",\"MESH_QBITTORRENT_URL\":\"http://127.0.0.1:8112\"},\"radarr\":{\"MESH_PLEX_BIND_FILE\":\"/var/lib/radarr/plex-api.json\",\"MESH_PLEX_TOKEN_FILE\":\"/var/lib/radarr/plex-api.secret\",\"MESH_RADARR_API_KEY_FILE\":\"/var/lib/mesh/radarr/api-key\",\"MESH_RADARR_CONFIG_DIR\":\"/var/lib/radarr/config\",\"MESH_RADARR_URL\":\"http://127.0.0.1:20019\"},\"redis\":{\"MESH_PROVISION_PASSWORD_FILE\":\"/var/lib/redis/default.secret\",\"MESH_PROVISION_REDIS\":\"127.0.0.1:20003\",\"MESH_RECEIVES\":\"/var/lib/redis/grants/mesh.json\"},\"restic\":{\"MESH_BACKUP_DATA\":\"/var/lib/restic/data.conf\",\"MESH_BACKUP_DECLARED\":\"/var/lib/restic/backups.conf\",\"MESH_BACKUP_PASSWORD_FILE\":\"/var/lib/restic/repository.secret\",\"MESH_BACKUP_REPOSITORY\":\"/storage/restic\",\"MESH_BACKUP_STATE\":\"/var/lib/restic\"},\"searxng\":{\"MESH_SEARXNG_CONFIG_FILE\":\"/var/lib/mesh/searxng/config.json\",\"MESH_SEARXNG_URL\":\"http://127.0.0.1:20000\"},\"sonarr\":{\"MESH_PLEX_BIND_FILE\":\"/var/lib/sonarr/plex-api.json\",\"MESH_PLEX_TOKEN_FILE\":\"/var/lib/sonarr/plex-api.secret\",\"MESH_SONARR_API_KEY_FILE\":\"/var/lib/mesh/sonarr/api-key\",\"MESH_SONARR_CONFIG_DIR\":\"/var/lib/sonarr/config\",\"MESH_SONARR_RECYCLE_DIR\":\"/var/lib/sonarr/recycle-bin\",\"MESH_SONARR_URL\":\"http://127.0.0.1:20018\"},\"systemd\":{\"MESH_SYSTEMD_STATE\":\"/var/lib/mesh/systemd\"},\"tautulli\":{\"MESH_TAUTULLI_CONFIG_DIR\":\"/var/lib/tautulli/config\",\"MESH_TAUTULLI_CONFIG_FILE\":\"/var/lib/mesh/tautulli/config.json\",\"MESH_TAUTULLI_CONTAINER\":\"tautulli\",\"MESH_TAUTULLI_URL\":\"http://127.0.0.1:20023\"},\"unifi\":{\"MESH_UNIFI_CONFIG_FILE\":\"/var/lib/mesh/unifi/config.json\",\"MESH_UNIFI_URL\":\"https://127.0.0.1:8443\"},\"youtube\":{\"MESH_YOUTUBE_DOWNLOADS\":\"/storage/downloads/youtube\",\"MESH_YOUTUBE_SETTINGS\":\"/var/lib/youtube/settings.json\"},\"zsh\":{\"ZSH_TOOLS_CONF\":\"/var/lib/zsh/tools.conf\"}}", + "MESH_TOOL_MODULES": "avahi=/var/lib/mesh/bundles/avahi/tools/avahi-tools,baserow=/var/lib/mesh/bundles/baserow/tools/tools/index.serve.mjs,bazarr=/var/lib/mesh/bundles/bazarr/code/index.serve.mjs,bazarr=/var/lib/mesh/bundles/bazarr/code/tools/index.serve.mjs,bookshelf=/var/lib/mesh/bundles/bookshelf/code/index.serve.mjs,bookshelf=/var/lib/mesh/bundles/bookshelf/code/tools/index.serve.mjs,claude-code=/var/lib/mesh/bundles/claude-code/tools/claude-code,dbus=/var/lib/mesh/bundles/dbus/tools-go/dbus-tools,disk-load=/var/lib/mesh/bundles/disk-load/tools-go/disk-load,dnsmasq=/var/lib/mesh/bundles/dnsmasq/tools/dnsmasq-tools,docker=/var/lib/mesh/bundles/docker/tools/docker-tools,fail2ban=/var/lib/mesh/bundles/fail2ban/tools/fail2ban-tools,grafana=/var/lib/mesh/bundles/grafana/code/index.serve.mjs,grafana=/var/lib/mesh/bundles/grafana/code/tools/index.serve.mjs,home-assistant=/var/lib/mesh/bundles/home-assistant/code/index.serve.mjs,home-assistant=/var/lib/mesh/bundles/home-assistant/code/tools/index.serve.mjs,hostname=/var/lib/mesh/bundles/hostname/tools/hostname-tools,icecast=/var/lib/mesh/bundles/icecast/code/index.serve.mjs,icecast=/var/lib/mesh/bundles/icecast/code/tools/index.serve.mjs,influxdb=/var/lib/mesh/bundles/influxdb/code/provisioner/index.serve.mjs,influxdb=/var/lib/mesh/bundles/influxdb/code/tools/index.serve.mjs,jackett=/var/lib/mesh/bundles/jackett/code/tools/index.serve.mjs,kernel=/var/lib/mesh/bundles/kernel/tools/kernel-tools,kometa=/var/lib/mesh/bundles/kometa/code/tools/index.serve.mjs,letta=/var/lib/mesh/bundles/letta/tools/tools/index.serve.mjs,lidarr=/var/lib/mesh/bundles/lidarr/tools-go/lidarr,localization=/var/lib/mesh/bundles/localization/tools/localization-tools,logrotate=/var/lib/mesh/bundles/logrotate/tools/logrotate-tools,media-importer=/var/lib/mesh/bundles/media-importer/tools/media-importer,mesh-files=/var/lib/mesh/bundles/mesh-files/tools/mesh-files,mosquitto=/var/lib/mesh/bundles/mosquitto/code/index.serve.mjs,mosquitto=/var/lib/mesh/bundles/mosquitto/code/provisioner/index.serve.mjs,mosquitto=/var/lib/mesh/bundles/mosquitto/code/tools/index.serve.mjs,mssql=/var/lib/mesh/bundles/mssql/code/index.serve.mjs,mssql=/var/lib/mesh/bundles/mssql/code/provisioner/index.serve.mjs,mssql=/var/lib/mesh/bundles/mssql/code/tools/index.serve.mjs,netcheck=/var/lib/mesh/bundles/netcheck/tools-go/netcheck,netcheck=/var/lib/mesh/bundles/netcheck/tools-typescript/tools/index.serve.mjs,networkmanager=/var/lib/mesh/bundles/networkmanager/tools/uplink-tools,nfs-server=/var/lib/mesh/bundles/nfs-server/tools/nfs-server,nftables=/var/lib/mesh/bundles/nftables/tools/tools/index.serve.mjs,nodered=/var/lib/mesh/bundles/nodered/code/tools/index.serve.mjs,nzbgeek=/var/lib/mesh/bundles/nzbgeek/tools/nzbgeek,nzbget=/var/lib/mesh/bundles/nzbget/code/nzbget,ombi=/var/lib/mesh/bundles/ombi/code/index.serve.mjs,ombi=/var/lib/mesh/bundles/ombi/code/tools/index.serve.mjs,pacman=/var/lib/mesh/bundles/pacman/tools/pacman-tools,plex=/var/lib/mesh/bundles/plex/code/plex,postgres=/var/lib/mesh/bundles/postgres/code/postgres-provider,power=/var/lib/mesh/bundles/power/tools-go/power,qbittorrent=/var/lib/mesh/bundles/qbittorrent/code/qbittorrent,radarr=/var/lib/mesh/bundles/radarr/tools-go/radarr,redis=/var/lib/mesh/bundles/redis/code/index.serve.mjs,redis=/var/lib/mesh/bundles/redis/code/provisioner/index.serve.mjs,redis=/var/lib/mesh/bundles/redis/code/tools/index.serve.mjs,restic=/var/lib/mesh/bundles/restic/tools/restic-backups,route-proxy=/var/lib/mesh/bundles/route-proxy/provider/route-provider,searxng=/var/lib/mesh/bundles/searxng/tools/tools/index.serve.mjs,sonarr=/var/lib/mesh/bundles/sonarr/code/index.serve.mjs,sonarr=/var/lib/mesh/bundles/sonarr/code/tools/index.serve.mjs,ssh-client=/var/lib/mesh/bundles/ssh-client/tools/ssh-client-tools,sudo=/var/lib/mesh/bundles/sudo/tools/sudo-tools,systemd=/var/lib/mesh/bundles/systemd/tools/systemd-tools,tautulli=/var/lib/mesh/bundles/tautulli/code/index.serve.mjs,tautulli=/var/lib/mesh/bundles/tautulli/code/tools/index.serve.mjs,time-sync=/var/lib/mesh/bundles/time-sync/tools/time-sync-tools,unifi=/var/lib/mesh/bundles/unifi/tools/tools/index.serve.mjs,youtube=/var/lib/mesh/bundles/youtube/tools/youtube,zsh=/var/lib/mesh/bundles/zsh/tools/tools/index.serve.mjs" + }, + "id": "node-tools.runtime", + "module": "node-tools", + "name": "node-tools", + "restart-on": [ + "avahi.bundle-tools", + "baserow.bundle-tools", + "bazarr.bundle-code", + "bookshelf.bundle-code", + "claude-code.bundle-tools", + "dbus.bundle-tools-go", + "disk-load.bundle-tools-go", + "dnsmasq.bundle-tools", + "docker.bundle-tools", + "fail2ban.bundle-tools", + "grafana.bundle-code", + "home-assistant.bundle-code", + "hostname.bundle-tools", + "icecast.bundle-code", + "influxdb.bundle-code", + "jackett.bundle-code", + "kernel.bundle-tools", + "kometa.bundle-code", + "letta.bundle-tools", + "lidarr.bundle-tools-go", + "localization.bundle-tools", + "logrotate.bundle-tools", + "media-importer.bundle-tools", + "mesh-files.bundle-tools", + "mosquitto.bundle-code", + "mssql.bundle-code", + "netcheck.bundle-tools-go", + "netcheck.bundle-tools-typescript", + "networkmanager.bundle-tools", + "nfs-server.bundle-tools", + "nftables.bundle-tools", + "node-tools.needs-broker", + "nodered.bundle-code", + "nzbgeek.bundle-tools", + "nzbget.bundle-code", + "ombi.bundle-code", + "pacman.bundle-tools", + "plex.bundle-code", + "postgres.bundle-code", + "power.bundle-tools-go", + "qbittorrent.bundle-code", + "radarr.bundle-tools-go", + "redis.bundle-code", + "restic.bundle-tools", + "route-proxy.bundle-provider", + "searxng.bundle-tools", + "sonarr.bundle-code", + "ssh-client.bundle-tools", + "sudo.bundle-tools", + "systemd.bundle-tools", + "tautulli.bundle-code", + "time-sync.bundle-tools", + "unifi.bundle-tools", + "youtube.bundle-tools", + "zsh.bundle-tools", + "baserow.runtime-config", + "bazarr.runtime-config", + "claude-code.facts", + "claude-code.settings", + "grafana.runtime-config", + "home-assistant.needs-token", + "home-assistant.runtime-config", + "icecast.runtime-config", + "icecast.secret-admin", + "influxdb.needs-admin-token", + "influxdb.received-influxdb-api", + "influxdb.runtime-config", + "jackett.needs-api-key", + "jackett.runtime-config", + "kometa.settings", + "letta.runtime-config", + "lidarr.bound-plex-api", + "lidarr.needs-api-key", + "lidarr.secret-plex-api", + "media-importer.settings", + "mosquitto.needs-admin", + "mosquitto.received-mqtt-topic", + "mssql.needs-reader", + "mssql.needs-sa", + "mssql.received-mssql-database", + "nodered.runtime-config", + "nzbgeek.needs-api-key", + "nzbgeek.settings", + "nzbget.needs-password", + "nzbget.runtime-config", + "ombi.needs-api-key", + "ombi.runtime-config", + "pacman.keeper-config", + "postgres.consumers", + "postgres.needs-reader", + "postgres.needs-superuser", + "postgres.received-postgres-database", + "qbittorrent.needs-password", + "qbittorrent.runtime-config", + "radarr.bound-plex-api", + "radarr.needs-api-key", + "radarr.secret-plex-api", + "redis.received-redis-cache", + "redis.secret-secret", + "restic.data-declared", + "restic.declared", + "restic.needs-repository", + "searxng.runtime-config", + "sonarr.bound-plex-api", + "sonarr.needs-api-key", + "sonarr.secret-plex-api", + "tautulli.runtime-config", + "unifi.runtime-config", + "youtube.settings", + "zsh.tools-conf" + ], + "run": [ + "./node-tools" + ], + "source": "http://novox.internal:5100/v2/node-tools/runtime/blobs/sha256:ef790876783d817903b337b8418b1eb21fb49d34128a390bbf7c3d08a4b2be8a", + "type": "process", + "user": "ace" + } + ] + } + }, + { + "name": "a module the engine could not compose", + "of": "state:composed/..", + "given": { + "v": 1, + "node": "ace", + "module": "nzbget", + "fp": "src2:62756c6c6462756c6c6462756c6c6462756c6c6462756c6c6462756c6c646275", + "resources": [], + "refused": "a secret the vault does not hold" + } + }, + { + "name": "the node's own composition", + "of": "state:composed/.node", + "given": { + "v": 1, + "node": "ace", + "resources": [], + "own": { + "adoption": { + "taken": [ + "avahi" + ], + "untaken": { + "plex": [ + "plex.bundle-code" + ] + } + }, + "identities": [ + { + "module": "plex", + "name": "plex", + "uid": 1001, + "gid": 1001, + "groups": [ + 1002 + ], + "runner": "plex" + } + ], + "groups": [ + { + "name": "mesh-media", + "gid": 1002 + } + ], + "libraries": [ + { + "library": "movies", + "root": "/srv/media/movies", + "group": "mesh-media", + "gid": 1002, + "home": false, + "taken": true, + "paths": [ + "/srv/media/movies" + ] + } + ], + "provisioner_account": "mesh-provisioner-client" + } + } + }, + { + "name": "a module running (typed from the engine's own values; no read tool answers a have key)", + "of": "state:have/.", + "given": { + "v": 1, + "fp": "src2:62756c6c6462756c6c6462756c6c6462756c6c6462756c6c6462756c6c646275", + "composed": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "build": "b1", + "phase": "running", + "health": "ok", + "sealed": [ + "web.secret" + ], + "sealed-at": { + "web.secret#sealed": "box1" + } + } + }, + { + "name": "a failed apply waiting on a store's holder (typed from the engine's values)", + "of": "state:have/.", + "given": { + "v": 1, + "fp": "src2:62756c6c6462756c6c6462756c6c6462756c6c6462756c6c6462756c6c646275", + "composed": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "phase": "failed", + "health": "failed", + "detail": "the pull met a store that is not settled", + "pull-wait": { + "image": "registry.example/web:1", + "seat": "container-image-registry", + "holder": "gitea", + "began": "2026-11-05T12:00:00Z", + "retried": "the holder settled", + "retried-at": "2026-11-05T12:05:00Z" + } + } + }, + { + "name": "a move waiting for a person's yes", + "of": "state:have/.", + "given": { + "v": 1, + "fp": "src2:62756c6c6462756c6c6462756c6c6462756c6c6462756c6c6462756c6c646275", + "composed": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "phase": "waiting-yes", + "health": "ok", + "judgement": "recreates", + "waits": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "build-alone": true, + "kept": [ + "tool runner not declared" + ] + } + }, + { + "name": "the bus module's have carries the user list written (typed from the engine's values)", + "of": "state:have/.", + "given": { + "v": 1, + "phase": "running", + "health": "ok", + "user-list": { + "source": "user-list", + "revision": 7, + "wanted": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "users": 3, + "at": "2026-11-05T12:00:00Z", + "follows": "grants", + "key": "-----BEGIN PUBLIC KEY-----", + "handed": { + "from": "ace", + "to": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "users": 3, + "box": { + "v": 1, + "eph": "e", + "iv": "i", + "tag": "t", + "ct": "c" + }, + "at": "2026-11-05T12:00:00Z" + } + } + } + }, + { + "name": "a node's own facts with its record and an answer to a candidate (typed from the engine's values)", + "of": "state:have/.node", + "given": { + "v": 1, + "composed": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "record": { + "v": 1, + "node": "ace", + "tunnel-key": "k", + "capabilities": [ + "docker" + ], + "networks": [ + { + "link": "eth0", + "network": "192.0.2.0/24" + } + ], + "networks-said": true, + "contract": 3 + }, + "composes": "own", + "candidates": { + "p1": { + "proposal": "p1", + "revision": 4, + "verdict": "differs", + "same": [ + "web" + ], + "differs": [ + { + "module": "api", + "composed": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "running": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "why": "a file differs", + "bodies": [ + { + "id": "api.cfg", + "why": "content", + "paths": [ + "/etc/api.conf" + ] + } + ] + } + ], + "added": [ + { + "module": "db", + "unissued": [ + "db.secret" + ] + } + ], + "removed": [ + "old" + ], + "refusals": [ + { + "module": "mail", + "why": "a value is missing", + "today": "runs" + } + ] + } + } + } + }, + { + "name": "a module's data items on a node (typed from the engine's values)", + "of": "state:data/.", + "given": { + "v": 1, + "node": "ace", + "module": "postgres", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "items": [ + { + "module": "postgres", + "item": "cluster", + "class": "database", + "path": "/var/lib/postgres", + "covered_by": "/var/lib/postgres", + "protection": "backup", + "measure": "dataset" + } + ] + } + }, + { + "name": "a node's health (typed from the engine's values)", + "of": "state:health/", + "given": { + "v": 1, + "node": "ace", + "network": { + "state": "running", + "since": "2026-11-05T10:00:00Z", + "parts": [ + { + "part": "resolver", + "state": "running", + "since": "2026-11-05T10:00:00Z" + } + ] + }, + "peers": [ + { + "key": "k", + "addresses": [ + "198.51.100.1" + ], + "seen": true + } + ], + "units": { + "state": "degraded", + "failed": [ + { + "unit": "x.service", + "scope": "system", + "result": "exit-code", + "since": "2026-11-05T11:00:00Z" + } + ] + }, + "relogin": [ + { + "account": "web", + "reason": "password changed", + "since": "2026-11-05T11:00:00Z" + } + ], + "contract": 3, + "at": "2026-11-05T12:00:00Z" + } + } +] diff --git a/seats/node-engine/fixtures/verbs.json b/seats/node-engine/fixtures/verbs.json new file mode 100644 index 0000000..3375279 --- /dev/null +++ b/seats/node-engine/fixtures/verbs.json @@ -0,0 +1,121 @@ +[ + { + "name": "manifest-reads answers what the engine reads and when it stated it", + "of": "verb:manifest-reads.answer", + "given": { + "v": 1, + "node": "ace", + "manifest-reads": [ + "form:seat-reference", + "word:series" + ], + "stated-at": "2026-11-04T10:00:00Z" + } + }, + { + "name": "manifest-reads of an engine built before it said them is the baseline", + "of": "verb:manifest-reads.answer", + "given": { + "v": 1, + "node": "g14", + "stated-at": "2026-11-04T10:05:00Z" + } + }, + { + "name": "compose-candidate asks the candidate and, as a boolean, its composition input", + "of": "verb:compose-candidate.input", + "given": { + "candidate": "mesh-host.0123abcd", + "input": true + } + }, + { + "name": "compose-candidate answers per module and the node's own", + "of": "verb:compose-candidate.answer", + "given": { + "v": 1, + "node": "ace", + "candidate": "mesh-host.0123abcd", + "verdict": "composed", + "modules": [ + { + "module": "plex", + "fp": "fp-1", + "state": "composed", + "digest": "sha256:aa", + "changed": true + } + ], + "own": { + "module": "node", + "state": "composed", + "digest": "sha256:bb" + }, + "took": 1200 + } + }, + { + "name": "acts answers each act kept", + "of": "verb:acts.answer", + "given": { + "node": "ace", + "acts": [ + { + "id": "hand-over-1", + "act": "hand-over", + "subject": "/srv/media", + "caller": "mesh-tools", + "at": "2026-11-04T09:00:00Z", + "state": "asked", + "ask": "hand-over-1", + "expires": "2026-11-05T09:00:00Z" + } + ] + } + }, + { + "name": "seat-list answers what the engine holds and what it waits for", + "of": "verb:seat-list.answer", + "given": { + "status": { + "heard": false, + "held": [ + { + "seat": "node-engine", + "source": "bootstrap" + }, + { + "seat": "node-files", + "source": "trunk", + "digest": "3f2a9c" + } + ], + "waiting": [ + "node-engine" + ], + "last-error": "no answer" + }, + "health": "the seat list is held from the bootstrap and nothing new is acted on" + } + }, + { + "name": "seat-list-again answers the error when the read failed", + "of": "verb:seat-list-again.answer", + "given": { + "status": { + "heard": false, + "held": [ + { + "seat": "node-engine", + "source": "bootstrap" + } + ], + "waiting": [ + "node-engine" + ] + }, + "health": "the seat list is held from the bootstrap", + "error": "seatlist: no answer" + } + } +] diff --git a/seats/node-engine/seat.json b/seats/node-engine/seat.json new file mode 100644 index 0000000..49462e9 --- /dev/null +++ b/seats/node-engine/seat.json @@ -0,0 +1,2663 @@ +{ + "format": 1, + "seat": "node-engine", + "scope": "node", + "grain": "whole", + "domain": "Core", + "decision": "novox/hq ADR 0563", + "description": "The node seat every node's engine holds (novox/hq ADR 0563, ADR 0618 §3, §4): its hand acts on the operator's warrant, its read-only judgements, its compositions and what it reads of a manifest. Each verb is answered on this node's subject, mesh.seat.node-engine.tool.., and each event is published on mesh.seat.node-engine.event.., the node's name its last token.", + "held-by-the-engine": true, + "verbs": [ + { + "name": "hand-over", + "description": "Hand a directory this machine uses as found to the mesh (novox/hq ADR 0563 §3, §5): the engine judges the path as it does every hand-over (absolute, as it states it, used as found, reached through no link) and refuses in words, or asks the operator on their channel at the level approve, binding the owner, group and mode it will set. Nothing changes on the call: only the operator's yes hands it over. One ask is open per path; acts shows where it stands.", + "input": { + "properties": { + "path": { + "description": "the directory's absolute path, as the engine states it", + "type": "string" + }, + "why": { + "description": "why it is asked, in words the operator reads", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "answer": { + "$ref": "#/defs/Asked" + } + }, + { + "name": "access-act", + "description": "Ask the operator for the take act on a library whose root holds data outside its group (novox/hq ADR 0328 §7, ADR 0563 §3, §4), on the library's home engine: the access as declared and its root as last declared, judged and refused in words or asked at the level approve. Only the operator's yes sets the library's group, group write and setgid under its root. One ask is open per access.", + "input": { + "properties": { + "access": { + "description": "the access, library:", + "type": "string" + }, + "act": { + "description": "the act: take", + "type": "string" + }, + "why": { + "description": "why it is asked, in words the operator reads", + "type": "string" + } + }, + "required": [ + "act", + "access" + ], + "type": "object" + }, + "answer": { + "$ref": "#/defs/Asked" + } + }, + { + "name": "judge-directories", + "description": "Which of this machine's declared directories the engine would refuse to make or keep, one line each (novox/hq ADR 0563 §2). Read-only: changes no owner or mode.", + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "$ref": "#/defs/Said" + } + }, + { + "name": "setuid-search", + "description": "Throw away the engine's last search for setuid programs and start a full one (novox/hq ADR 0563 §2); until it completes root-free answers that the machine is not judged yet. Refused while one runs, and when one was started within the hour, naming when the next may start.", + "input": { + "properties": { + "why": { + "description": "why it is asked, in words", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "$ref": "#/defs/Said" + } + }, + { + "name": "root-free", + "description": "Whether this machine is root-free (novox/hq ADR 0259 §8, ADR 0563 §6): free only on the engine's own verdict, at most fifteen minutes old, that the account agents run as cannot become root without a person, and the login shell's execute not served here. Anything else is no pass, and says why. Answered by this machine's engine alone.", + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "machines": { + "type": "array", + "items": { + "$ref": "#/defs/RootVerdict" + }, + "description": "this machine's verdict" + } + }, + "required": [ + "machines" + ] + } + }, + { + "name": "acts", + "description": "Every act this machine's engine was asked through this seat (novox/hq ADR 0563 §9): each hand-over, access act and setuid search, open, done or refused, with who asked, the ask and the operator's warrant. Read-only.", + "input": { + "properties": { + "id": { + "description": "one act, by its id", + "type": "string" + }, + "limit": { + "description": "at most this many, newest", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "$ref": "#/defs/Acts" + } + }, + { + "name": "packages", + "description": "The packages this machine's compositions declare, present or absent, each with the modules that declare it, as the engine keeps them in its key of `packages`; until the controller hands its lease away, beside the controller's mirror for this machine and their difference. With write, the key written again from what the engine composed.", + "optional": true, + "input": { + "properties": { + "write": { + "description": "\"true\": write this machine's key again from what the engine composed", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "source": { + "type": "string", + "description": "where the key is read: the engine's state packages" + }, + "key": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string", + "description": "the package" + }, + "modules": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the modules that declare it" + }, + "absent": { + "type": "boolean", + "description": "declared absent" + } + }, + "required": [ + "package", + "modules", + "absent" + ] + }, + "description": "its packages sorted by name, absent after present" + } + }, + "required": [ + "node", + "packages" + ], + "description": "the node's key as the bus holds it; absent when the engine wrote none" + }, + "key_error": { + "type": "string", + "description": "why the key could not be read" + }, + "wrote": { + "type": "boolean", + "description": "with write: the key was written again (false: it already held what the engine composed)" + }, + "note": { + "type": "string", + "description": "a note, such as that no key is written yet" + } + }, + "required": [ + "node", + "source" + ] + } + }, + { + "name": "composition", + "description": "One module's composition on this machine beside what runs of it, read-only: the composition its want's build has in the bucket composed- (the latest revision, or the one whose digest is given, from the revisions the bucket keeps), the digest of each kept revision, what runs (applied, its have, or the kept declaration), its want and have (phase running, failed, held or waiting-yes, and the digest that waits for a yes), and the move's judgement with its words: why a record module waits for a yes. With diff, every resource added, removed or changed, a provider's received file naming the consumers that join or leave.", + "optional": true, + "input": { + "properties": { + "diff": { + "description": "\"true\": list the resources that differ from what runs", + "type": "string" + }, + "digest": { + "description": "a composition's digest (sha256:…), to read that revision rather than the latest", + "type": "string" + }, + "module": { + "description": "the module, as placed on this machine; node for the node's own", + "type": "string" + } + }, + "required": [ + "module" + ], + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "module": { + "type": "string", + "description": "the module" + }, + "want": { + "type": "object", + "properties": { + "fp": { + "type": "string", + "description": "its build's fingerprint" + }, + "composed": { + "type": "string", + "description": "the composition's digest it names" + }, + "yes": { + "type": "string", + "description": "the digest a person said yes to" + }, + "removed": { + "type": "boolean", + "description": "the module is removed" + }, + "self": { + "type": "boolean", + "description": "the node composes the module itself (ADR 0547 §2)" + }, + "backup": { + "type": "string", + "description": "the operator's answer to a failed backup, again or skip" + }, + "words": { + "type": "string", + "description": "its words" + }, + "revision": { + "type": "integer", + "description": "the revision of the want's key read last" + } + }, + "required": [ + "fp", + "revision" + ], + "description": "the module's want as the agent holds it; absent when it holds none" + }, + "attempt": { + "type": "object", + "properties": { + "fp": { + "type": "string", + "description": "the fingerprint tried" + }, + "composed": { + "type": "string", + "description": "the digest tried" + }, + "want_revision": { + "type": "integer", + "description": "the want's revision it was tried at" + }, + "composition_revision": { + "type": "integer", + "description": "the composition's revision it was tried at" + }, + "held": { + "type": "boolean", + "description": "held for a failed backup" + }, + "answered_revision": { + "type": "integer", + "description": "the want's revision the hold took" + }, + "transient": { + "type": "boolean", + "description": "it failed because the bus did not answer for a moment" + } + }, + "required": [ + "fp", + "want_revision", + "composition_revision" + ], + "description": "the module's last apply that did not end running; absent when none" + }, + "started": { + "type": "boolean", + "description": "the agent has looked at the module once since it started" + }, + "have": { + "type": "object", + "properties": { + "fp": { + "type": "string", + "description": "fingerprint" + }, + "composed": { + "type": "string", + "description": "digest" + }, + "phase": { + "type": "string", + "description": "running, failed, held or waiting-yes" + }, + "detail": { + "type": "string", + "description": "detail" + }, + "judgement": { + "type": "string", + "description": "judgement" + }, + "waits": { + "type": "string", + "description": "the digest that waits for a yes" + } + }, + "required": [ + "phase" + ], + "description": "what the module's have key says now; absent when it says nothing" + }, + "composition": { + "$ref": "#/defs/Revision" + }, + "revisions": { + "type": "array", + "items": { + "$ref": "#/defs/Revision" + }, + "description": "the revisions of its key the bucket keeps, oldest first" + }, + "runs": { + "type": "object", + "properties": { + "fp": { + "type": "string", + "description": "fingerprint" + }, + "composed": { + "type": "string", + "description": "digest" + }, + "phase": { + "type": "string", + "description": "phase" + }, + "from": { + "type": "string", + "description": "applied, have or kept" + }, + "resources": { + "type": "integer", + "description": "how many resources run" + } + }, + "required": [ + "from", + "resources" + ], + "description": "what runs of the module; absent when nothing runs" + }, + "judged": { + "type": "object", + "properties": { + "judgement": { + "type": "string", + "description": "the judgement" + }, + "words": { + "type": "string", + "description": "its words" + }, + "waits_for_yes": { + "type": "boolean", + "description": "the agent would wait for a person's yes before applying it" + } + }, + "required": [ + "judgement", + "words", + "waits_for_yes" + ], + "description": "the move from what runs to the composition read" + }, + "diff": { + "type": "array", + "items": { + "type": "object", + "description": "one resource added, removed or changed, as the engine's judge says it" + }, + "description": "every resource that differs from what runs, when asked" + }, + "own": { + "type": "object", + "properties": { + "provisioner_account": { + "type": "string", + "description": "the account the provisioner's client runs as, as the revision names it" + }, + "runs_provisioner_account": { + "type": "string", + "description": "as what runs names it" + }, + "adoption": { + "type": "object", + "description": "the revision's adoption; absent for a converged node" + }, + "identities": { + "type": "integer", + "description": "identities the revision names" + }, + "groups": { + "type": "integer", + "description": "groups" + }, + "libraries": { + "type": "integer", + "description": "libraries" + }, + "held": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "each module the node's composer held in its last pass, with why" + } + }, + "required": [ + "provisioner_account" + ], + "description": "of the node's own composition, its own fields beside what runs names" + } + }, + "required": [ + "node", + "module", + "started", + "composition", + "judged" + ] + } + }, + { + "name": "compose", + "description": "Compose this machine's modules now and say how it went, so a composition that failed, or a module held, recovers without a restart: what the pass refused or held, each with why, and when the engine composes again on its own after a seat that did not answer (a deadline, no responders: retried with a growing wait; a refusal never). With module, that module's apply is also tried again. Changes only what a pass the engine makes on its own would change.", + "optional": true, + "input": { + "properties": { + "module": { + "description": "a module placed on this machine whose apply is also tried again (optional)", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "retried": { + "type": "string", + "description": "the module whose apply was tried again" + }, + "pass": { + "type": "object", + "properties": { + "at": { + "type": "string", + "description": "when the pass ended, RFC 3339" + }, + "refused": { + "type": "string", + "description": "why the node composed none of its modules" + }, + "held": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "each module refused alone, with why" + }, + "handed": { + "type": "integer", + "description": "how many compositions the pass handed the agent that differ from before" + }, + "again": { + "type": "string", + "description": "when the node composes again on its own, RFC 3339" + }, + "earlier": { + "type": "boolean", + "description": "the answer is the last pass, made moments ago" + } + }, + "required": [ + "at", + "handed" + ], + "description": "how the pass went" + } + }, + "required": [ + "pass" + ] + } + }, + { + "name": "compose-candidate", + "description": "Compose a check's candidate on this machine in shadow, with its own sealed values and the indexer's manifest verdicts, and say per module placed here whether it composes (with its digest and whether the candidate changes it), is refused by the candidate or is held without it, each with why, and the verdict of the machine's own composition (novox/hq ADR 0593 §7). Read-only: applies nothing and writes nothing. Answered by this machine's engine alone, within the asker's bound.", + "optional": true, + "input": { + "properties": { + "candidate": { + "description": "the key of the indexer's candidate, .", + "type": "string" + }, + "input": { + "description": "also answer the composition input the candidate was composed from, for the sandbox path", + "type": "boolean" + } + }, + "required": [ + "candidate" + ], + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout's version" + }, + "node": { + "type": "string", + "description": "the node" + }, + "candidate": { + "type": "string", + "description": "the candidate's key" + }, + "verdict": { + "type": "string", + "enum": [ + "composed", + "refused", + "unready", + "not-judged" + ], + "description": "the node's verdict (ADR 0593 §7, §9)" + }, + "modules": { + "type": "array", + "items": { + "$ref": "#/defs/Module" + }, + "description": "every module placed on the node, sorted by name" + }, + "own": { + "$ref": "#/defs/Module" + }, + "refused": { + "type": "string", + "description": "why the node could not compose with the candidate at all" + }, + "took": { + "type": "integer", + "description": "how long the engine took, in milliseconds" + }, + "input": { + "type": "object", + "description": "when asked: the composition input it composed from" + } + }, + "required": [ + "v", + "node", + "candidate", + "verdict", + "modules", + "own", + "took" + ] + } + }, + { + "name": "manifest-reads", + "optional": true, + "description": "The reader capabilities this machine's engine states (manifest words, forms and seat names it reads, novox/hq ADR 0618 §3) and when it last stated them. An engine built before it said them states none, which a reader takes as the baseline. Read-only. Answered by this machine's engine alone.", + "input": { + "type": "object", + "properties": {} + }, + "answer": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout's version" + }, + "node": { + "type": "string", + "description": "the node" + }, + "manifest-reads": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "the reader capabilities its engine states: the manifest words, forms and seat names it reads; empty for an engine built before it said them, read as the baseline" + }, + "stated-at": { + "type": "string", + "description": "when the engine last stated them, RFC 3339" + } + }, + "required": [ + "v", + "node", + "stated-at" + ], + "description": "what this machine's engine reads of a manifest (ADR 0618 §3)" + } + }, + { + "name": "seat-list", + "description": "The seat list this engine holds and what it waits for (novox/hq ADR 0636 §7): each seat's source (trunk or bootstrap) and digest, the seats held from the bootstrap alone, the definitions refused, the last error and whether the seat mesh-catalog has answered. Read-only.", + "callers": "the operator and the healers", + "input": { + "type": "object", + "properties": {} + }, + "answer": { + "$ref": "#/defs/SeatList" + } + }, + { + "name": "seat-list-again", + "description": "Make this engine read the seat list from the seat mesh-catalog now (novox/hq ADR 0636 §7), the recovery of a list unread; it answers what it holds afterwards, and the error when the read failed. A read that changed nothing still ends the engine's wait.", + "callers": "the operator and the healers", + "input": { + "type": "object", + "properties": {} + }, + "answer": { + "$ref": "#/defs/SeatList" + } + } + ], + "events": [ + { + "name": "asked", + "by-caller": true, + "body": { + "$ref": "#/defs/ActSaid" + } + }, + { + "name": "done", + "by-caller": true, + "body": { + "$ref": "#/defs/ActSaid" + } + }, + { + "name": "refused", + "by-caller": true, + "body": { + "$ref": "#/defs/ActSaid" + } + }, + { + "name": "manifest-reads-changed", + "by-caller": true, + "body": { + "$ref": "#/defs/ManifestReadsChanged" + } + } + ], + "family": { + "name": "node-engine.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "have", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "one module's state on a node: what the engine has built, composed and run", + "properties": { + "v": { + "type": "integer" + }, + "fp": { + "type": "string", + "description": "the fingerprint held" + }, + "composed": { + "type": "string", + "description": "the digest of the composition the node runs" + }, + "build": { + "type": "string" + }, + "phase": { + "type": "string", + "description": "how far it has come", + "enum": [ + "backing-up", + "applying", + "checking", + "running", + "failed", + "removed", + "held", + "waiting-yes" + ] + }, + "health": { + "type": "string", + "description": "what the module's own check says", + "enum": [ + "ok", + "failed", + "waits", + "unknown" + ] + }, + "detail": { + "type": "string" + }, + "user-list": { + "$ref": "#/defs/UserListWritten" + }, + "judgement": { + "type": "string", + "description": "in phase waiting-yes, the judgement of the move", + "enum": [ + "recreates", + "moves-data", + "cannot-judge" + ] + }, + "waits": { + "type": "string", + "description": "in phase waiting-yes, the digest of the composition that waits" + }, + "build-alone": { + "type": "boolean", + "description": "the move changes the build alone" + }, + "kept": { + "type": "array", + "items": { + "type": "string" + }, + "description": "what the node keeps of the module as it runs" + }, + "sealed": { + "type": "array", + "items": { + "type": "string" + }, + "description": "each value sealed to the node's key" + }, + "sealed-at": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "the sealed values by slot" + }, + "pull-wait": { + "$ref": "#/defs/PullWait" + }, + "handed": { + "$ref": "#/defs/AccountsHanded" + } + }, + "required": [ + "v", + "phase", + "health" + ] + }, + "description": "one module on a node" + }, + { + "key": ".node", + "value": { + "type": "object", + "description": "a node's own record", + "properties": { + "v": { + "type": "integer" + }, + "composed": { + "type": "string", + "description": "the digest of the node's own composition it runs" + }, + "windows": { + "type": "array", + "items": { + "type": "object" + } + }, + "busy": { + "type": "array", + "items": { + "type": "object" + } + }, + "strays": { + "type": "array", + "items": { + "type": "object" + } + }, + "filters": { + "type": "array", + "items": { + "type": "object" + } + }, + "profile": { + "type": "object" + }, + "record": { + "$ref": "#/defs/NodeRecord" + }, + "sealed": { + "type": "array", + "items": { + "type": "string" + } + }, + "sealed-at": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "candidates": { + "type": "object", + "additionalProperties": { + "$ref": "#/defs/CandidateAnswer" + }, + "description": "the node's answer to each placement candidate naming it, by proposal id" + }, + "composes": { + "type": "string", + "description": "own, or none (empty)" + }, + "composes-why": { + "type": "string" + } + }, + "required": [ + "v" + ] + }, + "description": "the node's own record" + } + ], + "history": 20, + "description": "what each node has of each module, written by its engine" + }, + { + "name": "composed", + "template": "state", + "key-forms": [ + { + "key": "..", + "value": { + "type": "object", + "description": "one composition of a module on a node under a fingerprint", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "module": { + "type": "string" + }, + "fp": { + "type": "string" + }, + "data": { + "type": "boolean", + "description": "the module declares data" + }, + "resources": { + "type": "array", + "items": { + "type": "object" + }, + "description": "the declaration resources, each saying its module" + }, + "filled_files": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the files the provisioner fills" + }, + "plan": { + "type": "object", + "description": "the module's whole provisioning plan, catalogue.Plan, kept whole: it is the manifest layer's type" + }, + "refused": { + "type": "string", + "description": "why the module cannot be composed at this fingerprint" + } + }, + "required": [ + "v", + "node", + "module", + "fp", + "resources" + ] + }, + "description": "one composition" + }, + { + "key": ".node", + "value": { + "type": "object", + "description": "the node's own composition: its resources and parts", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "data": { + "type": "boolean", + "description": "the module declares data" + }, + "resources": { + "type": "array", + "items": { + "type": "object" + }, + "description": "the declaration resources, each saying its module" + }, + "filled_files": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the files the provisioner fills" + }, + "own": { + "$ref": "#/defs/NodeOwn" + } + }, + "required": [ + "v", + "node", + "resources" + ] + }, + "description": "the node's own composition" + } + ], + "description": "the compositions of one node, kept in a bucket per node named composed-" + }, + { + "name": "data", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "the data items a module has on a node", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "module": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/defs/PlacedDataItem" + } + } + }, + "required": [ + "v", + "node", + "module", + "digest", + "items" + ] + }, + "description": "a module's data on a node" + } + ], + "description": "the data items of each module on each node" + }, + { + "name": "packages", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "the packages a node declares", + "properties": { + "node": { + "type": "string" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "modules": { + "type": "array", + "items": { + "type": "string" + } + }, + "absent": { + "type": "boolean" + } + }, + "required": [ + "package", + "modules", + "absent" + ] + } + } + }, + "required": [ + "node", + "packages" + ] + }, + "description": "a node's declared packages" + } + ], + "description": "the packages each node declares" + }, + { + "name": "health", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "a node's health as its engine reports it", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "network": { + "$ref": "#/defs/NetworkHealth" + }, + "peers": { + "type": "array", + "items": { + "$ref": "#/defs/PeerSeen" + } + }, + "units": { + "$ref": "#/defs/UnitsHealth" + }, + "relogin": { + "type": "array", + "items": { + "$ref": "#/defs/NodeAccount" + } + }, + "found": { + "type": "array", + "items": { + "$ref": "#/defs/NodeDirectory" + } + }, + "fallback": { + "$ref": "#/defs/LoginFallback" + }, + "root": { + "type": "array", + "items": { + "$ref": "#/defs/RootAccountVerdict" + } + }, + "contract": { + "type": "integer" + }, + "at": { + "type": "string", + "description": "when", + "format": "date-time" + } + }, + "required": [ + "v", + "node", + "at" + ] + }, + "description": "a node's health" + } + ], + "description": "each node's health as its engine reports it" + }, + { + "name": "findings", + "template": "state", + "writer": "holder", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "one thing an observer sees wrong now", + "properties": { + "v": { + "type": "integer", + "description": "layout version, 1" + }, + "key": { + "type": "string", + "description": "scope.id.token" + }, + "kind": { + "type": "string" + }, + "subject": { + "$ref": "#/defs/FindingSubject" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "resolved": { + "type": "string" + }, + "needs": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/defs/FindingAction" + } + }, + "evidence": { + "type": "array", + "description": "newest first, at most 10", + "items": { + "$ref": "#/defs/FindingEvidence" + } + }, + "source": { + "type": "string", + "description": "the row, probe or event that saw it" + }, + "resolver": { + "type": "string", + "description": "self, operator, agent or healer:" + }, + "ref": { + "type": "string", + "description": "the key's last part when it is not the kind" + }, + "observer": { + "type": "string", + "description": "the module that wrote it" + }, + "run": { + "type": "string", + "description": "what of its own saw it last" + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "v", + "key", + "kind", + "subject", + "severity", + "summary", + "source", + "observer", + "at" + ] + }, + "description": "one finding of the observer that wrote it" + } + ], + "description": "the findings this node's engine raises about itself, one key per finding, in the layout of the seat mesh-conditions (its findings state): a key written raises the condition of the same key, a key deleted clears it (novox/hq ADR 0564 §2, ADR 0636 §1); today `machine..seat-list-unread`" + } + ], + "defs": { + "Act": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the act's id" + }, + "act": { + "type": "string", + "description": "the act: hand-over, access-act, judge-directories or setuid-search" + }, + "subject": { + "type": "string", + "description": "the path of a hand-over, or the access of an access act" + }, + "caller": { + "type": "string", + "description": "the caller as the runtime proved it" + }, + "why": { + "type": "string", + "description": "why it was asked, in words" + }, + "at": { + "type": "string", + "description": "when it was asked, RFC 3339" + }, + "state": { + "type": "string", + "enum": [ + "asked", + "done", + "refused", + "answered" + ], + "description": "where it stands" + }, + "said": { + "type": "string", + "description": "what the engine said of it: its outcome or why it was refused" + }, + "ended": { + "type": "string", + "description": "when it ended, RFC 3339" + }, + "ask": { + "type": "string", + "description": "the operator's ask of a hand-over or an access act" + }, + "sets": { + "type": "string", + "description": "the owner, group and mode it sets, in words" + }, + "disk": { + "type": "string", + "description": "what the engine read on disk when it judged" + }, + "path": { + "type": "string", + "description": "the directory the act changes" + }, + "expires": { + "type": "string", + "description": "when the ask ends unanswered, RFC 3339" + }, + "by": { + "type": "string", + "description": "who answered, as the operator's warrant names them" + }, + "via": { + "type": "string", + "description": "the channel the warrant came on" + }, + "proofs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the warrant's proofs" + }, + "auto": { + "type": "boolean", + "description": "the engine asked it itself, on finding a library's root holding data outside its group (ADR 0563 §4)" + } + }, + "required": [ + "id", + "act", + "caller", + "at", + "state" + ], + "description": "one act asked through the seat, as acts reads it (ADR 0563 §9)" + }, + "ActSaid": { + "type": "object", + "properties": { + "act": { + "type": "string", + "description": "the act" + }, + "subject": { + "type": "string", + "description": "the path or the access" + }, + "caller": { + "type": "string", + "description": "the caller as the runtime proved it" + }, + "ask": { + "type": "string", + "description": "the operator's ask" + }, + "state": { + "type": "string", + "enum": [ + "asked", + "done", + "refused" + ], + "description": "where the act stands" + }, + "said": { + "type": "string", + "description": "what the engine said of it" + }, + "node": { + "type": "string", + "description": "the node whose engine says it" + } + }, + "required": [ + "act", + "caller", + "state", + "node" + ], + "description": "what the seat says of an act: the act, the path or access, the caller as the runtime proved it and the ask (ADR 0563 §1)" + }, + "Acts": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "acts": { + "type": "array", + "items": { + "$ref": "#/defs/Act" + }, + "description": "the acts, newest first" + } + }, + "required": [ + "node", + "acts" + ], + "description": "the acts kept" + }, + "Asked": { + "type": "object", + "properties": { + "asked": { + "type": "string", + "description": "the ask's id: acts shows where it stands" + }, + "node": { + "type": "string", + "description": "the node" + }, + "says": { + "type": "string", + "description": "what happens next, in words" + } + }, + "required": [ + "asked", + "node", + "says" + ], + "description": "the answer of an act the operator is asked" + }, + "ManifestReadsChanged": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout's version" + }, + "node": { + "type": "string", + "description": "the node" + }, + "manifest-reads": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "what its engine states it reads now" + }, + "at": { + "type": "string", + "description": "when it says it, RFC 3339" + }, + "why": { + "type": "string", + "enum": [ + "started", + "returned" + ], + "description": "started: the engine started or what it states changed; returned: its node returned after being away" + } + }, + "required": [ + "v", + "node", + "at", + "why" + ], + "description": "what a node-engine says when what it reads changed or its node returned (ADR 0618 §4)" + }, + "Module": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module, node for the node's own composition" + }, + "fp": { + "type": "string", + "description": "the fingerprint it was composed at" + }, + "state": { + "type": "string", + "enum": [ + "composed", + "refused", + "held" + ], + "description": "composed (digest set), refused by the candidate, or held without it too (words set)" + }, + "digest": { + "type": "string", + "description": "the composition's digest when composed" + }, + "changed": { + "type": "boolean", + "description": "the candidate changes it from the one composed without it" + }, + "words": { + "type": "string", + "description": "why, for a refused or held one" + } + }, + "required": [ + "module", + "state" + ], + "description": "one module's composition with the candidate" + }, + "Revision": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "the composition's key" + }, + "revision": { + "type": "integer", + "description": "its revision" + }, + "digest": { + "type": "string", + "description": "its digest" + }, + "resources": { + "type": "integer", + "description": "how many resources it names" + }, + "latest": { + "type": "boolean", + "description": "the latest revision" + }, + "refused": { + "type": "string", + "description": "why it cannot be read or trusted" + } + }, + "required": [ + "key" + ], + "description": "one revision of a composition key" + }, + "RootVerdict": { + "type": "object", + "properties": { + "machine": { + "type": "string", + "description": "the machine" + }, + "free": { + "type": "boolean", + "description": "the machine is root-free" + }, + "why": { + "type": "string", + "description": "why, in words" + }, + "judged": { + "type": "string", + "description": "when the engine judged it, RFC 3339" + } + }, + "required": [ + "machine", + "free", + "why", + "judged" + ], + "description": "the engine's root-free verdict (ADR 0563 §6)" + }, + "Said": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "said": { + "type": "string", + "description": "the engine's judgement or search, one line per directory or program" + } + }, + "required": [ + "node", + "said" + ], + "description": "the answer of a read-only judgement" + }, + "SeatListHeld": { + "type": "object", + "properties": { + "seat": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "trunk", + "bootstrap" + ] + }, + "digest": { + "type": "string", + "description": "the digest of the definition held; empty for a bootstrapped one" + } + }, + "required": [ + "seat", + "source" + ] + }, + "SeatList": { + "type": "object", + "description": "what an engine holds of the seat list (novox/hq ADR 0636 §7)", + "properties": { + "status": { + "type": "object", + "properties": { + "heard": { + "type": "boolean", + "description": "the seat mesh-catalog has answered" + }, + "held": { + "type": "array", + "items": { + "$ref": "#/defs/SeatListHeld" + } + }, + "waiting": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the seats held from the bootstrap alone" + }, + "refused": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "a trunk definition that does not parse, by seat: the one held stands" + }, + "last-error": { + "type": "string" + }, + "revision": { + "type": "integer" + } + }, + "required": [ + "heard", + "held" + ] + }, + "health": { + "type": "string", + "description": "the one line the engine says of it; empty when nothing waits" + }, + "error": { + "type": "string", + "description": "why the read failed, for seat-list-again" + } + }, + "required": [ + "status", + "health" + ] + }, + "FindingSubject": { + "type": "object", + "description": "what the condition is about", + "properties": { + "scope": { + "type": "string", + "description": "machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module" + }, + "id": { + "type": "string", + "description": "its id within the scope" + }, + "machine": { + "type": "string", + "description": "the machine it concerns, when there is one" + }, + "also": { + "type": "array", + "description": "the other machines it concerns", + "items": { + "type": "string" + } + } + }, + "required": [ + "scope", + "id" + ] + }, + "FindingAction": { + "type": "object", + "description": "one answer the operator may give from a notification", + "properties": { + "label": { + "type": "string" + }, + "verb": { + "type": "string", + "description": "the seat verb the operator channel calls" + }, + "machine": { + "type": "string" + }, + "arguments": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "level": { + "type": "string", + "enum": [ + "acknowledge", + "approve" + ] + } + }, + "required": [ + "label", + "verb" + ] + }, + "FindingEvidence": { + "type": "object", + "description": "one observation, as it was said", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "said": { + "type": "string" + } + }, + "required": [ + "at", + "said" + ] + }, + "UserListWritten": { + "type": "object", + "description": "the bus's user list as the engine of the bus node wrote it to the bus module's file", + "properties": { + "source": { + "type": "string", + "description": "the key the list was taken from" + }, + "revision": { + "type": "integer" + }, + "wanted": { + "type": "string", + "description": "the digest of the list the source holds, sha256:" + }, + "digest": { + "type": "string", + "description": "the digest of the file on the machine after the write" + }, + "users": { + "type": "integer" + }, + "at": { + "type": "string", + "description": "when", + "format": "date-time" + }, + "error": { + "type": "string", + "description": "why a write failed" + }, + "follows": { + "type": "string", + "description": "which list the node follows now" + }, + "key": { + "type": "string", + "description": "the bus node's sealing key, its public half" + }, + "unwatched": { + "type": "string", + "description": "why the bus node does not watch the grants part's key now" + }, + "handed": { + "$ref": "#/defs/AccountsHanded" + } + }, + "required": [ + "source", + "revision", + "wanted", + "users", + "at" + ] + }, + "AccountsHanded": { + "type": "object", + "description": "the hashes of the users of the list, sealed to the vault", + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string", + "description": "the fingerprint of the vault's key" + }, + "users": { + "type": "integer" + }, + "box": { + "$ref": "#/defs/SealedBox" + }, + "at": { + "type": "string", + "description": "when", + "format": "date-time" + } + }, + "required": [ + "from", + "to", + "users", + "box", + "at" + ] + }, + "SealedBox": { + "type": "object", + "description": "a value sealed to a key", + "properties": { + "v": { + "type": "integer" + }, + "eph": { + "type": "string" + }, + "iv": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "ct": { + "type": "string" + } + }, + "required": [ + "v", + "eph", + "iv", + "tag", + "ct" + ] + }, + "PullWait": { + "type": "object", + "description": "the pull from a store a failed apply waits on", + "properties": { + "image": { + "type": "string" + }, + "seat": { + "type": "string", + "description": "the seat the pull came through" + }, + "holder": { + "type": "string", + "description": "that seat's holder, whose settling the apply waits for" + }, + "began": { + "type": "string", + "description": "when the pull began", + "format": "date-time" + }, + "retried": { + "type": "string", + "description": "what triggered the last try again, in words" + }, + "retried-at": { + "type": "string", + "description": "when the apply was last tried again", + "format": "date-time" + } + }, + "required": [ + "image", + "holder", + "began" + ] + }, + "NodeRecord": { + "type": "object", + "description": "what the machine observes of itself, for every node and composing part to read", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "tunnel-key": { + "type": "string" + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "sealing-key": { + "type": "string" + }, + "serving-key": { + "type": "string" + }, + "certificate": { + "type": "string" + }, + "outward-links": { + "type": "array", + "items": { + "type": "string" + } + }, + "networks": { + "type": "array", + "items": { + "$ref": "#/defs/AttachedNetwork" + } + }, + "networks-said": { + "type": "boolean" + }, + "contract": { + "type": "integer" + }, + "authority": { + "type": "string" + }, + "trusts": { + "type": "array", + "items": { + "type": "string" + } + }, + "manifest-reads": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "v", + "node" + ] + }, + "AttachedNetwork": { + "type": "object", + "description": "a network attached to a link", + "properties": { + "link": { + "type": "string" + }, + "network": { + "type": "string" + } + }, + "required": [ + "link", + "network" + ] + }, + "CandidateAnswer": { + "type": "object", + "description": "the node's answer to one placement candidate naming it", + "properties": { + "proposal": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "verdict": { + "type": "string", + "description": "same, differs or refused", + "enum": [ + "same", + "differs", + "refused" + ] + }, + "same": { + "type": "array", + "items": { + "type": "string" + }, + "description": "modules whose composition is the one running" + }, + "differs": { + "type": "array", + "items": { + "$ref": "#/defs/CandidateDiffers" + } + }, + "added": { + "type": "array", + "items": { + "$ref": "#/defs/CandidateAdded" + } + }, + "removed": { + "type": "array", + "items": { + "type": "string" + }, + "description": "modules the candidate removes" + }, + "refusals": { + "type": "array", + "items": { + "$ref": "#/defs/CandidateRefusal" + } + }, + "held": { + "type": "array", + "items": { + "$ref": "#/defs/CandidateRefusal" + } + }, + "refused": { + "type": "string", + "description": "why the node cannot compose with the candidate at all" + }, + "returns": { + "type": "string" + }, + "note": { + "type": "string" + }, + "values_differ": { + "type": "integer" + } + }, + "required": [ + "proposal", + "revision", + "verdict" + ] + }, + "CandidateDiffers": { + "type": "object", + "description": "a module whose composition with the candidate differs from the one running", + "properties": { + "module": { + "type": "string" + }, + "composed": { + "type": "string" + }, + "running": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "why": { + "type": "string" + }, + "bodies": { + "type": "array", + "items": { + "$ref": "#/defs/CandidateBodies" + } + } + }, + "required": [ + "module", + "why" + ] + }, + "CandidateBodies": { + "type": "object", + "description": "the bodies of one resource that differ", + "properties": { + "id": { + "type": "string" + }, + "why": { + "type": "string" + }, + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "composed": { + "type": "string" + }, + "running": { + "type": "string" + } + }, + "required": [ + "id", + "why" + ] + }, + "CandidateAdded": { + "type": "object", + "description": "a module the candidate adds", + "properties": { + "module": { + "type": "string" + }, + "composed": { + "type": "string" + }, + "unissued": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "module" + ] + }, + "CandidateRefusal": { + "type": "object", + "description": "a module refused or held with the candidate", + "properties": { + "module": { + "type": "string" + }, + "why": { + "type": "string" + }, + "today": { + "type": "string" + } + }, + "required": [ + "module", + "why" + ] + }, + "NodeOwn": { + "type": "object", + "description": "what a node's own composition carries beside its resources", + "properties": { + "adoption": { + "$ref": "#/defs/Adoption" + }, + "identities": { + "type": "array", + "items": { + "$ref": "#/defs/DeclaredIdentity" + } + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/defs/DeclaredGroup" + } + }, + "libraries": { + "type": "array", + "items": { + "$ref": "#/defs/DeclaredLibrary" + } + }, + "provisioner_account": { + "type": "string", + "description": "the account the provisioner's client runs as on the node" + } + } + }, + "Adoption": { + "type": "object", + "description": "the node is adopted and which of its modules are taken", + "properties": { + "taken": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the modules taken" + }, + "untaken": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "by module, the resources left untaken" + } + }, + "required": [ + "taken" + ] + }, + "DeclaredIdentity": { + "type": "object", + "description": "the identity of a module assigned to the node", + "properties": { + "module": { + "type": "string" + }, + "name": { + "type": "string" + }, + "uid": { + "type": "integer" + }, + "gid": { + "type": "integer" + }, + "groups": { + "type": "array", + "items": { + "type": "integer" + } + }, + "runner": { + "type": "string" + } + }, + "required": [ + "module", + "name", + "uid", + "gid" + ] + }, + "DeclaredGroup": { + "type": "object", + "description": "a group", + "properties": { + "name": { + "type": "string" + }, + "gid": { + "type": "integer" + } + }, + "required": [ + "name", + "gid" + ] + }, + "DeclaredLibrary": { + "type": "object", + "description": "a library the node is reached by", + "properties": { + "library": { + "type": "string" + }, + "root": { + "type": "string" + }, + "group": { + "type": "string" + }, + "gid": { + "type": "integer" + }, + "home": { + "type": "boolean" + }, + "taken": { + "type": "boolean" + }, + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "runner": { + "type": "string" + } + }, + "required": [ + "library", + "root", + "group", + "gid", + "home" + ] + }, + "PlacedDataItem": { + "type": "object", + "description": "one data item a module declares, as placed on a node", + "properties": { + "module": { + "type": "string" + }, + "item": { + "type": "string" + }, + "class": { + "type": "string" + }, + "path": { + "type": "string", + "description": "the item's place on the node" + }, + "covered_by": { + "type": "string", + "description": "the path whose snapshot keeps it, empty when it is not backed up" + }, + "protection": { + "type": "string", + "description": "backup, redundancy, both or none" + }, + "measure": { + "type": "string", + "description": "walk, dataset or shallow" + } + }, + "required": [ + "module", + "item", + "class", + "path" + ] + }, + "NetworkHealth": { + "type": "object", + "description": "the node's network", + "properties": { + "state": { + "type": "string", + "description": "running, degraded or unknown" + }, + "since": { + "type": "string", + "description": "since when", + "format": "date-time" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/defs/NetworkPart" + } + } + }, + "required": [ + "state", + "since", + "parts" + ] + }, + "NetworkPart": { + "type": "object", + "description": "one part of the node's network", + "properties": { + "part": { + "type": "string" + }, + "state": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "said": { + "type": "string" + }, + "writer": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "toward": { + "type": "array", + "items": { + "type": "string" + } + }, + "targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "since": { + "type": "string", + "description": "since when", + "format": "date-time" + }, + "streak": { + "type": "integer" + } + }, + "required": [ + "part", + "state", + "since" + ] + }, + "UnitsHealth": { + "type": "object", + "description": "the node's units", + "properties": { + "state": { + "type": "string" + }, + "failed": { + "type": "array", + "items": { + "$ref": "#/defs/FailedUnit" + } + }, + "unread": { + "type": "array", + "items": { + "type": "string" + } + }, + "away": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "state", + "failed" + ] + }, + "FailedUnit": { + "type": "object", + "description": "a unit that failed", + "properties": { + "unit": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "load": { + "type": "string" + }, + "result": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "since": { + "type": "string", + "description": "since when", + "format": "date-time" + } + }, + "required": [ + "unit", + "scope", + "since" + ] + }, + "PeerSeen": { + "type": "object", + "description": "a tunnel peer and whether it is seen", + "properties": { + "key": { + "type": "string" + }, + "addresses": { + "type": "array", + "items": { + "type": "string" + } + }, + "seen": { + "type": "boolean" + } + }, + "required": [ + "key", + "seen" + ] + }, + "NodeAccount": { + "type": "object", + "description": "an account the node needs to log in again", + "properties": { + "account": { + "type": "string" + }, + "module": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "since": { + "type": "string", + "description": "since when", + "format": "date-time" + } + }, + "required": [ + "account", + "reason", + "since" + ] + }, + "NodeDirectory": { + "type": "object", + "description": "a directory found where the node expected none", + "properties": { + "module": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "path": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "since": { + "type": "string", + "description": "since when", + "format": "date-time" + } + }, + "required": [ + "module", + "resource", + "path", + "reason", + "since" + ] + }, + "LoginFallback": { + "type": "object", + "description": "a login that fell back to another node", + "properties": { + "node": { + "type": "string" + }, + "from": { + "type": "string" + }, + "why": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when", + "format": "date-time" + } + }, + "required": [ + "node", + "from", + "why", + "at" + ] + }, + "RootAccountVerdict": { + "type": "object", + "description": "the engine's verdict of whether an account's resource is root-free, as the node's health lists it", + "properties": { + "account": { + "type": "string" + }, + "module": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "state": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "since": { + "type": "string", + "description": "since when", + "format": "date-time" + } + }, + "required": [ + "account", + "module", + "resource", + "state", + "since" + ] + } + } +} diff --git a/seats/node-environment/seat.json b/seats/node-environment/seat.json new file mode 100644 index 0000000..6a73957 --- /dev/null +++ b/seats/node-environment/seat.json @@ -0,0 +1,8 @@ +{ + "format": 1, + "seat": "node-environment", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0203" +} diff --git a/seats/node-files/seat.json b/seats/node-files/seat.json new file mode 100644 index 0000000..6a43cf4 --- /dev/null +++ b/seats/node-files/seat.json @@ -0,0 +1,590 @@ +{ + "format": 1, + "seat": "node-files", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0394", + "verbs": [ + { + "name": "stat", + "description": "Each component of one path or several, from / down: its type, owner and group (name and number), mode, whether it is a link and to what, and whether it is a mount point and of which file system. A link is not followed unless follow is \"true\". At most 200 paths in one call. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "replaces": [ + "stat", + "ls -ld", + "namei -l", + "findmnt -T" + ], + "input": { + "properties": { + "follow": { + "description": "\"true\": also say where the path leads once every link is followed", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "paths": { + "description": "one absolute path, or several separated by line breaks", + "type": "string" + } + }, + "required": [ + "paths" + ], + "type": "object" + } + }, + { + "name": "readlink", + "description": "What one link points to, as written, and where the path leads once every link is followed, and whether that exists. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "replaces": [ + "readlink", + "readlink -f", + "realpath" + ], + "input": { + "properties": { + "path": { + "description": "an absolute path", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + }, + { + "name": "list", + "description": "Every entry of one directory with its type, owner, group, mode, size and, for a link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept is refused by name. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "replaces": [ + "ls", + "ls -la" + ], + "input": { + "properties": { + "path": { + "description": "an absolute path to a directory", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + }, + { + "name": "read", + "description": "One text file, or a part of it by line: at most 256 KiB in one answer (64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its number said. /proc and /sys are read as far as the holder's account may. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "replaces": [ + "cat", + "head", + "tail", + "less" + ], + "input": { + "properties": { + "bytes": { + "description": "the most bytes to answer, up to 262144 (optional, 65536 unless given)", + "type": "string" + }, + "from": { + "description": "the first line to answer, counted from 1 (optional)", + "type": "string" + }, + "lines": { + "description": "how many lines to answer (optional)", + "type": "string" + }, + "path": { + "description": "an absolute path to a file", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + }, + { + "name": "search", + "description": "Find a fixed string (never a pattern) in one file or under one directory: every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), 8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a secret is withheld. The answer says which bound it met. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "replaces": [ + "grep", + "grep -r", + "grep -F" + ], + "input": { + "properties": { + "case": { + "description": "\"false\": match whatever the case (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "depth": { + "description": "how many directories deep, up to 8 (optional)", + "type": "string" + }, + "matches": { + "description": "the most matches to answer, up to 500 (optional)", + "type": "string" + }, + "path": { + "description": "an absolute path to a file or a directory", + "type": "string" + }, + "text": { + "description": "the fixed string to find", + "type": "string" + } + }, + "required": [ + "text", + "path" + ], + "type": "object" + } + }, + { + "name": "account", + "description": "One account or group by name or number, as this node's account files say: an account's number, group, home, shell and every group it is in; a group's number and members. With neither, every account and group. Never a password field. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "replaces": [ + "getent passwd", + "getent group", + "id", + "groups" + ], + "input": { + "properties": { + "group": { + "description": "a group's name or number (optional)", + "type": "string" + }, + "name": { + "description": "an account's name or number (optional)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "write", + "description": "Write one text file whole, replacing what it holds; the old content goes to the node's trash. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "replaces": [ + "tee", + "\u003e" + ], + "input": { + "properties": { + "content": { + "description": "the text to write", + "type": "string" + }, + "mode": { + "description": "the file's mode, such as 0644 (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "path": { + "description": "an absolute path", + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + } + }, + "required": [ + "path", + "content", + "reason" + ], + "type": "object" + } + }, + { + "name": "copy", + "description": "Copy a file or directory to a path on this node. A target on another node (\u003cnode\u003e:/path) is refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "replaces": [ + "cp", + "cp -a", + "ssh \u003cnode\u003e cp" + ], + "input": { + "properties": { + "from": { + "description": "an absolute path", + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + }, + "to": { + "description": "an absolute path on this node", + "type": "string" + } + }, + "required": [ + "from", + "to", + "reason" + ], + "type": "object" + } + }, + { + "name": "move", + "description": "Move or rename a file or directory. A target on another node is refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "replaces": [ + "mv" + ], + "input": { + "properties": { + "from": { + "description": "an absolute path", + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + }, + "to": { + "description": "an absolute path", + "type": "string" + } + }, + "required": [ + "from", + "to", + "reason" + ], + "type": "object" + } + }, + { + "name": "delete", + "description": "Delete a file or directory into the node's trash, where restore can bring it back. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "replaces": [ + "rm", + "rm -r" + ], + "input": { + "properties": { + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "path": { + "description": "an absolute path", + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + }, + "recursive": { + "description": "\"true\": delete a directory with what it holds", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "required": [ + "path", + "reason" + ], + "type": "object" + } + }, + { + "name": "mkdir", + "description": "Make a directory. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "replaces": [ + "mkdir", + "mkdir -p" + ], + "input": { + "properties": { + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "parents": { + "description": "\"true\": make the missing parents too", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "path": { + "description": "an absolute path", + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + } + }, + "required": [ + "path", + "reason" + ], + "type": "object" + } + }, + { + "name": "chmod", + "description": "Change the mode of a file or directory. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "replaces": [ + "chmod" + ], + "input": { + "properties": { + "mode": { + "description": "the mode, such as 0644 or u+x", + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "path": { + "description": "an absolute path", + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + }, + "recursive": { + "description": "\"true\": also everything under a directory", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "required": [ + "path", + "mode", + "reason" + ], + "type": "object" + } + }, + { + "name": "chown", + "description": "Change the owner and group of a file or directory. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "replaces": [ + "chown", + "chgrp" + ], + "input": { + "properties": { + "group": { + "description": "a group's name or number (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "owner": { + "description": "an account's name or number", + "type": "string" + }, + "path": { + "description": "an absolute path", + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + }, + "recursive": { + "description": "\"true\": also everything under a directory", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "required": [ + "path", + "owner", + "reason" + ], + "type": "object" + } + }, + { + "name": "upload", + "description": "Send one file of this node into a bucket of the object store, over a short signed link and never over the bus; answers the key (the file's SHA-256 when none is given), the size and the SHA-256. At most 100 MiB. A place a secret is kept, a known credential file, or a link to one is refused by name, before anything is sent. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "input": { + "properties": { + "bucket": { + "description": "the bucket the file goes into", + "type": "string" + }, + "key": { + "description": "the object's key (optional; the file's SHA-256 when not given)", + "type": "string" + }, + "path": { + "description": "an absolute path to a file", + "type": "string" + } + }, + "required": [ + "path", + "bucket" + ], + "type": "object" + } + }, + { + "name": "download", + "description": "Write one object of a bucket to a file of this node, over a short signed link and never over the bus; the old file goes to the node's trash. At most 100 MiB. The bytes must hash to sha256, or to the key when the key is a SHA-256; otherwise no file is written. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "input": { + "properties": { + "bucket": { + "description": "the bucket", + "type": "string" + }, + "key": { + "description": "the object's key", + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "type": "string" + }, + "path": { + "description": "an absolute path", + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + }, + "sha256": { + "description": "the SHA-256 the bytes must have (optional; the key when that is a SHA-256)", + "type": "string" + } + }, + "required": [ + "bucket", + "key", + "path", + "reason" + ], + "type": "object" + } + }, + { + "name": "trash", + "description": "What the node's trash holds: each deleted or replaced item with its id, original path and when it went.", + "replaces": [ + "ls ~/.local/share/Trash" + ], + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "restore", + "description": "Put an item of the node's trash back where it was; refuses when something is there now. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "replaces": [ + "trash-restore" + ], + "input": { + "properties": { + "id": { + "description": "an id from trash", + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + } + }, + "required": [ + "id", + "reason" + ], + "type": "object" + } + } + ] +} diff --git a/seats/node-hotkeys/seat.json b/seats/node-hotkeys/seat.json new file mode 100644 index 0000000..aec1269 --- /dev/null +++ b/seats/node-hotkeys/seat.json @@ -0,0 +1,14 @@ +{ + "format": 1, + "seat": "node-hotkeys", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0212", + "receives": [ + { + "kind": "trigger", + "comment": "#" + } + ] +} diff --git a/seats/node-launcher/seat.json b/seats/node-launcher/seat.json new file mode 100644 index 0000000..977e537 --- /dev/null +++ b/seats/node-launcher/seat.json @@ -0,0 +1,69 @@ +{ + "format": 1, + "seat": "node-launcher", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0208", + "verbs": [ + { + "name": "menu", + "description": "Put a menu of choices in front of the operator and answer with the one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.", + "input": { + "properties": { + "choices": { + "description": "the lines to choose between, in order", + "items": { + "type": "string" + }, + "type": "array" + }, + "prompt": { + "description": "what the menu asks (optional)", + "type": "string" + } + }, + "required": [ + "choices" + ], + "type": "object" + } + }, + { + "name": "secret", + "description": "Ask the operator for a value in a prompt that does not show what is typed, and answer it sealed to the key the asker gives — never in the clear — or cancelled when the prompt was dismissed or not answered in time.", + "optional": true, + "input": { + "properties": { + "module": { + "description": "the module whose own secret is asked for", + "type": "string" + }, + "node": { + "description": "the machine the module runs on", + "type": "string" + }, + "seal_to": { + "description": "the asker's public sealing key: the answer is sealed to it", + "type": "string" + }, + "secret": { + "description": "the own secret's name", + "type": "string" + }, + "timeout_seconds": { + "description": "give up after this long (optional)", + "type": "string" + } + }, + "required": [ + "module", + "secret", + "node", + "seal_to" + ], + "type": "object" + } + } + ] +} diff --git a/seats/node-lock-screen/seat.json b/seats/node-lock-screen/seat.json new file mode 100644 index 0000000..38e5340 --- /dev/null +++ b/seats/node-lock-screen/seat.json @@ -0,0 +1,18 @@ +{ + "format": 1, + "seat": "node-lock-screen", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0208", + "verbs": [ + { + "name": "lock", + "description": "Lock the operator's session now.", + "input": { + "properties": {}, + "type": "object" + } + } + ] +} diff --git a/seats/node-login-manager/seat.json b/seats/node-login-manager/seat.json new file mode 100644 index 0000000..2b3d4fa --- /dev/null +++ b/seats/node-login-manager/seat.json @@ -0,0 +1,18 @@ +{ + "format": 1, + "seat": "node-login-manager", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0208", + "verbs": [ + { + "name": "sessions", + "description": "The sessions the login manager offers on this machine, and which one the operator account starts by default.", + "input": { + "properties": {}, + "type": "object" + } + } + ] +} diff --git a/seats/node-login-shell/seat.json b/seats/node-login-shell/seat.json new file mode 100644 index 0000000..9d6295a --- /dev/null +++ b/seats/node-login-shell/seat.json @@ -0,0 +1,31 @@ +{ + "format": 1, + "seat": "node-login-shell", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0204", + "verbs": [ + { + "name": "execute", + "description": "Run one command on this machine as the operator account, in a non-interactive login shell in its home; answers with what it printed and how it exited.", + "optional": true, + "input": { + "properties": { + "command": { + "description": "the command line, as you would type it", + "type": "string" + }, + "timeout_seconds": { + "description": "give up after this long, at most 25 (default 20)", + "type": "string" + } + }, + "required": [ + "command" + ], + "type": "object" + } + } + ] +} diff --git a/seats/node-message-bus/seat.json b/seats/node-message-bus/seat.json new file mode 100644 index 0000000..4c19846 --- /dev/null +++ b/seats/node-message-bus/seat.json @@ -0,0 +1,8 @@ +{ + "format": 1, + "seat": "node-message-bus", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0215" +} diff --git a/seats/node-notifier/seat.json b/seats/node-notifier/seat.json new file mode 100644 index 0000000..565d898 --- /dev/null +++ b/seats/node-notifier/seat.json @@ -0,0 +1,52 @@ +{ + "format": 1, + "seat": "node-notifier", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0208", + "verbs": [ + { + "name": "send", + "description": "Show the operator a notification.", + "input": { + "properties": { + "body": { + "description": "its text (optional)", + "type": "string" + }, + "title": { + "description": "the notification's summary", + "type": "string" + }, + "urgency": { + "description": "low, normal (the default) or critical", + "enum": [ + "low", + "normal", + "critical" + ], + "type": "string" + } + }, + "required": [ + "title" + ], + "type": "object" + } + }, + { + "name": "history", + "description": "The notifications shown lately, newest first.", + "input": { + "properties": { + "limit": { + "description": "how many (optional, default 20)", + "type": "string" + } + }, + "type": "object" + } + } + ] +} diff --git a/seats/node-package-manager/seat.json b/seats/node-package-manager/seat.json new file mode 100644 index 0000000..b5fec3a --- /dev/null +++ b/seats/node-package-manager/seat.json @@ -0,0 +1,8 @@ +{ + "format": 1, + "seat": "node-package-manager", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0207" +} diff --git a/seats/node-power/seat.json b/seats/node-power/seat.json new file mode 100644 index 0000000..13679b4 --- /dev/null +++ b/seats/node-power/seat.json @@ -0,0 +1,8 @@ +{ + "format": 1, + "seat": "node-power", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0211" +} diff --git a/seats/node-reads/seat.json b/seats/node-reads/seat.json new file mode 100644 index 0000000..5dae465 --- /dev/null +++ b/seats/node-reads/seat.json @@ -0,0 +1,180 @@ +{ + "format": 1, + "seat": "node-reads", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0310", + "verbs": [ + { + "name": "stat", + "description": "Each component of one path or several, from / down: its type, owner and group (name and number), mode, whether it is a link and to what, and whether it is a mount point and of which file system. A link is not followed unless follow is \"true\". Read as the holder's own account, which holds no secret: a component it may not reach says so. At most 200 paths in one call.", + "replaces": [ + "stat", + "ls -ld", + "namei -l", + "findmnt -T" + ], + "input": { + "properties": { + "follow": { + "description": "\"true\": also say where the path leads once every link is followed", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "paths": { + "description": "one absolute path, or several separated by line breaks", + "type": "string" + } + }, + "required": [ + "paths" + ], + "type": "object" + } + }, + { + "name": "readlink", + "description": "What one link points to, as written, and where the path leads once every link is followed, and whether that exists.", + "replaces": [ + "readlink", + "readlink -f", + "realpath" + ], + "input": { + "properties": { + "path": { + "description": "an absolute path", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + }, + { + "name": "list", + "description": "Every entry of one directory with its type, owner, group, mode, size and, for a link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept is refused by name.", + "replaces": [ + "ls", + "ls -la" + ], + "input": { + "properties": { + "path": { + "description": "an absolute path to a directory", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + }, + { + "name": "read", + "description": "One text file, or a part of it by line: at most 256 KiB in one answer (64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its number said. /proc and /sys are read as far as the holder's account may.", + "replaces": [ + "cat", + "head", + "tail", + "less" + ], + "input": { + "properties": { + "bytes": { + "description": "the most bytes to answer, up to 262144 (optional, 65536 unless given)", + "type": "string" + }, + "from": { + "description": "the first line to answer, counted from 1 (optional)", + "type": "string" + }, + "lines": { + "description": "how many lines to answer (optional)", + "type": "string" + }, + "path": { + "description": "an absolute path to a file", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + }, + { + "name": "search", + "description": "Find a fixed string (never a pattern) in one file or under one directory: every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), 8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a secret is withheld. The answer says which bound it met.", + "replaces": [ + "grep", + "grep -r", + "grep -F" + ], + "input": { + "properties": { + "case": { + "description": "\"false\": match whatever the case (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "depth": { + "description": "how many directories deep, up to 8 (optional)", + "type": "string" + }, + "matches": { + "description": "the most matches to answer, up to 500 (optional)", + "type": "string" + }, + "path": { + "description": "an absolute path to a file or a directory", + "type": "string" + }, + "text": { + "description": "the fixed string to find", + "type": "string" + } + }, + "required": [ + "text", + "path" + ], + "type": "object" + } + }, + { + "name": "account", + "description": "One account or group by name or number, as this node's account files say: an account's number, group, home, shell and every group it is in; a group's number and members. With neither, every account and group. Never a password field.", + "replaces": [ + "getent passwd", + "getent group", + "id", + "groups" + ], + "input": { + "properties": { + "group": { + "description": "a group's name or number (optional)", + "type": "string" + }, + "name": { + "description": "an account's name or number (optional)", + "type": "string" + } + }, + "type": "object" + } + } + ] +} diff --git a/seats/node-service-manager/seat.json b/seats/node-service-manager/seat.json new file mode 100644 index 0000000..c46eeff --- /dev/null +++ b/seats/node-service-manager/seat.json @@ -0,0 +1,306 @@ +{ + "format": 1, + "seat": "node-service-manager", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0177", + "verbs": [ + { + "name": "units", + "description": "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.", + "replaces": [ + "systemctl list-units" + ], + "input": { + "properties": { + "pattern": { + "description": "a glob the unit's name must match (optional)", + "type": "string" + }, + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "status", + "description": "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.", + "replaces": [ + "systemctl status", + "systemctl is-active" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + } + }, + { + "name": "start", + "description": "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply.", + "replaces": [ + "systemctl start" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + } + }, + { + "name": "stop", + "description": "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state.", + "replaces": [ + "systemctl stop" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + } + }, + { + "name": "restart", + "description": "Restart one unit.", + "replaces": [ + "systemctl restart" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + } + }, + { + "name": "enable", + "description": "Make one unit start at boot (or at the account's login, in user scope).", + "replaces": [ + "systemctl enable" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + } + }, + { + "name": "disable", + "description": "Stop one unit starting at boot (or at login, in user scope).", + "replaces": [ + "systemctl disable" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + } + }, + { + "name": "journal", + "description": "The last lines of one unit's journal (at most 2000), in a time window and narrowed to a priority and to lines holding a text when asked. A secret the unit printed is shown as [redacted: \u003cwhat it was\u003e].", + "replaces": [ + "journalctl" + ], + "input": { + "properties": { + "lines": { + "description": "how many lines from the end of what matches (default 100, at most 2000)", + "type": "string" + }, + "match": { + "description": "only the lines holding this text, as written — a fixed string, not a pattern (optional)", + "type": "string" + }, + "priority": { + "description": "only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)", + "type": "string" + }, + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "since": { + "description": "the window's start: an RFC 3339 time (2026-10-07T09:30:00Z) or relative to now (-30min, -2h, yesterday) (optional)", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + }, + "until": { + "description": "the window's end, in the same forms (optional; now when absent)", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + } + }, + { + "name": "failed", + "description": "Every failed unit on this machine, in the system manager and in the operator account's; a manager that does not answer is reported with its error, never as nothing failed.", + "optional": true, + "replaces": [ + "systemctl --failed" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" or \"user\": only that manager (both when absent)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "reset-failed", + "description": "Clear one unit's failed record in its manager — its failed state and its start-limit count — and answer its state after. It removes no file and starts nothing.", + "replaces": [ + "systemctl reset-failed" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + } + }, + { + "name": "wanted-by", + "description": "What wants, requires or triggers one unit, read-only: the manager's reverse dependencies (WantedBy, RequiredBy, UpheldBy, BoundBy, TriggeredBy and the rest) and every enable link naming it in the scope's configuration directories (*.wants, *.requires, *.upholds), each with where it points and whether it dangles — the account's own directory included in user scope.", + "replaces": [ + "systemctl list-dependencies --reverse", + "systemctl show -p WantedBy", + "ls ~/.config/systemd/user/*.wants" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + } + }, + { + "name": "unlink-dangling", + "description": "Remove the dangling enable links named for one unit — links in a *.wants, *.requires or *.upholds directory of the account's or the machine's own configuration whose target does not exist, which `disable` leaves once the unit's file is gone — on the person's word: `why` is required. Each is recorded (its path, its target, the reason) before it is removed, and the manager reloaded; a link whose target exists, or one a package, a generator or the runtime placed, is left and said.", + "replaces": [ + "rm ~/.config/systemd/user/*.wants/\u003cunit\u003e", + "unlink /etc/systemd/system/*.wants/\u003cunit\u003e" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + }, + "why": { + "description": "the person's reason for removing the links, kept in the record", + "type": "string" + } + }, + "required": [ + "unit", + "why" + ], + "type": "object" + } + } + ] +} diff --git a/seats/node-terminal-emulator/seat.json b/seats/node-terminal-emulator/seat.json new file mode 100644 index 0000000..dd856ee --- /dev/null +++ b/seats/node-terminal-emulator/seat.json @@ -0,0 +1,27 @@ +{ + "format": 1, + "seat": "node-terminal-emulator", + "scope": "node", + "grain": "seat-list", + "domain": "Core", + "decision": "novox/hq ADR 0208", + "verbs": [ + { + "name": "open", + "description": "Open a terminal window in the operator's session, running a command or the login shell, in a directory or the account's home.", + "input": { + "properties": { + "command": { + "description": "what to run in it (optional; the login shell when absent)", + "type": "string" + }, + "directory": { + "description": "where it starts (optional; the account's home when absent)", + "type": "string" + } + }, + "type": "object" + } + } + ] +} diff --git a/ts/seats/meshbroker/package-lock.json b/ts/seats/meshbroker/package-lock.json new file mode 100644 index 0000000..2f792b1 --- /dev/null +++ b/ts/seats/meshbroker/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-contracts-mesh-broker", + "packages": { + "": { + "name": "@novox/mesh-contracts-mesh-broker", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/ts/seats/meshbroker/package.json b/ts/seats/meshbroker/package.json new file mode 100644 index 0000000..0d845e8 --- /dev/null +++ b/ts/seats/meshbroker/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-broker, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-contracts-mesh-broker", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/ts/seats/meshbroker/src/index.ts b/ts/seats/meshbroker/src/index.ts new file mode 100644 index 0000000..a2b85a2 --- /dev/null +++ b/ts/seats/meshbroker/src/index.ts @@ -0,0 +1,271 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "mesh-broker", defined whole (seats/mesh-broker/seat.json): The mesh's bus, and the bus state modules ask of it in requires (design 25 § 12). create, apply, recreate and drop are granted to the bus module's own credential alone. + +export const seat = "mesh-broker"; + +export const verbs = { + list: "list", + show: "show", + check: "check", + orphans: "orphans", + create: "create", + apply: "apply", + recreate: "recreate", + drop: "drop", +} as const; + +export const events = { + created: "created", + refused: "refused", + drifted: "drifted", + driftCleared: "drift-cleared", + full: "full", + fullCleared: "full-cleared", + orphaned: "orphaned", + recreated: "recreated", + dropped: "dropped", +} as const; + +export const family = { name: "mesh-broker.events", maxAgeDays: 7 } as const; + +export const createdSubject = "mesh.seat.mesh-broker.event.created"; + +export const refusedSubject = "mesh.seat.mesh-broker.event.refused"; + +export const driftedSubject = "mesh.seat.mesh-broker.event.drifted"; + +export const driftClearedSubject = "mesh.seat.mesh-broker.event.drift-cleared"; + +export const fullSubject = "mesh.seat.mesh-broker.event.full"; + +export const fullClearedSubject = "mesh.seat.mesh-broker.event.full-cleared"; + +export const orphanedSubject = "mesh.seat.mesh-broker.event.orphaned"; + +export const recreatedSubject = "mesh.seat.mesh-broker.event.recreated"; + +export const droppedSubject = "mesh.seat.mesh-broker.event.dropped"; + +/** the binding a module receives in its membership for one entry (ADR 0595 §1, §7) */ +export interface Binding { + /** the entry's name on the bus */ + "bus-name": string; + /** how the bus keeps it */ + "storage-type": string; + /** its subjects, for a stream */ + "subjects"?: string[]; + /** its template */ + "template"?: string; +} + +/** one piece of bus state as a manifest asks it */ +export interface Entry { + /** its own configuration, where no template fits */ + "configuration"?: Record; + /** its local name in the module's manifest */ + "local-name": string; + /** the module asking it */ + "module": string; + /** the template's options the module set, within their bounds */ + "options"?: Record; + /** how the bus keeps it */ + "storage-type": "stream" | "key-value" | "object-store" | "ledger"; + /** its template; empty for a configuration of its own */ + "template"?: "state" | "events" | "work-queue" | "log" | "objects" | "ledger" | ""; +} + +export interface Fill { + /** bytes held */ + "bytes": number; + /** its cap in bytes */ + "cap": number; + /** messages, keys or objects held */ + "count": number; +} + +/** one entry as the bus holds it */ +export interface Held { + /** its name on the bus, _ */ + "bus-name": string; + /** its own configuration, where no template fits */ + "configuration"?: Record; + "fill"?: Fill; + /** its local name in the module's manifest */ + "local-name": string; + /** the module asking it */ + "module": string; + /** the template's options the module set, within their bounds */ + "options"?: Record; + /** the settings the bus holds */ + "settings"?: Record; + /** the entry's state: held as asked, refused, waiting to be made, drifted from what is asked, full, or orphaned (no manifest asks it) */ + "state"?: "held" | "refused" | "waiting" | "drifted" | "full" | "orphaned"; + /** how the bus keeps it */ + "storage-type": "stream" | "key-value" | "object-store" | "ledger"; + /** its template; empty for a configuration of its own */ + "template"?: "state" | "events" | "work-queue" | "log" | "objects" | "ledger" | ""; + /** why it is in its state, in plain words; empty when held as asked */ + "why"?: string; +} + +export interface Named { + /** the entry's local name */ + "local-name": string; + /** the module */ + "module": string; +} + +/** the body of every event of the seat */ +export interface Said { + /** the settings asked */ + "asked"?: Record; + "binding"?: Binding; + /** its name on the bus */ + "bus-name": string; + /** its own configuration, where no template fits */ + "configuration"?: Record; + "fill"?: Fill; + /** the settings held */ + "held"?: Record; + /** its local name in the module's manifest */ + "local-name": string; + /** the module asking it */ + "module": string; + /** the template's options the module set, within their bounds */ + "options"?: Record; + /** the indexer's revision of the verdict the bus module acted on (ADR 0622 §2), so a reader orders what it hears */ + "revision"?: number; + /** the stream snapshot taken before a recreate or drop */ + "snapshot"?: string; + /** how the bus keeps it */ + "storage-type"?: "stream" | "key-value" | "object-store" | "ledger"; + /** its template; empty for a configuration of its own */ + "template"?: "state" | "events" | "work-queue" | "log" | "objects" | "ledger" | ""; + /** why: a refusal's cause, a drift's difference, a drop's or recreate's reason */ + "why"?: string; +} + +export interface Warranted { + /** the entry's local name */ + "local-name": string; + /** the module */ + "module": string; + /** the operator's warrant, as the operator channel decided it (ADR 0259 §6) */ + "warrant": string; + /** why, kept in the record */ + "why": string; +} + +/** is what list takes. */ +export interface ListInput { + /** one module (optional) */ + "module"?: string; +} + +/** is what list answers. */ +export interface ListAnswer { + /** every entry */ + "entries": Held[]; +} + +/** is what show takes. */ +export type ShowInput = Named; + +/** is what show answers. */ +export type ShowAnswer = Held; + +/** is what check takes. */ +export interface CheckInput { + /** one module (optional) */ + "module"?: string; +} + +export interface CheckAnswerDifferencesItem { + /** asked */ + "asked"?: Record; + /** the difference only grows: apply may act on it */ + "grows"?: boolean; + /** held */ + "held"?: Record; + /** the entry */ + "local-name": string; + /** the module */ + "module": string; +} + +/** is what check answers. */ +export interface CheckAnswer { + /** every difference */ + "differences": CheckAnswerDifferencesItem[]; +} + +/** is what orphans takes. */ +export interface OrphansInput { +} + +/** is what orphans answers. */ +export interface OrphansAnswer { + /** every orphan */ + "entries": Held[]; +} + +/** is what create takes. */ +export type CreateInput = Entry; + +/** is what create answers. */ +export type CreateAnswer = Held; + +/** is what apply takes. */ +export type ApplyInput = Named; + +/** is what apply answers. */ +export type ApplyAnswer = Held; + +/** is what recreate takes. */ +export type RecreateInput = Warranted; + +/** is what recreate answers. */ +export type RecreateAnswer = Held; + +/** is what drop takes. */ +export type DropInput = Warranted; + +/** is what drop answers. */ +export interface DropAnswer { + /** it is gone */ + "dropped": boolean; + /** the snapshot taken first */ + "snapshot"?: string; +} + +/** is the body of the event created. */ +export type CreatedEvent = Said; + +/** is the body of the event refused. */ +export type RefusedEvent = Said; + +/** is the body of the event drifted. */ +export type DriftedEvent = Said; + +/** is the body of the event drift-cleared. */ +export type DriftClearedEvent = Said; + +/** is the body of the event full. */ +export type FullEvent = Said; + +/** is the body of the event full-cleared. */ +export type FullClearedEvent = Said; + +/** is the body of the event orphaned. */ +export type OrphanedEvent = Said; + +/** is the body of the event recreated. */ +export type RecreatedEvent = Said; + +/** is the body of the event dropped. */ +export type DroppedEvent = Said; + +/** is the form of the provision bus-state. */ +export type BusStateForm = Binding; + diff --git a/ts/seats/meshbroker/tsconfig.json b/ts/seats/meshbroker/tsconfig.json new file mode 100644 index 0000000..60274c6 --- /dev/null +++ b/ts/seats/meshbroker/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/ts/seats/meshconfig/package-lock.json b/ts/seats/meshconfig/package-lock.json new file mode 100644 index 0000000..a4037cc --- /dev/null +++ b/ts/seats/meshconfig/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-contracts-mesh-config", + "packages": { + "": { + "name": "@novox/mesh-contracts-mesh-config", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/ts/seats/meshconfig/package.json b/ts/seats/meshconfig/package.json new file mode 100644 index 0000000..3c4108c --- /dev/null +++ b/ts/seats/meshconfig/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-config, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-contracts-mesh-config", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/ts/seats/meshconfig/src/index.ts b/ts/seats/meshconfig/src/index.ts new file mode 100644 index 0000000..7a6aa32 --- /dev/null +++ b/ts/seats/meshconfig/src/index.ts @@ -0,0 +1,222 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "mesh-config", defined whole (seats/mesh-config/seat.json): The mesh's config values: read and set one, and the renames still open. mesh-config's own events stay under its own subjects (ADR 0620 §2). + +export const seat = "mesh-config"; + +export const verbs = { + config: "config", + renames: "renames", +} as const; + +export const holderEvents = { + configChanged: "config-changed", + renamed: "renamed", + retiring: "retiring", + retired: "retired", +} as const; + +export function configChangedHolderSubject(holder: string): string { + return "mesh.mod." + holder + ".event.config-changed"; +} + +export function renamedHolderSubject(holder: string): string { + return "mesh.stream." + holder + ".event.renamed"; +} + +export function retiringHolderSubject(holder: string): string { + return "mesh.stream." + holder + ".event.retiring"; +} + +export function retiredHolderSubject(holder: string): string { + return "mesh.stream." + holder + ".event.retired"; +} + +export const states = { + resolved: "resolved", + kept: "kept", +} as const; + +/** The key of one value of the state resolved under the form "..". */ +export function resolvedByNodeModuleKeyKey(node: string, module: string, key: string): string { + return node + "." + module + "." + key; +} + +/** The parts of a key of the form "..", or undefined for a key of another form. */ +export function parseResolvedByNodeModuleKeyKey(_key: string): { node: string; module: string; key: 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); + const _at2 = _rest.indexOf("."); + if (_at2 <= 0) return undefined; + const module = _rest.slice(0, _at2); + _rest = _rest.slice(_at2 + 1); + if (_rest === "") return undefined; + const key = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node, module, key }; +} + +/** The key of one value of the state kept under the form ".....". */ +export function keptByOwnerRepositoryNumberNodeModuleKeyKey(owner: string, repository: string, number: string, node: string, module: string, key: string): string { + return owner + "." + repository + "." + number + "." + node + "." + module + "." + key; +} + +/** The parts of a key of the form ".....", or undefined for a key of another form. */ +export function parseKeptByOwnerRepositoryNumberNodeModuleKeyKey(_key: string): { owner: string; repository: string; number: string; node: string; module: string; key: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const owner = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + const _at2 = _rest.indexOf("."); + if (_at2 <= 0) return undefined; + const repository = _rest.slice(0, _at2); + _rest = _rest.slice(_at2 + 1); + const _at4 = _rest.indexOf("."); + if (_at4 <= 0) return undefined; + const number = _rest.slice(0, _at4); + _rest = _rest.slice(_at4 + 1); + const _at6 = _rest.indexOf("."); + if (_at6 <= 0) return undefined; + const node = _rest.slice(0, _at6); + _rest = _rest.slice(_at6 + 1); + const _at8 = _rest.indexOf("."); + if (_at8 <= 0) return undefined; + const module = _rest.slice(0, _at8); + _rest = _rest.slice(_at8 + 1); + if (_rest === "") return undefined; + const key = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { owner, repository, number, node, module, key }; +} + +/** is what config takes. */ +export interface ConfigInput { + /** list (when not given), set, clear or history */ + "action"?: string; + /** who gave the value at the controller's terminal */ + "by"?: string; + /** the config key */ + "key"?: string; + /** for history: at most this many, newest */ + "limit"?: number; + /** a module: its own value, with node */ + "module"?: string; + /** a node */ + "node"?: string; + /** for set: the value, of the key's type: a number, a string, a boolean, or for a trusted key an object or a list */ + "value"?: unknown; + /** for set and clear: why */ + "why"?: string; +} + +/** is what config answers. */ +export type ConfigAnswer = Record; + +/** is what renames takes. */ +export interface RenamesInput { + /** one module (optional) */ + "module"?: string; +} + +export interface RenamesAnswerRenamesItem { + /** when it moved, RFC 3339 */ + "at"?: string; + /** the old name */ + "from": string; + /** the module */ + "module": string; + /** the nodes still holding the old name open */ + "open"?: string[]; + /** the new name */ + "to": string; +} + +/** is what renames answers. */ +export interface RenamesAnswer { + /** every open rename */ + "renames": RenamesAnswerRenamesItem[]; +} + +/** is the body of the holder's event renamed. */ +export interface RenamedHolderEvent { + /** when, RFC 3339 */ + "at": string; + /** the old name */ + "from": string; + /** the module */ + "module": string; + /** the new name */ + "to": string; +} + +/** is the body of the holder's event retiring. */ +export interface RetiringHolderEvent { + /** the ask's id */ + "ask"?: string; + /** the value's or secret's name */ + "key": string; + "kind": "value" | "secret"; + /** the module */ + "module": string; + /** since when, RFC 3339 */ + "since": string; +} + +/** is the body of the holder's event retired. */ +export interface RetiredHolderEvent { + /** the ask's id */ + "ask"?: string; + /** the module */ + "module": string; + /** the name */ + "name": string; + /** the node */ + "node"?: string; +} + +/** is one value of the state resolved under the key ... */ +export interface ResolvedByNodeModuleKeyValue { + "key": string; + "module": string; + "node": string; + /** the predefined key the value falls back to */ + "predefined"?: string; + /** an unset value is the operator's own to set */ + "required"?: boolean; + "source": string; + "trusted"?: boolean; + "value"?: unknown; +} + +/** is one value of the state kept under the key ...... */ +export interface KeptByOwnerRepositoryNumberNodeModuleKeyValue { + /** who approved it, as the proposal kept it */ + "approved"?: string; + /** when it was kept */ + "at"?: string; + "by"?: string; + /** the digest of what the declaration holds the value to; the value stays only while it is the same */ + "declaration": string; + /** the head the declaration was read at */ + "head": string; + /** the config key */ + "key": string; + "module": string; + "node": string; + /** the proposal's id */ + "proposal"?: string; + /** the pull request, owner/repository#number */ + "pull": string; + /** the value held, as the operator approved it */ + "value": unknown; + /** where the value was proposed from */ + "via"?: string; + "why"?: string; +} + diff --git a/ts/seats/meshconfig/tsconfig.json b/ts/seats/meshconfig/tsconfig.json new file mode 100644 index 0000000..60274c6 --- /dev/null +++ b/ts/seats/meshconfig/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/ts/seats/meshmoduleassigner/package-lock.json b/ts/seats/meshmoduleassigner/package-lock.json new file mode 100644 index 0000000..c05cbfb --- /dev/null +++ b/ts/seats/meshmoduleassigner/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-contracts-mesh-module-assigner", + "packages": { + "": { + "name": "@novox/mesh-contracts-mesh-module-assigner", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/ts/seats/meshmoduleassigner/package.json b/ts/seats/meshmoduleassigner/package.json new file mode 100644 index 0000000..e7e83e9 --- /dev/null +++ b/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-contracts-mesh-module-assigner", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/ts/seats/meshmoduleassigner/src/index.ts b/ts/seats/meshmoduleassigner/src/index.ts new file mode 100644 index 0000000..5ce1e00 --- /dev/null +++ b/ts/seats/meshmoduleassigner/src/index.ts @@ -0,0 +1,507 @@ +// 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 assignments and the proposals that change it. + +export const seat = "mesh-module-assigner"; + +export const verbs = { + assignments: "assignments", + proposals: "proposals", + propose: "propose", + withdraw: "withdraw", + again: "again", +} as const; + +export const events = { + assignmentsProposed: "assignments-proposed", + assignmentsWithdrawn: "assignments-withdrawn", + assignmentsJudged: "assignments-judged", + assignmentsChanged: "assignments-changed", +} as const; + +export const family = { name: "mesh-module-assigner.events", maxAgeDays: 7 } as const; + +export const assignmentsProposedSubject = "mesh.seat.mesh-module-assigner.event.assignments-proposed"; + +export const assignmentsWithdrawnSubject = "mesh.seat.mesh-module-assigner.event.assignments-withdrawn"; + +export const assignmentsJudgedSubject = "mesh.seat.mesh-module-assigner.event.assignments-judged"; + +export const assignmentsChangedSubject = "mesh.seat.mesh-module-assigner.event.assignments-changed"; + +export const states = { + assignments: "assignments", + proposals: "proposals", + candidates: "candidates", + pulses: "pulses", + seatHolders: "seat-holders", +} as const; + +/** The key of one value of the state assignments under the form ".node". */ +export function assignmentsByNodeNodeKey(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 parseAssignmentsByNodeNodeKey(_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 assignments under the form ".". */ +export function assignmentsByNodeModuleKey(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 parseAssignmentsByNodeModuleKey(_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 node's assignments assembled */ +export interface Assignments { + "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 AssignmentsEventWarrant { + "at": string; + "warrant": string; + "words": string; +} + +/** what each assignments event carries */ +export interface AssignmentsEvent { + /** for assignments-withdrawn, why it ended */ + "ended"?: string; + /** for assignments-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"?: AssignmentsEventWarrant; +} + +/** a tunnel found on the machine when it was adopted: everything but its private key */ +export interface FoundTunnel { + "address"?: string; + "config"?: string; + "interface": string; + "mtu"?: number; + "peers"?: FoundTunnelPeer[]; + "port"?: number; + "public-key"?: string; + "range"?: string; + "unit"?: string; +} + +/** one peer of a found tunnel */ +export interface FoundTunnelPeer { + "address"?: string; + "public-key": string; +} + +/** 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"?: FoundTunnel; +} + +/** 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 assignments */ + "layer"?: Record; + /** the whole mesh's settings layer of the module's assignments */ + "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; +} + +/** 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 assignments, for a seed */ + "whole"?: Assignments; +} + +/** 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 assignments takes. */ +export interface AssignmentsInput { + /** "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 assignments answers. */ +export type AssignmentsAnswer = 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 assignments-proposed. */ +export type AssignmentsProposedEvent = AssignmentsEvent; + +/** is the body of the event assignments-withdrawn. */ +export type AssignmentsWithdrawnEvent = AssignmentsEvent; + +/** is the body of the event assignments-judged. */ +export type AssignmentsJudgedEvent = AssignmentsEvent; + +/** is the body of the event assignments-changed. */ +export type AssignmentsChangedEvent = AssignmentsEvent; + +export interface AssignmentsByNodeNodeValueModulesItem { + /** the digest of the module key's value it stands for */ + "digest": string; + "module": string; +} + +/** is one value of the state assignments under the key .node. */ +export interface AssignmentsByNodeNodeValue { + "decided": NodeDecided; + "holds"?: Holding[]; + /** the modules placed, in the order the composition reads them */ + "modules": AssignmentsByNodeNodeValueModulesItem[]; + "node": string; + "pins"?: Record; + "v"?: number; +} + +/** is one value of the state assignments under the key .. */ +export interface AssignmentsByNodeModuleValue { + "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 assignments with the proposal laid over it; absent when the proposal takes its key away */ + "assignments"?: Assignments; + "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/ts/seats/meshmoduleassigner/tsconfig.json b/ts/seats/meshmoduleassigner/tsconfig.json new file mode 100644 index 0000000..60274c6 --- /dev/null +++ b/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/ts/seats/nodeengine/package-lock.json b/ts/seats/nodeengine/package-lock.json new file mode 100644 index 0000000..1fd0476 --- /dev/null +++ b/ts/seats/nodeengine/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-contracts-node-engine", + "packages": { + "": { + "name": "@novox/mesh-contracts-node-engine", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/ts/seats/nodeengine/package.json b/ts/seats/nodeengine/package.json new file mode 100644 index 0000000..b7ac56a --- /dev/null +++ b/ts/seats/nodeengine/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat node-engine, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-contracts-node-engine", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/ts/seats/nodeengine/src/index.ts b/ts/seats/nodeengine/src/index.ts new file mode 100644 index 0000000..d68248e --- /dev/null +++ b/ts/seats/nodeengine/src/index.ts @@ -0,0 +1,1162 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "node-engine", defined whole (seats/node-engine/seat.json): The node seat every node's engine holds (novox/hq ADR 0563, ADR 0618 §3, §4): its hand acts on the operator's warrant, its read-only judgements, its compositions and what it reads of a manifest. Each verb is answered on this node's subject, mesh.seat.node-engine.tool.., and each event is published on mesh.seat.node-engine.event.., the node's name its last token. + +export const seat = "node-engine"; + +export const verbs = { + handOver: "hand-over", + accessAct: "access-act", + judgeDirectories: "judge-directories", + setuidSearch: "setuid-search", + rootFree: "root-free", + acts: "acts", + packages: "packages", + composition: "composition", + compose: "compose", + composeCandidate: "compose-candidate", + manifestReads: "manifest-reads", + seatList: "seat-list", + seatListAgain: "seat-list-again", +} as const; + +export const events = { + asked: "asked", + done: "done", + refused: "refused", + manifestReadsChanged: "manifest-reads-changed", +} as const; + +export const family = { name: "node-engine.events", maxAgeDays: 7 } as const; + +export function askedSubject(caller: string): string { + return "mesh.seat.node-engine.event.asked." + caller; +} + +export function doneSubject(caller: string): string { + return "mesh.seat.node-engine.event.done." + caller; +} + +export function refusedSubject(caller: string): string { + return "mesh.seat.node-engine.event.refused." + caller; +} + +export function manifestReadsChangedSubject(caller: string): string { + return "mesh.seat.node-engine.event.manifest-reads-changed." + caller; +} + +export const states = { + have: "have", + composed: "composed", + data: "data", + packages: "packages", + health: "health", + findings: "findings", +} as const; + +/** The key of one value of the state have under the form ".". */ +export function haveByNodeModuleKey(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 parseHaveByNodeModuleKey(_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 have under the form ".node". */ +export function haveByNodeNodeKey(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 parseHaveByNodeNodeKey(_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 composed under the form "..". */ +export function composedByNodeModuleFingerprintKey(node: string, module: string, fingerprint: string): string { + return node + "." + module + "." + fingerprint; +} + +/** The parts of a key of the form "..", or undefined for a key of another form. */ +export function parseComposedByNodeModuleFingerprintKey(_key: string): { node: string; module: string; fingerprint: 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); + const _at2 = _rest.indexOf("."); + if (_at2 <= 0) return undefined; + const module = _rest.slice(0, _at2); + _rest = _rest.slice(_at2 + 1); + if (_rest === "") return undefined; + const fingerprint = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node, module, fingerprint }; +} + +/** The key of one value of the state composed under the form ".node". */ +export function composedByNodeNodeKey(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 parseComposedByNodeNodeKey(_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 data under the form ".". */ +export function dataByNodeModuleKey(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 parseDataByNodeModuleKey(_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 packages under the form "". */ +export function packagesByNodeKey(node: string): string { + return node; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parsePackagesByNodeKey(_key: string): { node: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const node = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node }; +} + +/** The key of one value of the state health under the form "". */ +export function healthByNodeKey(node: string): string { + return node; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseHealthByNodeKey(_key: string): { node: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const node = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node }; +} + +/** The key of one value of the state findings under the form "". */ +export function findingsByKeyKey(key: string): string { + return key; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseFindingsByKeyKey(_key: string): { key: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const key = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { key }; +} + +/** the hashes of the users of the list, sealed to the vault */ +export interface AccountsHanded { + /** when */ + "at": string; + "box": SealedBox; + "from": string; + /** the fingerprint of the vault's key */ + "to": string; + "users": number; +} + +/** one act asked through the seat, as acts reads it (ADR 0563 §9) */ +export interface Act { + /** the act: hand-over, access-act, judge-directories or setuid-search */ + "act": string; + /** the operator's ask of a hand-over or an access act */ + "ask"?: string; + /** when it was asked, RFC 3339 */ + "at": string; + /** the engine asked it itself, on finding a library's root holding data outside its group (ADR 0563 §4) */ + "auto"?: boolean; + /** who answered, as the operator's warrant names them */ + "by"?: string; + /** the caller as the runtime proved it */ + "caller": string; + /** what the engine read on disk when it judged */ + "disk"?: string; + /** when it ended, RFC 3339 */ + "ended"?: string; + /** when the ask ends unanswered, RFC 3339 */ + "expires"?: string; + /** the act's id */ + "id": string; + /** the directory the act changes */ + "path"?: string; + /** the warrant's proofs */ + "proofs"?: string[]; + /** what the engine said of it: its outcome or why it was refused */ + "said"?: string; + /** the owner, group and mode it sets, in words */ + "sets"?: string; + /** where it stands */ + "state": "asked" | "done" | "refused" | "answered"; + /** the path of a hand-over, or the access of an access act */ + "subject"?: string; + /** the channel the warrant came on */ + "via"?: string; + /** why it was asked, in words */ + "why"?: string; +} + +/** what the seat says of an act: the act, the path or access, the caller as the runtime proved it and the ask (ADR 0563 §1) */ +export interface ActSaid { + /** the act */ + "act": string; + /** the operator's ask */ + "ask"?: string; + /** the caller as the runtime proved it */ + "caller": string; + /** the node whose engine says it */ + "node": string; + /** what the engine said of it */ + "said"?: string; + /** where the act stands */ + "state": "asked" | "done" | "refused"; + /** the path or the access */ + "subject"?: string; +} + +/** the acts kept */ +export interface Acts { + /** the acts, newest first */ + "acts": Act[]; + /** the node */ + "node": string; +} + +/** the node is adopted and which of its modules are taken */ +export interface Adoption { + /** the modules taken */ + "taken": string[]; + /** by module, the resources left untaken */ + "untaken"?: Record; +} + +/** the answer of an act the operator is asked */ +export interface Asked { + /** the ask's id: acts shows where it stands */ + "asked": string; + /** the node */ + "node": string; + /** what happens next, in words */ + "says": string; +} + +/** a network attached to a link */ +export interface AttachedNetwork { + "link": string; + "network": string; +} + +/** a module the candidate adds */ +export interface CandidateAdded { + "composed"?: string; + "module": string; + "unissued"?: string[]; +} + +/** the node's answer to one placement candidate naming it */ +export interface CandidateAnswer { + "added"?: CandidateAdded[]; + "differs"?: CandidateDiffers[]; + "held"?: CandidateRefusal[]; + "note"?: string; + "proposal": string; + "refusals"?: CandidateRefusal[]; + /** why the node cannot compose with the candidate at all */ + "refused"?: string; + /** modules the candidate removes */ + "removed"?: string[]; + "returns"?: string; + "revision": number; + /** modules whose composition is the one running */ + "same"?: string[]; + "values_differ"?: number; + /** same, differs or refused */ + "verdict": "same" | "differs" | "refused"; +} + +/** the bodies of one resource that differ */ +export interface CandidateBodies { + "composed"?: string; + "id": string; + "paths"?: string[]; + "running"?: string; + "why": string; +} + +/** a module whose composition with the candidate differs from the one running */ +export interface CandidateDiffers { + "bodies"?: CandidateBodies[]; + "composed"?: string; + "module": string; + "resource"?: string; + "running"?: string; + "why": string; +} + +/** a module refused or held with the candidate */ +export interface CandidateRefusal { + "module": string; + "today"?: string; + "why": string; +} + +/** a group */ +export interface DeclaredGroup { + "gid": number; + "name": string; +} + +/** the identity of a module assigned to the node */ +export interface DeclaredIdentity { + "gid": number; + "groups"?: number[]; + "module": string; + "name": string; + "runner"?: string; + "uid": number; +} + +/** a library the node is reached by */ +export interface DeclaredLibrary { + "gid": number; + "group": string; + "home": boolean; + "library": string; + "paths"?: string[]; + "root": string; + "runner"?: string; + "taken"?: boolean; +} + +/** a unit that failed */ +export interface FailedUnit { + "load"?: string; + "resource"?: string; + "result"?: string; + "scope": string; + /** since when */ + "since": string; + "unit": string; +} + +/** one answer the operator may give from a notification */ +export interface FindingAction { + "arguments"?: Record; + "label": string; + "level"?: "acknowledge" | "approve"; + "machine"?: string; + /** the seat verb the operator channel calls */ + "verb": string; +} + +/** one observation, as it was said */ +export interface FindingEvidence { + "at": string; + "said": string; +} + +/** what the condition is about */ +export interface FindingSubject { + /** the other machines it concerns */ + "also"?: string[]; + /** its id within the scope */ + "id": string; + /** the machine it concerns, when there is one */ + "machine"?: string; + /** machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module */ + "scope": string; +} + +/** a login that fell back to another node */ +export interface LoginFallback { + /** when */ + "at": string; + "from": string; + "node": string; + "why": string; +} + +/** what a node-engine says when what it reads changed or its node returned (ADR 0618 §4) */ +export interface ManifestReadsChanged { + /** when it says it, RFC 3339 */ + "at": string; + /** what its engine states it reads now */ + "manifest-reads"?: string[]; + /** the node */ + "node": string; + /** the layout's version */ + "v": number; + /** started: the engine started or what it states changed; returned: its node returned after being away */ + "why": "started" | "returned"; +} + +/** one module's composition with the candidate */ +export interface Module { + /** the candidate changes it from the one composed without it */ + "changed"?: boolean; + /** the composition's digest when composed */ + "digest"?: string; + /** the fingerprint it was composed at */ + "fp"?: string; + /** the module, node for the node's own composition */ + "module": string; + /** composed (digest set), refused by the candidate, or held without it too (words set) */ + "state": "composed" | "refused" | "held"; + /** why, for a refused or held one */ + "words"?: string; +} + +/** the node's network */ +export interface NetworkHealth { + "parts": NetworkPart[]; + /** since when */ + "since": string; + /** running, degraded or unknown */ + "state": string; +} + +/** one part of the node's network */ +export interface NetworkPart { + "owner"?: string; + "part": string; + "reason"?: string; + "said"?: string; + /** since when */ + "since": string; + "state": string; + "streak"?: number; + "targets"?: string[]; + "toward"?: string[]; + "writer"?: string; +} + +/** an account the node needs to log in again */ +export interface NodeAccount { + "account": string; + "module"?: string; + "reason": string; + "resource"?: string; + /** since when */ + "since": string; +} + +/** a directory found where the node expected none */ +export interface NodeDirectory { + "module": string; + "path": string; + "reason": string; + "resource": string; + /** since when */ + "since": string; +} + +/** what a node's own composition carries beside its resources */ +export interface NodeOwn { + "adoption"?: Adoption; + "groups"?: DeclaredGroup[]; + "identities"?: DeclaredIdentity[]; + "libraries"?: DeclaredLibrary[]; + /** the account the provisioner's client runs as on the node */ + "provisioner_account"?: string; +} + +/** what the machine observes of itself, for every node and composing part to read */ +export interface NodeRecord { + "authority"?: string; + "capabilities"?: string[]; + "certificate"?: string; + "contract"?: number; + "manifest-reads"?: string[]; + "networks"?: AttachedNetwork[]; + "networks-said"?: boolean; + "node": string; + "outward-links"?: string[]; + "sealing-key"?: string; + "serving-key"?: string; + "trusts"?: string[]; + "tunnel-key"?: string; + "v": number; +} + +/** a tunnel peer and whether it is seen */ +export interface PeerSeen { + "addresses"?: string[]; + "key": string; + "seen": boolean; +} + +/** one data item a module declares, as placed on a node */ +export interface PlacedDataItem { + "class": string; + /** the path whose snapshot keeps it, empty when it is not backed up */ + "covered_by"?: string; + "item": string; + /** walk, dataset or shallow */ + "measure"?: string; + "module": string; + /** the item's place on the node */ + "path": string; + /** backup, redundancy, both or none */ + "protection"?: string; +} + +/** the pull from a store a failed apply waits on */ +export interface PullWait { + /** when the pull began */ + "began": string; + /** that seat's holder, whose settling the apply waits for */ + "holder": string; + "image": string; + /** what triggered the last try again, in words */ + "retried"?: string; + /** when the apply was last tried again */ + "retried-at"?: string; + /** the seat the pull came through */ + "seat"?: string; +} + +/** one revision of a composition key */ +export interface Revision { + /** its digest */ + "digest"?: string; + /** the composition's key */ + "key": string; + /** the latest revision */ + "latest"?: boolean; + /** why it cannot be read or trusted */ + "refused"?: string; + /** how many resources it names */ + "resources"?: number; + /** its revision */ + "revision"?: number; +} + +/** the engine's verdict of whether an account's resource is root-free, as the node's health lists it */ +export interface RootAccountVerdict { + "account": string; + "module": string; + "reason"?: string; + "resource": string; + /** since when */ + "since": string; + "state": string; +} + +/** the engine's root-free verdict (ADR 0563 §6) */ +export interface RootVerdict { + /** the machine is root-free */ + "free": boolean; + /** when the engine judged it, RFC 3339 */ + "judged": string; + /** the machine */ + "machine": string; + /** why, in words */ + "why": string; +} + +/** the answer of a read-only judgement */ +export interface Said { + /** the node */ + "node": string; + /** the engine's judgement or search, one line per directory or program */ + "said": string; +} + +/** a value sealed to a key */ +export interface SealedBox { + "ct": string; + "eph": string; + "iv": string; + "tag": string; + "v": number; +} + +export interface SeatListStatus { + /** the seat mesh-catalog has answered */ + "heard": boolean; + "held": SeatListHeld[]; + "last-error"?: string; + /** a trunk definition that does not parse, by seat: the one held stands */ + "refused"?: Record; + "revision"?: number; + /** the seats held from the bootstrap alone */ + "waiting"?: string[]; +} + +/** what an engine holds of the seat list (novox/hq ADR 0636 §7) */ +export interface SeatList { + /** why the read failed, for seat-list-again */ + "error"?: string; + /** the one line the engine says of it; empty when nothing waits */ + "health": string; + "status": SeatListStatus; +} + +export interface SeatListHeld { + /** the digest of the definition held; empty for a bootstrapped one */ + "digest"?: string; + "seat": string; + "source": "trunk" | "bootstrap"; +} + +/** the node's units */ +export interface UnitsHealth { + "away"?: string[]; + "failed": FailedUnit[]; + "state": string; + "unread"?: string[]; +} + +/** the bus's user list as the engine of the bus node wrote it to the bus module's file */ +export interface UserListWritten { + /** when */ + "at": string; + /** the digest of the file on the machine after the write */ + "digest"?: string; + /** why a write failed */ + "error"?: string; + /** which list the node follows now */ + "follows"?: string; + "handed"?: AccountsHanded; + /** the bus node's sealing key, its public half */ + "key"?: string; + "revision": number; + /** the key the list was taken from */ + "source": string; + /** why the bus node does not watch the grants part's key now */ + "unwatched"?: string; + "users": number; + /** the digest of the list the source holds, sha256: */ + "wanted": string; +} + +/** is what hand-over takes. */ +export interface HandOverInput { + /** the directory's absolute path, as the engine states it */ + "path": string; + /** why it is asked, in words the operator reads */ + "why"?: string; +} + +/** is what hand-over answers. */ +export type HandOverAnswer = Asked; + +/** is what access-act takes. */ +export interface AccessActInput { + /** the access, library: */ + "access": string; + /** the act: take */ + "act": string; + /** why it is asked, in words the operator reads */ + "why"?: string; +} + +/** is what access-act answers. */ +export type AccessActAnswer = Asked; + +/** is what judge-directories takes. */ +export interface JudgeDirectoriesInput { +} + +/** is what judge-directories answers. */ +export type JudgeDirectoriesAnswer = Said; + +/** is what setuid-search takes. */ +export interface SetuidSearchInput { + /** why it is asked, in words */ + "why"?: string; +} + +/** is what setuid-search answers. */ +export type SetuidSearchAnswer = Said; + +/** is what root-free takes. */ +export interface RootFreeInput { +} + +/** is what root-free answers. */ +export interface RootFreeAnswer { + /** this machine's verdict */ + "machines": RootVerdict[]; +} + +/** is what acts takes. */ +export interface ActsInput { + /** one act, by its id */ + "id"?: string; + /** at most this many, newest */ + "limit"?: string; +} + +/** is what acts answers. */ +export type ActsAnswer = Acts; + +/** is what packages takes. */ +export interface PackagesInput { + /** "true": write this machine's key again from what the engine composed */ + "write"?: "true" | "false"; +} + +export interface PackagesAnswerKeyPackagesItem { + /** declared absent */ + "absent": boolean; + /** the modules that declare it */ + "modules": string[]; + /** the package */ + "package": string; +} + +/** the node's key as the bus holds it; absent when the engine wrote none */ +export interface PackagesAnswerKey { + /** the node */ + "node": string; + /** its packages sorted by name, absent after present */ + "packages": PackagesAnswerKeyPackagesItem[]; +} + +/** is what packages answers. */ +export interface PackagesAnswer { + /** the node's key as the bus holds it; absent when the engine wrote none */ + "key"?: PackagesAnswerKey; + /** why the key could not be read */ + "key_error"?: string; + /** the node */ + "node": string; + /** a note, such as that no key is written yet */ + "note"?: string; + /** where the key is read: the engine's state packages */ + "source": string; + /** with write: the key was written again (false: it already held what the engine composed) */ + "wrote"?: boolean; +} + +/** is what composition takes. */ +export interface CompositionInput { + /** "true": list the resources that differ from what runs */ + "diff"?: string; + /** a composition's digest (sha256:…), to read that revision rather than the latest */ + "digest"?: string; + /** the module, as placed on this machine; node for the node's own */ + "module": string; +} + +/** the module's last apply that did not end running; absent when none */ +export interface CompositionAnswerAttempt { + /** the want's revision the hold took */ + "answered_revision"?: number; + /** the digest tried */ + "composed"?: string; + /** the composition's revision it was tried at */ + "composition_revision": number; + /** the fingerprint tried */ + "fp": string; + /** held for a failed backup */ + "held"?: boolean; + /** it failed because the bus did not answer for a moment */ + "transient"?: boolean; + /** the want's revision it was tried at */ + "want_revision": number; +} + +/** what the module's have key says now; absent when it says nothing */ +export interface CompositionAnswerHave { + /** digest */ + "composed"?: string; + /** detail */ + "detail"?: string; + /** fingerprint */ + "fp"?: string; + /** judgement */ + "judgement"?: string; + /** running, failed, held or waiting-yes */ + "phase": string; + /** the digest that waits for a yes */ + "waits"?: string; +} + +/** the move from what runs to the composition read */ +export interface CompositionAnswerJudged { + /** the judgement */ + "judgement": string; + /** the agent would wait for a person's yes before applying it */ + "waits_for_yes": boolean; + /** its words */ + "words": string; +} + +/** of the node's own composition, its own fields beside what runs names */ +export interface CompositionAnswerOwn { + /** the revision's adoption; absent for a converged node */ + "adoption"?: Record; + /** groups */ + "groups"?: number; + /** each module the node's composer held in its last pass, with why */ + "held"?: Record; + /** identities the revision names */ + "identities"?: number; + /** libraries */ + "libraries"?: number; + /** the account the provisioner's client runs as, as the revision names it */ + "provisioner_account": string; + /** as what runs names it */ + "runs_provisioner_account"?: string; +} + +/** what runs of the module; absent when nothing runs */ +export interface CompositionAnswerRuns { + /** digest */ + "composed"?: string; + /** fingerprint */ + "fp"?: string; + /** applied, have or kept */ + "from": string; + /** phase */ + "phase"?: string; + /** how many resources run */ + "resources": number; +} + +/** the module's want as the agent holds it; absent when it holds none */ +export interface CompositionAnswerWant { + /** the operator's answer to a failed backup, again or skip */ + "backup"?: string; + /** the composition's digest it names */ + "composed"?: string; + /** its build's fingerprint */ + "fp": string; + /** the module is removed */ + "removed"?: boolean; + /** the revision of the want's key read last */ + "revision": number; + /** the node composes the module itself (ADR 0547 §2) */ + "self"?: boolean; + /** its words */ + "words"?: string; + /** the digest a person said yes to */ + "yes"?: string; +} + +/** is what composition answers. */ +export interface CompositionAnswer { + /** the module's last apply that did not end running; absent when none */ + "attempt"?: CompositionAnswerAttempt; + "composition": Revision; + /** every resource that differs from what runs, when asked */ + "diff"?: Record[]; + /** what the module's have key says now; absent when it says nothing */ + "have"?: CompositionAnswerHave; + /** the move from what runs to the composition read */ + "judged": CompositionAnswerJudged; + /** the module */ + "module": string; + /** the node */ + "node": string; + /** of the node's own composition, its own fields beside what runs names */ + "own"?: CompositionAnswerOwn; + /** the revisions of its key the bucket keeps, oldest first */ + "revisions"?: Revision[]; + /** what runs of the module; absent when nothing runs */ + "runs"?: CompositionAnswerRuns; + /** the agent has looked at the module once since it started */ + "started": boolean; + /** the module's want as the agent holds it; absent when it holds none */ + "want"?: CompositionAnswerWant; +} + +/** is what compose takes. */ +export interface ComposeInput { + /** a module placed on this machine whose apply is also tried again (optional) */ + "module"?: string; +} + +/** how the pass went */ +export interface ComposeAnswerPass { + /** when the node composes again on its own, RFC 3339 */ + "again"?: string; + /** when the pass ended, RFC 3339 */ + "at": string; + /** the answer is the last pass, made moments ago */ + "earlier"?: boolean; + /** how many compositions the pass handed the agent that differ from before */ + "handed": number; + /** each module refused alone, with why */ + "held"?: Record; + /** why the node composed none of its modules */ + "refused"?: string; +} + +/** is what compose answers. */ +export interface ComposeAnswer { + /** how the pass went */ + "pass": ComposeAnswerPass; + /** the module whose apply was tried again */ + "retried"?: string; +} + +/** is what compose-candidate takes. */ +export interface ComposeCandidateInput { + /** the key of the indexer's candidate, . */ + "candidate": string; + /** also answer the composition input the candidate was composed from, for the sandbox path */ + "input"?: boolean; +} + +/** is what compose-candidate answers. */ +export interface ComposeCandidateAnswer { + /** the candidate's key */ + "candidate": string; + /** when asked: the composition input it composed from */ + "input"?: Record; + /** every module placed on the node, sorted by name */ + "modules": Module[]; + /** the node */ + "node": string; + "own": Module; + /** why the node could not compose with the candidate at all */ + "refused"?: string; + /** how long the engine took, in milliseconds */ + "took": number; + /** the layout's version */ + "v": number; + /** the node's verdict (ADR 0593 §7, §9) */ + "verdict": "composed" | "refused" | "unready" | "not-judged"; +} + +/** is what manifest-reads takes. */ +export interface ManifestReadsInput { +} + +/** is what manifest-reads answers. */ +export interface ManifestReadsAnswer { + /** the reader capabilities its engine states: the manifest words, forms and seat names it reads; empty for an engine built before it said them, read as the baseline */ + "manifest-reads"?: string[]; + /** the node */ + "node": string; + /** when the engine last stated them, RFC 3339 */ + "stated-at": string; + /** the layout's version */ + "v": number; +} + +/** is what seat-list takes. */ +export interface SeatListInput { +} + +/** is what seat-list answers. */ +export type SeatListAnswer = SeatList; + +/** is what seat-list-again takes. */ +export interface SeatListAgainInput { +} + +/** is what seat-list-again answers. */ +export type SeatListAgainAnswer = SeatList; + +/** is the body of the event asked. */ +export type AskedEvent = ActSaid; + +/** is the body of the event done. */ +export type DoneEvent = ActSaid; + +/** is the body of the event refused. */ +export type RefusedEvent = ActSaid; + +/** is the body of the event manifest-reads-changed. */ +export type ManifestReadsChangedEvent = ManifestReadsChanged; + +/** is one value of the state have under the key .. */ +export interface HaveByNodeModuleValue { + "build"?: string; + /** the move changes the build alone */ + "build-alone"?: boolean; + /** the digest of the composition the node runs */ + "composed"?: string; + "detail"?: string; + /** the fingerprint held */ + "fp"?: string; + "handed"?: AccountsHanded; + /** what the module's own check says */ + "health": "ok" | "failed" | "waits" | "unknown"; + /** in phase waiting-yes, the judgement of the move */ + "judgement"?: "recreates" | "moves-data" | "cannot-judge"; + /** what the node keeps of the module as it runs */ + "kept"?: string[]; + /** how far it has come */ + "phase": "backing-up" | "applying" | "checking" | "running" | "failed" | "removed" | "held" | "waiting-yes"; + "pull-wait"?: PullWait; + /** each value sealed to the node's key */ + "sealed"?: string[]; + /** the sealed values by slot */ + "sealed-at"?: Record; + "user-list"?: UserListWritten; + "v": number; + /** in phase waiting-yes, the digest of the composition that waits */ + "waits"?: string; +} + +/** is one value of the state have under the key .node. */ +export interface HaveByNodeNodeValue { + "busy"?: Record[]; + /** the node's answer to each placement candidate naming it, by proposal id */ + "candidates"?: Record; + /** the digest of the node's own composition it runs */ + "composed"?: string; + /** own, or none (empty) */ + "composes"?: string; + "composes-why"?: string; + "filters"?: Record[]; + "profile"?: Record; + "record"?: NodeRecord; + "sealed"?: string[]; + "sealed-at"?: Record; + "strays"?: Record[]; + "v": number; + "windows"?: Record[]; +} + +/** is one value of the state composed under the key ... */ +export interface ComposedByNodeModuleFingerprintValue { + /** the module declares data */ + "data"?: boolean; + /** the files the provisioner fills */ + "filled_files"?: string[]; + "fp": string; + "module": string; + "node": string; + /** the module's whole provisioning plan, catalogue.Plan, kept whole: it is the manifest layer's type */ + "plan"?: Record; + /** why the module cannot be composed at this fingerprint */ + "refused"?: string; + /** the declaration resources, each saying its module */ + "resources": Record[]; + "v": number; +} + +/** is one value of the state composed under the key .node. */ +export interface ComposedByNodeNodeValue { + /** the module declares data */ + "data"?: boolean; + /** the files the provisioner fills */ + "filled_files"?: string[]; + "node": string; + "own"?: NodeOwn; + /** the declaration resources, each saying its module */ + "resources": Record[]; + "v": number; +} + +/** is one value of the state data under the key .. */ +export interface DataByNodeModuleValue { + "digest": string; + "items": PlacedDataItem[]; + "module": string; + "node": string; + "v": number; +} + +export interface PackagesByNodeValuePackagesItem { + "absent": boolean; + "modules": string[]; + "package": string; +} + +/** is one value of the state packages under the key . */ +export interface PackagesByNodeValue { + "node": string; + "packages": PackagesByNodeValuePackagesItem[]; +} + +/** is one value of the state health under the key . */ +export interface HealthByNodeValue { + /** when */ + "at": string; + "contract"?: number; + "fallback"?: LoginFallback; + "found"?: NodeDirectory[]; + "network"?: NetworkHealth; + "node": string; + "peers"?: PeerSeen[]; + "relogin"?: NodeAccount[]; + "root"?: RootAccountVerdict[]; + "units"?: UnitsHealth; + "v": number; +} + +/** is one value of the state findings under the key . */ +export interface FindingsByKeyValue { + "actions"?: FindingAction[]; + "at": string; + /** newest first, at most 10 */ + "evidence"?: FindingEvidence[]; + "explanation"?: string; + "headline"?: string; + /** scope.id.token */ + "key": string; + "kind": string; + "needs"?: string; + /** the module that wrote it */ + "observer": string; + /** the key's last part when it is not the kind */ + "ref"?: string; + "resolved"?: string; + /** self, operator, agent or healer: */ + "resolver"?: string; + /** what of its own saw it last */ + "run"?: string; + "severity": "urgent" | "warning"; + /** the row, probe or event that saw it */ + "source": string; + "subject": FindingSubject; + /** one line */ + "summary": string; + /** layout version, 1 */ + "v": number; +} + diff --git a/ts/seats/nodeengine/tsconfig.json b/ts/seats/nodeengine/tsconfig.json new file mode 100644 index 0000000..60274c6 --- /dev/null +++ b/ts/seats/nodeengine/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +}