Feature 57 (config and provisioning): release mesh-sdk, the reviewed release branch on top of main
mesh/merge-gate head 1bc5719b8b85 merged into trunk a2d2af00dc5d beside mesh-host#116@bff1ac14 — pass, with a note: builds mesh-sdk (+40 dependent(s)) → ac…
mesh/repo-check head 1bc5719b8b85 merged into trunk a2d2af00dc5d beside mesh-host#116@bff1ac14 — pass: its merge-check.sh passed, each part in its toolchai…
mesh/delivery delivered
mesh/delivery-group group release/57-config-and-provisioning delivered: every member is delivered
mesh/merge-gate head 1bc5719b8b85 merged into trunk a2d2af00dc5d beside mesh-host#116@bff1ac14 — pass, with a note: builds mesh-sdk (+40 dependent(s)) → ac…
mesh/repo-check head 1bc5719b8b85 merged into trunk a2d2af00dc5d beside mesh-host#116@bff1ac14 — pass: its merge-check.sh passed, each part in its toolchai…
mesh/delivery delivered
mesh/delivery-group group release/57-config-and-provisioning delivered: every member is delivered
This commit is contained in:
@@ -20,7 +20,13 @@ Not the manifest schema (the control plane owns and parses that, in Go). The sha
|
||||
contains — the contract both a provider and a consumer conform to), and the tool and event types.
|
||||
These change rarely and deliberately; when one does, a rebuild of everything is *correct*.
|
||||
|
||||
### `provisioner` — the reconcile harness every provider shares
|
||||
### `provider-loop` — the reconcile harness every provider on the grant files shares
|
||||
Called `provisioner` until design 57 (novox/hq issue 678); that word is mesh-provisioner's now. The old path and
|
||||
names (`/provisioner`, `runProvisioner`, `provisionerTools`) stay aliases until the providers importing them move to
|
||||
the Go helper that answers consumers over the bus (`go/provider`, design 57 B2 and B3) and this loop retires with the
|
||||
grant files. Its events and tools keep their names (`provisioner.failing`, `provisioner_retirement`, …): the
|
||||
controller and the operator read them.
|
||||
|
||||
The loop is identical for postgres, redis, minio and umami: watch the grants directory, for each
|
||||
requested grant call the provider's adapter, write the sealed credential, handle withdrawal, and
|
||||
keep converging (`watch`). That loop lives here. A **module provides only the adapter** — "create
|
||||
@@ -146,12 +152,12 @@ import { UmamiClient } from "../client.js"; // the client lives in the module, n
|
||||
registerModuleTools("umami", (env) => getUmamiTools(new UmamiClient(env.UMAMI_URL)));
|
||||
```
|
||||
|
||||
A provider's provisioner:
|
||||
A provider's loop:
|
||||
|
||||
```ts
|
||||
import { runProvisioner, Grant, Credential } from "@novox/mesh-sdk/provisioner";
|
||||
import { runProviderLoop, Grant, Credential } from "@novox/mesh-sdk/provider-loop";
|
||||
|
||||
runProvisioner("analytics", {
|
||||
runProviderLoop("analytics", {
|
||||
async create(grant: Grant): Promise<Credential> { /* create a umami site, return the grant */ },
|
||||
async remove(grant: Grant): Promise<void> { /* delete the site */ },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Release 57: config and provisioning
|
||||
|
||||
This branch carries the work of hq design 57 (ADR 0348, ADR 0349) in this repository. Each package
|
||||
merges here through its own reviewed pull request; the branch reaches main as one pull request, in one
|
||||
delivery group with the branch of the same name in mesh-controller, mesh-host, mesh-sdk, mesh-catalog
|
||||
and hq, after a rehearsal in the lab. This file is deleted by the last commit before that pull request.
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
`stdio.Serve("", tools)` speaks MCP over stdio to the node's tool runtime, as the module the runtime
|
||||
names (novox/hq ADR 0188, ADR 0193). A `Tool` is a name, a description, an argument schema and a
|
||||
function; calls run concurrently. `stdio.CallerOf(args)` inside a tool is who called it: the bus user the runtime took from the subject the call arrived on, named in the call's `_meta`, never read from the arguments — or `stdio.Unproven` when the runtime named none (novox/hq issue 365). A tool named `<seat>.<verb>` is the seat's implementation.
|
||||
`stdio.Emit(type, body)` publishes an event as the module, `stdio.Subscribe(pattern, handler)` receives the module's events (taken when the handler returns nil, offered again when it returns an error — the one rule is in the repository README, "Taking an event") and `stdio.Ask(key, body)` calls a tool, and `stdio.State(name)` gets, puts, deletes, lists, watches and reads the kept history of the module's declared state, and `stdio.Log(name)` appends to, reads, and measures a log of operations the module declared — all through the runtime (novox/hq ADR 0193, 0198, 0201, 0297, 0307). Standard library only.
|
||||
`stdio.Emit(type, body)` publishes an event as the module, `stdio.Subscribe(pattern, handler)` receives the module's events (taken when the handler returns nil, offered again when it returns an error — the one rule is in the repository README, "Taking an event") and `stdio.Ask(key, body)` calls a tool, and `stdio.State(name)` gets, puts, deletes, lists, watches and reads the kept history of the module's declared state, and `stdio.Log(name)` appends to, reads, and measures a log of operations the module declared — all through the runtime (novox/hq ADR 0193, 0198, 0201, 0297, 0307). `stdio.Config(key)` and `stdio.Bound(provision, key)` read, and `stdio.WatchConfig` and `stdio.WatchBound` watch, the module's own config values and the non-secret values of what it is bound to, for what its manifest declares: the runtime asks its machine's provisioner client as the module it launched, never a module the code names. `stdio.Secret(name)` is refused while the module's code runs in the tool runner; its secrets reach it through its files (novox/hq ADR 0349 §14, ADR 0370). `provider` is a providing module's half of a binding (novox/hq ADR 0349 §12, issue 678): it sees each consumer that binds the provider in the plans of the assignments mirror, writes one entry per consumer to `<module>_bound` (`<node>.<module>.<provision>[.<local>]`: served values composed with the consumer's identity, the login, and the name of the vault's credential, never a secret), hands the provider's adapter the vault's value (`provision/<provider>/<provision>` shared, `pair/<provider>/<provision>/<node>/<module>[/<local>]` per consumer), refuses a derived value to a consumer bound under a local name, naming both ends, and removes the entry of one that unbinds; the bus and the vault are interfaces the provider wires, the vault as `provider.ConsumersFile`, the file its machine's provisioner client fills from `${consumers:<provision>}` with each bound consumer's credential (novox/hq ADR 0373), since a provider in the tool runner never asks the vault itself. Standard library only.
|
||||
|
||||
## Installing
|
||||
|
||||
@@ -16,4 +16,4 @@ at the same version as the TypeScript package.
|
||||
|
||||
What it holds, under ADR 0039's test: the stdio loop and the tool type, and `cli`, the local protocol
|
||||
`mesh-cli` speaks to the node-engine on its machine (novox/hq ADR 0272): the socket's path and one request and one
|
||||
answer. Nothing else — no bus, no client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it.
|
||||
answer; and `seal`, the mesh's sealed box (novox/hq ADR 0206: X25519, HKDF-SHA256, AES-256-GCM, `{v, eph, iv, tag, ct}`), byte for byte the box the licence manager made before it lived here and the TypeScript `@novox/mesh-sdk/seal` makes. Nothing else — no bus, no client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
// The provider's consumers' credentials, from the file its machine's provisioner client fills (novox/hq ADR 0373,
|
||||
// issue 679).
|
||||
//
|
||||
// **The provider never asks the vault.** A provider in the tool runner has no account of its own, and the vault
|
||||
// hands a secret only to a node's provisioner client or to a module under an account of its own (ADR 0349 clause
|
||||
// 11, ADR 0370 clause 2). So the provider's template names `${consumers:<provision>}`, and its machine's client
|
||||
// fills that file with one entry per consumer bound to it for that provision, each credential asked of the vault in
|
||||
// the client's own name; the file is the provider's alone (mode 0600, owned). ConsumersFile is that file as the
|
||||
// helper's Vault: it reads the file on each ask, so a consumer that binds or a credential the vault remakes reaches
|
||||
// the provider at its next pass.
|
||||
|
||||
// ConsumerCredential is one entry of the file: the consumer, the vault's name of its credential, and its value.
|
||||
type ConsumerCredential struct {
|
||||
// Consumer and Module are the consumer's machine and module, Local its local name for the provision, if any.
|
||||
Consumer string `json:"consumer"`
|
||||
Module string `json:"module"`
|
||||
Local string `json:"local,omitempty"`
|
||||
// Credential is the vault's name of the consumer's credential, PairName's.
|
||||
Credential string `json:"credential"`
|
||||
// Value is the credential itself.
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// ConsumersFile answers a credential from the files the provider machine's client fills: one per provision whose
|
||||
// template names `${consumers:<provision>}`, each a JSON list of ConsumerCredential.
|
||||
type ConsumersFile struct {
|
||||
Paths []string
|
||||
}
|
||||
|
||||
// Value is the credential named name, from the first file carrying it. A file not there yet is passed over; one not
|
||||
// in the client's form is an error, never read as no consumers.
|
||||
func (f ConsumersFile) Value(_ context.Context, name string) ([]byte, error) {
|
||||
var missing []string
|
||||
for _, at := range f.Paths {
|
||||
raw, err := os.ReadFile(at)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
missing = append(missing, at)
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the consumers' file %s: %w", at, err)
|
||||
}
|
||||
var entries []ConsumerCredential
|
||||
if err := json.Unmarshal(raw, &entries); err != nil {
|
||||
return nil, fmt.Errorf("the consumers' file %s is not the provisioner client's list of consumers", at)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Credential == name {
|
||||
if e.Value == "" {
|
||||
return nil, fmt.Errorf("the consumers' file %s carries %s with no value", at, name)
|
||||
}
|
||||
return []byte(e.Value), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(missing) == len(f.Paths) {
|
||||
return nil, fmt.Errorf("%s: no consumers' file is filled yet (%v)", name, f.Paths)
|
||||
}
|
||||
return nil, fmt.Errorf("%s is in no consumers' file: the vault holds none for it yet, or its machine's client "+
|
||||
"has not filled it", name)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The provider reads its consumers' credentials only from the file its machine's provisioner client fills (novox/hq
|
||||
// ADR 0373 clause 3, issue 679): one entry per consumer, naming the consumer and the vault's name for its credential,
|
||||
// and carrying the value. The helper is wired to it as its Vault and asks nothing of the vault itself.
|
||||
func TestTheConsumersFileAnswersEachConsumersCredential(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
at := filepath.Join(dir, "consumers.json")
|
||||
file := `[{"consumer":"ace","module":"letta","credential":"pair/novox/postgres-database/ace/letta","value":"s3cret"},
|
||||
{"consumer":"g14","module":"gitea","local":"cache","credential":"pair/novox/postgres-database/g14/gitea/cache","value":"other"}]`
|
||||
if err := os.WriteFile(at, []byte(file), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v := ConsumersFile{Paths: []string{filepath.Join(dir, "absent.json"), at}}
|
||||
got, err := v.Value(context.Background(), PairName("novox", "postgres-database", "ace", "letta", ""))
|
||||
if err != nil || string(got) != "s3cret" {
|
||||
t.Fatalf("ace's letta: %q, %v; want s3cret", got, err)
|
||||
}
|
||||
got, err = v.Value(context.Background(), PairName("novox", "postgres-database", "g14", "gitea", "cache"))
|
||||
if err != nil || string(got) != "other" {
|
||||
t.Fatalf("g14's gitea under cache: %q, %v; want other", got, err)
|
||||
}
|
||||
// A consumer the file does not carry is waited for, and never told of a login its provider has not made.
|
||||
_, err = v.Value(context.Background(), PairName("novox", "postgres-database", "shanks", "photos", ""))
|
||||
if err == nil || !strings.Contains(err.Error(), "pair/novox/postgres-database/shanks/photos") {
|
||||
t.Fatalf("a consumer the file does not carry: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "s3cret") || strings.Contains(err.Error(), "other") {
|
||||
t.Fatalf("an error names a credential: %v", err)
|
||||
}
|
||||
|
||||
// Wired as the helper's Vault, the file is what makes each consumer's login.
|
||||
bus := newStandIn()
|
||||
bus.Put(context.Background(), AssignmentsBucket, "ace.letta", []byte(`{"node":"ace","module":"letta",
|
||||
"provisioning":{"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres"}]}}`))
|
||||
var made []string
|
||||
p := New(Options{Node: "novox", Module: "postgres", Bus: bus, Vault: v,
|
||||
Offers: map[string]Offer{"postgres-database": {Serves: map[string]any{"port": 5432}}},
|
||||
Make: func(_ context.Context, c Consumer) error {
|
||||
made = append(made, c.Entry.User+"="+string(c.Credential))
|
||||
return nil
|
||||
}, Logf: func(string, ...any) {}})
|
||||
if _, err := p.Pass(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(made) != 1 || made[0] != "mesh_ace_letta=s3cret" {
|
||||
t.Fatalf("made %v, want ace's letta under the file's credential", made)
|
||||
}
|
||||
}
|
||||
|
||||
// A file that is not the client's form is said, never read as no consumers.
|
||||
func TestAConsumersFileNotInItsFormIsRefused(t *testing.T) {
|
||||
at := filepath.Join(t.TempDir(), "consumers.json")
|
||||
if err := os.WriteFile(at, []byte(`{"not": "a list"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := ConsumersFile{Paths: []string{at}}.Value(context.Background(), "pair/novox/p/ace/m")
|
||||
if err == nil || !strings.Contains(err.Error(), at) {
|
||||
t.Fatalf("a file not in the client's form: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
// Package provider is a providing module's half of a binding: it answers the provider's consumers itself, over the
|
||||
// bus (novox/hq ADR 0349 clause 12, design 57 B1, issue 678).
|
||||
//
|
||||
// **Who binds the provider.** Each node's provisioner client records the needs of the modules handed to it from
|
||||
// their plans, and the controller names in each plan, per required provision and local name, the provider that
|
||||
// resolution chose: `provisioning.provisions` in `<node>.<module>` of the assignments mirror (issue 760). A recorded
|
||||
// need names no provider (issue 677's note 7), so the plan's entry is where a bind is seen, as the vault sees it
|
||||
// (mesh-vault pair.go). A consumer binds this provider when an entry names this machine and this module, and
|
||||
// unbinds when the entry, or the assignment, goes. A consumer still filled the old way has no plan and is not seen
|
||||
// here; the controller answers it as before (ADR 0349 clause 17).
|
||||
//
|
||||
// **One entry per consumer, in the provider's own bucket.** For each bind the helper composes the consumer's
|
||||
// served values from the provider's offer (its config already composed into it, ADR 0202 §7) and the consumer's
|
||||
// identity (`${consumer:as}`, `${consumer:as:dns}`, ADR 0202 §1), and writes one entry, `<node>.<module>.
|
||||
// <provision>[.<local>]` of `<provider module>_bound`: the one statement both ends read. It never carries a
|
||||
// secret. It names the credential the vault keeps for the pair, which the consumer's client asks the vault for.
|
||||
//
|
||||
// **The credential is the vault's** (ADR 0349 clause 8). The helper mints nothing and reads nothing of the
|
||||
// provider's own secrets: it reads the value from the vault and hands it to the provider's adapter, which makes the
|
||||
// login its software keeps. An offer whose software holds one credential (ADR 0158) is the vault's one value per
|
||||
// provider and provision, `provision/<provider node>/<provision>`, with the provider's one user name; an offer with
|
||||
// a login per consumer (ADR 0048, ADR 0158 §11, the default) is each consumer's own value,
|
||||
// `pair/<provider node>/<provision>/<node>/<module>[/<local>]`, with the consumer's identity as its login (issue
|
||||
// 677's notes 12 and 13). A credential the vault does not hold yet is waited for: nothing is made and no entry is
|
||||
// written, so a consumer is never told of a login its provider has not made. A provider in the tool runner never asks
|
||||
// the vault itself: its Vault is ConsumersFile, the file its machine's provisioner client fills (ADR 0373).
|
||||
//
|
||||
// **A multi-holder consumer gets no derived value** (ADR 0202 §10 as ADR 0349 clause 12 changes it): a consumer
|
||||
// bound under a local name keeps several holders of the provision, each with its own login, while what the
|
||||
// provider derives names one; its entry refuses it, naming both ends, and its client reports it.
|
||||
//
|
||||
// The helper speaks to the bus and the vault through the two interfaces below, so a provider wires it to its
|
||||
// runtime and a test to a stand-in. Standard library only.
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// AssignmentsBucket is the controller's assignments mirror, `<node>.<module>`, whose value carries the plan of a
|
||||
// module handed to the provisioner.
|
||||
const AssignmentsBucket = "mesh-controller_assignments"
|
||||
|
||||
// BucketOf is a providing module's own bucket of entries, one per consumer: `<module>_bound`, the module's state
|
||||
// `bound`.
|
||||
func BucketOf(module string) string { return module + "_bound" }
|
||||
|
||||
// Bus is what the helper needs of the bus: a key-value watch and the provider's writes to its own bucket.
|
||||
type Bus interface {
|
||||
// Watch hands handle every current key and value of bucket, then each change as it comes, a removed key with a
|
||||
// nil value; it returns once the current values are handed, and the watch lasts until ctx ends.
|
||||
Watch(ctx context.Context, bucket string, handle func(key string, value []byte)) error
|
||||
Put(ctx context.Context, bucket, key string, value []byte) error
|
||||
Delete(ctx context.Context, bucket, key string) error
|
||||
}
|
||||
|
||||
// Vault is how the provider reads a credential the vault keeps, by its name, opened.
|
||||
type Vault interface {
|
||||
Value(ctx context.Context, name string) ([]byte, error)
|
||||
}
|
||||
|
||||
// CredentialForm is which credential an offer's consumers present.
|
||||
type CredentialForm int
|
||||
|
||||
const (
|
||||
// PerConsumer is a login of each consumer's own (ADR 0048, ADR 0158 §11): the default.
|
||||
PerConsumer CredentialForm = iota
|
||||
// Shared is the one credential the provider's software holds, shared with every consumer (ADR 0158).
|
||||
Shared
|
||||
// NoCredential is an offer whose consumers present none.
|
||||
NoCredential
|
||||
)
|
||||
|
||||
// Offer is what the provider serves one provision with.
|
||||
type Offer struct {
|
||||
// Serves are the values served to each consumer, the provider's config already composed into them; a string
|
||||
// may name the consumer's identity, `${consumer:as}` or `${consumer:as:dns}`.
|
||||
Serves map[string]any
|
||||
// Credential is the form of the credential its consumers present.
|
||||
Credential CredentialForm
|
||||
// User is the provider's one user name, for a Shared credential.
|
||||
User string
|
||||
}
|
||||
|
||||
// Entry is one consumer's entry in the provider's bucket: the one statement both ends read. Never a secret.
|
||||
type Entry struct {
|
||||
Provider string `json:"provider"`
|
||||
ProviderModule string `json:"provider-module"`
|
||||
Provision string `json:"provision"`
|
||||
Consumer string `json:"consumer"`
|
||||
Module string `json:"module"`
|
||||
Local string `json:"local,omitempty"`
|
||||
// As is the consumer's identity, the mesh's (ADR 0202 §1).
|
||||
As string `json:"as,omitempty"`
|
||||
// User is the login the consumer presents: its identity, or the provider's one user name for a shared credential.
|
||||
User string `json:"user,omitempty"`
|
||||
// Values are the served values, composed for this consumer: what `${bound:<provision>:<key>}` reads.
|
||||
Values map[string]any `json:"values,omitempty"`
|
||||
// Credential is the name of the credential in the vault, which the consumer's client asks the vault for.
|
||||
Credential string `json:"credential,omitempty"`
|
||||
// Refused, when set, is why the consumer is not served, naming both ends.
|
||||
Refused string `json:"refused,omitempty"`
|
||||
// Contributes is what the consumer's module tells its provider about this provision (a database's name, its
|
||||
// extensions), from its plan's entry (issue 776). Handed to Make and Gone; never written to the bucket.
|
||||
Contributes map[string]any `json:"-"`
|
||||
// At and From are where the consumer's plan says the provider is and which machine it is (issue 788): the values
|
||||
// today's binding file gives that consumer. Written into Values as `at` and `from`; never alone to the bucket.
|
||||
At, From string `json:"-"`
|
||||
// Names are the names the controller composed for this consumer on this provision (issue 792): its own name, its
|
||||
// internal name, and one of each per local name it contributes under, keyed as its binding file names them.
|
||||
// Written into Values over anything the provider serves under the key; never alone to the bucket.
|
||||
Names map[string]string `json:"-"`
|
||||
}
|
||||
|
||||
// Key is the entry's key in the provider's bucket: `<node>.<module>.<provision>`, and `.<local>` for a bind under a
|
||||
// local name.
|
||||
func (e Entry) Key() string {
|
||||
k := e.Consumer + "." + e.Module + "." + e.Provision
|
||||
if e.Local != "" {
|
||||
k += "." + e.Local
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// Consumer is what the provider's adapter makes: the consumer's entry and the credential the vault keeps for it.
|
||||
type Consumer struct {
|
||||
Entry Entry
|
||||
Credential []byte
|
||||
}
|
||||
|
||||
// Options are the provider's.
|
||||
type Options struct {
|
||||
// Node and Module are this provider: the machine it runs on and the module serving.
|
||||
Node, Module string
|
||||
// Offers are the provisions it serves, by name.
|
||||
Offers map[string]Offer
|
||||
Bus Bus
|
||||
Vault Vault
|
||||
// Make brings a consumer into being in the provider's software under the vault's credential. Asked on every
|
||||
// pass for every consumer answered, so it must leave one that is already as asked alone; a rotated credential
|
||||
// reaches the software this way.
|
||||
Make func(ctx context.Context, c Consumer) error
|
||||
// Gone, when set, hears that a consumer no longer binds the provider, before its entry is removed; an error
|
||||
// keeps the entry, and it is heard again on the next pass. Retiring what was kept for it is the provider's
|
||||
// (ADR 0230).
|
||||
Gone func(ctx context.Context, e Entry) error
|
||||
// Identity is the consumer's identity for an entry whose plan names none; ConsumerIdentity of the node and the
|
||||
// module when not set.
|
||||
Identity func(node, module string) string
|
||||
// Every is how often a pass runs without a change watched; a minute when not set.
|
||||
Every time.Duration
|
||||
// Logf says what a pass did that differs from the last; the standard log when not set.
|
||||
Logf func(format string, args ...any)
|
||||
}
|
||||
|
||||
// Provider answers its consumers.
|
||||
type Provider struct {
|
||||
opts Options
|
||||
|
||||
mu sync.Mutex
|
||||
assignments map[string][]byte
|
||||
entries map[string][]byte
|
||||
said map[string]string
|
||||
poke chan struct{}
|
||||
}
|
||||
|
||||
// New is a provider answering its consumers as opts say.
|
||||
func New(opts Options) *Provider {
|
||||
if opts.Identity == nil {
|
||||
opts.Identity = ConsumerIdentity
|
||||
}
|
||||
if opts.Every <= 0 {
|
||||
opts.Every = time.Minute
|
||||
}
|
||||
if opts.Logf == nil {
|
||||
opts.Logf = func(f string, a ...any) { fmt.Printf("[provider] "+f+"\n", a...) }
|
||||
}
|
||||
return &Provider{opts: opts, assignments: map[string][]byte{}, entries: map[string][]byte{},
|
||||
said: map[string]string{}, poke: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
// What a pass did for one bind.
|
||||
const (
|
||||
Answered = "answered"
|
||||
Refused = "refused"
|
||||
Waiting = "waiting"
|
||||
Failed = "failed"
|
||||
Removed = "removed"
|
||||
)
|
||||
|
||||
// Outcome is what a pass did for one consumer's entry.
|
||||
type Outcome struct {
|
||||
Key string `json:"key"`
|
||||
Status string `json:"status"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// Run watches the binds and the provider's own entries, and passes once at start, on every change and every
|
||||
// Every, until ctx ends.
|
||||
func (p *Provider) Run(ctx context.Context) error {
|
||||
if err := p.watch(ctx, BucketOf(p.opts.Module), p.entries); err != nil {
|
||||
return fmt.Errorf("watching %s: %w", BucketOf(p.opts.Module), err)
|
||||
}
|
||||
if err := p.watch(ctx, AssignmentsBucket, p.assignments); err != nil {
|
||||
return fmt.Errorf("watching %s: %w", AssignmentsBucket, err)
|
||||
}
|
||||
tick := time.NewTicker(p.opts.Every)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
p.mu.Lock()
|
||||
assignments, entries := clone(p.assignments), clone(p.entries)
|
||||
p.mu.Unlock()
|
||||
if _, err := p.pass(ctx, assignments, entries); err != nil {
|
||||
p.say("pass", err.Error())
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-p.poke:
|
||||
case <-tick.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) watch(ctx context.Context, bucket string, into map[string][]byte) error {
|
||||
return p.opts.Bus.Watch(ctx, bucket, func(key string, value []byte) {
|
||||
p.mu.Lock()
|
||||
if value == nil {
|
||||
delete(into, key)
|
||||
} else {
|
||||
into[key] = append([]byte(nil), value...)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if bucket == AssignmentsBucket {
|
||||
select {
|
||||
case p.poke <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Pass reads the binds and the provider's entries from the bus and answers once: each bind of this provider gets
|
||||
// its entry, and each entry of this provider no bind asks for is removed. An assignment that cannot be read stops
|
||||
// the pass before anything is removed. Run passes by itself; Pass is for a provider that passes when it chooses.
|
||||
func (p *Provider) Pass(ctx context.Context) ([]Outcome, error) {
|
||||
watchCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
var mu sync.Mutex
|
||||
read := func(bucket string) (map[string][]byte, error) {
|
||||
got := map[string][]byte{}
|
||||
err := p.opts.Bus.Watch(watchCtx, bucket, func(key string, value []byte) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if value == nil {
|
||||
delete(got, key)
|
||||
} else {
|
||||
got[key] = append([]byte(nil), value...)
|
||||
}
|
||||
})
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return clone(got), err
|
||||
}
|
||||
entries, err := read(BucketOf(p.opts.Module))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", BucketOf(p.opts.Module), err)
|
||||
}
|
||||
assignments, err := read(AssignmentsBucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", AssignmentsBucket, err)
|
||||
}
|
||||
return p.pass(ctx, assignments, entries)
|
||||
}
|
||||
|
||||
// planned is as much of an assignment as the helper reads: lenient, since the plan is the client's to act on.
|
||||
type planned struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Provisioning *struct {
|
||||
Provisions []struct {
|
||||
Provision string `json:"provision"`
|
||||
Local string `json:"local"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderModule string `json:"provider-module"`
|
||||
Contributes map[string]any `json:"contributes"`
|
||||
At string `json:"at"`
|
||||
From string `json:"from"`
|
||||
As string `json:"as"`
|
||||
Names map[string]string `json:"names"`
|
||||
} `json:"provisions"`
|
||||
} `json:"provisioning"`
|
||||
}
|
||||
|
||||
func (p *Provider) pass(ctx context.Context, assignments, entries map[string][]byte) ([]Outcome, error) {
|
||||
var binds []Entry
|
||||
for _, key := range sortedKeys(assignments) {
|
||||
var a planned
|
||||
if err := json.Unmarshal(assignments[key], &a); err != nil || a.Node+"."+a.Module != key {
|
||||
return nil, fmt.Errorf("the assignment %s is not the controller's, so who binds %s on %s is not known; "+
|
||||
"nothing is answered or removed", key, p.opts.Module, p.opts.Node)
|
||||
}
|
||||
if a.Provisioning == nil {
|
||||
continue
|
||||
}
|
||||
for _, b := range a.Provisioning.Provisions {
|
||||
if b.Provider != p.opts.Node || b.ProviderModule != p.opts.Module {
|
||||
continue
|
||||
}
|
||||
binds = append(binds, Entry{Provider: p.opts.Node, ProviderModule: p.opts.Module,
|
||||
Provision: b.Provision, Consumer: a.Node, Module: a.Module, Local: b.Local,
|
||||
Contributes: b.Contributes, At: b.At, From: b.From, As: b.As, Names: b.Names})
|
||||
}
|
||||
}
|
||||
|
||||
var out []Outcome
|
||||
want := map[string]bool{}
|
||||
for _, b := range binds {
|
||||
key := b.Key()
|
||||
want[key] = true
|
||||
o := p.answer(ctx, b, entries[key])
|
||||
out = append(out, o)
|
||||
p.say(key, o.Status+" "+o.Why)
|
||||
}
|
||||
for _, key := range sortedKeys(entries) {
|
||||
if want[key] {
|
||||
continue
|
||||
}
|
||||
var e Entry
|
||||
if err := json.Unmarshal(entries[key], &e); err != nil || e.Provider != p.opts.Node ||
|
||||
e.ProviderModule != p.opts.Module {
|
||||
continue // another machine's instance of this provider wrote it, or nothing of this helper's
|
||||
}
|
||||
if p.opts.Gone != nil {
|
||||
if err := p.opts.Gone(ctx, e); err != nil {
|
||||
o := Outcome{Key: key, Status: Failed, Why: "no longer bound, and the provider could not let it go: " +
|
||||
err.Error()}
|
||||
out = append(out, o)
|
||||
p.say(key, o.Status+" "+o.Why)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := p.opts.Bus.Delete(ctx, BucketOf(p.opts.Module), key); err != nil {
|
||||
o := Outcome{Key: key, Status: Failed, Why: "no longer bound, and its entry could not be removed: " +
|
||||
err.Error()}
|
||||
out = append(out, o)
|
||||
p.say(key, o.Status+" "+o.Why)
|
||||
continue
|
||||
}
|
||||
out = append(out, Outcome{Key: key, Status: Removed})
|
||||
p.say(key, Removed)
|
||||
p.mu.Lock()
|
||||
delete(p.said, key)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// answer makes one bind's entry and writes it when it differs from what the bucket holds.
|
||||
func (p *Provider) answer(ctx context.Context, e Entry, have []byte) Outcome {
|
||||
key := e.Key()
|
||||
offer, offered := p.opts.Offers[e.Provision]
|
||||
switch {
|
||||
case !offered:
|
||||
e.Refused = fmt.Sprintf("%s on %s is bound to %s on %s for %s, which %s does not serve", e.Module,
|
||||
e.Consumer, e.ProviderModule, e.Provider, e.Provision, e.ProviderModule)
|
||||
case e.Local != "" && derives(offer.Serves):
|
||||
e.Refused = fmt.Sprintf("%s on %s keeps several holders of %s (this one is %q), and %s on %s derives %s for "+
|
||||
"each consumer from its login: each holder has a login of its own and a consumer is told one value per "+
|
||||
"requirement, so the two ends would name different things (novox/hq ADR 0202 §10, ADR 0349 clause 12)",
|
||||
e.Module, e.Consumer, e.Provision, e.Local, e.ProviderModule, e.Provider,
|
||||
strings.Join(derivedKeys(offer.Serves), ", "))
|
||||
}
|
||||
if e.Refused != "" {
|
||||
return p.write(ctx, e, have, Outcome{Key: key, Status: Refused, Why: e.Refused})
|
||||
}
|
||||
|
||||
// The plan's identity is the controller's, slug first; Identity only answers for an entry without one.
|
||||
if e.As == "" {
|
||||
e.As = p.opts.Identity(e.Consumer, e.Module)
|
||||
}
|
||||
values, err := ServedTo(offer.Serves, e.As)
|
||||
if err != nil {
|
||||
e.Refused = fmt.Sprintf("%s on %s serving %s to %s on %s: %v", e.ProviderModule, e.Provider, e.Provision,
|
||||
e.Module, e.Consumer, err)
|
||||
e.As = ""
|
||||
return p.write(ctx, e, have, Outcome{Key: key, Status: Refused, Why: e.Refused})
|
||||
}
|
||||
if e.At != "" || e.From != "" {
|
||||
if values == nil {
|
||||
values = map[string]any{}
|
||||
}
|
||||
if e.At != "" {
|
||||
values["at"] = e.At
|
||||
}
|
||||
if e.From != "" {
|
||||
values["from"] = e.From
|
||||
}
|
||||
}
|
||||
if len(e.Names) > 0 {
|
||||
if values == nil {
|
||||
values = map[string]any{}
|
||||
}
|
||||
for key, name := range e.Names {
|
||||
values[key] = name
|
||||
}
|
||||
}
|
||||
e.Values = values
|
||||
var credential []byte
|
||||
switch offer.Credential {
|
||||
case Shared:
|
||||
e.User = offer.User
|
||||
e.Credential = SharedName(e.Provider, e.Provision)
|
||||
case PerConsumer:
|
||||
e.User = e.As
|
||||
e.Credential = PairName(e.Provider, e.Provision, e.Consumer, e.Module, e.Local)
|
||||
}
|
||||
if e.Credential != "" {
|
||||
credential, err = p.opts.Vault.Value(ctx, e.Credential)
|
||||
if err != nil {
|
||||
return Outcome{Key: key, Status: Waiting, Why: fmt.Sprintf("the vault's %s: %v", e.Credential, err)}
|
||||
}
|
||||
}
|
||||
if p.opts.Make != nil {
|
||||
if err := p.opts.Make(ctx, Consumer{Entry: e, Credential: credential}); err != nil {
|
||||
why := err.Error()
|
||||
if len(credential) > 0 {
|
||||
why = strings.ReplaceAll(why, string(credential), "<credential>")
|
||||
}
|
||||
return Outcome{Key: key, Status: Failed, Why: "the provider could not make it: " + why}
|
||||
}
|
||||
}
|
||||
return p.write(ctx, e, have, Outcome{Key: key, Status: Answered})
|
||||
}
|
||||
|
||||
func (p *Provider) write(ctx context.Context, e Entry, have []byte, o Outcome) Outcome {
|
||||
body, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return Outcome{Key: o.Key, Status: Failed, Why: err.Error()}
|
||||
}
|
||||
if sameJSON(have, body) {
|
||||
return o
|
||||
}
|
||||
if err := p.opts.Bus.Put(ctx, BucketOf(p.opts.Module), o.Key, body); err != nil {
|
||||
return Outcome{Key: o.Key, Status: Failed, Why: "its entry could not be written: " + err.Error()}
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// say logs what a key's pass said when it differs from the last.
|
||||
func (p *Provider) say(key, what string) {
|
||||
p.mu.Lock()
|
||||
same := p.said[key] == what
|
||||
p.said[key] = what
|
||||
p.mu.Unlock()
|
||||
if !same {
|
||||
p.opts.Logf("%s: %s", key, strings.TrimSpace(what))
|
||||
}
|
||||
}
|
||||
|
||||
// SharedName is the vault's one value per provider and provision (ADR 0158, issue 677).
|
||||
func SharedName(provider, provision string) string { return "provision/" + provider + "/" + provision }
|
||||
|
||||
// PairName is the vault's value of one consumer's own login (ADR 0048, ADR 0158 §11, issue 677's note 12).
|
||||
func PairName(provider, provision, node, module, local string) string {
|
||||
n := "pair/" + provider + "/" + provision + "/" + node + "/" + module
|
||||
if local != "" {
|
||||
n += "/" + local
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// identityUnusable is every character a consumer's identity does not hold (mesh-controller internal/catalogue
|
||||
// identity.go, the one derivation).
|
||||
var identityUnusable = regexp.MustCompile(`[^a-z0-9_]+`)
|
||||
|
||||
// ConsumerIdentity is what one module on one machine is called wherever it authenticates: `mesh_<node>_<module>`,
|
||||
// lower case, anything but letters, digits and `_` written `_` (mesh-controller ConsumerIdentity). module is the
|
||||
// identity source: the module's slug when it declares one, otherwise its name (ADR 0049).
|
||||
func ConsumerIdentity(node, module string) string {
|
||||
clean := func(s string) string {
|
||||
return strings.Trim(identityUnusable.ReplaceAllString(strings.ToLower(s), "_"), "_")
|
||||
}
|
||||
return "mesh_" + clean(node) + "_" + clean(module)
|
||||
}
|
||||
|
||||
// consumerFact is `${consumer:<fact>}` or `${consumer:<fact>:<alphabet>}`.
|
||||
var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`)
|
||||
|
||||
// ServedTo fills an offer's served values for one consumer whose identity is as (ADR 0202 §1): `${consumer:as}` as
|
||||
// it is, `${consumer:as:dns}` with `-` for `_`. Only strings carry placeholders; any other fact or alphabet is
|
||||
// refused, never left standing.
|
||||
func ServedTo(serves map[string]any, as string) (map[string]any, error) {
|
||||
if serves == nil {
|
||||
return nil, nil
|
||||
}
|
||||
out := make(map[string]any, len(serves))
|
||||
for _, key := range sortedKeys(serves) {
|
||||
text, ok := serves[key].(string)
|
||||
if !ok || !strings.Contains(text, "${consumer:") {
|
||||
out[key] = serves[key]
|
||||
continue
|
||||
}
|
||||
var failed error
|
||||
out[key] = consumerFact.ReplaceAllStringFunc(text, func(match string) string {
|
||||
parts := consumerFact.FindStringSubmatch(match)
|
||||
switch {
|
||||
case parts[1] == "as" && parts[2] == "":
|
||||
return as
|
||||
case parts[1] == "as" && parts[2] == "dns":
|
||||
return strings.ReplaceAll(as, "_", "-")
|
||||
}
|
||||
if failed == nil {
|
||||
failed = fmt.Errorf("the value served as %q says %s, and the mesh states only ${consumer:as} and "+
|
||||
"${consumer:as:dns}", key, match)
|
||||
}
|
||||
return match
|
||||
})
|
||||
if failed != nil {
|
||||
return nil, failed
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func derives(serves map[string]any) bool { return len(derivedKeys(serves)) > 0 }
|
||||
|
||||
func derivedKeys(serves map[string]any) []string {
|
||||
var out []string
|
||||
for _, k := range sortedKeys(serves) {
|
||||
if text, ok := serves[k].(string); ok && strings.Contains(text, "${consumer:") {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clone(m map[string][]byte) map[string][]byte {
|
||||
out := make(map[string][]byte, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sameJSON(a, b []byte) bool {
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
var x, y any
|
||||
if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil {
|
||||
return false
|
||||
}
|
||||
ja, _ := json.Marshal(x)
|
||||
jb, _ := json.Marshal(y)
|
||||
return string(ja) == string(jb)
|
||||
}
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// StateBus is the Bus of a provider that runs as a module bundle over the runtime's stdio: the provider's own bucket,
|
||||
// BucketOf(module), is the module's state `bound`, and AssignmentsBucket is the state it reads,
|
||||
// `mesh-controller.assignments` (both declared in the module's manifest). Any other bucket is refused.
|
||||
//
|
||||
// A stdio state watch lasts as long as the process and cannot be ended (stdio.KeptState.Watch), so Watch honours the
|
||||
// Bus contract by silencing instead: once ctx has ended the handler is never called again, though the runtime keeps
|
||||
// sending changes that are dropped. A Pass, which watches twice per call, is therefore correct, only the dropped
|
||||
// changes of its two spent watches remain.
|
||||
func StateBus(module string) Bus { return stateBus{module: module} }
|
||||
|
||||
type stateBus struct{ module string }
|
||||
|
||||
func (b stateBus) state(bucket string) (stdio.KeptState, error) {
|
||||
switch bucket {
|
||||
case BucketOf(b.module):
|
||||
return stdio.State("bound"), nil
|
||||
case AssignmentsBucket:
|
||||
return stdio.State("mesh-controller.assignments"), nil
|
||||
}
|
||||
return stdio.KeptState{}, fmt.Errorf("%s is not a bucket this provider keeps or reads", bucket)
|
||||
}
|
||||
|
||||
func (b stateBus) Watch(ctx context.Context, bucket string, handle func(key string, value []byte)) error {
|
||||
s, err := b.state(bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Watch("", func(ch stdio.StateChange) error {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if ch.Op == "delete" {
|
||||
handle(ch.Key, nil)
|
||||
} else {
|
||||
handle(ch.Key, ch.Value)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (b stateBus) Put(_ context.Context, bucket, key string, value []byte) error {
|
||||
s, err := b.state(bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.Put(key, json.RawMessage(value))
|
||||
return err
|
||||
}
|
||||
|
||||
func (b stateBus) Delete(_ context.Context, bucket, key string) error {
|
||||
s, err := b.state(bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Delete(key)
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// standIn is a stand-in bus: buckets of keys and values, and watches that hand the current values first and then
|
||||
// every change, as the bus's key-value watch does.
|
||||
type standIn struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]map[string][]byte
|
||||
watchers map[string][]func(string, []byte)
|
||||
}
|
||||
|
||||
func newStandIn() *standIn {
|
||||
return &standIn{buckets: map[string]map[string][]byte{}, watchers: map[string][]func(string, []byte){}}
|
||||
}
|
||||
|
||||
func (s *standIn) Watch(ctx context.Context, bucket string, handle func(key string, value []byte)) error {
|
||||
s.mu.Lock()
|
||||
current := map[string][]byte{}
|
||||
for k, v := range s.buckets[bucket] {
|
||||
current[k] = v
|
||||
}
|
||||
s.watchers[bucket] = append(s.watchers[bucket], handle)
|
||||
s.mu.Unlock()
|
||||
for _, k := range sortedKeys(current) {
|
||||
handle(k, current[k])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *standIn) Put(_ context.Context, bucket, key string, value []byte) error {
|
||||
s.mu.Lock()
|
||||
if s.buckets[bucket] == nil {
|
||||
s.buckets[bucket] = map[string][]byte{}
|
||||
}
|
||||
s.buckets[bucket][key] = append([]byte(nil), value...)
|
||||
ws := slices.Clone(s.watchers[bucket])
|
||||
s.mu.Unlock()
|
||||
for _, w := range ws {
|
||||
w(key, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *standIn) Delete(_ context.Context, bucket, key string) error {
|
||||
s.mu.Lock()
|
||||
delete(s.buckets[bucket], key)
|
||||
ws := slices.Clone(s.watchers[bucket])
|
||||
s.mu.Unlock()
|
||||
for _, w := range ws {
|
||||
w(key, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *standIn) get(bucket, key string) []byte {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.buckets[bucket][key]
|
||||
}
|
||||
|
||||
func (s *standIn) keys(bucket string) []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return sortedKeys(s.buckets[bucket])
|
||||
}
|
||||
|
||||
// everything is every value the bus holds, for a test that no secret is in a bucket.
|
||||
func (s *standIn) everything() []byte {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var all []byte
|
||||
for _, b := range s.buckets {
|
||||
for k, v := range b {
|
||||
all = append(all, k...)
|
||||
all = append(all, v...)
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// vault is a stand-in vault: a value per name, or nothing yet.
|
||||
type vault struct {
|
||||
mu sync.Mutex
|
||||
values map[string]string
|
||||
asked []string
|
||||
}
|
||||
|
||||
func (v *vault) Value(_ context.Context, name string) ([]byte, error) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
v.asked = append(v.asked, name)
|
||||
val, ok := v.values[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the vault holds no %s yet", name)
|
||||
}
|
||||
return []byte(val), nil
|
||||
}
|
||||
|
||||
// maker records what the provider's adapter was asked to make, with the credential it was handed.
|
||||
type maker struct {
|
||||
mu sync.Mutex
|
||||
made map[string]string
|
||||
// contributed is what each consumer's entry carried of its plan's contribution.
|
||||
contributed map[string]map[string]any
|
||||
// as is the identity each consumer's entry was made under.
|
||||
as map[string]string
|
||||
gone []string
|
||||
fail error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (m *maker) Make(_ context.Context, c Consumer) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.calls++
|
||||
if m.fail != nil {
|
||||
return m.fail
|
||||
}
|
||||
if m.made == nil {
|
||||
m.made = map[string]string{}
|
||||
}
|
||||
m.made[c.Entry.Key()] = string(c.Credential)
|
||||
if m.contributed == nil {
|
||||
m.contributed = map[string]map[string]any{}
|
||||
}
|
||||
m.contributed[c.Entry.Key()] = c.Entry.Contributes
|
||||
if m.as == nil {
|
||||
m.as = map[string]string{}
|
||||
}
|
||||
m.as[c.Entry.Key()] = c.Entry.As
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *maker) Gone(_ context.Context, e Entry) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.gone = append(m.gone, e.Key())
|
||||
return nil
|
||||
}
|
||||
|
||||
// assign puts a consumer's assignment with its plan's provisions into the stand-in mirror.
|
||||
func assign(t *testing.T, bus *standIn, node, module string, provisions ...map[string]string) {
|
||||
t.Helper()
|
||||
ps := []map[string]string{}
|
||||
ps = append(ps, provisions...)
|
||||
raw, err := json.Marshal(map[string]any{"node": node, "module": module, "assigned": "2026-10-15T00:00:00Z",
|
||||
"provisioning": map[string]any{"templates": map[string]string{"source": "x", "digest": "y"},
|
||||
"provisions": ps}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bus.Put(context.Background(), AssignmentsBucket, node+"."+module, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func bind(provision, local, provider, providerModule string) map[string]string {
|
||||
b := map[string]string{"provision": provision, "provider": provider, "provider-module": providerModule}
|
||||
if local != "" {
|
||||
b["local"] = local
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func entryOf(t *testing.T, bus *standIn, key string) Entry {
|
||||
t.Helper()
|
||||
raw := bus.get(BucketOf("postgres"), key)
|
||||
if raw == nil {
|
||||
t.Fatalf("no entry %s in %s; it holds %v", key, BucketOf("postgres"), bus.keys(BucketOf("postgres")))
|
||||
}
|
||||
var e Entry
|
||||
if err := json.Unmarshal(raw, &e); err != nil {
|
||||
t.Fatalf("entry %s: %v", key, err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// postgres is a provider of postgres-database on novox with a login per consumer (ADR 0048, ADR 0158 §11), its
|
||||
// served values derived from each consumer's identity (ADR 0202 §1).
|
||||
func postgres(bus *standIn, v *vault, m *maker) *Provider {
|
||||
return New(Options{
|
||||
Node: "novox", Module: "postgres", Bus: bus, Vault: v, Make: m.Make, Gone: m.Gone,
|
||||
Offers: map[string]Offer{
|
||||
"postgres-database": {Credential: PerConsumer, Serves: map[string]any{
|
||||
"host": "postgres.novox.mesh", "port": 5432, "database": "${consumer:as}"}},
|
||||
},
|
||||
Logf: func(string, ...any) {},
|
||||
})
|
||||
}
|
||||
|
||||
// A provider answers each consumer that binds it with an entry of its own in its own bucket: composed from its
|
||||
// served values and the consumer's identity, naming the credential the vault keeps for that consumer, and never
|
||||
// the credential itself; its adapter is handed each consumer's own value read from the vault.
|
||||
func TestEachConsumerGetsAnEntryOfItsOwnAndTheVaultsValue(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{
|
||||
"pair/novox/postgres-database/ace/letta": "letta-secret-1",
|
||||
"pair/novox/postgres-database/g14/umami": "umami-secret-2",
|
||||
}}
|
||||
assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres"))
|
||||
assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres"))
|
||||
p := postgres(bus, v, m)
|
||||
out, err := p.Pass(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := bus.keys(BucketOf("postgres")); !slices.Equal(got, []string{"ace.letta.postgres-database",
|
||||
"g14.umami.postgres-database"}) {
|
||||
t.Fatalf("entries %v", got)
|
||||
}
|
||||
e := entryOf(t, bus, "ace.letta.postgres-database")
|
||||
if e.Provider != "novox" || e.ProviderModule != "postgres" || e.Consumer != "ace" || e.Module != "letta" ||
|
||||
e.As != "mesh_ace_letta" || e.User != "mesh_ace_letta" ||
|
||||
e.Credential != "pair/novox/postgres-database/ace/letta" || e.Refused != "" {
|
||||
t.Fatalf("entry %+v", e)
|
||||
}
|
||||
if e.Values["database"] != "mesh_ace_letta" || e.Values["host"] != "postgres.novox.mesh" ||
|
||||
e.Values["port"] != float64(5432) {
|
||||
t.Fatalf("values %v", e.Values)
|
||||
}
|
||||
if m.made["ace.letta.postgres-database"] != "letta-secret-1" ||
|
||||
m.made["g14.umami.postgres-database"] != "umami-secret-2" {
|
||||
t.Fatalf("made %v", m.made)
|
||||
}
|
||||
if bytes.Contains(bus.everything(), []byte("secret-")) {
|
||||
t.Fatal("a credential is in a bucket")
|
||||
}
|
||||
for _, o := range out {
|
||||
if o.Status != Answered {
|
||||
t.Fatalf("outcome %+v", o)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A provider whose offer shares one credential (ADR 0158) names the vault's one value for the provision, and its
|
||||
// one user name, to every consumer.
|
||||
func TestASharedCredentialIsTheVaultsOneValueAndTheProvidersOneUser(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{"provision/novox/sonarr-api": "one-value"}}
|
||||
assign(t, bus, "ace", "overseerr", bind("sonarr-api", "", "novox", "sonarr"))
|
||||
assign(t, bus, "g14", "homepage", bind("sonarr-api", "", "novox", "sonarr"))
|
||||
p := New(Options{Node: "novox", Module: "sonarr", Bus: bus, Vault: v, Make: m.Make, Logf: func(string, ...any) {},
|
||||
Offers: map[string]Offer{"sonarr-api": {Credential: Shared, User: "sonarr",
|
||||
Serves: map[string]any{"url": "http://sonarr.novox.mesh:8989"}}}})
|
||||
if _, err := p.Pass(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"ace.overseerr.sonarr-api", "g14.homepage.sonarr-api"} {
|
||||
var e Entry
|
||||
if err := json.Unmarshal(bus.get(BucketOf("sonarr"), key), &e); err != nil {
|
||||
t.Fatalf("%s: %v", key, err)
|
||||
}
|
||||
if e.Credential != "provision/novox/sonarr-api" || e.User != "sonarr" || e.Values["url"] == nil {
|
||||
t.Fatalf("%s: %+v", key, e)
|
||||
}
|
||||
if m.made[key] != "one-value" {
|
||||
t.Fatalf("%s made with %q", key, m.made[key])
|
||||
}
|
||||
}
|
||||
if bytes.Contains(bus.everything(), []byte("one-value")) {
|
||||
t.Fatal("the shared credential is in a bucket")
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer that keeps several holders of one provision is served no derived value: its entry refuses it, naming
|
||||
// both ends, so its client can report it (ADR 0349 clause 12, ADR 0202 §10); nothing is made for it.
|
||||
func TestAMultiHolderConsumerIsRefusedADerivedValueNamingBothEnds(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta/archive": "x-secret"}}
|
||||
assign(t, bus, "ace", "letta", bind("postgres-database", "archive", "novox", "postgres"))
|
||||
out, err := postgres(bus, v, m).Pass(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := entryOf(t, bus, "ace.letta.postgres-database.archive")
|
||||
for _, says := range []string{"letta on ace", "postgres on novox", "postgres-database", "archive"} {
|
||||
if !strings.Contains(e.Refused, says) {
|
||||
t.Fatalf("the refusal does not name %q: %s", says, e.Refused)
|
||||
}
|
||||
}
|
||||
if e.Values != nil || e.Credential != "" {
|
||||
t.Fatalf("a refused entry carries values: %+v", e)
|
||||
}
|
||||
if m.calls != 0 {
|
||||
t.Fatal("the adapter made a refused consumer")
|
||||
}
|
||||
if len(out) != 1 || out[0].Status != Refused {
|
||||
t.Fatalf("outcomes %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer bound under a local name is served when the offer derives nothing from it: its credential is its own
|
||||
// pair row under that local name.
|
||||
func TestALocalNameIsServedWhenNothingIsDerived(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{"pair/novox/redis-cache/ace/letta/queue": "q-secret"}}
|
||||
assign(t, bus, "ace", "letta", bind("redis-cache", "queue", "novox", "redis"))
|
||||
p := New(Options{Node: "novox", Module: "redis", Bus: bus, Vault: v, Make: m.Make, Logf: func(string, ...any) {},
|
||||
Offers: map[string]Offer{"redis-cache": {Credential: PerConsumer, Serves: map[string]any{"port": 6379}}}})
|
||||
if _, err := p.Pass(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var e Entry
|
||||
if err := json.Unmarshal(bus.get(BucketOf("redis"), "ace.letta.redis-cache.queue"), &e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.Refused != "" || e.Credential != "pair/novox/redis-cache/ace/letta/queue" || e.Local != "queue" {
|
||||
t.Fatalf("entry %+v", e)
|
||||
}
|
||||
if m.made["ace.letta.redis-cache.queue"] != "q-secret" {
|
||||
t.Fatalf("made %v", m.made)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer that unbinds loses its entry and the adapter hears it is gone; an entry another machine's instance of
|
||||
// the same provider wrote is not this one's to remove.
|
||||
func TestAnUnbindRemovesTheEntryAndLeavesAnotherProvidersAlone(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s1"}}
|
||||
assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres"))
|
||||
other, _ := json.Marshal(Entry{Provider: "shanks", ProviderModule: "postgres", Provision: "postgres-database",
|
||||
Consumer: "g14", Module: "gitea"})
|
||||
_ = bus.Put(context.Background(), BucketOf("postgres"), "g14.gitea.postgres-database", other)
|
||||
p := postgres(bus, v, m)
|
||||
if _, err := p.Pass(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = bus.Delete(context.Background(), AssignmentsBucket, "ace.letta")
|
||||
out, err := p.Pass(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := bus.keys(BucketOf("postgres")); !slices.Equal(got, []string{"g14.gitea.postgres-database"}) {
|
||||
t.Fatalf("entries left %v", got)
|
||||
}
|
||||
if !slices.Equal(m.gone, []string{"ace.letta.postgres-database"}) {
|
||||
t.Fatalf("gone %v", m.gone)
|
||||
}
|
||||
if len(out) != 1 || out[0].Status != Removed {
|
||||
t.Fatalf("outcomes %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A bind whose credential the vault does not hold yet is waited for: nothing is made and no entry is written, so a
|
||||
// consumer is never told of a login its provider has not made; once the vault holds it, it is answered.
|
||||
func TestACredentialTheVaultDoesNotHoldYetIsWaitedFor(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{}}
|
||||
assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres"))
|
||||
p := postgres(bus, v, m)
|
||||
out, err := p.Pass(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out) != 1 || out[0].Status != Waiting || !strings.Contains(out[0].Why, "pair/novox/postgres-database/ace/letta") {
|
||||
t.Fatalf("outcomes %+v", out)
|
||||
}
|
||||
if bus.keys(BucketOf("postgres")) != nil || m.calls != 0 {
|
||||
t.Fatal("a consumer was answered before its credential was there")
|
||||
}
|
||||
v.values["pair/novox/postgres-database/ace/letta"] = "late"
|
||||
if _, err := p.Pass(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entryOf(t, bus, "ace.letta.postgres-database").Refused != "" || m.made["ace.letta.postgres-database"] != "late" {
|
||||
t.Fatal("not answered once the vault held the value")
|
||||
}
|
||||
}
|
||||
|
||||
// Only the binds naming this provider, on this machine, are its own; a bind naming it for a provision it does not
|
||||
// offer is refused, naming both ends.
|
||||
func TestOnlyBindsNamingThisProviderAreAnswered(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s"}}
|
||||
assign(t, bus, "ace", "letta", bind("postgres-database", "", "shanks", "postgres"),
|
||||
bind("mongodb-database", "", "novox", "postgres"))
|
||||
assign(t, bus, "ace", "other", bind("postgres-database", "", "novox", "mssql"))
|
||||
out, err := postgres(bus, v, m).Pass(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := bus.keys(BucketOf("postgres")); !slices.Equal(got, []string{"ace.letta.mongodb-database"}) {
|
||||
t.Fatalf("entries %v", got)
|
||||
}
|
||||
e := entryOf(t, bus, "ace.letta.mongodb-database")
|
||||
if !strings.Contains(e.Refused, "postgres on novox") || !strings.Contains(e.Refused, "letta on ace") {
|
||||
t.Fatalf("refusal %q", e.Refused)
|
||||
}
|
||||
if len(out) != 1 || m.calls != 0 {
|
||||
t.Fatalf("outcomes %+v, %d made", out, m.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// An assignment that cannot be read stops the pass: a bind missed would read as an unbind and remove an entry its
|
||||
// consumer still reads.
|
||||
func TestAnUnreadableAssignmentRemovesNothing(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s"}}
|
||||
assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres"))
|
||||
p := postgres(bus, v, m)
|
||||
if _, err := p.Pass(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = bus.Put(context.Background(), AssignmentsBucket, "ace.letta", []byte("{not json"))
|
||||
if _, err := p.Pass(context.Background()); err == nil {
|
||||
t.Fatal("a pass over an unreadable assignment went on")
|
||||
}
|
||||
if bus.get(BucketOf("postgres"), "ace.letta.postgres-database") == nil || len(m.gone) != 0 {
|
||||
t.Fatal("an entry was removed on an unreadable assignment")
|
||||
}
|
||||
}
|
||||
|
||||
// An adapter that fails is said with the credential taken out of its words, and the entry is not written.
|
||||
func TestAFailedMakeIsSaidWithoutTheCredential(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{fail: errors.New(`password "hunter2-secret" rejected`)}
|
||||
v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "hunter2-secret"}}
|
||||
assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres"))
|
||||
var logged []string
|
||||
p := postgres(bus, v, m)
|
||||
p.opts.Logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) }
|
||||
out, err := p.Pass(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out) != 1 || out[0].Status != Failed || strings.Contains(out[0].Why, "hunter2") {
|
||||
t.Fatalf("outcomes %+v", out)
|
||||
}
|
||||
if strings.Contains(strings.Join(logged, "\n"), "hunter2") || len(logged) == 0 {
|
||||
t.Fatalf("logged %q", logged)
|
||||
}
|
||||
if bus.keys(BucketOf("postgres")) != nil {
|
||||
t.Fatal("an entry was written for a consumer the adapter could not make")
|
||||
}
|
||||
}
|
||||
|
||||
// Run watches the binds on the stand-in bus: a consumer that binds is answered, and one that unbinds is removed,
|
||||
// without a pass asked by hand.
|
||||
func TestRunAnswersBindsAndUnbindsAsTheyAreWatched(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s"}}
|
||||
p := postgres(bus, v, m)
|
||||
p.opts.Every = time.Hour
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- p.Run(ctx) }()
|
||||
assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres"))
|
||||
waitFor(t, func() bool { return bus.get(BucketOf("postgres"), "ace.letta.postgres-database") != nil })
|
||||
_ = bus.Delete(context.Background(), AssignmentsBucket, "ace.letta")
|
||||
waitFor(t, func() bool { return bus.get(BucketOf("postgres"), "ace.letta.postgres-database") == nil })
|
||||
cancel()
|
||||
if err := <-done; err != nil && !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, ok func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for !ok() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("not within 3s")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// The consumer's identity is the mesh's (ADR 0202 §1): mesh_<node>_<module>, lower case, `_` for anything else,
|
||||
// and its DNS form writes `-` for `_`.
|
||||
func TestTheIdentityIsTheMeshsAndItsDNSForm(t *testing.T) {
|
||||
if got := ConsumerIdentity("Home.Server", "my-app"); got != "mesh_home_server_my_app" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
got, err := ServedTo(map[string]any{"bucket": "b-${consumer:as:dns}", "n": 3}, "mesh_ace_letta")
|
||||
if err != nil || got["bucket"] != "b-mesh-ace-letta" || got["n"] != 3 {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
if _, err := ServedTo(map[string]any{"x": "${consumer:node}"}, "mesh_ace_letta"); err == nil {
|
||||
t.Fatal("a fact the mesh does not state was filled")
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer's plan entry may carry what its module contributes to the provider (issue 776): the helper hands it to
|
||||
// the provider's adapter with the consumer, and does not write it to the bucket.
|
||||
func TestAConsumersContributionIsHandedToTheProviderPerConsumer(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{
|
||||
"pair/novox/postgres-database/ace/nextcloud": "a", "pair/novox/postgres-database/g14/umami": "b"}}
|
||||
raw := `{"node":"ace","module":"nextcloud","provisioning":{"templates":{"source":"x","digest":"y"},` +
|
||||
`"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` +
|
||||
`"contributes":{"name":"nextcloud","extensions":["pg_trgm"]}}]}}`
|
||||
if err := bus.Put(context.Background(), AssignmentsBucket, "ace.nextcloud", []byte(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres"))
|
||||
if _, err := postgres(bus, v, m).Pass(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]any{"name": "nextcloud", "extensions": []any{"pg_trgm"}}
|
||||
if got := m.contributed["ace.nextcloud.postgres-database"]; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("the adapter was handed the contribution %v, want %v", got, want)
|
||||
}
|
||||
if got := m.contributed["g14.umami.postgres-database"]; got != nil {
|
||||
t.Fatalf("a consumer contributing nothing was handed %v", got)
|
||||
}
|
||||
if bytes.Contains(bus.get(BucketOf("postgres"), "ace.nextcloud.postgres-database"), []byte("pg_trgm")) {
|
||||
t.Fatal("a contribution is in the provider's bucket")
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer's plan entry carries where its provider is, which machine, and who the consumer is (issue 788): the
|
||||
// entry's values carry `at` and `from` as the binding file gives them, and its `as` is the plan's, so a module with a
|
||||
// slug keeps the login it has today. An entry of a plan without them is as it was.
|
||||
func TestAConsumersEntryCarriesAtFromAndTheAsOfItsPlan(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{
|
||||
"pair/novox/postgres-database/ace/claude-code": "a", "pair/novox/postgres-database/g14/umami": "b"}}
|
||||
raw := `{"node":"ace","module":"claude-code","provisioning":{"templates":{"source":"x","digest":"y"},` +
|
||||
`"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` +
|
||||
`"at":"10.7.0.1","from":"novox","as":"mesh_ace_agent"}]}}`
|
||||
if err := bus.Put(context.Background(), AssignmentsBucket, "ace.claude-code", []byte(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres"))
|
||||
if _, err := postgres(bus, v, m).Pass(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var e Entry
|
||||
if err := json.Unmarshal(bus.get(BucketOf("postgres"), "ace.claude-code.postgres-database"), &e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.Values["at"] != "10.7.0.1" || e.Values["from"] != "novox" || e.Values["port"] == nil {
|
||||
t.Fatalf("the entry's values are %v", e.Values)
|
||||
}
|
||||
if e.As != "mesh_ace_agent" || e.User != "mesh_ace_agent" || m.as["ace.claude-code.postgres-database"] != "mesh_ace_agent" {
|
||||
t.Fatalf("the entry is %+v, made as %q", e, m.as["ace.claude-code.postgres-database"])
|
||||
}
|
||||
var plain Entry
|
||||
if err := json.Unmarshal(bus.get(BucketOf("postgres"), "g14.umami.postgres-database"), &plain); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, has := plain.Values["at"]; has || plain.As != "mesh_g14_umami" {
|
||||
t.Fatalf("an entry of a plan without them changed: %+v", plain)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer's plan entry may carry the names the controller composed for it (issue 792): each is copied into the
|
||||
// entry's values under its key, over anything the provider serves under it. An entry without them is as it was.
|
||||
func TestAConsumersEntryCarriesTheNamesOfItsPlan(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{
|
||||
"pair/novox/postgres-database/ace/gitea": "a", "pair/novox/postgres-database/g14/umami": "b"}}
|
||||
raw := `{"node":"ace","module":"gitea","provisioning":{"templates":{"source":"x","digest":"y"},` +
|
||||
`"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` +
|
||||
`"names":{"name":"git.example.org","internal-name":"git.mesh","name-web":"web.example.org"}}]}}`
|
||||
if err := bus.Put(context.Background(), AssignmentsBucket, "ace.gitea", []byte(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres"))
|
||||
if _, err := postgres(bus, v, m).Pass(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var e Entry
|
||||
if err := json.Unmarshal(bus.get(BucketOf("postgres"), "ace.gitea.postgres-database"), &e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.Values["name"] != "git.example.org" || e.Values["internal-name"] != "git.mesh" ||
|
||||
e.Values["name-web"] != "web.example.org" || e.Values["port"] == nil {
|
||||
t.Fatalf("the entry's values are %v", e.Values)
|
||||
}
|
||||
var plain Entry
|
||||
if err := json.Unmarshal(bus.get(BucketOf("postgres"), "g14.umami.postgres-database"), &plain); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, has := plain.Values["name"]; has {
|
||||
t.Fatalf("an entry of a plan without names changed: %+v", plain)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// runtime drives a bundle over pipes as the runtime does: it reads what the bundle says and sends what the runtime would.
|
||||
type runtime struct {
|
||||
t *testing.T
|
||||
lines *bufio.Scanner
|
||||
in io.Writer
|
||||
}
|
||||
|
||||
func (r *runtime) next() map[string]any {
|
||||
done := make(chan map[string]any, 1)
|
||||
go func() {
|
||||
if r.lines.Scan() {
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(r.lines.Bytes(), &m)
|
||||
done <- m
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case m := <-done:
|
||||
return m
|
||||
case <-time.After(3 * time.Second):
|
||||
r.t.Fatal("the bundle said nothing")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (r *runtime) send(m map[string]any) {
|
||||
b, _ := json.Marshal(m)
|
||||
_, _ = r.in.Write(append(b, '\n'))
|
||||
}
|
||||
|
||||
func (r *runtime) call(id int, tool string, args map[string]any) {
|
||||
r.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": map[string]any{"name": tool, "arguments": args}})
|
||||
}
|
||||
|
||||
func serve(t *testing.T, tools []stdio.Tool) *runtime {
|
||||
t.Setenv(stdio.ServedAs, "keeper")
|
||||
inR, inW := io.Pipe()
|
||||
outR, outW := io.Pipe()
|
||||
go func() { _ = stdio.ServeOn("", tools, inR, outW) }()
|
||||
t.Cleanup(func() { _ = inW.Close() })
|
||||
return &runtime{t: t, lines: bufio.NewScanner(outR), in: inW}
|
||||
}
|
||||
|
||||
// The bus of a moved provider maps its own bucket to the state `bound` and the assignments to the state
|
||||
// `mesh-controller.assignments`, writes only the own bucket's state, and refuses any other bucket.
|
||||
func TestStateBusMapsTheProvidersBucketsToTheRuntimesStates(t *testing.T) {
|
||||
bus := StateBus("postgres")
|
||||
rt := serve(t, []stdio.Tool{
|
||||
{Name: "watch", Run: func(a map[string]any) (any, error) {
|
||||
return nil, bus.Watch(context.Background(), a["bucket"].(string), func(string, []byte) {})
|
||||
}},
|
||||
{Name: "put", Run: func(map[string]any) (any, error) {
|
||||
return "put", bus.Put(context.Background(), BucketOf("postgres"), "k", []byte(`{"a":1}`))
|
||||
}},
|
||||
{Name: "delete", Run: func(map[string]any) (any, error) {
|
||||
return "deleted", bus.Delete(context.Background(), BucketOf("postgres"), "k")
|
||||
}},
|
||||
})
|
||||
for i, c := range []struct{ bucket, state string }{
|
||||
{BucketOf("postgres"), "bound"},
|
||||
{AssignmentsBucket, "mesh-controller.assignments"},
|
||||
} {
|
||||
rt.call(i+1, "watch", map[string]any{"bucket": c.bucket})
|
||||
w := rt.next()
|
||||
if w["method"] != "mesh/state.watch" || w["params"].(map[string]any)["state"] != c.state {
|
||||
t.Fatalf("bucket %s asked %v, want the state %s", c.bucket, w, c.state)
|
||||
}
|
||||
rt.send(map[string]any{"jsonrpc": "2.0", "id": w["id"], "result": map[string]any{}})
|
||||
rt.next()
|
||||
}
|
||||
rt.call(3, "put", nil)
|
||||
p := rt.next()
|
||||
if p["method"] != "mesh/state.put" || p["params"].(map[string]any)["state"] != "bound" || p["params"].(map[string]any)["key"] != "k" {
|
||||
t.Fatalf("put asked %v", p)
|
||||
}
|
||||
rt.send(map[string]any{"jsonrpc": "2.0", "id": p["id"], "result": map[string]any{"revision": 1}})
|
||||
rt.next()
|
||||
rt.call(4, "delete", nil)
|
||||
d := rt.next()
|
||||
if d["method"] != "mesh/state.delete" || d["params"].(map[string]any)["state"] != "bound" {
|
||||
t.Fatalf("delete asked %v", d)
|
||||
}
|
||||
rt.send(map[string]any{"jsonrpc": "2.0", "id": d["id"], "result": map[string]any{}})
|
||||
rt.next()
|
||||
|
||||
for _, bucket := range []string{"other_bound", "elsewhere"} {
|
||||
if err := bus.Watch(context.Background(), bucket, func(string, []byte) {}); err == nil || !strings.Contains(err.Error(), "not a bucket") {
|
||||
t.Fatalf("a watch of %s gave %v", bucket, err)
|
||||
}
|
||||
if err := bus.Put(context.Background(), bucket, "k", nil); err == nil {
|
||||
t.Fatalf("a put to %s was allowed", bucket)
|
||||
}
|
||||
if err := bus.Delete(context.Background(), bucket, "k"); err == nil {
|
||||
t.Fatalf("a delete in %s was allowed", bucket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A watch whose ctx has ended calls its handler no more, though the runtime keeps sending changes: the Bus contract
|
||||
// says a watch lasts until ctx ends, and a stdio state watch lasts as long as the process.
|
||||
func TestStateBusWatchCallsItsHandlerNoMoreOnceItsContextEnded(t *testing.T) {
|
||||
bus := StateBus("postgres")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
handled := make(chan string, 8)
|
||||
rt := serve(t, []stdio.Tool{
|
||||
{Name: "start", Run: func(map[string]any) (any, error) {
|
||||
return "started", bus.Watch(ctx, AssignmentsBucket, func(key string, value []byte) {
|
||||
handled <- key + "=" + string(value)
|
||||
})
|
||||
}},
|
||||
{Name: "stop", Run: func(map[string]any) (any, error) { cancel(); return "stopped", nil }},
|
||||
})
|
||||
change := func(id int, watch any, key, op string) {
|
||||
rt.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": "mesh/state", "params": map[string]any{"watch": watch,
|
||||
"change": map[string]any{"state": "mesh-controller.assignments", "key": key, "op": op, "value": "v"}}})
|
||||
if r := rt.next(); r["id"] != float64(id) || r["error"] != nil {
|
||||
t.Fatalf("a change was answered %v", r)
|
||||
}
|
||||
}
|
||||
rt.call(1, "start", nil)
|
||||
w := rt.next()
|
||||
rt.send(map[string]any{"jsonrpc": "2.0", "id": w["id"], "result": map[string]any{}})
|
||||
rt.next()
|
||||
|
||||
change(50, w["id"], "before", "put")
|
||||
if got := <-handled; got != `before="v"` {
|
||||
t.Fatalf("handled %q", got)
|
||||
}
|
||||
rt.call(2, "stop", nil)
|
||||
rt.next()
|
||||
change(51, w["id"], "after", "put")
|
||||
change(52, w["id"], "after", "delete")
|
||||
select {
|
||||
case got := <-handled:
|
||||
t.Fatalf("the handler was called after its context ended: %q", got)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package stdio
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A module's code reads its values at runtime (novox/hq ADR 0349 clause 14, ADR 0370, design 57 S1): its config
|
||||
// values and its bindings' values that are not secret, for what its manifest declares, and watches them. The
|
||||
// runtime that launched this bundle asks its machine's provisioner client **as this bundle's module** — the module it
|
||||
// launched, never a name this code passes — and the client answers only what that module declares, only while the
|
||||
// mesh places it on that machine. A secret is refused while the module's code runs in the tool runner: the vault
|
||||
// hands a secret only to a caller the bus proves, and the bus proves only the tool runner there, so a module's
|
||||
// secrets reach it through its files until it runs under a bus account of its own (novox/hq issue 569).
|
||||
|
||||
// ProvisionChange is what a watch hands over: the value now, or — once it is no longer answered, the module taken
|
||||
// off the machine or the key no longer declared — Refused, why.
|
||||
type ProvisionChange struct {
|
||||
Kind string `json:"kind"`
|
||||
Key string `json:"key"`
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
Refused string `json:"refused,omitempty"`
|
||||
}
|
||||
|
||||
const provisionTimeout = 30 * time.Second
|
||||
|
||||
func provisionGet(kind, key string) (json.RawMessage, error) {
|
||||
c, err := live()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.ask("mesh/provision.get", map[string]any{"kind": kind, "key": key}, provisionTimeout)
|
||||
}
|
||||
|
||||
func provisionWatch(kind, key string, handle func(ProvisionChange) error) error {
|
||||
if handle == nil {
|
||||
return errors.New("a watch needs a handler")
|
||||
}
|
||||
c, err := live()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := c.newID()
|
||||
c.mu.Lock()
|
||||
c.provisioned[id] = handle
|
||||
c.mu.Unlock()
|
||||
if _, err := c.askAs(id, "mesh/provision.watch", map[string]any{"kind": kind, "key": key}, provisionTimeout); err != nil {
|
||||
c.mu.Lock()
|
||||
delete(c.provisioned, id)
|
||||
c.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Config is one of this module's config values as mesh-config resolved it for this machine, as JSON: the key as
|
||||
// its manifest declares it under `config`. A key it does not declare is refused, with the reason.
|
||||
func Config(key string) (json.RawMessage, error) { return provisionGet("config", key) }
|
||||
|
||||
// WatchConfig hands handle the config value now and then every change, in order, and returns once the value now is
|
||||
// handled. The watch lasts as long as the process.
|
||||
func WatchConfig(key string, handle func(ProvisionChange) error) error {
|
||||
return provisionWatch("config", key, handle)
|
||||
}
|
||||
|
||||
// Bound is one value, not a secret, of what this module is bound to: the provision as its manifest requires it,
|
||||
// and the key its provider answers (`${bound:<provision>:<key>}`).
|
||||
func Bound(provision, key string) (json.RawMessage, error) {
|
||||
return provisionGet("bound", provision+":"+key)
|
||||
}
|
||||
|
||||
// WatchBound is WatchConfig for a bound value.
|
||||
func WatchBound(provision, key string, handle func(ProvisionChange) error) error {
|
||||
return provisionWatch("bound", provision+":"+key, handle)
|
||||
}
|
||||
|
||||
// Secret is one of this module's secrets. Refused, with the reason, while the module's code runs in the tool
|
||||
// runner (novox/hq ADR 0349 §11, ADR 0370 §2): its secrets reach it through its files.
|
||||
func Secret(name string) ([]byte, error) {
|
||||
raw, err := provisionGet("secret", name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return nil, errors.New("the runtime answered a secret that is not a string")
|
||||
}
|
||||
return []byte(s), nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package stdio
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A Go bundle reads its values at runtime through the runtime (novox/hq ADR 0349 §14, ADR 0370, issue 685): Config,
|
||||
// Bound and their watches ask the runtime, which asks as the module; a watch's value now is handled before it
|
||||
// returns and each change after is named by the watch's id; what the module does not declare and every secret come
|
||||
// back refused, in the runtime's words; a change for no watch of this bundle is refused.
|
||||
func TestABundleReadsAndWatchesItsValuesThroughTheRuntime(t *testing.T) {
|
||||
t.Setenv(ServedAs, "claude-code")
|
||||
inR, inW := io.Pipe()
|
||||
outR, outW := io.Pipe()
|
||||
seen := make(chan string, 16)
|
||||
watched := make(chan error, 1)
|
||||
answers := make(chan string, 4)
|
||||
go func() {
|
||||
_ = ServeOn("", []Tool{
|
||||
{Name: "start", Run: func(map[string]any) (any, error) {
|
||||
go func() {
|
||||
watched <- WatchConfig("managed_settings", func(c ProvisionChange) error {
|
||||
seen <- c.Kind + " " + c.Key + " " + string(c.Value) + c.Refused
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
return "started", nil
|
||||
}},
|
||||
{Name: "read", Run: func(a map[string]any) (any, error) {
|
||||
var v json.RawMessage
|
||||
var err error
|
||||
switch a["what"] {
|
||||
case "config":
|
||||
v, err = Config(a["key"].(string))
|
||||
case "bound":
|
||||
v, err = Bound("mcp-endpoint", "url")
|
||||
case "secret":
|
||||
var s []byte
|
||||
s, err = Secret("gitea-read")
|
||||
v = json.RawMessage(s)
|
||||
}
|
||||
if err != nil {
|
||||
answers <- "refused: " + err.Error()
|
||||
} else {
|
||||
answers <- string(v)
|
||||
}
|
||||
return "read", nil
|
||||
}},
|
||||
}, inR, outW)
|
||||
}()
|
||||
lines := bufio.NewScanner(outR)
|
||||
next := func() map[string]any {
|
||||
done := make(chan map[string]any, 1)
|
||||
go func() {
|
||||
if lines.Scan() {
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(lines.Bytes(), &m)
|
||||
done <- m
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case m := <-done:
|
||||
return m
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("the bundle said nothing")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
send := func(m map[string]any) { b, _ := json.Marshal(m); _, _ = inW.Write(append(b, '\n')) }
|
||||
// asked is the bundle's next ask of the runtime, beside its answers to this tool call and those before.
|
||||
asked := func(callID int) map[string]any {
|
||||
t.Helper()
|
||||
var ask map[string]any
|
||||
for ask == nil {
|
||||
m := next()
|
||||
if m["method"] != nil {
|
||||
ask = m
|
||||
} else if id, ok := m["id"].(float64); !ok || id > float64(callID) || m["error"] != nil {
|
||||
t.Fatalf("said %v", m)
|
||||
}
|
||||
}
|
||||
return ask
|
||||
}
|
||||
|
||||
// The watch: asked of the runtime with the kind and the key and no module, the module being the runtime's word.
|
||||
send(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{"name": "start"}})
|
||||
watch := asked(1)
|
||||
if watch["method"] != "mesh/provision.watch" {
|
||||
t.Fatalf("asked %v", watch)
|
||||
}
|
||||
if p := watch["params"].(map[string]any); p["kind"] != "config" || p["key"] != "managed_settings" || len(p) != 2 {
|
||||
t.Fatalf("asked to watch %v", p)
|
||||
}
|
||||
send(map[string]any{"jsonrpc": "2.0", "id": 50, "method": "mesh/provision", "params": map[string]any{"watch": watch["id"],
|
||||
"change": map[string]any{"kind": "config", "key": "managed_settings", "value": map[string]any{"spinnerTipsEnabled": false}}}})
|
||||
for {
|
||||
if r := next(); r["id"] == float64(50) {
|
||||
if r["error"] != nil {
|
||||
t.Fatalf("the value now was answered %v", r)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if s := <-seen; s != `config managed_settings {"spinnerTipsEnabled":false}` {
|
||||
t.Fatalf("handled %q", s)
|
||||
}
|
||||
select {
|
||||
case err := <-watched:
|
||||
t.Fatalf("the watch returned before it was answered: %v", err)
|
||||
default:
|
||||
}
|
||||
send(map[string]any{"jsonrpc": "2.0", "id": watch["id"], "result": map[string]any{}})
|
||||
if err := <-watched; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A change, and then the value no longer answered.
|
||||
send(map[string]any{"jsonrpc": "2.0", "id": 51, "method": "mesh/provision", "params": map[string]any{"watch": watch["id"],
|
||||
"change": map[string]any{"kind": "config", "key": "managed_settings", "value": map[string]any{"spinnerTipsEnabled": true}}}})
|
||||
if r := next(); r["id"] != float64(51) || r["error"] != nil {
|
||||
t.Fatalf("a change was answered %v", r)
|
||||
}
|
||||
if s := <-seen; s != `config managed_settings {"spinnerTipsEnabled":true}` {
|
||||
t.Fatalf("handled %q", s)
|
||||
}
|
||||
send(map[string]any{"jsonrpc": "2.0", "id": 52, "method": "mesh/provision", "params": map[string]any{"watch": watch["id"],
|
||||
"change": map[string]any{"kind": "config", "key": "managed_settings", "refused": "claude-code is not placed on g14"}}})
|
||||
if r := next(); r["id"] != float64(52) || r["error"] != nil {
|
||||
t.Fatalf("a refusal was answered %v", r)
|
||||
}
|
||||
if s := <-seen; s != `config managed_settings claude-code is not placed on g14` {
|
||||
t.Fatalf("handled %q", s)
|
||||
}
|
||||
// A change for a watch this bundle never asked for is refused.
|
||||
send(map[string]any{"jsonrpc": "2.0", "id": 53, "method": "mesh/provision", "params": map[string]any{"watch": "bundle-999",
|
||||
"change": map[string]any{"kind": "config", "key": "x"}}})
|
||||
if r := next(); r["id"] != float64(53) || !strings.Contains(r["error"].(map[string]any)["message"].(string), "names no watch") {
|
||||
t.Fatalf("a change for no watch was answered %v", r)
|
||||
}
|
||||
|
||||
// Each reader asks the runtime, and hands back its answer or its refusal.
|
||||
for i, c := range []struct {
|
||||
what, key, kind, askedKey string
|
||||
result any
|
||||
refusal string
|
||||
want string
|
||||
}{
|
||||
{what: "config", key: "role", kind: "config", askedKey: "role", result: "the build machine", want: `"the build machine"`},
|
||||
{what: "config", key: "nothing", kind: "config", askedKey: "nothing",
|
||||
refusal: "claude-code declares no config value nothing", want: "refused: claude-code declares no config value nothing"},
|
||||
{what: "bound", kind: "bound", askedKey: "mcp-endpoint:url", result: "http://127.0.0.1:9000", want: `"http://127.0.0.1:9000"`},
|
||||
{what: "secret", kind: "secret", askedKey: "gitea-read",
|
||||
refusal: "the vault hands a secret only to a caller the bus proves", want: "refused: the vault hands a secret only to a caller the bus proves"},
|
||||
} {
|
||||
id := 10 + i
|
||||
send(map[string]any{"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": map[string]any{"name": "read",
|
||||
"arguments": map[string]any{"what": c.what, "key": c.key}}})
|
||||
get := asked(id)
|
||||
if p := get["params"].(map[string]any); get["method"] != "mesh/provision.get" || p["kind"] != c.kind || p["key"] != c.askedKey || len(p) != 2 {
|
||||
t.Fatalf("%s: asked %v", c.what, get)
|
||||
}
|
||||
if c.refusal != "" {
|
||||
send(map[string]any{"jsonrpc": "2.0", "id": get["id"], "error": map[string]any{"code": -32000, "message": c.refusal}})
|
||||
} else {
|
||||
send(map[string]any{"jsonrpc": "2.0", "id": get["id"], "result": c.result})
|
||||
}
|
||||
if got := <-answers; got != c.want {
|
||||
t.Errorf("%s %s: %q, want %q", c.what, c.key, got, c.want)
|
||||
}
|
||||
}
|
||||
_ = inW.Close()
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
// Package seal is the mesh's sealed box (novox/hq ADR 0183, ADR 0206): a value sealed to one recipient's
|
||||
// public key, which only that recipient's private key opens. X25519 for the agreement, HKDF-SHA256 for the
|
||||
// key, AES-256-GCM for the box — the box the licence manager made and opened before it lived here, byte
|
||||
// for byte, and the one the SDK's TypeScript `seal` makes and opens.
|
||||
//
|
||||
// A box is `{ v: 1, eph, iv, tag, ct }`, every field standard base64; `eph` is the one-time public key as
|
||||
// SPKI DER. The key is derived with the salt eph-DER ‖ the recipient's raw public key and the info
|
||||
// "novox-mesh sealed box v1", so a box cannot be re-addressed to another key.
|
||||
//
|
||||
// Keys travel as PEM: the public key as SPKI ("PUBLIC KEY"), the private key as PKCS #8 ("PRIVATE KEY").
|
||||
// Standard library only.
|
||||
package seal
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Box is a value sealed to one recipient, as it travels in JSON.
|
||||
type Box struct {
|
||||
V int `json:"v"`
|
||||
Eph string `json:"eph"`
|
||||
IV string `json:"iv"`
|
||||
Tag string `json:"tag"`
|
||||
Ct string `json:"ct"`
|
||||
}
|
||||
|
||||
// KeyPair is a recipient's keypair as the two PEM strings it is kept and sent as.
|
||||
type KeyPair struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
PrivateKey string `json:"privateKey"`
|
||||
}
|
||||
|
||||
// Version is the only box version Open opens.
|
||||
const Version = 1
|
||||
|
||||
const info = "novox-mesh sealed box v1"
|
||||
|
||||
// GenerateKeyPair makes a fresh X25519 keypair, PEM-encoded.
|
||||
func GenerateKeyPair() (KeyPair, error) {
|
||||
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(priv.PublicKey())
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
privDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
return KeyPair{
|
||||
PublicKey: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})),
|
||||
PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Seal seals plaintext to the recipient's PEM public key, with a fresh one-time key and iv.
|
||||
func Seal(plaintext, recipientPEM string) (Box, error) {
|
||||
eph, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
iv := make([]byte, 12)
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
return sealWith(plaintext, recipientPEM, eph, iv)
|
||||
}
|
||||
|
||||
// sealWith is Seal with its two sources of randomness given, for the byte-for-byte tests.
|
||||
func sealWith(plaintext, recipientPEM string, eph *ecdh.PrivateKey, iv []byte) (Box, error) {
|
||||
recipient, err := publicFromPEM(recipientPEM)
|
||||
if err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
secret, err := eph.ECDH(recipient)
|
||||
if err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
ephDER, err := x509.MarshalPKIXPublicKey(eph.PublicKey())
|
||||
if err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
gcm, err := newGCM(boxKey(secret, ephDER, recipient.Bytes()))
|
||||
if err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
out := gcm.Seal(nil, iv, []byte(plaintext), nil)
|
||||
ct, tag := out[:len(out)-gcm.Overhead()], out[len(out)-gcm.Overhead():]
|
||||
b64 := base64.StdEncoding.EncodeToString
|
||||
return Box{V: Version, Eph: b64(ephDER), IV: b64(iv), Tag: b64(tag), Ct: b64(ct)}, nil
|
||||
}
|
||||
|
||||
// Open opens a box with the recipient's PEM private key. It fails for a box sealed to another key, one
|
||||
// changed in any field, or one of another version.
|
||||
func Open(box Box, privatePEM string) (string, error) {
|
||||
if box.V != Version {
|
||||
return "", fmt.Errorf("a sealed box of version %d, not %d", box.V, Version)
|
||||
}
|
||||
priv, err := privateFromPEM(privatePEM)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
d := base64.StdEncoding.DecodeString
|
||||
ephDER, err := d(box.Eph)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the box's eph: %w", err)
|
||||
}
|
||||
ephKey, err := x509.ParsePKIXPublicKey(ephDER)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the box's eph: %w", err)
|
||||
}
|
||||
eph, ok := ephKey.(*ecdh.PublicKey)
|
||||
if !ok || eph.Curve() != ecdh.X25519() {
|
||||
return "", errors.New("the box's eph is not an X25519 key")
|
||||
}
|
||||
secret, err := priv.ECDH(eph)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iv, err1 := d(box.IV)
|
||||
tag, err2 := d(box.Tag)
|
||||
ct, err3 := d(box.Ct)
|
||||
if err := errors.Join(err1, err2, err3); err != nil {
|
||||
return "", fmt.Errorf("the box's fields: %w", err)
|
||||
}
|
||||
gcm, err := newGCM(boxKey(secret, ephDER, priv.PublicKey().Bytes()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(iv) != gcm.NonceSize() || len(tag) != gcm.Overhead() {
|
||||
return "", errors.New("the box does not open with this key")
|
||||
}
|
||||
plain, err := gcm.Open(nil, iv, append(append([]byte{}, ct...), tag...), nil)
|
||||
if err != nil {
|
||||
return "", errors.New("the box does not open with this key")
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
func publicFromPEM(p string) (*ecdh.PublicKey, error) {
|
||||
block, _ := pem.Decode([]byte(p))
|
||||
if block == nil {
|
||||
return nil, errors.New("not a PEM public key")
|
||||
}
|
||||
k, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pub, ok := k.(*ecdh.PublicKey)
|
||||
if !ok || pub.Curve() != ecdh.X25519() {
|
||||
return nil, errors.New("not an X25519 public key")
|
||||
}
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
func privateFromPEM(p string) (*ecdh.PrivateKey, error) {
|
||||
block, _ := pem.Decode([]byte(p))
|
||||
if block == nil {
|
||||
return nil, errors.New("not a PEM private key")
|
||||
}
|
||||
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
priv, ok := k.(*ecdh.PrivateKey)
|
||||
if !ok || priv.Curve() != ecdh.X25519() {
|
||||
return nil, errors.New("not an X25519 private key")
|
||||
}
|
||||
return priv, nil
|
||||
}
|
||||
|
||||
// boxKey is HKDF-SHA256 (RFC 5869) for one 32-byte block: written out because crypto/hkdf needs Go 1.24
|
||||
// and this module promises 1.22; the vectors prove it equals the licence manager's hkdf.Key.
|
||||
func boxKey(secret, ephDER, recipientRaw []byte) []byte {
|
||||
salt := append(append([]byte{}, ephDER...), recipientRaw...)
|
||||
extract := hmac.New(sha256.New, salt)
|
||||
extract.Write(secret)
|
||||
prk := extract.Sum(nil)
|
||||
expand := hmac.New(sha256.New, prk)
|
||||
expand.Write([]byte(info))
|
||||
expand.Write([]byte{1})
|
||||
return expand.Sum(nil)
|
||||
}
|
||||
|
||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package seal
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fixture struct {
|
||||
Recipient KeyPair `json:"recipient"`
|
||||
Sealed []struct {
|
||||
Plaintext string `json:"plaintext"`
|
||||
Box Box `json:"box"`
|
||||
} `json:"sealed"`
|
||||
Vectors []struct {
|
||||
Plaintext string `json:"plaintext"`
|
||||
EphPrivate string `json:"ephPrivate"`
|
||||
IV string `json:"iv"`
|
||||
Box Box `json:"box"`
|
||||
} `json:"vectors"`
|
||||
}
|
||||
|
||||
func readFixture(t *testing.T) fixture {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("testdata/sealed-by-licence-manager.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var f fixture
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.Sealed) == 0 || len(f.Vectors) == 0 {
|
||||
t.Fatal("the fixture holds no boxes")
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// Every box the licence manager's own Seal made opens here to its plaintext.
|
||||
func TestABoxTheLicenceManagerSealedOpens(t *testing.T) {
|
||||
f := readFixture(t)
|
||||
for _, s := range f.Sealed {
|
||||
got, err := Open(s.Box, f.Recipient.PrivateKey)
|
||||
if err != nil || got != s.Plaintext {
|
||||
t.Fatalf("opened %q, %v; want %q", got, err, s.Plaintext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Given the same one-time key and iv, this package seals byte for byte what the licence manager sealed:
|
||||
// the reverse direction, so whatever opens the licence manager's boxes opens these.
|
||||
func TestSealIsByteForByteTheLicenceManagersBox(t *testing.T) {
|
||||
f := readFixture(t)
|
||||
for _, v := range f.Vectors {
|
||||
d := base64.StdEncoding.DecodeString
|
||||
ephRaw, err1 := d(v.EphPrivate)
|
||||
iv, err2 := d(v.IV)
|
||||
if err1 != nil || err2 != nil {
|
||||
t.Fatal(err1, err2)
|
||||
}
|
||||
eph, err := ecdh.X25519().NewPrivateKey(ephRaw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := sealWith(v.Plaintext, f.Recipient.PublicKey, eph, iv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != v.Box {
|
||||
t.Fatalf("sealed %+v\nthe licence manager sealed %+v", got, v.Box)
|
||||
}
|
||||
if plain, err := Open(got, f.Recipient.PrivateKey); err != nil || plain != v.Plaintext {
|
||||
t.Fatalf("reopened %q, %v", plain, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A box the agent module's TypeScript sealed (the fixture the licence manager keeps) opens here too.
|
||||
func TestABoxSealedInTypeScriptOpens(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/sealed-by-typescript.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var f struct {
|
||||
PrivateKey string `json:"privateKey"`
|
||||
Box Box `json:"box"`
|
||||
Plaintext string `json:"plaintext"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := Open(f.Box, f.PrivateKey); err != nil || got != f.Plaintext {
|
||||
t.Fatalf("opened %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// What Seal makes opens with its recipient's key and no other, and a fresh box differs every time.
|
||||
func TestABoxOpensOnlyForItsRecipient(t *testing.T) {
|
||||
a, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := GenerateKeyPair()
|
||||
box, err := Seal("a token", a.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := Open(box, a.PrivateKey); err != nil || got != "a token" {
|
||||
t.Fatalf("opened %q, %v", got, err)
|
||||
}
|
||||
if _, err := Open(box, b.PrivateKey); err == nil {
|
||||
t.Fatal("a box opened for another key")
|
||||
}
|
||||
again, _ := Seal("a token", a.PublicKey)
|
||||
if again.Eph == box.Eph || again.IV == box.IV {
|
||||
t.Fatal("two boxes share a one-time key or iv")
|
||||
}
|
||||
}
|
||||
|
||||
// A box changed in any field, or of another version, does not open.
|
||||
func TestATamperedBoxDoesNotOpen(t *testing.T) {
|
||||
f := readFixture(t)
|
||||
good := f.Sealed[1].Box
|
||||
flip := func(s string) string {
|
||||
b, _ := base64.StdEncoding.DecodeString(s)
|
||||
b[len(b)-1] ^= 1
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
for name, box := range map[string]Box{
|
||||
"ct": {V: 1, Eph: good.Eph, IV: good.IV, Tag: good.Tag, Ct: flip(good.Ct)},
|
||||
"tag": {V: 1, Eph: good.Eph, IV: good.IV, Tag: flip(good.Tag), Ct: good.Ct},
|
||||
"iv": {V: 1, Eph: good.Eph, IV: flip(good.IV), Tag: good.Tag, Ct: good.Ct},
|
||||
"eph": {V: 1, Eph: flip(good.Eph), IV: good.IV, Tag: good.Tag, Ct: good.Ct},
|
||||
"version": {V: 2, Eph: good.Eph, IV: good.IV, Tag: good.Tag, Ct: good.Ct},
|
||||
} {
|
||||
if _, err := Open(box, f.Recipient.PrivateKey); err == nil {
|
||||
t.Fatalf("a box with a changed %s opened", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A key that is not X25519 PEM is refused, not misread.
|
||||
func TestAKeyOfAnotherKindIsRefused(t *testing.T) {
|
||||
if _, err := Seal("x", "not a key"); err == nil {
|
||||
t.Fatal("sealed to text")
|
||||
}
|
||||
f := readFixture(t)
|
||||
if _, err := Open(f.Sealed[0].Box, f.Recipient.PublicKey); err == nil {
|
||||
t.Fatal("opened with a public key")
|
||||
}
|
||||
if _, err := Seal("x", f.Recipient.PrivateKey); err == nil {
|
||||
t.Fatal("sealed to a private key")
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"about": "Made by the claude-licence-manager's seal.go (mesh-catalog feat/57-config-and-provisioning at 8aa37e8), unchanged but for its two sources of randomness, which the vectors fix. The recipient is a test key derived from a fixed label; it guards nothing.",
|
||||
"recipient": {
|
||||
"publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VuAyEAkZXnghyfA1BR5rWtvBLmjdZMCbC99xxF3WKHe1E+3XM=\n-----END PUBLIC KEY-----\n",
|
||||
"privateKey": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VuBCIEIHjV954xXPa72FyGUw5PsWCbsHrliVp/7xoAgG9fGs7q\n-----END PRIVATE KEY-----\n"
|
||||
},
|
||||
"sealed": [
|
||||
{
|
||||
"plaintext": "",
|
||||
"box": {
|
||||
"v": 1,
|
||||
"eph": "MCowBQYDK2VuAyEAwN2gt32hv+9VyFh0oQ8oetPBHsZ9dt80DzwPwcB0bHg=",
|
||||
"iv": "0xHCegxP6o48EGLq",
|
||||
"tag": "VYQZm/lBZB0YMPUqYVPKaQ==",
|
||||
"ct": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"plaintext": "a grant sealed by the licence manager",
|
||||
"box": {
|
||||
"v": 1,
|
||||
"eph": "MCowBQYDK2VuAyEA54v96LEb8SwT2uJZCa90/2jcLvEbK/BZpbMS6TxAAW0=",
|
||||
"iv": "xm6Jg4oOnRpRpDGK",
|
||||
"tag": "cmbOzIV63Nv0lPfpTgP2kg==",
|
||||
"ct": "InWe8PZ8hkd7F9QaHujjhdiBECKzLAK22d2aIEXwBhtxIowc/Q=="
|
||||
}
|
||||
},
|
||||
{
|
||||
"plaintext": "sk-ant-api03-test-0123456789abcdefghij\n",
|
||||
"box": {
|
||||
"v": 1,
|
||||
"eph": "MCowBQYDK2VuAyEAIXdum2FYLzvLDj/Of2253hH8Lu0szU4tLxJzfXTahDs=",
|
||||
"iv": "KMPNa60C/Qjhtp0Z",
|
||||
"tag": "PPa82ZdFyWz9kbLSSGkl3g==",
|
||||
"ct": "Phu3ZsxIWGlBWbYOGzPopncEbc0VhTAloxzeHs4KDBMFZU9Pet2V"
|
||||
}
|
||||
},
|
||||
{
|
||||
"plaintext": "ünïcødé ✓ and a NUL \u0000 inside",
|
||||
"box": {
|
||||
"v": 1,
|
||||
"eph": "MCowBQYDK2VuAyEAo1WaRyrAJFM6If/cpFgdpZjsC6XebuJtVSugbI+m8RU=",
|
||||
"iv": "H9+WvM6ZYbCYJj6D",
|
||||
"tag": "K5p986nSpeItYK1m4bcVPQ==",
|
||||
"ct": "eyZs3uVeZguNbqSmrMc1rWgxhW3nP0GABH6Ek06/8UuBqw=="
|
||||
}
|
||||
},
|
||||
{
|
||||
"plaintext": "a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; ",
|
||||
"box": {
|
||||
"v": 1,
|
||||
"eph": "MCowBQYDK2VuAyEAzjZdTMu08I0Z+FWXSr9f++EADfqg3qr2FVt+3Q/hxm0=",
|
||||
"iv": "lTG2VwPA5SwHbZE/",
|
||||
"tag": "Il6ICxoZgZr9dOgeIZOPrg==",
|
||||
"ct": "ZWQ8m1nxrqq0oIGRoeNlUu9/JpTMrxlncTCL5cDqsJrx9NjS0nZX292JJ5p0+BYSVLUN+yGUuBOnH860ywvKK7XhPOc0Yo2nq7X2O3OYn9Za4y+HK6dVDM8zhsS8P3Di/MJCwS+AnremVCaj2k3xHL3oc331RBZZ5w2JfgSX3ouwl2+VbIPyJVSDsxnLLoHsiIR7nB0GNCe0fynhVkoIEwC8IXLnL6m3rjjN3YCQDmVP2UoftniLAypd5ioc9GCtpvUL6ktjs82JkvEtFbHtc+YzIjP6ZnjTj/bvbRHkIqN4MDiu+fvGOr0jtGWO24ecGC+6KtrKGOaEYHDqOLaK3c+UcrDsFYC+cuKCdCNhs5UNhYYylVXE648KFa488eFk"
|
||||
}
|
||||
}
|
||||
],
|
||||
"vectors": [
|
||||
{
|
||||
"plaintext": "",
|
||||
"ephPrivate": "W8iT0SrRDnazANHYsXHC72sM1wzx+/Y8vowREeGGz/A=",
|
||||
"iv": "DD7WV6Y8hLGvaG0b",
|
||||
"box": {
|
||||
"v": 1,
|
||||
"eph": "MCowBQYDK2VuAyEAdCCqlFhxbKBpv1JClXceH43AqEvcaM6SGfyXx05UJUs=",
|
||||
"iv": "DD7WV6Y8hLGvaG0b",
|
||||
"tag": "ctb+ZQjx8FyynvT88rfwkQ==",
|
||||
"ct": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"plaintext": "a grant sealed by the licence manager",
|
||||
"ephPrivate": "lzIbkYmIbkKTYDL4dZiAFUFv2h+GWBiqlQfnsVD2An8=",
|
||||
"iv": "xz2FIBXNM3scOAJb",
|
||||
"box": {
|
||||
"v": 1,
|
||||
"eph": "MCowBQYDK2VuAyEAS3L49p4KrGMo/NmHZq8osoPbk2ZCQWLoNIKnpB4T5hg=",
|
||||
"iv": "xz2FIBXNM3scOAJb",
|
||||
"tag": "7dC8bYqzsJ/17EC7y6lhCA==",
|
||||
"ct": "FEMfh4HMvMwqx4QE4YMMR3d3g5rNuxa/+u6u+i/aRLed3q8kBw=="
|
||||
}
|
||||
},
|
||||
{
|
||||
"plaintext": "sk-ant-api03-test-0123456789abcdefghij\n",
|
||||
"ephPrivate": "tAjRQTQHk6nNJLblzdslDbWmyYm8cE6/G4ipXHR0zlE=",
|
||||
"iv": "BPF1JDQ5mDNhKKr3",
|
||||
"box": {
|
||||
"v": 1,
|
||||
"eph": "MCowBQYDK2VuAyEA1GianODxSXUHYMdUmZLcQztp5ry2/xtwUSeHZWrGFAo=",
|
||||
"iv": "BPF1JDQ5mDNhKKr3",
|
||||
"tag": "uiIIFD7LgPmLV3Y6uleSzg==",
|
||||
"ct": "+maa0GDDOkcyzD0chFQ4a/0NvBrvl23NVqcGv1BEKgmMUenf9HHd"
|
||||
}
|
||||
},
|
||||
{
|
||||
"plaintext": "ünïcødé ✓ and a NUL \u0000 inside",
|
||||
"ephPrivate": "QBYUxuk4jwiWKOPEWeE7v6WxY8BDmZZCDwlY1WJ1PSA=",
|
||||
"iv": "u76JO8baqokfnjcK",
|
||||
"box": {
|
||||
"v": 1,
|
||||
"eph": "MCowBQYDK2VuAyEAePakOoCmmXLtnN3EykbpqfdbfXpkg04F2djxnJWH220=",
|
||||
"iv": "u76JO8baqokfnjcK",
|
||||
"tag": "sfBPRnwhfP/p7vlsUx+e9w==",
|
||||
"ct": "B9yc0FbdKYDtDeX1jmKa+DBP8fPN1O9SbeYBDn9b3+Bq2Q=="
|
||||
}
|
||||
},
|
||||
{
|
||||
"plaintext": "a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; ",
|
||||
"ephPrivate": "2VYOyzqLrb+zWUhVpHA9aV3vMAyRWlNPEq2ASf60F9M=",
|
||||
"iv": "0Gv6ejq07LwbM8cY",
|
||||
"box": {
|
||||
"v": 1,
|
||||
"eph": "MCowBQYDK2VuAyEARSOBczrJ6X1MkxT7Mrd1ryDLmXAMBgYIgpfwBjHz1GA=",
|
||||
"iv": "0Gv6ejq07LwbM8cY",
|
||||
"tag": "Vm+E0myXgdMwA6knJDg/+w==",
|
||||
"ct": "tJwZpUXeQYOu2PG1g9AwIV8RhKKDorUHWQSygEus3xHOpuTwh3+D4j2htIerMB/3AKToQYABeZMyEAOzhL2x+Cg06WlUKrggZjvdX5W7X813KQBgzg0YnnCZTKTYlviDWe1E9TZ3aAauhlcgpBaQXUh/akkDiqeq2Ua5ruY6IGH5HgqWWPYJo3b4zkbArhvdP2HJX9Pn1mQa0BtGMinyutyD0am5vZuemdKGYS1Ao00M81sMXYc2CBy9K48Ofw8GapcmYNHsE0s6NSgNZumnVaR8TQRy/oqEdL3Q6z31hAZMzTdS0ckXg1NCgcgXVuzwNov/D4320E3DxFS3v8Z5p74ZkqAmo/1JcCNJwwQyRCWk/4iJFjP1OKEavOGo+bcp"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"privateKey": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VuBCIEIAi2NK/bN+p7cqYUwv/kz72TgLdmJUfOHCDZTrMpQzpS\n-----END PRIVATE KEY-----\n",
|
||||
"publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VuAyEARYvD/w+9ah0KWS9T9pd6Ea6CBymUE48vEVk983QPKyY=\n-----END PUBLIC KEY-----\n",
|
||||
"box": {
|
||||
"v": 1,
|
||||
"eph": "MCowBQYDK2VuAyEAIYlSGrJvF8qSjR1aTFWbEQM/NFbZXrarglN0aRLZZ0E=",
|
||||
"iv": "ABqT/hMukn6+xpjh",
|
||||
"tag": "POzmsWTPHSn9xLMMJJ9Akg==",
|
||||
"ct": "m2u0kuQ0PwrsGelM99Z5aBw6FCd6lFISUbwiK5TzzDw4ZrGtsHEvxytoIeFC"
|
||||
},
|
||||
"plaintext": "a grant sealed by the TypeScript agent module"
|
||||
}
|
||||
+26
-1
@@ -66,6 +66,8 @@ type channel struct {
|
||||
subscribed int
|
||||
// watches are this bundle's state watches by the id each was asked with (ADR 0201).
|
||||
watches map[string]func(StateChange) error
|
||||
// provisioned are this bundle's watches of its values, by the id each was asked with (provision.go).
|
||||
provisioned map[string]func(ProvisionChange) error
|
||||
// takers and provers are this bundle's seat work and proof handlers, by worker and subject (ADR 0259).
|
||||
takers map[string]func(Work) error
|
||||
provers map[string]func(subject string, body json.RawMessage) (any, error)
|
||||
@@ -169,7 +171,8 @@ func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error {
|
||||
byName[t.Name] = t
|
||||
}
|
||||
c := &channel{enc: json.NewEncoder(out), waiting: map[string]chan message{}, watches: map[string]func(StateChange) error{},
|
||||
takers: map[string]func(Work) error{}, provers: map[string]func(string, json.RawMessage) (any, error){}}
|
||||
provisioned: map[string]func(ProvisionChange) error{},
|
||||
takers: map[string]func(Work) error{}, provers: map[string]func(string, json.RawMessage) (any, error){}}
|
||||
currentMu.Lock()
|
||||
current = c
|
||||
currentMu.Unlock()
|
||||
@@ -297,6 +300,28 @@ func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error {
|
||||
}
|
||||
c.say(map[string]any{"id": id, "result": map[string]any{}})
|
||||
}(id, *p.Change)
|
||||
case "mesh/provision":
|
||||
// One change to a value of this module's that this bundle watches (provision.go), named by the
|
||||
// watch's id, answered once its handler has taken it. Beside the loop, like a tool call.
|
||||
var p struct {
|
||||
Watch string `json:"watch"`
|
||||
Change *ProvisionChange `json:"change"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Params, &p)
|
||||
c.mu.Lock()
|
||||
handle := c.provisioned[p.Watch]
|
||||
c.mu.Unlock()
|
||||
if handle == nil || p.Change == nil {
|
||||
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32602, "message": "mesh/provision names no watch of this bundle"}})
|
||||
continue
|
||||
}
|
||||
go func(id any, change ProvisionChange) {
|
||||
if err := handle(change); err != nil {
|
||||
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}})
|
||||
return
|
||||
}
|
||||
c.say(map[string]any{"id": id, "result": map[string]any{}})
|
||||
}(id, *p.Change)
|
||||
case "mesh/work":
|
||||
// One piece of a seat's work this bundle takes (novox/hq ADR 0259): taken by an answer that is
|
||||
// not an error, offered again after one. Beside the loop, like a tool call.
|
||||
|
||||
+4
-2
@@ -6,13 +6,15 @@
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./contracts": "./dist/contracts/index.js",
|
||||
"./provisioner": "./dist/provisioner/index.js",
|
||||
"./provider-loop": "./dist/provider-loop/index.js",
|
||||
"./provisioner": "./dist/provider-loop/index.js",
|
||||
"./tools": "./dist/tools/index.js",
|
||||
"./messaging": "./dist/messaging/index.js",
|
||||
"./events": "./dist/events/index.js",
|
||||
"./primitives": "./dist/primitives/index.js",
|
||||
"./stdio": "./dist/stdio/index.js",
|
||||
"./state": "./dist/state/index.js"
|
||||
"./state": "./dist/state/index.js",
|
||||
"./seal": "./dist/seal/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
+5
-2
@@ -1,13 +1,16 @@
|
||||
// @novox/mesh-sdk — the stable spine a module's own code builds against (novox/hq ADR 0039).
|
||||
//
|
||||
// Import the area you need directly (`@novox/mesh-sdk/tools`, `/provisioner`, …) or the whole
|
||||
// Import the area you need directly (`@novox/mesh-sdk/tools`, `/provider-loop`, …) or the whole
|
||||
// surface from here. Everything specific to one module — its API client, its tool implementations,
|
||||
// its create-a-resource adapter — lives in the module, never here.
|
||||
|
||||
export * from "./contracts/index.js";
|
||||
export * as tools from "./tools/index.js";
|
||||
export * as provisioner from "./provisioner/index.js";
|
||||
export * as providerLoop from "./provider-loop/index.js";
|
||||
/** @deprecated The provider loop's name before design 57 (novox/hq issue 678): `providerLoop`. */
|
||||
export * as provisioner from "./provider-loop/index.js";
|
||||
export * as messaging from "./messaging/index.js";
|
||||
export * as events from "./events/index.js";
|
||||
export * as primitives from "./primitives/index.js";
|
||||
export * as state from "./state/index.js";
|
||||
export * as seal from "./seal/index.js";
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
// The provider loop: the reconcile harness every provider on the controller's grant files shares.
|
||||
// Called `provisioner` until design 57 (novox/hq issue 678): that word is mesh-provisioner's, which
|
||||
// places every value a module needs (ADR 0349), so the loop is named for what it is. The old path
|
||||
// `@novox/mesh-sdk/provisioner` and the names `runProvisioner`, `provisionerTools`, `Provisioner` and
|
||||
// `ProvisionerOptions` stay as aliases of these until the providers that import them move to the Go
|
||||
// helper that answers consumers over the bus (mesh-sdk go/provider, design 57 B2 and B3) and this loop
|
||||
// retires with the grant files. The events (`provisioner.failing`, `.recovered`, `.retirement`) and the
|
||||
// tool names (`provisioner_retirement`, …) are what the controller and the operator read on the bus,
|
||||
// and keep their names.
|
||||
//
|
||||
// The reconcile harness every provider shares. The loop below is identical for postgres, redis,
|
||||
// minio and umami: read the contributions the mesh delivered, bring each consumer's resource into
|
||||
// being through the provider's adapter under the login and password the mesh minted, and retire
|
||||
@@ -140,9 +150,9 @@ export interface Act {
|
||||
readonly via?: string;
|
||||
}
|
||||
|
||||
/** The running provisioner: a stop function, as before, that also answers and takes the person's
|
||||
/** The running provider loop: a stop function, as before, that also answers and takes the person's
|
||||
* acts on retirement (novox/hq ADR 0230). */
|
||||
export type Provisioner = (() => void) & {
|
||||
export type ProviderLoop = (() => void) & {
|
||||
retirement(): Promise<Retirement>;
|
||||
approve(consumers: readonly string[], act: Act): Promise<{ retired: string[] }>;
|
||||
reject(consumers: readonly string[], act: Act): Promise<{ kept: string[] }>;
|
||||
@@ -167,7 +177,7 @@ export const CLASS_REFUSED = "refused";
|
||||
/** How long a consumer fails without one success before it is announced failing (ADR 0224). */
|
||||
export const FAILING_AFTER_MS = 5 * 60_000;
|
||||
|
||||
export interface ProvisionerOptions {
|
||||
export interface ProviderLoopOptions {
|
||||
/** The contributions file the mesh writes for this resource (the module's `receives` path).
|
||||
* Defaults to $MESH_RECEIVES. */
|
||||
receives?: string;
|
||||
@@ -250,7 +260,7 @@ interface Contribution {
|
||||
* `retireFraction` of those held where more than one is held, is not retired without a person: the
|
||||
* provider waits for approve or reject. RETIRED → DELETED only through `provisioner_delete`.
|
||||
*/
|
||||
export function runProvisioner(resource: string, adapter: Adapter, opts: ProvisionerOptions = {}): Provisioner {
|
||||
export function runProviderLoop(resource: string, adapter: Adapter, opts: ProviderLoopOptions = {}): ProviderLoop {
|
||||
const receives = opts.receives ?? envOrThrow("MESH_RECEIVES");
|
||||
const everyMs = opts.everyMs ?? 5000;
|
||||
const verifyEveryMs = opts.verifyEveryMs ?? 60_000;
|
||||
@@ -757,8 +767,8 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
};
|
||||
const handle: Provisioner = Object.assign(stop, { retirement, approve, reject, deleteRetired });
|
||||
if (opts.module) registerModuleTools(opts.module, () => provisionerTools(handle));
|
||||
const handle: ProviderLoop = Object.assign(stop, { retirement, approve, reject, deleteRetired });
|
||||
if (opts.module) registerModuleTools(opts.module, () => providerLoopTools(handle));
|
||||
return handle;
|
||||
}
|
||||
|
||||
@@ -767,7 +777,7 @@ const text = (v: unknown): string => (typeof v === "string" ? v : "");
|
||||
|
||||
/** The four tools every provider serves for retirement (novox/hq ADR 0230), the controller's `retire`
|
||||
* and `cleanup` verbs asking them per machine. */
|
||||
export function provisionerTools(p: Provisioner): ToolDefinition[] {
|
||||
export function providerLoopTools(p: ProviderLoop): ToolDefinition[] {
|
||||
const act = { type: "string" };
|
||||
return [
|
||||
{
|
||||
@@ -900,3 +910,12 @@ function envOrThrow(name: string): string {
|
||||
if (!v) throw new Error(`${name} is not set — the provisioner cannot run without it`);
|
||||
return v;
|
||||
}
|
||||
|
||||
/** @deprecated The provider loop's name before design 57: {@link runProviderLoop}. */
|
||||
export const runProvisioner = runProviderLoop;
|
||||
/** @deprecated {@link providerLoopTools}. */
|
||||
export const provisionerTools = providerLoopTools;
|
||||
/** @deprecated {@link ProviderLoop}. */
|
||||
export type Provisioner = ProviderLoop;
|
||||
/** @deprecated {@link ProviderLoopOptions}. */
|
||||
export type ProvisionerOptions = ProviderLoopOptions;
|
||||
@@ -0,0 +1,90 @@
|
||||
// The sealed box itself (novox/hq ADR 0183, ADR 0206), with its two sources of randomness as arguments so
|
||||
// the tests can prove it byte for byte against the Go package's vectors. Modules import `./index.js`.
|
||||
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
diffieHellman,
|
||||
hkdfSync,
|
||||
type KeyObject,
|
||||
} from "node:crypto";
|
||||
|
||||
/** A value sealed to one recipient, as it travels in JSON; every field but `v` is standard base64. */
|
||||
export interface SealedBox {
|
||||
readonly v: number;
|
||||
/** The one-time public key, SPKI DER. */
|
||||
readonly eph: string;
|
||||
readonly iv: string;
|
||||
readonly tag: string;
|
||||
readonly ct: string;
|
||||
}
|
||||
|
||||
/** The only box version `open` opens. */
|
||||
export const VERSION = 1;
|
||||
|
||||
const INFO = "novox-mesh sealed box v1";
|
||||
const IV_BYTES = 12;
|
||||
const TAG_BYTES = 16;
|
||||
|
||||
function x25519(key: KeyObject, what: string): KeyObject {
|
||||
if (key.asymmetricKeyType !== "x25519") throw new Error(`not an X25519 ${what} key`);
|
||||
return key;
|
||||
}
|
||||
|
||||
export function publicFromPem(pem: string): KeyObject {
|
||||
return x25519(createPublicKey({ key: pem, format: "pem" }), "public");
|
||||
}
|
||||
|
||||
export function privateFromPem(pem: string): KeyObject {
|
||||
return x25519(createPrivateKey({ key: pem, format: "pem" }), "private");
|
||||
}
|
||||
|
||||
/** The recipient's raw 32-byte public key: the last 32 bytes of its SPKI DER. */
|
||||
function raw(pub: KeyObject): Buffer {
|
||||
const der = pub.export({ format: "der", type: "spki" });
|
||||
return der.subarray(der.length - 32);
|
||||
}
|
||||
|
||||
function boxKey(secret: Buffer, ephDer: Buffer, recipientRaw: Buffer): Buffer {
|
||||
return Buffer.from(hkdfSync("sha256", secret, Buffer.concat([ephDer, recipientRaw]), INFO, 32));
|
||||
}
|
||||
|
||||
/** `seal` with the one-time private key and the iv given. */
|
||||
export function sealWith(plaintext: string, recipientPem: string, eph: KeyObject, iv: Buffer): SealedBox {
|
||||
const recipient = publicFromPem(recipientPem);
|
||||
x25519(eph, "one-time");
|
||||
if (iv.length !== IV_BYTES) throw new Error(`an iv of ${iv.length} bytes, not ${IV_BYTES}`);
|
||||
const secret = diffieHellman({ privateKey: eph, publicKey: recipient });
|
||||
const ephDer = createPublicKey(eph).export({ format: "der", type: "spki" });
|
||||
const cipher = createCipheriv("aes-256-gcm", boxKey(secret, ephDer, raw(recipient)), iv);
|
||||
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
return {
|
||||
v: VERSION,
|
||||
eph: ephDer.toString("base64"),
|
||||
iv: iv.toString("base64"),
|
||||
tag: cipher.getAuthTag().toString("base64"),
|
||||
ct: ct.toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Open a box with the recipient's PEM private key; throws for a box to another key, changed, or of another version. */
|
||||
export function open(box: SealedBox, privatePem: string): string {
|
||||
if (box.v !== VERSION) throw new Error(`a sealed box of version ${box.v}, not ${VERSION}`);
|
||||
const priv = privateFromPem(privatePem);
|
||||
const ephDer = Buffer.from(box.eph, "base64");
|
||||
const eph = x25519(createPublicKey({ key: ephDer, format: "der", type: "spki" }), "one-time");
|
||||
const iv = Buffer.from(box.iv, "base64");
|
||||
const tag = Buffer.from(box.tag, "base64");
|
||||
// node accepts a shorter tag than 16 bytes; the box never has one, so a short one is refused here.
|
||||
if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) throw new Error("the box does not open with this key");
|
||||
const secret = diffieHellman({ privateKey: priv, publicKey: eph });
|
||||
const decipher = createDecipheriv("aes-256-gcm", boxKey(secret, ephDer, raw(createPublicKey(priv))), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
try {
|
||||
return Buffer.concat([decipher.update(Buffer.from(box.ct, "base64")), decipher.final()]).toString("utf8");
|
||||
} catch {
|
||||
throw new Error("the box does not open with this key");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Sealing to one recipient (novox/hq ADR 0183, ADR 0206): X25519 for the agreement, HKDF-SHA256 for the
|
||||
// key, AES-256-GCM for the box. The TypeScript twin of the Go package `go/seal`, byte for byte: the same
|
||||
// fixtures prove both. A box is `{ v: 1, eph, iv, tag, ct }`; keys travel as PEM (SPKI public, PKCS #8
|
||||
// private).
|
||||
|
||||
import { generateKeyPairSync, randomBytes } from "node:crypto";
|
||||
import { sealWith, open, VERSION, type SealedBox } from "./box.js";
|
||||
|
||||
export { open, VERSION, type SealedBox };
|
||||
|
||||
/** A recipient's keypair as the two PEM strings it is kept and sent as. */
|
||||
export interface KeyPair {
|
||||
readonly publicKey: string;
|
||||
readonly privateKey: string;
|
||||
}
|
||||
|
||||
/** A fresh X25519 keypair, PEM-encoded. */
|
||||
export function generateKeyPair(): KeyPair {
|
||||
const { publicKey, privateKey } = generateKeyPairSync("x25519");
|
||||
return {
|
||||
publicKey: publicKey.export({ format: "pem", type: "spki" }) as string,
|
||||
privateKey: privateKey.export({ format: "pem", type: "pkcs8" }) as string,
|
||||
};
|
||||
}
|
||||
|
||||
/** Seal plaintext to the recipient's PEM public key, with a fresh one-time key and iv. */
|
||||
export function seal(plaintext: string, recipientPem: string): SealedBox {
|
||||
return sealWith(plaintext, recipientPem, generateKeyPairSync("x25519").privateKey, randomBytes(12));
|
||||
}
|
||||
+6
-6
@@ -8,7 +8,7 @@ import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { runProvisioner, provisionerTools, RETIREMENT_EVENT, type Provisioner } from "../dist/provisioner/index.js";
|
||||
import { runProviderLoop, providerLoopTools, RETIREMENT_EVENT, type ProviderLoop } from "../dist/provider-loop/index.js";
|
||||
import { resetTools, collectTools } from "../dist/tools/index.js";
|
||||
|
||||
const EVERY = 10;
|
||||
@@ -32,7 +32,7 @@ interface World {
|
||||
reenabled: string[];
|
||||
events: Record<string, unknown>[];
|
||||
said: string[];
|
||||
p: Provisioner;
|
||||
p: ProviderLoop;
|
||||
done(): void;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ async function world(
|
||||
...(opts.retire === false ? {} : { async retire(p: { as: string; why: string }) { w.retired.push({ as: p.as, why: p.why }); } }),
|
||||
...(opts.held ? { async held() { return opts.held!; } } : {}),
|
||||
};
|
||||
w.p = runProvisioner("postgres-database", adapter, {
|
||||
w.p = runProviderLoop("postgres-database", adapter, {
|
||||
receives, everyMs: EVERY, node: "anchor", stableForMs: opts.stableForMs ?? 0,
|
||||
// Only retirement is these tests'; the standing (provisioner.failing, .recovered) is standing.test.ts's.
|
||||
announce: (event, body) => { if (event === RETIREMENT_EVENT) w.events.push(body); },
|
||||
@@ -168,7 +168,7 @@ test("five passes are not enough: the same set must also hold for stableForMs",
|
||||
});
|
||||
|
||||
test("the default hold is ten minutes", async () => {
|
||||
const { STABLE_FOR_MS } = await import("../dist/provisioner/index.js");
|
||||
const { STABLE_FOR_MS } = await import("../dist/provider-loop/index.js");
|
||||
assert.equal(STABLE_FOR_MS, 10 * 60_000);
|
||||
});
|
||||
|
||||
@@ -327,7 +327,7 @@ test("the four tools are registered for the module and answer", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "retire-tools-"));
|
||||
const receives = join(dir, "r.json");
|
||||
await writeFile(receives, JSON.stringify({ requirement: "r", given: [] }));
|
||||
const p = runProvisioner("r", { async create() {}, async remove() {} }, { receives, everyMs: EVERY, module: "postgres" });
|
||||
const p = runProviderLoop("r", { async create() {}, async remove() {} }, { receives, everyMs: EVERY, module: "postgres" });
|
||||
try {
|
||||
const mine = collectTools({}).find((c) => c.module === "postgres");
|
||||
assert.deepEqual(mine?.tools.map((t) => t.name), [
|
||||
@@ -336,7 +336,7 @@ test("the four tools are registered for the module and answer", async () => {
|
||||
const answer = (await mine!.tools[0].run({})) as { resource: string; waiting: unknown };
|
||||
assert.equal(answer.resource, "r");
|
||||
assert.equal(answer.waiting, null);
|
||||
assert.equal(provisionerTools(p).length, 4);
|
||||
assert.equal(providerLoopTools(p).length, 4);
|
||||
} finally {
|
||||
p();
|
||||
resetTools();
|
||||
|
||||
+26
-13
@@ -6,7 +6,7 @@ import { join } from "node:path";
|
||||
|
||||
import { compareVersions } from "../dist/primitives/index.js";
|
||||
import { registerModuleTools, collectTools, collectToolsEach, resetTools, serveTools, listTools, invokeTool } from "../dist/tools/index.js";
|
||||
import { runProvisioner } from "../dist/provisioner/index.js";
|
||||
import { runProviderLoop } from "../dist/provider-loop/index.js";
|
||||
import { emit, on, type Event } from "../dist/events/index.js";
|
||||
import { useBroker } from "../dist/messaging/index.js";
|
||||
|
||||
@@ -47,7 +47,7 @@ test("provisioner creates each consumer with the mesh's login and password; remo
|
||||
doc([{ from: "webapp", node: "anchor", as: "webapp-anchor", secret: join(dir, "webapp.secret"), values: { name: "webapp" } }]),
|
||||
);
|
||||
|
||||
const stop = runProvisioner(
|
||||
const stop = runProviderLoop(
|
||||
"analytics",
|
||||
{
|
||||
async create(p) {
|
||||
@@ -101,7 +101,7 @@ test("provisioner hands the adapter what the mesh derived, and still has it at w
|
||||
});
|
||||
await writeFile(receives, doc([entry("mesh-anchor-ncloud")]));
|
||||
|
||||
const stop = runProvisioner(
|
||||
const stop = runProviderLoop(
|
||||
"s3-bucket",
|
||||
{
|
||||
async create(p) {
|
||||
@@ -153,7 +153,7 @@ test("provisioner stops calling an unreadable secret a race, and says what to lo
|
||||
console.error = (line: unknown) => { said.push(String(line)); };
|
||||
let stop: (() => void) | undefined;
|
||||
try {
|
||||
stop = runProvisioner("db", { async create() {}, async remove() {} }, { receives, everyMs: 5 });
|
||||
stop = runProviderLoop("db", { async create() {}, async remove() {} }, { receives, everyMs: 5 });
|
||||
await waitFor(() => said.some((l) => l.includes("CANNOT READ")), 3000);
|
||||
} finally {
|
||||
stop?.();
|
||||
@@ -185,7 +185,7 @@ test("provisioner removes nothing while its contributions file cannot be used (n
|
||||
]);
|
||||
await writeFile(receives, both);
|
||||
|
||||
const stop = runProvisioner(
|
||||
const stop = runProviderLoop(
|
||||
"postgres-database",
|
||||
{
|
||||
async create(p) {
|
||||
@@ -239,7 +239,7 @@ test("provisioner applies again what the backend no longer holds, and trusts mem
|
||||
const backend = new Set<string>();
|
||||
let creates = 0;
|
||||
let asked = 0;
|
||||
const stop = runProvisioner(
|
||||
const stop = runProviderLoop(
|
||||
"cache",
|
||||
{
|
||||
async create(p) {
|
||||
@@ -271,7 +271,7 @@ test("provisioner applies again what the backend no longer holds, and trusts mem
|
||||
// An adapter without holds is trusted from memory, as before: a forgotten backend stays forgotten.
|
||||
const forgetful = new Set<string>();
|
||||
let plainCreates = 0;
|
||||
const stopPlain = runProvisioner(
|
||||
const stopPlain = runProviderLoop(
|
||||
"cache",
|
||||
{
|
||||
async create(p) {
|
||||
@@ -299,7 +299,7 @@ test("provisioner keeps a consumer applied when the backend cannot be asked", as
|
||||
);
|
||||
let creates = 0;
|
||||
let asked = 0;
|
||||
const stop = runProvisioner(
|
||||
const stop = runProviderLoop(
|
||||
"cache",
|
||||
{
|
||||
async create() {
|
||||
@@ -330,7 +330,7 @@ test("provisioner backs off when create does not satisfy holds, and says so", as
|
||||
const original = console.error;
|
||||
console.error = (...a: unknown[]) => logged.push(a.join(" "));
|
||||
try {
|
||||
const stop = runProvisioner(
|
||||
const stop = runProviderLoop(
|
||||
"cache",
|
||||
{
|
||||
async create() {
|
||||
@@ -371,7 +371,7 @@ test("provisioner treats a failed or hung holds as could-not-ask, and never logs
|
||||
const original = console.error;
|
||||
console.error = (...a: unknown[]) => logged.push(a.join(" "));
|
||||
try {
|
||||
const stop = runProvisioner(
|
||||
const stop = runProviderLoop(
|
||||
"cache",
|
||||
{
|
||||
async create() {
|
||||
@@ -404,7 +404,7 @@ test("provisioner treats a failed or hung holds as could-not-ask, and never logs
|
||||
const hungLogged: string[] = [];
|
||||
console.error = (...a: unknown[]) => hungLogged.push(a.join(" "));
|
||||
try {
|
||||
const stopHung = runProvisioner(
|
||||
const stopHung = runProviderLoop(
|
||||
"cache",
|
||||
{
|
||||
async create() {
|
||||
@@ -438,7 +438,7 @@ test("provisioner stops a verify pass at a timeout, and retries a failed re-appl
|
||||
try {
|
||||
// A timeout on "a" means "b" is not asked in that pass.
|
||||
const askedFor: string[] = [];
|
||||
const stopHung = runProvisioner(
|
||||
const stopHung = runProviderLoop(
|
||||
"cache",
|
||||
{
|
||||
async create() {},
|
||||
@@ -460,7 +460,7 @@ test("provisioner stops a verify pass at a timeout, and retries a failed re-appl
|
||||
let createCalls = 0;
|
||||
const logged: string[] = [];
|
||||
console.error = (...a: unknown[]) => logged.push(a.join(" "));
|
||||
const stop = runProvisioner(
|
||||
const stop = runProviderLoop(
|
||||
"cache",
|
||||
{
|
||||
async create(p) {
|
||||
@@ -649,3 +649,16 @@ test("each registration is given the environment asked for it, and sees no other
|
||||
assert.deepEqual(seen.beta, { BETA_URL: "http://127.0.0.1:3000" });
|
||||
resetTools();
|
||||
});
|
||||
|
||||
test("the provider loop is named for what it is, and its old name stays an alias until its providers move (issue 678)", async () => {
|
||||
const loop = await import("../dist/provider-loop/index.js");
|
||||
const old = await import("../dist/index.js");
|
||||
assert.equal(old.provisioner.runProvisioner, loop.runProviderLoop);
|
||||
assert.equal(old.provisioner.provisionerTools, loop.providerLoopTools);
|
||||
assert.equal(old.providerLoop.runProviderLoop, loop.runProviderLoop);
|
||||
const pkg = JSON.parse(await (await import("node:fs/promises")).readFile(new URL("../package.json", import.meta.url), "utf8"));
|
||||
assert.equal(pkg.exports["./provider-loop"], "./dist/provider-loop/index.js");
|
||||
assert.equal(pkg.exports["./provisioner"], pkg.exports["./provider-loop"]);
|
||||
// The events and tools are what the controller and the operator read on the bus: their names stay.
|
||||
assert.equal(loop.FAILING_EVENT, "provisioner.failing");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createPrivateKey } from "node:crypto";
|
||||
|
||||
import { seal, open, generateKeyPair, type SealedBox } from "../dist/seal/index.js";
|
||||
import { sealWith } from "../dist/seal/box.js";
|
||||
|
||||
// The fixtures the Go package proves itself against: boxes the licence manager's own seal.go made, and
|
||||
// the one the agent module's TypeScript made. One format, three implementations.
|
||||
const fixtures = new URL("../go/seal/testdata/", import.meta.url);
|
||||
const lm = JSON.parse(readFileSync(new URL("sealed-by-licence-manager.json", fixtures), "utf8")) as {
|
||||
recipient: { publicKey: string; privateKey: string };
|
||||
sealed: { plaintext: string; box: SealedBox }[];
|
||||
vectors: { plaintext: string; ephPrivate: string; iv: string; box: SealedBox }[];
|
||||
};
|
||||
|
||||
// PKCS #8 for an X25519 private key is this prefix and the 32 raw bytes.
|
||||
const pkcs8 = (raw: Buffer) =>
|
||||
createPrivateKey({ key: Buffer.concat([Buffer.from("302e020100300506032b656e04220420", "hex"), raw]), format: "der", type: "pkcs8" });
|
||||
|
||||
test("seal: every box the licence manager sealed opens to its plaintext", () => {
|
||||
assert.ok(lm.sealed.length > 0);
|
||||
for (const s of lm.sealed) assert.equal(open(s.box, lm.recipient.privateKey), s.plaintext);
|
||||
});
|
||||
|
||||
test("seal: given the same one-time key and iv, the box is byte for byte the licence manager's", () => {
|
||||
assert.ok(lm.vectors.length > 0);
|
||||
for (const v of lm.vectors) {
|
||||
const box = sealWith(v.plaintext, lm.recipient.publicKey, pkcs8(Buffer.from(v.ephPrivate, "base64")), Buffer.from(v.iv, "base64"));
|
||||
assert.deepEqual(box, v.box);
|
||||
assert.equal(open(box, lm.recipient.privateKey), v.plaintext);
|
||||
}
|
||||
});
|
||||
|
||||
test("seal: the agent module's TypeScript box opens", () => {
|
||||
const f = JSON.parse(readFileSync(new URL("sealed-by-typescript.json", fixtures), "utf8"));
|
||||
assert.equal(open(f.box, f.privateKey), f.plaintext);
|
||||
});
|
||||
|
||||
test("seal: a box opens for its recipient only, and two boxes share no one-time key or iv", () => {
|
||||
const a = generateKeyPair();
|
||||
const b = generateKeyPair();
|
||||
const box = seal("a token", a.publicKey);
|
||||
assert.equal(open(box, a.privateKey), "a token");
|
||||
assert.throws(() => open(box, b.privateKey));
|
||||
const again = seal("a token", a.publicKey);
|
||||
assert.notEqual(again.eph, box.eph);
|
||||
assert.notEqual(again.iv, box.iv);
|
||||
});
|
||||
|
||||
test("seal: a box changed in any field, or of another version, does not open", () => {
|
||||
const good = lm.sealed[1].box;
|
||||
const flip = (s: string) => {
|
||||
const b = Buffer.from(s, "base64");
|
||||
b[b.length - 1] ^= 1;
|
||||
return b.toString("base64");
|
||||
};
|
||||
for (const box of [
|
||||
{ ...good, ct: flip(good.ct) },
|
||||
{ ...good, tag: flip(good.tag) },
|
||||
{ ...good, iv: flip(good.iv) },
|
||||
{ ...good, eph: flip(good.eph) },
|
||||
{ ...good, v: 2 },
|
||||
]) assert.throws(() => open(box as SealedBox, lm.recipient.privateKey));
|
||||
});
|
||||
|
||||
test("seal: a key that is not X25519 PEM is refused", () => {
|
||||
assert.throws(() => seal("x", "not a key"));
|
||||
assert.throws(() => open(lm.sealed[0].box, lm.recipient.publicKey));
|
||||
});
|
||||
@@ -9,9 +9,9 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
runProvisioner, classOf, FAILING_EVENT, RECOVERED_EVENT, RETIREMENT_EVENT,
|
||||
runProviderLoop, classOf, FAILING_EVENT, RECOVERED_EVENT, RETIREMENT_EVENT,
|
||||
CLASS_CREDENTIALS, CLASS_UNREACHABLE, CLASS_SECRET, CLASS_REFUSED, type Adapter,
|
||||
} from "../dist/provisioner/index.js";
|
||||
} from "../dist/provider-loop/index.js";
|
||||
import { useBroker } from "../dist/messaging/index.js";
|
||||
|
||||
const EVERY = 10;
|
||||
@@ -68,7 +68,7 @@ async function world(opts: { classify?: boolean; announce?: false; stableForMs?:
|
||||
};
|
||||
const was = console.error;
|
||||
console.error = () => {};
|
||||
const stop = runProvisioner("postgres-database", adapter, {
|
||||
const stop = runProviderLoop("postgres-database", adapter, {
|
||||
receives, everyMs: EVERY, verifyEveryMs: 0, node: "home-server",
|
||||
failingAfterMs: FAILING_AFTER, sayAgainEveryMs: SAY_AGAIN, stableForMs: opts.stableForMs ?? 0,
|
||||
...(opts.announce === false ? {} : {
|
||||
|
||||
Reference in New Issue
Block a user