Merge pull request 'Registry milestone: mesh-contracts (issue 1966)' (#1) from feat/registry-milestone into main
This commit was merged in pull request #1.
This commit is contained in:
@@ -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>/seat.json`, its fixtures in `seats/<seat>/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 (`<asker>.<id>`) 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:<name>/<key>`.
|
||||
- **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/<seat without hyphens>`, 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/<seat without hyphens>` (`@novox/mesh-contracts-<seat>`), 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/<seat without hyphens>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).
|
||||
|
||||
@@ -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>/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/<name>.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.<seat>.event.<name>"
|
||||
},
|
||||
"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/<name>.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/<seat>/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: <asker>.<id>"
|
||||
},
|
||||
"value": {
|
||||
"type": "object",
|
||||
"description": "a JSON Schema"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>.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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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`: `<seat>` for a seat held once for the mesh (node empty),
|
||||
// `<seat>.<node>` 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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 `<node>.>` alone and a change of one module writes that module's key:
|
||||
// `<node>.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 `<node>.<module>`, 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>.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, `<node>.<module>`; 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, `<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>.node or <node>.<module>", 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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.<id>`) 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
|
||||
}
|
||||
@@ -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>.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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, `<escaped
|
||||
// group>.<heads hash>`, 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, <group>.<heads hash>", 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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>.node carries", c.Module)
|
||||
}
|
||||
}
|
||||
if c.Refused != "" && (len(c.Resources) > 0 || len(c.Filled) > 0 || c.Data || len(c.Plan) > 0) {
|
||||
return fmt.Errorf("node-engine: the composition of %s on %s is refused and holds resources, filled files, data or a plan", c.Module, c.Node)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
files := map[string][]*declaration.File{}
|
||||
for _, raw := range c.Resources {
|
||||
r, err := declaration.ParseResource(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("node-engine: the composition of %q on %s: %w", c.Module, c.Node, err)
|
||||
}
|
||||
if seen[r.Identity()] {
|
||||
return fmt.Errorf("node-engine: the composition of %q on %s holds the resource %q twice", c.Module, c.Node, r.Identity())
|
||||
}
|
||||
seen[r.Identity()] = true
|
||||
if r.DeclaredModule() != c.Module {
|
||||
return fmt.Errorf("node-engine: the resource %q is of %q, and this is the composition of %q on %s",
|
||||
r.Identity(), r.DeclaredModule(), c.Module, c.Node)
|
||||
}
|
||||
if f, ok := r.(*declaration.File); ok {
|
||||
if f.TravelsByDigest() {
|
||||
return fmt.Errorf("node-engine: the file %q is over %d bytes and travels by digest, not whole",
|
||||
f.ID, declaration.FileReferenceThreshold)
|
||||
}
|
||||
files[f.Path] = append(files[f.Path], f)
|
||||
}
|
||||
}
|
||||
// A file the provisioner fills says no content, so a composition that does not say it is filled would have the
|
||||
// engine write it empty (issue 1945): refused, naming it.
|
||||
filledHere := make(map[string]bool, len(c.Filled))
|
||||
for _, path := range c.Filled {
|
||||
filledHere[path] = true
|
||||
}
|
||||
for path, fs := range files {
|
||||
for _, f := range fs {
|
||||
if f.OwnerAtRuntime == declaration.RuntimeOwned && !filledHere[path] && !saysContent(f) {
|
||||
return fmt.Errorf("node-engine: the file %q says no content and the composition of %q on %s does not say "+
|
||||
"the provisioner fills %s: the engine would write it empty", f.ID, c.Module, c.Node, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, path := range c.Filled {
|
||||
if len(files[path]) == 0 {
|
||||
return fmt.Errorf("node-engine: the composition of %q fills %q, and declares no file there", c.Module, path)
|
||||
}
|
||||
for _, f := range files[path] {
|
||||
if f.OwnerAtRuntime == "" {
|
||||
return fmt.Errorf("node-engine: the file %q is filled by the provisioner and says no owner-at-runtime", f.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saysContent(f *declaration.File) bool {
|
||||
return f.Content != "" || f.Bytes != "" || f.Digest != "" || f.Sealed != "" || f.VaultSealed != nil
|
||||
}
|
||||
|
||||
func (c Composition) wire() (composedWire, error) {
|
||||
if err := c.check(); err != nil {
|
||||
return composedWire{}, err
|
||||
}
|
||||
w := composedWire{V: keys.Version, Node: c.Node, Module: c.Module, FP: c.FP, Data: c.Data, Filled: c.Filled,
|
||||
Refused: c.Refused, Resources: make([]json.RawMessage, len(c.Resources))}
|
||||
for i, r := range c.Resources {
|
||||
canon, err := canonicalJSON(r)
|
||||
if err != nil {
|
||||
return composedWire{}, fmt.Errorf("node-engine: a resource of %q is not JSON: %w", c.Module, err)
|
||||
}
|
||||
w.Resources[i] = canon
|
||||
}
|
||||
if len(c.Plan) > 0 {
|
||||
canon, err := canonicalJSON(c.Plan)
|
||||
if err != nil {
|
||||
return composedWire{}, fmt.Errorf("node-engine: the plan of %q is not JSON: %w", c.Module, err)
|
||||
}
|
||||
w.Plan = canon
|
||||
}
|
||||
if !ownIsEmpty(c.Own) {
|
||||
w.Own = ownToWire(c.Own)
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// EncodeComposition is c as JSON at the version, each resource canonical. It refuses a composition whose node or
|
||||
// fingerprint a key refuses, a resource its kind refuses, a resource of another module, a module's composition carrying
|
||||
// the node's own parts, a file over 64 KiB carried whole, and a filled path no file declares.
|
||||
func EncodeComposition(c Composition) ([]byte, error) {
|
||||
w, err := c.wire()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(w)
|
||||
}
|
||||
|
||||
// DecodeComposition reads a composition; it refuses an unknown version (keys.ErrUnknownVersion), a field it does not
|
||||
// know, and what EncodeComposition refuses.
|
||||
func DecodeComposition(data []byte) (Composition, error) {
|
||||
if err := keys.CheckVersion(data); err != nil {
|
||||
return Composition{}, err
|
||||
}
|
||||
var w composedWire
|
||||
d := json.NewDecoder(bytes.NewReader(data))
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(&w); err != nil {
|
||||
return Composition{}, fmt.Errorf("node-engine: a composition does not fit its shape: %w", err)
|
||||
}
|
||||
c := Composition{V: w.V, Node: w.Node, Module: w.Module, FP: w.FP, Data: w.Data, Resources: w.Resources,
|
||||
Filled: w.Filled, Plan: w.Plan, Refused: w.Refused}
|
||||
if w.Own != nil {
|
||||
c.Own = ownFromWire(*w.Own)
|
||||
}
|
||||
if err := c.check(); err != nil {
|
||||
return Composition{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ComposedDigest is a composition's digest, "sha256:<hex>", of its encoding without its plan. A want names it (ADR 0539
|
||||
// clause 1), and the node's engine writes it beside the composition. A refused composition has no digest.
|
||||
func ComposedDigest(c Composition) (string, error) {
|
||||
if c.Refused != "" {
|
||||
return "", fmt.Errorf("node-engine: the composition of %s on %s is refused (%s) and has no digest", c.Module, c.Node, c.Refused)
|
||||
}
|
||||
c.Plan = nil
|
||||
raw, err := EncodeComposition(c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// SameComposition says whether two compositions are the same value, their plans included, so the engine writes only
|
||||
// when they differ.
|
||||
func SameComposition(a, b Composition) bool {
|
||||
ra, errA := EncodeComposition(a)
|
||||
rb, errB := EncodeComposition(b)
|
||||
return errA == nil && errB == nil && bytes.Equal(ra, rb)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package nodeengineclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
)
|
||||
|
||||
// captured is resources of ace's declaration of 2026-10-24 (the controller's `plan` verb answered it; testdata/captured-ace.json
|
||||
// keeps three modules of it, never typed by hand), composed here by module under a stand-in fingerprint.
|
||||
func captured(t *testing.T) (account string, byModule map[string][]json.RawMessage) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("testdata/captured-ace.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var c struct {
|
||||
Account string `json:"provisioner_account"`
|
||||
Resources []json.RawMessage `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byModule = map[string][]json.RawMessage{}
|
||||
for _, r := range c.Resources {
|
||||
var h struct {
|
||||
Module string `json:"module"`
|
||||
}
|
||||
_ = json.Unmarshal(r, &h)
|
||||
byModule[h.Module] = append(byModule[h.Module], r)
|
||||
}
|
||||
return c.Account, byModule
|
||||
}
|
||||
|
||||
func fp(n int) string { return sourcePrefix + fmt.Sprintf("%064x", n) }
|
||||
|
||||
// TestCompositionDigests: a composition's digest is the one the first writer computed (the values are the digests
|
||||
// mesh-sdk's delivery package gave for the same compositions when it moved here), whatever order the contract's generated
|
||||
// types sort their fields in, and the plan is no part of it.
|
||||
func TestCompositionDigests(t *testing.T) {
|
||||
account, by := captured(t)
|
||||
own := seat.NodeOwn{
|
||||
Adoption: &seat.Adoption{Taken: []string{"avahi"}, Untaken: map[string][]string{"plex": {"plex.bundle-code"}}},
|
||||
Identities: []seat.DeclaredIdentity{{Module: "plex", Name: "plex", Uid: 1001, Gid: 1001, Groups: []int64{1002}, Runner: "plex"}},
|
||||
Groups: []seat.DeclaredGroup{{Name: "mesh-media", Gid: 1002}},
|
||||
Libraries: []seat.DeclaredLibrary{{Library: "movies", Root: "/srv/media/movies", Group: "mesh-media", Gid: 1002, Taken: true, Paths: []string{"/srv/media/movies"}}},
|
||||
ProvisionerAccount: "mesh-provisioner-client",
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
c Composition
|
||||
digest string
|
||||
}{
|
||||
{"a node's own, the account only", Composition{Node: "ace", Own: seat.NodeOwn{ProvisionerAccount: account}}, "sha256:17fde3a215d522619c18bc80b8c236975e25b2d2980a984aebd7c18376b095a2"},
|
||||
{"a node's own, every part", Composition{Node: "ace", Own: own}, "sha256:b8ac7ace490a3c483c3ae18a9cda58e607aa6b69167fa274c062949c7b34fdbf"},
|
||||
{"one package", Composition{Node: "ace", Module: "buildah", FP: fp(7), Resources: by["buildah"]}, "sha256:84e480a7a62333960b3e4be21a7548bbaa09572ef73ca8fb9dc6dfd1228aeb6b"},
|
||||
{"a service and a file", Composition{Node: "ace", Module: "mesh-wireguard", FP: fp(14), Resources: by["mesh-wireguard"]}, "sha256:d6edec2474f606503e55d4ee2e0b4f5d1ffc25d016e4bc595e86ea67fe205b8c"},
|
||||
{"a plan beside the resources", Composition{Node: "ace", Module: "hostname", FP: fp(8), Resources: by["hostname"], Plan: json.RawMessage(`{"files":[{"path":"/etc/hostname"}]}`)}, "sha256:49e17d6c471ee65cdef745ca3ac5cea4d37b6a100e11d8434dc64a6e2949e57e"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := ComposedDigest(c.c)
|
||||
if err != nil || got != c.digest {
|
||||
t.Errorf("%s: digest %s, %v; want %s", c.name, got, err, c.digest)
|
||||
}
|
||||
raw, err := EncodeComposition(c.c)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", c.name, err)
|
||||
continue
|
||||
}
|
||||
back, err := DecodeComposition(raw)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", c.name, err)
|
||||
continue
|
||||
}
|
||||
if !SameComposition(c.c, back) {
|
||||
t.Errorf("%s: reads back as another composition", c.name)
|
||||
}
|
||||
again, _ := ComposedDigest(back)
|
||||
if again != c.digest {
|
||||
t.Errorf("%s: read back, its digest is %s", c.name, again)
|
||||
}
|
||||
}
|
||||
a := Composition{Node: "ace", Module: "hostname", FP: fp(8), Resources: by["hostname"], Plan: json.RawMessage(`{"a":1}`)}
|
||||
b := a
|
||||
b.Plan = json.RawMessage(`{"a":2}`)
|
||||
da, _ := ComposedDigest(a)
|
||||
db, _ := ComposedDigest(b)
|
||||
if da != db || SameComposition(a, b) {
|
||||
t.Error("a plan changes the composition's encoding and not its digest")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompositionRefusals: what the codec refuses.
|
||||
func TestCompositionRefusals(t *testing.T) {
|
||||
_, by := captured(t)
|
||||
ok := Composition{Node: "ace", Module: "buildah", FP: fp(7), Resources: by["buildah"]}
|
||||
cases := []struct {
|
||||
name string
|
||||
c Composition
|
||||
want string
|
||||
}{
|
||||
{"an old-form fingerprint", Composition{Node: "ace", Module: "buildah", FP: "src1:abc", Resources: ok.Resources}, "new form"},
|
||||
{"a resource of another module", Composition{Node: "ace", Module: "hostname", FP: fp(8), Resources: ok.Resources}, "is of"},
|
||||
{"the node's own parts on a module", Composition{Node: "ace", Module: "buildah", FP: fp(7), Own: seat.NodeOwn{ProvisionerAccount: "x"}}, "node's own parts"},
|
||||
{"a refusal that holds resources", Composition{Node: "ace", Module: "buildah", FP: fp(7), Resources: ok.Resources, Refused: "no secret"}, "refused and holds"},
|
||||
{"the node's own with a fingerprint", Composition{Node: "ace", FP: fp(7)}, "names no fingerprint"},
|
||||
{"a node name with a dot", Composition{Node: "a.ce"}, "node"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, err := EncodeComposition(c.c); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v, want an error saying %q", c.name, err, c.want)
|
||||
}
|
||||
}
|
||||
if _, err := ComposedDigest(Composition{Node: "ace", Module: "buildah", FP: fp(7), Refused: "no secret"}); err == nil {
|
||||
t.Error("a refused composition has a digest")
|
||||
}
|
||||
if _, err := DecodeComposition([]byte(`{"v":2,"node":"ace","resources":[]}`)); err == nil {
|
||||
t.Error("a composition of version 2 is read")
|
||||
}
|
||||
if _, err := DecodeComposition([]byte(`{"v":1,"node":"ace","resources":[],"extra":1}`)); err == nil {
|
||||
t.Error("a composition with a field it does not know is read")
|
||||
}
|
||||
if _, err := DecodeComposition([]byte(`{"v":1,"node":"ace","resources":[],"signature":"x"}`)); err != nil {
|
||||
t.Errorf("a signed composition of the ended controller is refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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>.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>.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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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:<hex>` 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 `<node>.<module>` and the node's own facts
|
||||
// under `<node>.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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, `<node>.<module>`): 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, `<node>.<module>`, 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, `<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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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: `<node>.<module>`. A module named `node` is refused,
|
||||
// since `<node>.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>.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, <node>.<module>", 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:
|
||||
// `<node>.<module>.<hex>`, 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>.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, <node>.<module>.<fingerprint hex> or <node>.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>.node`.
|
||||
func HaveNodeKey(node string) (string, error) {
|
||||
if err := keys.CheckName("node", node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return node + "." + NodeWord, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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, <module>_<local name>
|
||||
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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-contracts/go/seats/meshbroker
|
||||
|
||||
go 1.26.0
|
||||
@@ -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 "<node>.<module>.<key>".
|
||||
func ResolvedByNodeModuleKeyKey(node, module, key string) string {
|
||||
return node + "." + module + "." + key
|
||||
}
|
||||
|
||||
// ParseResolvedByNodeModuleKeyKey reads the parts of a key of the form "<node>.<module>.<key>", 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 "<node>.<module>.<key>", 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 "<owner>.<repository>.<number>.<node>.<module>.<key>".
|
||||
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 "<owner>.<repository>.<number>.<node>.<module>.<key>", 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 "<owner>.<repository>.<number>.<node>.<module>.<key>", 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 <node>.<module>.<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 <owner>.<repository>.<number>.<node>.<module>.<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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-contracts/go/seats/meshconfig
|
||||
|
||||
go 1.26.0
|
||||
@@ -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>.node".
|
||||
func AssignmentsByNodeNodeKey(node string) string { return node + ".node" }
|
||||
|
||||
// ParseAssignmentsByNodeNodeKey reads the parts of a key of the form "<node>.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>.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 "<node>.<module>".
|
||||
func AssignmentsByNodeModuleKey(node, module string) string { return node + "." + module }
|
||||
|
||||
// ParseAssignmentsByNodeModuleKey reads the parts of a key of the form "<node>.<module>", 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 "<node>.<module>", 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 "<proposal>".
|
||||
func ProposalsByProposalKey(proposal string) string { return proposal }
|
||||
|
||||
// ParseProposalsByProposalKey reads the parts of a key of the form "<proposal>", 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 "<proposal>", 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 "<proposal>.<node>".
|
||||
func CandidatesByProposalNodeKey(proposal, node string) string { return proposal + "." + node }
|
||||
|
||||
// ParseCandidatesByProposalNodeKey reads the parts of a key of the form "<proposal>.<node>", 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 "<proposal>.<node>", 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.<proposal>.<node>".
|
||||
func PulsesByAnswerProposalNodeKey(proposal, node string) string {
|
||||
return "answer." + proposal + "." + node
|
||||
}
|
||||
|
||||
// ParsePulsesByAnswerProposalNodeKey reads the parts of a key of the form "answer.<proposal>.<node>", 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.<proposal>.<node>", 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.<proposal>".
|
||||
func PulsesByAskProposalKey(proposal string) string { return "ask." + proposal }
|
||||
|
||||
// ParsePulsesByAskProposalKey reads the parts of a key of the form "ask.<proposal>", 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.<proposal>", 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 "<seat>".
|
||||
func SeatHoldersBySeatKey(seat string) string { return seat }
|
||||
|
||||
// ParseSeatHoldersBySeatKey reads the parts of a key of the form "<seat>", 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 "<seat>", 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 "<seat>.<node>".
|
||||
func SeatHoldersBySeatNodeKey(seat, node string) string { return seat + "." + node }
|
||||
|
||||
// ParseSeatHoldersBySeatNodeKey reads the parts of a key of the form "<seat>.<node>", 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 "<seat>.<node>", 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>.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 <node>.<module>.
|
||||
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 <proposal>.
|
||||
type ProposalsByProposalValue = Proposal
|
||||
|
||||
// CandidatesByProposalNodeValue is one value of the state candidates under the key <proposal>.<node>.
|
||||
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.<proposal>.<node>.
|
||||
type PulsesByAnswerProposalNodeValue struct {
|
||||
V int64 `json:"v,omitempty"`
|
||||
}
|
||||
|
||||
// PulsesByAskProposalValue is one value of the state pulses under the key ask.<proposal>.
|
||||
type PulsesByAskProposalValue struct {
|
||||
V int64 `json:"v,omitempty"`
|
||||
}
|
||||
|
||||
// SeatHoldersBySeatValue is one value of the state seat-holders under the key <seat>.
|
||||
type SeatHoldersBySeatValue = SeatHolders
|
||||
|
||||
// SeatHoldersBySeatNodeValue is one value of the state seat-holders under the key <seat>.<node>.
|
||||
type SeatHoldersBySeatNodeValue = SeatHolders
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner
|
||||
|
||||
go 1.26.0
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-contracts/go/seats/nodeengine
|
||||
|
||||
go 1.26.0
|
||||
@@ -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, `<module>.<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))
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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: `<observer>.<family>`, 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...)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, `<module>_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: `<scope>.<id>.<token>`, 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)
|
||||
}
|
||||
@@ -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"}
|
||||
+1
@@ -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"}
|
||||
@@ -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:<seat>.<verb>`): 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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 `<node>`).
|
||||
NodePresence = seat.PresenceByNodeValue
|
||||
// PartPresence is whether a part's instance is online (state presence, key `part.<part>.<instance>`).
|
||||
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: `<node>`. 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.<part>.<instance> is a part's key`)
|
||||
}
|
||||
return seat.PresenceByNodeKey(node), nil
|
||||
}
|
||||
|
||||
// PresencePartKey is the key of a part's instance: `part.<part>.<instance>`.
|
||||
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, <node> or part.<part>.<instance>", 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: `<bucket>.<key>`. 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, <bucket>.<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: `<bucket>.<key>`, 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 "<key>".
|
||||
func ConditionsByKeyKey(key string) string { return key }
|
||||
|
||||
// ParseConditionsByKeyKey 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 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 "<key>", 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 "<key>".
|
||||
func FindingsByKeyKey(key string) string { return key }
|
||||
|
||||
// ParseFindingsByKeyKey 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 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 "<key>", 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:<name>
|
||||
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 <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:<name>
|
||||
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 <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:<name>
|
||||
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"
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions
|
||||
|
||||
go 1.26.0
|
||||
@@ -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 "<id>".
|
||||
func ActsByIDKey(id string) string { return id }
|
||||
|
||||
// ParseActsByIDKey reads the parts of a key of the form "<id>", 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 "<id>", 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 "<asker>.<ask>".
|
||||
func WarrantsByAskerAskKey(asker, ask string) string { return asker + "." + ask }
|
||||
|
||||
// ParseWarrantsByAskerAskKey reads the parts of a key of the form "<asker>.<ask>", 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 "<asker>.<ask>", 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 <id>.
|
||||
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 <asker>.<ask>.
|
||||
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"`
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts
|
||||
|
||||
go 1.26.0
|
||||
@@ -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 "<attempt>".
|
||||
func AttemptsByAttemptKey(attempt string) string { return attempt }
|
||||
|
||||
// ParseAttemptsByAttemptKey reads the parts of a key of the form "<attempt>", 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 "<attempt>", 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 "<would>".
|
||||
func WouldByWouldKey(would string) string { return would }
|
||||
|
||||
// ParseWouldByWouldKey reads the parts of a key of the form "<would>", 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 "<would>", 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 <attempt>.
|
||||
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 <would>.
|
||||
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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhealers
|
||||
|
||||
go 1.26.0
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshselfcheck
|
||||
|
||||
go 1.26.0
|
||||
@@ -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 "<node>".
|
||||
func PresenceByNodeKey(node string) string { return node }
|
||||
|
||||
// ParsePresenceByNodeKey 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 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 "<node>", 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.<part>.<instance>".
|
||||
func PresenceByPartPartInstanceKey(part, instance string) string {
|
||||
return "part." + part + "." + instance
|
||||
}
|
||||
|
||||
// ParsePresenceByPartPartInstanceKey reads the parts of a key of the form "part.<part>.<instance>", 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.<part>.<instance>", 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 "<bucket>.<key>".
|
||||
func PulseByBucketKeyKey(bucket, key string) string { return bucket + "." + key }
|
||||
|
||||
// ParsePulseByBucketKeyKey reads the parts of a key of the form "<bucket>.<key>", 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 "<bucket>.<key>", 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 "<bucket>.<key>".
|
||||
func StuckByBucketKeyKey(bucket, key string) string { return bucket + "." + key }
|
||||
|
||||
// ParseStuckByBucketKeyKey reads the parts of a key of the form "<bucket>.<key>", 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 "<bucket>.<key>", 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 <stream>.<consumer>: 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 <node>.
|
||||
type PresenceByNodeValue struct {
|
||||
Online bool `json:"online"`
|
||||
V int64 `json:"v,omitempty"`
|
||||
}
|
||||
|
||||
// PresenceByPartPartInstanceValue is one value of the state presence under the key part.<part>.<instance>.
|
||||
type PresenceByPartPartInstanceValue struct {
|
||||
Online bool `json:"online"`
|
||||
V int64 `json:"v,omitempty"`
|
||||
}
|
||||
|
||||
// PulseByBucketKeyValue is one value of the state pulse under the key <bucket>.<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 <bucket>.<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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs
|
||||
|
||||
go 1.26.0
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"format": 1,
|
||||
"seats": [
|
||||
"mesh-conditions",
|
||||
"mesh-hand-acts",
|
||||
"mesh-healers",
|
||||
"mesh-self-check",
|
||||
"mesh-watchdogs"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
[
|
||||
{
|
||||
"name": "a condition raised from a finding, as the keeper holds it (captured; empty fields left out)",
|
||||
"of": "state:conditions/<key>",
|
||||
"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/<key>",
|
||||
"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/<key>",
|
||||
"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/<key>",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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": "<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:<name>"
|
||||
},
|
||||
"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": "<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:<name>"
|
||||
},
|
||||
"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:<name>"
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "<id>",
|
||||
"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": "<asker>.<ask>",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"name": "an act a healer would have made, kept from the controller's time (captured)",
|
||||
"of": "state:would/<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"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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": "<attempt>",
|
||||
"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": "<would>",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
[
|
||||
{
|
||||
"name": "a node's agent online (captured)",
|
||||
"of": "state:presence/<node>",
|
||||
"given": {
|
||||
"online": true,
|
||||
"v": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a delivery part's instance online (captured)",
|
||||
"of": "state:presence/part.<part>.<instance>",
|
||||
"given": {
|
||||
"online": true,
|
||||
"v": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a thing that has not moved since a time, with what it waited for (captured)",
|
||||
"of": "state:stuck/<bucket>.<key>",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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 <stream>.<consumer>: 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": "<node>",
|
||||
"value": {
|
||||
"type": "object",
|
||||
"description": "whether a node is online",
|
||||
"properties": {
|
||||
"v": {
|
||||
"type": "integer"
|
||||
},
|
||||
"online": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"online"
|
||||
]
|
||||
},
|
||||
"description": "a node"
|
||||
},
|
||||
{
|
||||
"key": "part.<part>.<instance>",
|
||||
"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": "<bucket>.<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": "<bucket>.<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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 "<key>". */
|
||||
export function conditionsByKeyKey(key: string): string {
|
||||
return key;
|
||||
}
|
||||
|
||||
/** The parts of a key of the form "<key>", 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 "<key>". */
|
||||
export function findingsByKeyKey(key: string): string {
|
||||
return key;
|
||||
}
|
||||
|
||||
/** The parts of a key of the form "<key>", 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<string, string>;
|
||||
"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:<name> */
|
||||
"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<string, unknown>;
|
||||
|
||||
/** 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<string, unknown>;
|
||||
|
||||
/** is what resync takes. */
|
||||
export interface ResyncInput {
|
||||
}
|
||||
|
||||
/** is what resync answers. */
|
||||
export type ResyncAnswer = Record<string, unknown>;
|
||||
|
||||
/** 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 <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:<name> */
|
||||
"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 <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:<name> */
|
||||
"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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 "<id>". */
|
||||
export function actsByIDKey(id: string): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The parts of a key of the form "<id>", 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 "<asker>.<ask>". */
|
||||
export function warrantsByAskerAskKey(asker: string, ask: string): string {
|
||||
return asker + "." + ask;
|
||||
}
|
||||
|
||||
/** The parts of a key of the form "<asker>.<ask>", 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<string, unknown>;
|
||||
|
||||
/** 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<string, unknown>;
|
||||
|
||||
/** 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<string, unknown>;
|
||||
|
||||
/** is the body of the event act-recorded. */
|
||||
export type ActRecordedEvent = Act;
|
||||
|
||||
/** is one value of the state acts under the key <id>. */
|
||||
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 <asker>.<ask>. */
|
||||
export interface WarrantsByAskerAskValue {
|
||||
"ask": string;
|
||||
"asker": string;
|
||||
"at"?: string;
|
||||
"by"?: string;
|
||||
"option"?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 "<attempt>". */
|
||||
export function attemptsByAttemptKey(attempt: string): string {
|
||||
return attempt;
|
||||
}
|
||||
|
||||
/** The parts of a key of the form "<attempt>", 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 "<would>". */
|
||||
export function wouldByWouldKey(would: string): string {
|
||||
return would;
|
||||
}
|
||||
|
||||
/** The parts of a key of the form "<would>", 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<string, unknown>;
|
||||
|
||||
/** 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<string, unknown>;
|
||||
|
||||
/** 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<string, unknown>;
|
||||
|
||||
/** 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 <attempt>. */
|
||||
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 <would>. */
|
||||
export interface WouldByWouldValue {
|
||||
"act": string;
|
||||
"at": string;
|
||||
"condition": string;
|
||||
"healer": string;
|
||||
"kind"?: string;
|
||||
"why"?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user