Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c20b9fb9b |
@@ -0,0 +1 @@
|
||||
example
|
||||
@@ -0,0 +1,8 @@
|
||||
CFLAGS ?= -O2 -Wall -Wextra
|
||||
JANSSON := $(shell pkg-config --cflags --libs jansson)
|
||||
|
||||
example: example.c mesh_sdk.c mesh_sdk.h
|
||||
$(CC) $(CFLAGS) -o $@ example.c mesh_sdk.c $(JANSSON)
|
||||
|
||||
clean:
|
||||
rm -f example
|
||||
@@ -0,0 +1,9 @@
|
||||
# mesh-sdk for C
|
||||
|
||||
The skeleton (novox/hq ADR 0188): `mesh_serve(name, tools, count)` speaks MCP over stdio to the
|
||||
node's tool runtime. A `mesh_tool` is a name, a description, an argument schema and a function
|
||||
returning JSON. A tool named `<seat>.<verb>` is the seat's implementation. JSON is jansson's, the
|
||||
one dependency.
|
||||
|
||||
What it holds, under ADR 0039's test: the stdio loop and the tool type. Nothing else — no bus, no
|
||||
client, nothing of any module's. `make` and pipe a `tools/list` request into `./example`.
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/* A tools bundle in C: one tool of its own and one seat verb, served over stdio. */
|
||||
#include "mesh_sdk.h"
|
||||
#include <stdio.h>
|
||||
|
||||
static json_t *greet(json_t *args, char **error) {
|
||||
(void)error;
|
||||
const char *who = json_string_value(json_object_get(args, "who"));
|
||||
char greeting[128];
|
||||
snprintf(greeting, sizeof greeting, "hello %s", who ? who : "world");
|
||||
return json_pack("{s:s,s:s}", "greeting", greeting, "language", "c");
|
||||
}
|
||||
|
||||
static json_t *on(json_t *args, char **error) {
|
||||
(void)args; (void)error;
|
||||
return json_pack("{s:b,s:s}", "on", 1, "language", "c");
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
json_t *who = json_pack("{s:{s:s,s:s}}", "who", "type", "string", "description", "whom");
|
||||
const mesh_tool tools[] = {
|
||||
{"greet", "say hello", who, greet},
|
||||
{"node-lamp.on", "the seat's verb", NULL, on},
|
||||
};
|
||||
int rc = mesh_serve("c-example", tools, 2);
|
||||
json_decref(who);
|
||||
return rc;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#include "mesh_sdk.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static void say(json_t *m) {
|
||||
json_object_set_new(m, "jsonrpc", json_string("2.0"));
|
||||
char *text = json_dumps(m, JSON_COMPACT);
|
||||
fprintf(stdout, "%s\n", text);
|
||||
fflush(stdout);
|
||||
free(text);
|
||||
json_decref(m);
|
||||
}
|
||||
|
||||
static json_t *as_schema(json_t *given) {
|
||||
if (!given || !json_is_object(given) || json_object_size(given) == 0)
|
||||
return json_pack("{s:s,s:{}}", "type", "object", "properties");
|
||||
if (json_object_get(given, "type") || json_object_get(given, "properties"))
|
||||
return json_incref(given);
|
||||
return json_pack("{s:s,s:O}", "type", "object", "properties", given);
|
||||
}
|
||||
|
||||
int mesh_serve(const char *name, const mesh_tool *tools, size_t count) {
|
||||
char *line = NULL;
|
||||
size_t cap = 0;
|
||||
ssize_t got;
|
||||
while ((got = getline(&line, &cap, stdin)) >= 0) {
|
||||
json_error_t err;
|
||||
json_t *req = json_loads(line, 0, &err);
|
||||
if (!req) continue;
|
||||
json_t *id = json_object_get(req, "id");
|
||||
const char *method = json_string_value(json_object_get(req, "method"));
|
||||
json_t *params = json_object_get(req, "params");
|
||||
if (!method) { json_decref(req); continue; }
|
||||
if (strcmp(method, "initialize") == 0) {
|
||||
say(json_pack("{s:O,s:{s:s,s:{s:{}},s:{s:s,s:s}}}", "id", id ? id : json_null(), "result",
|
||||
"protocolVersion", MESH_SDK_PROTOCOL, "capabilities", "tools",
|
||||
"serverInfo", "name", name, "version", "1"));
|
||||
} else if (strcmp(method, "notifications/initialized") == 0) {
|
||||
} else if (strcmp(method, "ping") == 0) {
|
||||
if (id) say(json_pack("{s:O,s:{}}", "id", id, "result"));
|
||||
} else if (strcmp(method, "tools/list") == 0) {
|
||||
json_t *listed = json_array();
|
||||
for (size_t i = 0; i < count; i++)
|
||||
json_array_append_new(listed, json_pack("{s:s,s:s,s:o}", "name", tools[i].name,
|
||||
"description", tools[i].description, "inputSchema", as_schema(tools[i].input)));
|
||||
say(json_pack("{s:O,s:{s:o}}", "id", id ? id : json_null(), "result", "tools", listed));
|
||||
} else if (strcmp(method, "tools/call") == 0) {
|
||||
const char *wanted = params ? json_string_value(json_object_get(params, "name")) : NULL;
|
||||
const mesh_tool *tool = NULL;
|
||||
for (size_t i = 0; wanted && i < count; i++)
|
||||
if (strcmp(tools[i].name, wanted) == 0) tool = &tools[i];
|
||||
if (!tool) {
|
||||
char message[256];
|
||||
snprintf(message, sizeof message, "%s has no tool %s", name, wanted ? wanted : "");
|
||||
say(json_pack("{s:O,s:{s:i,s:s}}", "id", id ? id : json_null(), "error", "code", -32602, "message", message));
|
||||
} else {
|
||||
json_t *args = params ? json_object_get(params, "arguments") : NULL;
|
||||
json_t *empty = args ? NULL : json_object();
|
||||
char *error = NULL;
|
||||
json_t *result = tool->run(args ? args : empty, &error);
|
||||
if (empty) json_decref(empty);
|
||||
if (result) {
|
||||
char *text = json_dumps(result, JSON_COMPACT);
|
||||
say(json_pack("{s:O,s:{s:[{s:s,s:s}]}}", "id", id ? id : json_null(), "result", "content", "type", "text", "text", text));
|
||||
free(text);
|
||||
json_decref(result);
|
||||
} else {
|
||||
say(json_pack("{s:O,s:{s:[{s:s,s:s}],s:b}}", "id", id ? id : json_null(), "result", "content",
|
||||
"type", "text", "text", error ? error : "the tool failed", "isError", 1));
|
||||
free(error);
|
||||
}
|
||||
}
|
||||
} else if (id) {
|
||||
char message[256];
|
||||
snprintf(message, sizeof message, "no %s", method);
|
||||
say(json_pack("{s:O,s:{s:i,s:s}}", "id", id, "error", "code", -32601, "message", message));
|
||||
}
|
||||
json_decref(req);
|
||||
}
|
||||
free(line);
|
||||
return ferror(stdin) ? -1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* The Novox Mesh SDK for C — the skeleton (novox/hq ADR 0188).
|
||||
*
|
||||
* A tools bundle is a process the node's runtime launches and speaks MCP over stdio to:
|
||||
* `initialize`, `tools/list` once, `tools/call` per call, newline-framed JSON-RPC on stdin and
|
||||
* stdout. This is the loop, so a bundle writes its tools and nothing else. A tool named
|
||||
* `<seat>.<verb>` is the module's implementation of that seat's verb; any other name is the
|
||||
* module's own. JSON is jansson's.
|
||||
*
|
||||
* No transport here: the bus, the subjects and the memberships are the runtime's (ADR 0039, 0175).
|
||||
* stdout is the protocol; log to stderr. */
|
||||
#ifndef MESH_SDK_H
|
||||
#define MESH_SDK_H
|
||||
#include <jansson.h>
|
||||
|
||||
#define MESH_SDK_PROTOCOL "2025-03-26"
|
||||
|
||||
/* One tool. `run` returns a new JSON value (the answer) or NULL with *error set to a message the
|
||||
* caller frees. `input` is the schema of the arguments, or NULL for a tool that takes nothing. */
|
||||
typedef struct {
|
||||
const char *name;
|
||||
const char *description;
|
||||
json_t *input;
|
||||
json_t *(*run)(json_t *args, char **error);
|
||||
} mesh_tool;
|
||||
|
||||
/* Serve these tools on stdin/stdout until stdin closes. Returns 0, or -1 on an I/O fault. */
|
||||
int mesh_serve(const char *name, const mesh_tool *tools, size_t count);
|
||||
|
||||
#endif
|
||||
+1
-3
@@ -16,6 +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; 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. Nothing else — no bus, no client, nothing of any module's.
|
||||
|
||||
From release 4a of design 47 (novox/hq ADR 0453 clause 3, ADR 0446 clause 4) it also holds the composition of a machine's declaration, over a plain input and with nothing behind it: `declaration` and `validate`, the node-engine's parser and its validator, copied from mesh-host; `catalogue`, the manifests, their resolution and rendering, and `overlay`, the private network's generator, copied from mesh-controller; `compose`, the controller's planFor, renderingFor and declarationWith as functions of an `Input` (the composition never allocates, makes, keeps or writes: what a send would do it names as foreseen or returns); `sdkversion`, which mesh-sdk version a program was built with; and, in `delivery`, the layouts of the keys the builder, the checker and the indexer write (the source fingerprint's new form, the builds, the verdicts and kept verdicts, the manifests by digest). None of them holds a bus, a store or a vault client, which `go list -deps` of `compose` shows.
|
||||
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.
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
// Package announce is how a module says on the bus which states it keeps and which of them others may read (novox/hq
|
||||
// ADR 0495 clauses 2 and 3; issue 1790): the subject it says it on, the value it says, and where the controller keeps
|
||||
// the last one said by each module. What a module announced is the truth of what it keeps; a reader is granted a read
|
||||
// from it, never from a list or from the owner's manifest.
|
||||
//
|
||||
// A module announces from its code (process.Conn.Keeps, stdio.Keeps, the TypeScript SDK's keeps), never from its
|
||||
// manifest, which only the core reads (clause 1). A module that keeps no state on the bus announces nothing.
|
||||
package announce
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Version is the version of the value this package writes.
|
||||
const Version = 1
|
||||
|
||||
// Event is the event type of an announcement, under the module's own namespace.
|
||||
const Event = "states"
|
||||
|
||||
// Subject is where module announces what it keeps: its own event subject, mesh.mod.<module>.event.states, which its
|
||||
// account may already publish to and the events stream keeps.
|
||||
func Subject(module string) string { return "mesh.mod." + module + ".event." + Event }
|
||||
|
||||
// SubjectAll is every module's announcement.
|
||||
const SubjectAll = "mesh.mod.*.event." + Event
|
||||
|
||||
var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
// Key is module's key where the last announcement of each module is kept: the module itself, which must be one plain name, since a module's name is part of
|
||||
// its buckets' names (the indexer's judge, novox/hq ADR 0578 §1).
|
||||
func Key(module string) (string, error) {
|
||||
if !name.MatchString(module) {
|
||||
return "", fmt.Errorf("announce: %q is not a module that keeps state: lower-case letters, digits and hyphens", module)
|
||||
}
|
||||
return module, nil
|
||||
}
|
||||
|
||||
// State is one state a module keeps, by its local name, and whether others may read it. Others never write it: a
|
||||
// state another module writes is the owner's own code's business.
|
||||
type State struct {
|
||||
Name string `json:"name"`
|
||||
Readable bool `json:"readable,omitempty"`
|
||||
// Readers, when named, are the only modules that may read the state (novox/hq ADR 0547 clause 9, issue 1945): a
|
||||
// state readable by some and not all, such as the vault's bus account hashes. Only ReadableBy grants them; Readable,
|
||||
// which a composer that knows no readers asks, stays false for it, so such a composer grants nobody.
|
||||
Readers []string `json:"readers,omitempty"`
|
||||
}
|
||||
|
||||
// States is an announcement: the module, the machine whose instance said it, and every state the module keeps, sorted
|
||||
// by name. An empty list says the module keeps nothing.
|
||||
type States struct {
|
||||
V int `json:"v"`
|
||||
Module string `json:"module"`
|
||||
Node string `json:"node,omitempty"`
|
||||
States []State `json:"states"`
|
||||
}
|
||||
|
||||
// Keeps says whether the module announced keeping the state.
|
||||
func (s States) Keeps(state string) bool {
|
||||
for _, k := range s.States {
|
||||
if k.Name == state {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ReadableBy says whether the module announced the state readable by reader: readable, and reader among the readers
|
||||
// when it names any.
|
||||
func (s States) ReadableBy(state, reader string) bool {
|
||||
for _, k := range s.States {
|
||||
if k.Name != state {
|
||||
continue
|
||||
}
|
||||
if len(k.Readers) > 0 {
|
||||
for _, r := range k.Readers {
|
||||
if r == reader {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return k.Readable
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Readable says whether the module announced the state readable by others.
|
||||
func (s States) Readable(state string) bool {
|
||||
for _, k := range s.States {
|
||||
if k.Name == state {
|
||||
return k.Readable
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *States) check() error {
|
||||
if s.Module == "" {
|
||||
return fmt.Errorf("announce: an announcement names no module")
|
||||
}
|
||||
if _, err := Key(s.Module); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, k := range s.States {
|
||||
if !name.MatchString(k.Name) {
|
||||
return fmt.Errorf("announce: %s announces the state %q, and a state is named locally: lower-case letters, digits and hyphens", s.Module, k.Name)
|
||||
}
|
||||
if seen[k.Name] {
|
||||
return fmt.Errorf("announce: %s announces the state %q twice", s.Module, k.Name)
|
||||
}
|
||||
seen[k.Name] = true
|
||||
for _, r := range k.Readers {
|
||||
if !name.MatchString(r) {
|
||||
return fmt.Errorf("announce: %s names %q as a reader of %s, and a reader is a module: lower-case letters, digits and hyphens", s.Module, r, k.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Encode is s as JSON at Version, its states sorted; it refuses a module or a state no bucket could be named by and a
|
||||
// state announced twice, naming it.
|
||||
func Encode(s States) ([]byte, error) {
|
||||
if err := s.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.V = Version
|
||||
s.States = append([]State{}, s.States...)
|
||||
sort.Slice(s.States, func(i, j int) bool { return s.States[i].Name < s.States[j].Name })
|
||||
return json.Marshal(s)
|
||||
}
|
||||
|
||||
// Decode reads an announcement; it refuses another version and what Encode refuses.
|
||||
func Decode(data []byte) (States, error) {
|
||||
var s States
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return States{}, fmt.Errorf("announce: the announcement is not JSON: %w", err)
|
||||
}
|
||||
if s.V != Version {
|
||||
return States{}, fmt.Errorf("announce: the announcement is version %d, and this reads version %d", s.V, Version)
|
||||
}
|
||||
if err := s.check(); err != nil {
|
||||
return States{}, err
|
||||
}
|
||||
sort.Slice(s.States, func(i, j int) bool { return s.States[i].Name < s.States[j].Name })
|
||||
return s, nil
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package announce
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module says on the bus which states it keeps and which of them others may read (novox/hq ADR 0495 clause 2), on its
|
||||
// own event subject, in a versioned value the controller keeps by module.
|
||||
func TestAnAnnouncementRoundTripsSortedAndSaysWhatIsReadable(t *testing.T) {
|
||||
data, err := Encode(States{Module: "mesh-controller", Node: "novox", States: []State{
|
||||
{Name: "plans-building", Readable: true}, {Name: "assignments", Readable: true}, {Name: "own"},
|
||||
{Name: "hashes", Readers: []string{"mesh-grants"}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := Decode(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.V != Version || back.Module != "mesh-controller" || back.Node != "novox" {
|
||||
t.Fatalf("decoded %+v", back)
|
||||
}
|
||||
var names []string
|
||||
for _, s := range back.States {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if strings.Join(names, ",") != "assignments,hashes,own,plans-building" {
|
||||
t.Fatalf("the states are not sorted: %v", names)
|
||||
}
|
||||
if !back.Readable("plans-building") || back.Readable("own") || back.Readable("absent") {
|
||||
t.Fatal("Readable does not say what was announced readable")
|
||||
}
|
||||
// Named readers (ADR 0547 clause 9): readable by them alone, and not readable to a composer that knows no readers.
|
||||
if back.Readable("hashes") || !back.ReadableBy("hashes", "mesh-grants") || back.ReadableBy("hashes", "spy") ||
|
||||
!back.ReadableBy("plans-building", "spy") || back.ReadableBy("own", "spy") {
|
||||
t.Fatal("ReadableBy does not say who was named")
|
||||
}
|
||||
if !back.Keeps("own") || back.Keeps("absent") {
|
||||
t.Fatal("Keeps does not say what was announced")
|
||||
}
|
||||
if Subject("mesh-controller") != "mesh.mod.mesh-controller.event.states" || SubjectAll != "mesh.mod.*.event.states" {
|
||||
t.Fatalf("the subjects are %q and %q", Subject("mesh-controller"), SubjectAll)
|
||||
}
|
||||
if k, err := Key("mesh-delivery-builder"); err != nil || k != "mesh-delivery-builder" {
|
||||
t.Fatalf("Key = %q, %v", k, err)
|
||||
}
|
||||
if nothing, err := Encode(States{Module: "quiet"}); err != nil || !strings.Contains(string(nothing), `"states":[]`) {
|
||||
t.Fatalf("a module keeping nothing: %s, %v", nothing, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAnnouncementRefusesWhatNoBucketCouldBe(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
s States
|
||||
says string
|
||||
}{
|
||||
{States{Module: ""}, "no module"},
|
||||
{States{Module: "a.b"}, `"a.b"`},
|
||||
{States{Module: "a", States: []State{{Name: "x_y"}}}, `"x_y"`},
|
||||
{States{Module: "a", States: []State{{Name: ""}}}, `""`},
|
||||
{States{Module: "a", States: []State{{Name: "x"}, {Name: "x", Readable: true}}}, "twice"},
|
||||
{States{Module: "a", States: []State{{Name: "x", Readers: []string{"b.c"}}}}, `"b.c"`},
|
||||
} {
|
||||
if _, err := Encode(c.s); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%+v: %v, wanted a refusal saying %s", c.s, err, c.says)
|
||||
}
|
||||
}
|
||||
if _, err := Decode([]byte(`{"v":2,"module":"a","states":[]}`)); err == nil {
|
||||
t.Error("an unknown version was read")
|
||||
}
|
||||
if _, err := Key("a.b"); err == nil {
|
||||
t.Error("a module with a dot became a key")
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// Package announcetest is the test each module runs with its own checks to hold that it announces on the bus every
|
||||
// state it keeps (novox/hq ADR 0495 clause 5; issue 1790): it starts the module against a bus of the test's own and
|
||||
// compares what the module announced with what its code declares. With the bind guard of process.Conn.Keeps,
|
||||
// stdio.Keeps and the TypeScript keeps, which refuses a state the code did not announce, announcement and code agree.
|
||||
//
|
||||
// It replaces the merge gate's check of one module's reads against another's manifest (clause 5); a test using it
|
||||
// carries `//go:build integration`, since it starts a bus.
|
||||
package announcetest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/testbus"
|
||||
)
|
||||
|
||||
// Wait is how long Check waits for the module's announcement once it started it.
|
||||
var Wait = 10 * time.Second
|
||||
|
||||
// Check starts a bus of the test's own, with a stream taking module's events, calls start with its address (start
|
||||
// runs the module, or its part that connects, against it, and answers how to stop it), waits for the module's
|
||||
// announcement and fails the test unless it is module's and names exactly want (in any order), readable as want says.
|
||||
// It answers the announcement.
|
||||
func Check(t testing.TB, module string, want []announce.State, start func(url string) (stop func())) announce.States {
|
||||
t.Helper()
|
||||
url := testbus.URL(t)
|
||||
nc, err := nats.Connect(url)
|
||||
if err != nil {
|
||||
t.Fatalf("announcetest: connecting to the test bus: %v", err)
|
||||
}
|
||||
defer nc.Close()
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), Wait)
|
||||
defer cancel()
|
||||
stream, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "ANNOUNCETEST", Subjects: []string{"mesh.mod." + module + ".event.>"}})
|
||||
if err != nil {
|
||||
t.Fatalf("announcetest: making the events stream: %v", err)
|
||||
}
|
||||
stop := start(url)
|
||||
if stop != nil {
|
||||
defer stop()
|
||||
}
|
||||
got, err := waitFor(ctx, stream, module)
|
||||
if err != nil {
|
||||
t.Fatalf("announcetest: %v", err)
|
||||
}
|
||||
if problem := differs(got, module, want); problem != "" {
|
||||
t.Fatalf("announcetest: %s", problem)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// waitFor reads the stream until module's announcement is in it.
|
||||
func waitFor(ctx context.Context, stream jetstream.Stream, module string) (announce.States, error) {
|
||||
for {
|
||||
msg, err := stream.GetLastMsgForSubject(ctx, announce.Subject(module))
|
||||
if err == nil {
|
||||
return announce.Decode(msg.Data)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return announce.States{}, fmt.Errorf("%s announced nothing on %s within %s", module, announce.Subject(module), Wait)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// differs says how got is not module's announcement of want, or "".
|
||||
func differs(got announce.States, module string, want []announce.State) string {
|
||||
if got.Module != module {
|
||||
return fmt.Sprintf("the announcement names the module %q, not %q", got.Module, module)
|
||||
}
|
||||
w := append([]announce.State{}, want...)
|
||||
sort.Slice(w, func(i, j int) bool { return w[i].Name < w[j].Name })
|
||||
g := append([]announce.State{}, got.States...)
|
||||
if !reflect.DeepEqual(g, w) && !(len(g) == 0 && len(w) == 0) {
|
||||
return fmt.Sprintf("%s announced %v, and its code declares %v", module, g, w)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package announcetest
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
)
|
||||
|
||||
// 1790/S11: a module started against the test bus announces through process.Conn.Keeps, and Check reads that
|
||||
// announcement back against what the code declares.
|
||||
func TestCheckReadsTheModulesAnnouncementOffARealBus(t *testing.T) {
|
||||
declared := []announce.State{{Name: "accounts", Readable: true}, {Name: "drafts"}}
|
||||
got := Check(t, "mesh-mail", declared, func(url string) func() {
|
||||
conn, err := process.Connect(process.Account{URL: url, Module: "mesh-mail", Node: "novox", User: "u", Password: "p"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := conn.Keeps(declared...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return conn.Close
|
||||
})
|
||||
if got.Node != "novox" {
|
||||
t.Fatalf("announced from %q", got.Node)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package announcetest
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
)
|
||||
|
||||
// The comparison is exact up to order: a state missing, one extra, a readability that differs or another module's
|
||||
// announcement each fail, naming what differs.
|
||||
func TestAnAnnouncementDiffersFromTheCodeByNameAndReadability(t *testing.T) {
|
||||
got := announce.States{Module: "m", States: []announce.State{{Name: "a", Readable: true}, {Name: "b"}}}
|
||||
if p := differs(got, "m", []announce.State{{Name: "b"}, {Name: "a", Readable: true}}); p != "" {
|
||||
t.Fatalf("an equal announcement differs: %s", p)
|
||||
}
|
||||
for _, want := range [][]announce.State{
|
||||
{{Name: "a", Readable: true}},
|
||||
{{Name: "a", Readable: true}, {Name: "b"}, {Name: "c"}},
|
||||
{{Name: "a"}, {Name: "b"}},
|
||||
} {
|
||||
if p := differs(got, "m", want); p == "" || !strings.Contains(p, "declares") {
|
||||
t.Errorf("want %v: %q", want, p)
|
||||
}
|
||||
}
|
||||
if p := differs(got, "other", nil); !strings.Contains(p, `"other"`) {
|
||||
t.Errorf("another module's announcement: %q", p)
|
||||
}
|
||||
if p := differs(announce.States{Module: "m"}, "m", nil); p != "" {
|
||||
t.Errorf("a module keeping nothing: %q", p)
|
||||
}
|
||||
}
|
||||
@@ -571,8 +571,3 @@ type ProofAnswer struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
Words string `json:"words"`
|
||||
}
|
||||
|
||||
// EngineAsker is the asker name a node's engine asks the operator under (novox/hq ADR 0563 §3, which changes ADR 0259
|
||||
// §2 to admit a node's engine as an asker): `node-engine-<node>`, the last token of the by-caller subjects granted to that
|
||||
// node's engine account alone, so no engine acts on another node's warrant.
|
||||
func EngineAsker(node string) string { return "node-engine-" + node }
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package buildstep
|
||||
|
||||
// CapacitySetting is the build modules' setting of the node's build capacity (novox/hq ADR 0525 §5, ADR 0567 §12): how
|
||||
// many asks the node takes at once, an integer config value set on each node's layer of mesh-config. No repository names
|
||||
// a node: each build module declares it with the default 0, which pulls nothing, and the controller (or mesh-config)
|
||||
// lays the node's value into the module's settings file.
|
||||
const CapacitySetting = "capacity"
|
||||
@@ -1,5 +0,0 @@
|
||||
// Package buildstep is the contract of a build step every delivery part reads (novox/hq ADR 0593 §1): a step's id, the
|
||||
// fetch request, a step a previous process left running and its end, a failure's words, a module tree's shape, the
|
||||
// capacity setting and the launchers' names. The runner the build modules share is the Go build module's own package
|
||||
// (mesh-catalog modules/mesh-build-go/buildstep), which imports this one.
|
||||
package buildstep
|
||||
@@ -1,141 +0,0 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/sdkversion"
|
||||
)
|
||||
|
||||
// Failure is one test that failed, or a package or file that failed with no test to name (a build that failed, a test
|
||||
// program that panicked, a test file that did not load). The JSON names are the ones mesh-build-go and the checker have
|
||||
// always used; File is new and an older reader ignores it.
|
||||
type Failure struct {
|
||||
// Package is the Go package's import path, or the TypeScript package's directory in the workspace.
|
||||
Package string `json:"package"`
|
||||
// Test names the failing test, or the tests that were running when the program stopped; "" when none can be named.
|
||||
Test string `json:"test,omitempty"`
|
||||
// File is where the failing test is (a Go test's file and line is in its Cause; node names the test's file:line:col).
|
||||
File string `json:"file,omitempty"`
|
||||
// Cause is the one line that says why: the assertion's or the error's first line, a "panic:" line, a build's first
|
||||
// error, or words saying how the program stopped. **Never empty, and never a passing line** (PassingLine).
|
||||
Cause string `json:"cause,omitempty"`
|
||||
// Output is the failing test's own output, its last lines up to OutputBytes, Cause first: an older checker reads
|
||||
// only its first line.
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
// OutputBytes is the most a failure keeps of its output, and LineBytes the most of one line: four kilobytes holds a long
|
||||
// assertion message and stays far under a bus message's or a verdict's bound.
|
||||
const (
|
||||
OutputBytes = 4096
|
||||
LineBytes = 1024
|
||||
)
|
||||
|
||||
// By names a build module and the SDK it was built with, for a result's "by": the checker names it when a failure hides
|
||||
// its cause ("the build module hid the failure (<by>)").
|
||||
func By(module string) string {
|
||||
return module + " with mesh-sdk " + sdkversion.Version()
|
||||
}
|
||||
|
||||
// PassingLine says a line is a test runner's word for a test or a package that passed: node's '✔', TAP's "ok N" (at any
|
||||
// indent), go test's "ok \t<pkg>", "PASS" and "--- PASS". A line of a test's own that starts "ok " is none of these. Such a line is never a failure's cause, nor part of a failure's output.
|
||||
func PassingLine(l string) bool {
|
||||
if strings.HasPrefix(l, "ok \t") { // go test's package line, at column 0
|
||||
return true
|
||||
}
|
||||
t := strings.TrimSpace(l)
|
||||
switch {
|
||||
case t == "PASS", strings.HasPrefix(t, "✔"), strings.HasPrefix(t, "--- PASS"):
|
||||
return true
|
||||
}
|
||||
return tapOk(t)
|
||||
}
|
||||
|
||||
// Hidden says how a failure hides why it failed, or "" when it does not: it has no cause, or its cause is a passing line.
|
||||
// The checker says it loudly (issue 1901): a build module that sends such a failure does not keep the contract.
|
||||
func Hidden(f Failure) string {
|
||||
c := strings.TrimSpace(f.Cause)
|
||||
switch {
|
||||
case c == "":
|
||||
return "it sent no cause"
|
||||
case PassingLine(c):
|
||||
return fmt.Sprintf("its cause is a passing line (%q)", CutLine(c))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Clip joins the last lines of a failure's output that fit OutputBytes, each cut to LineBytes, and says how many earlier
|
||||
// lines it left out. **Its first line is the cause**, put ahead when the kept lines do not start with it, or, with no
|
||||
// cause and lines left out, the first non-empty line, then the marker: an older checker reads only the first line.
|
||||
// Passing lines are dropped.
|
||||
func Clip(lines []string, cause string) string {
|
||||
own := make([]string, 0, len(lines))
|
||||
for _, l := range lines {
|
||||
if !PassingLine(l) {
|
||||
own = append(own, l)
|
||||
}
|
||||
}
|
||||
lines = own
|
||||
size, from := 0, len(lines)
|
||||
for from > 0 {
|
||||
n := len(CutLine(lines[from-1])) + 1
|
||||
if size+n > OutputBytes && from < len(lines) {
|
||||
break
|
||||
}
|
||||
size += n
|
||||
from--
|
||||
}
|
||||
kept := make([]string, 0, len(lines)-from+2)
|
||||
head := strings.TrimSpace(cause)
|
||||
for i := 0; head == "" && i < from; i++ {
|
||||
head = strings.TrimSpace(lines[i])
|
||||
}
|
||||
if head != "" && (from > 0 || len(lines) == 0 || strings.TrimSpace(lines[from]) != head) {
|
||||
kept = append(kept, CutLine(head))
|
||||
}
|
||||
if from > 0 {
|
||||
kept = append(kept, fmt.Sprintf("... (%d earlier lines)", from))
|
||||
}
|
||||
for _, l := range lines[from:] {
|
||||
kept = append(kept, CutLine(l))
|
||||
}
|
||||
return strings.Join(kept, "\n")
|
||||
}
|
||||
|
||||
// CutLine cuts a line to LineBytes, on a character boundary.
|
||||
func CutLine(l string) string {
|
||||
if len(l) <= LineBytes {
|
||||
return l
|
||||
}
|
||||
return strings.ToValidUTF8(l[:LineBytes], "") + "..."
|
||||
}
|
||||
|
||||
// ConsoleFailure is the failure of a command that is no test runner (go vet, gofmt, tsc, a build script): its cause is the
|
||||
// first line that names an error (tsc's "error TS", go vet's "file.go:12:3: ..."), else the first line that is not a
|
||||
// heading ("# <package>", npm's "> script"), and its output the command's lines without passing ones.
|
||||
func ConsoleFailure(pkg, test, out string) Failure {
|
||||
var lines []string
|
||||
for _, l := range strings.Split(strings.ReplaceAll(stripANSI(out), "\r\n", "\n"), "\n") {
|
||||
if t := strings.TrimSpace(l); t != "" && !PassingLine(t) {
|
||||
lines = append(lines, strings.TrimRight(l, " \t"))
|
||||
}
|
||||
}
|
||||
cause := ""
|
||||
for _, l := range lines {
|
||||
if t := strings.TrimSpace(l); strings.Contains(t, "error") || strings.Contains(t, "Error") || goPosition.MatchString(t) {
|
||||
cause = t
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, l := range lines {
|
||||
if t := strings.TrimSpace(l); cause == "" && !strings.HasPrefix(t, "# ") && !strings.HasPrefix(t, "> ") && !strings.HasPrefix(t, "npm ") {
|
||||
cause = t
|
||||
}
|
||||
}
|
||||
if cause == "" {
|
||||
cause = test + " failed and wrote nothing that says why"
|
||||
}
|
||||
cause = CutLine(cause)
|
||||
return Failure{Package: pkg, Test: test, Cause: cause, Output: Clip(lines, cause)}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// goPosition matches a line that starts with a Go file's position, as go vet and the compiler write an error.
|
||||
var goPosition = regexp.MustCompile(`^\S+\.go:\d+(:\d+)?: `)
|
||||
|
||||
var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m")
|
||||
|
||||
func stripANSI(s string) string { return ansiEscape.ReplaceAllString(s, "") }
|
||||
|
||||
// tapOk says a line is a passing test's "ok N - name", indented or not.
|
||||
func tapOk(l string) bool {
|
||||
rest, ok := strings.CutPrefix(strings.TrimSpace(l), "ok ")
|
||||
return ok && rest != "" && rest[0] >= '0' && rest[0] <= '9'
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package buildstep
|
||||
|
||||
import "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
|
||||
// LauncherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is started as
|
||||
// index.serve.mjs (novox/hq ADR 0193), an ES module by its own extension whatever the bundle's package.json says.
|
||||
const LauncherSuffix = catalogue.LauncherSuffix
|
||||
|
||||
// Launchers are the launchers a bundle's build writes, by entrypoint, relative to the bundle's root
|
||||
// (catalogue.Launchers): the one rule mesh-build-node, build-agent, the controller and a node taking in a build share
|
||||
// (R4e, issue 1126), so the name a build writes is the name a reader expects.
|
||||
func Launchers(language string, entrypoints []string) map[string]string {
|
||||
return catalogue.Launchers(language, entrypoints)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A TypeScript bundle's entrypoint x.js is started as x.serve.mjs beside it (novox/hq ADR 0193), the rule mesh-build-node
|
||||
// and build-agent both apply; an entrypoint that is not .js gets none, and another language gets none.
|
||||
func TestATypeScriptEntrypointIsStartedByTheLauncherBesideIt(t *testing.T) {
|
||||
got := Launchers("typescript", []string{"index.js", "tools/x.js", "data.json"})
|
||||
want := map[string]string{"index.js": "index.serve.mjs", "tools/x.js": "tools/x.serve.mjs"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Launchers(typescript) = %v, want %v", got, want)
|
||||
}
|
||||
if got := Launchers("go", []string{"index.js"}); len(got) != 0 {
|
||||
t.Fatalf("a Go bundle got launchers: %v", got)
|
||||
}
|
||||
if got := Launchers("typescript", nil); len(got) != 0 {
|
||||
t.Fatalf("a bundle with no entrypoint got launchers: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
var (
|
||||
requestCommitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
requestRepositoryOK = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||
)
|
||||
|
||||
// FetchRequest is what a build module's fetch tool is asked: the repository and the full commit, the pull request's
|
||||
// ref when only its head carries the commit, the trunk commit to merge onto for a merge preview, and, for a build of a
|
||||
// module whose every artifact names its paths, that module's directory and its named paths (novox/hq ADR 0499 clause 4).
|
||||
//
|
||||
// **The paths come from the builder, not from a manifest** (ADR 0495 clause 1; the lead's decision L8 of R4e): the
|
||||
// builder fills Dir and Paths from the indexer's Module value (Path, and KeptInputs2.Paths' keys), and the build module
|
||||
// gives the build and test steps a workspace holding those paths, the manifest and its lock files. A request without
|
||||
// them builds from the module's directory, as before.
|
||||
type FetchRequest struct {
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
Base string `json:"base,omitempty"`
|
||||
Dir string `json:"dir,omitempty"`
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
// Packages are the version of each mesh package the build or the check builds against (delivery.StepAsk.Packages,
|
||||
// novox/hq ADR 0573 §2, §4): the build module writes each into the workspace's go.mod and go.sum, whatever version
|
||||
// the repository names, checksum-verified, and names them in its log.
|
||||
Packages delivery.PackageVersions `json:"packages,omitempty"`
|
||||
}
|
||||
|
||||
// FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input.
|
||||
func FetchRequestProperties() map[string]any {
|
||||
return map[string]any{
|
||||
"dir": map[string]any{"type": "string", "description": "the module's directory in the repository (\".\" for its root), with paths"},
|
||||
"paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
"description": "the paths the module names, relative to dir, from the indexer's kept inputs: the build sees only these, the manifest and its lock files"},
|
||||
"packages": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"},
|
||||
"description": "optional: the version (a full commit) of each mesh package to build against, by its module path, whatever version go.mod names (novox/hq ADR 0573 §2); the builder and the checker fill it"},
|
||||
}
|
||||
}
|
||||
|
||||
// Args is r as a tool call's arguments.
|
||||
func (r FetchRequest) Args() map[string]any {
|
||||
args := map[string]any{"repository": r.Repository, "commit": r.Commit}
|
||||
for k, v := range map[string]string{"ref": r.Ref, "base": r.Base, "dir": r.Dir} {
|
||||
if v != "" {
|
||||
args[k] = v
|
||||
}
|
||||
}
|
||||
if len(r.Paths) > 0 {
|
||||
args["paths"] = append([]string(nil), r.Paths...)
|
||||
}
|
||||
if len(r.Packages) > 0 {
|
||||
args["packages"] = map[string]any{}
|
||||
for p, v := range r.Packages {
|
||||
args["packages"].(map[string]any)[p] = v
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// ParseFetchRequest reads a fetch tool's arguments; arguments it does not know (a build module's own) are left to the
|
||||
// caller. It refuses a repository that is not <owner>/<name>, a commit or a base that is not 40 hex digits, paths with no
|
||||
// module directory or a directory with no path, a directory leaving the repository and a path leaving the directory,
|
||||
// naming each.
|
||||
func ParseFetchRequest(args map[string]any) (FetchRequest, error) {
|
||||
var r FetchRequest
|
||||
r.Repository, _ = args["repository"].(string)
|
||||
r.Commit, _ = args["commit"].(string)
|
||||
r.Ref, _ = args["ref"].(string)
|
||||
r.Base, _ = args["base"].(string)
|
||||
r.Dir, _ = args["dir"].(string)
|
||||
switch v := args["paths"].(type) {
|
||||
case nil:
|
||||
case []string:
|
||||
r.Paths = append([]string(nil), v...)
|
||||
case []any:
|
||||
for _, p := range v {
|
||||
s, ok := p.(string)
|
||||
if !ok {
|
||||
return FetchRequest{}, fmt.Errorf("paths is a list of strings")
|
||||
}
|
||||
r.Paths = append(r.Paths, s)
|
||||
}
|
||||
default:
|
||||
return FetchRequest{}, fmt.Errorf("paths is a list of strings")
|
||||
}
|
||||
packages, err := PackagesArg(args["packages"])
|
||||
if err != nil {
|
||||
return FetchRequest{}, err
|
||||
}
|
||||
r.Packages = packages
|
||||
return r, r.Check()
|
||||
}
|
||||
|
||||
// PackagesArg reads a tool call's packages argument, an object of strings (as JSON gives it) or the map an ask gives;
|
||||
// nil for none.
|
||||
func PackagesArg(v any) (delivery.PackageVersions, error) {
|
||||
out := delivery.PackageVersions{}
|
||||
switch v := v.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case delivery.PackageVersions:
|
||||
maps.Copy(out, v)
|
||||
case map[string]string:
|
||||
maps.Copy(out, v)
|
||||
case map[string]any:
|
||||
for p, version := range v {
|
||||
s, ok := version.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("packages is an object of versions (strings), by package")
|
||||
}
|
||||
out[p] = s
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("packages is an object of versions (strings), by package")
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, out.Check()
|
||||
}
|
||||
|
||||
// Check is what ParseFetchRequest refuses.
|
||||
func (r FetchRequest) Check() error {
|
||||
if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") {
|
||||
return fmt.Errorf("%q is not a repository, <owner>/<name>", r.Repository)
|
||||
}
|
||||
if !requestCommitOK.MatchString(r.Commit) {
|
||||
return fmt.Errorf("%q is not a full commit (40 hex digits)", r.Commit)
|
||||
}
|
||||
if r.Base != "" && !requestCommitOK.MatchString(r.Base) {
|
||||
return fmt.Errorf("%q is not a trunk commit (40 hex digits)", r.Base)
|
||||
}
|
||||
switch {
|
||||
case len(r.Paths) > 0 && r.Dir == "":
|
||||
return fmt.Errorf("the request names paths and names no module directory (dir) they are relative to")
|
||||
case len(r.Paths) == 0 && r.Dir != "":
|
||||
return fmt.Errorf("the request names a module directory and no path")
|
||||
}
|
||||
if r.Dir != "" && (outside(r.Dir) || strings.HasPrefix(r.Dir, "/")) {
|
||||
return fmt.Errorf("the module directory %q leaves the repository", r.Dir)
|
||||
}
|
||||
for _, p := range r.Paths {
|
||||
if p == "" || strings.HasPrefix(p, "/") || outside(p) || path.Clean(p) == "." {
|
||||
return fmt.Errorf("the path %q leaves the module's directory or is not a path in it", p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func outside(p string) bool {
|
||||
c := path.Clean(p)
|
||||
return c == ".." || strings.HasPrefix(c, "../")
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A build's fetch is handed the paths of a module that names them (novox/hq ADR 0499 clause 4): the builder fills them
|
||||
// from the indexer's kept inputs and the build module reads them from the call, never from the manifest (ADR 0495
|
||||
// clause 1). The request survives the trip through a tool call's arguments.
|
||||
func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
|
||||
// And the versions of the mesh packages to build against (novox/hq ADR 0573 §2; issue 1585 S4).
|
||||
req := FetchRequest{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), Ref: "refs/pull/7/head",
|
||||
Dir: ".", Paths: []string{"package.json", "src"}, Packages: map[string]string{"git.novox.be/novox/mesh-sdk/go": strings.Repeat("d", 40)}}
|
||||
args := req.Args()
|
||||
// A tool call's arguments arrive as JSON: lists of any, objects of any.
|
||||
args["paths"] = []any{"package.json", "src"}
|
||||
if p, ok := args["packages"].(map[string]any); !ok || p["git.novox.be/novox/mesh-sdk/go"] != strings.Repeat("d", 40) {
|
||||
t.Fatalf("the packages are not in the call's arguments: %v", args["packages"])
|
||||
}
|
||||
got, err := ParseFetchRequest(args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, req) {
|
||||
t.Fatalf("got %+v, want %+v", got, req)
|
||||
}
|
||||
plain, err := ParseFetchRequest(map[string]any{"repository": "novox/x", "commit": strings.Repeat("b", 40),
|
||||
"base": strings.Repeat("c", 40), "beside": []any{}})
|
||||
if err != nil || plain.Paths != nil || plain.Base != strings.Repeat("c", 40) {
|
||||
t.Fatalf("a request without paths: %+v, %v", plain, err)
|
||||
}
|
||||
|
||||
ok := map[string]any{"repository": "novox/x", "commit": strings.Repeat("b", 40)}
|
||||
for _, c := range []struct {
|
||||
set map[string]any
|
||||
says string
|
||||
}{
|
||||
{map[string]any{"repository": "novox"}, "is not a repository"},
|
||||
{map[string]any{"repository": "novox/../x"}, "is not a repository"},
|
||||
{map[string]any{"commit": "abc"}, "is not a full commit"},
|
||||
{map[string]any{"base": "abc"}, "is not a trunk commit"},
|
||||
{map[string]any{"paths": []any{"src"}}, "names no module directory"},
|
||||
{map[string]any{"dir": "modules/a", "paths": []any{"../b"}}, "leaves the module's directory"},
|
||||
{map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"},
|
||||
{map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"},
|
||||
{map[string]any{"dir": "modules/a"}, "names a module directory and no path"},
|
||||
{map[string]any{"packages": map[string]any{"git.novox.be/novox/mesh-sdk/go": "v0.1.7"}}, "no full commit"},
|
||||
{map[string]any{"packages": map[string]any{"a b": strings.Repeat("d", 40)}}, "no module path"},
|
||||
{map[string]any{"packages": []any{"x"}}, "packages is an object"},
|
||||
} {
|
||||
args := map[string]any{}
|
||||
for k, v := range ok {
|
||||
args[k] = v
|
||||
}
|
||||
for k, v := range c.set {
|
||||
args[k] = v
|
||||
}
|
||||
if _, err := ParseFetchRequest(args); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%v: %v, wanted a refusal saying %q", c.set, err, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// RestartReason is why a step a previous process left running is ended.
|
||||
const RestartReason = "the build module restarted while it ran"
|
||||
|
||||
// Boot is when this process began. A step with no owner (written by an older build) started at or after it is taken
|
||||
// for this process's, which may still run it, so EndOne refuses it.
|
||||
var Boot = time.Now()
|
||||
|
||||
// Owner is this process as a step's owner (delivery.Step.Owner): `<boot id>.<pid>.<start>`, the node's boot id
|
||||
// (/proc/sys/kernel/random/boot_id), the pid and Boot in milliseconds, base 36. The start keeps two processes apart
|
||||
// that a reboot or a pid namespace gave the same pid. A running step whose owner is another is a previous process's.
|
||||
var Owner = ownerOf(bootID, os.Getpid(), Boot)
|
||||
|
||||
// bootID is the node's boot id as Owner holds it; noBootID when it could not be read, which EndStaleAtStart says once.
|
||||
var bootID = readBootID()
|
||||
|
||||
const noBootID = "no-boot-id"
|
||||
|
||||
func ownerOf(bootID string, pid int, start time.Time) string {
|
||||
return bootID + "." + strconv.Itoa(pid) + "." + strconv.FormatInt(start.UnixMilli(), 36)
|
||||
}
|
||||
|
||||
// readBootID is the node's boot id, or noBootID where it cannot be read (the pid and the start then tell the processes
|
||||
// apart).
|
||||
func readBootID() string {
|
||||
b, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
|
||||
if id := strings.TrimSpace(string(b)); err == nil && id != "" {
|
||||
return id
|
||||
}
|
||||
return noBootID
|
||||
}
|
||||
|
||||
// Scan reads and writes a steps state with its revisions: its keys, a key's stored value and revision (nil and 0 for
|
||||
// none), a write that lands only while the key is still at the revision read (KeptState.Update), and a delete of a key
|
||||
// still at the revision read; both fail with stdio.ErrChanged when another writer was first.
|
||||
type Scan struct {
|
||||
Keys func() ([]string, error)
|
||||
Get func(key string) (json.RawMessage, uint64, error)
|
||||
Update func(key string, value any, revision uint64) (uint64, error)
|
||||
Delete func(key string, revision uint64) error
|
||||
}
|
||||
|
||||
// ScanOf is the Scan of a state the process keeps.
|
||||
//
|
||||
// Its Delete reads the key again and deletes it only while it is still at the revision read: the runtime's
|
||||
// mesh/state.delete takes no revision yet, so a write landing between that read and the delete (a few milliseconds) is
|
||||
// deleted with it. For a step key that is a redelivered ask begun on this node in that moment: its running key leaves
|
||||
// the steps state until its run writes its end, which its asker still reads (a watch takes puts only).
|
||||
func ScanOf(s stdio.KeptState) Scan {
|
||||
get := func(key string) (json.RawMessage, uint64, error) {
|
||||
e, err := s.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return e.Value, e.Revision, nil
|
||||
}
|
||||
return Scan{Keys: s.Keys, Update: s.Update, Get: get, Delete: func(key string, revision uint64) error {
|
||||
_, rev, err := get(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rev != revision {
|
||||
return stdio.ErrChanged
|
||||
}
|
||||
return s.Delete(key)
|
||||
}}
|
||||
}
|
||||
|
||||
// isAsk says a step is a pulled ask's: its key is <node>.<ask id> and its tool is "<kind> ask" (a hand-started step
|
||||
// has the tool's name). An ask's key is the stream's to settle, never a restart's: the puller leaves an ask it was
|
||||
// running unacknowledged so the stream redelivers it, and the asker's wait takes the first end it reads on the key.
|
||||
// A key a gone process owns is deleted, never ended (EndStale); one with no owner is ended only once the asker's bound
|
||||
// has passed (delivery.StepBound), when it is nobody's.
|
||||
func isAsk(key string, s delivery.Step) bool {
|
||||
_, _, err := delivery.ParseAskStepKey(key)
|
||||
return err == nil && strings.HasSuffix(s.Tool, " ask")
|
||||
}
|
||||
|
||||
// AskWhat says what an ask is, in one line: its kind, and its repository, head and levels, or its module and
|
||||
// fingerprint.
|
||||
func AskWhat(a delivery.StepAsk) string {
|
||||
short := func(s string) string {
|
||||
if len(s) > 12 {
|
||||
return s[:12]
|
||||
}
|
||||
return s
|
||||
}
|
||||
str := func(m map[string]any, k string) string { v, _ := m[k].(string); return v }
|
||||
parts := []string{a.Kind + " ask"}
|
||||
switch {
|
||||
case a.Check != nil:
|
||||
parts = append(parts, str(a.Check.Fetch, "repository")+"@"+short(str(a.Check.Fetch, "commit")), "levels "+strings.Join(a.Check.Levels, ","))
|
||||
if a.Check.Run != "" {
|
||||
parts = append(parts, "run "+a.Check.Run)
|
||||
}
|
||||
case a.Build != nil:
|
||||
parts = append(parts, "module "+a.Build.Module)
|
||||
if r := str(a.Build.Fetch, "repository"); r != "" {
|
||||
parts = append(parts, r+"@"+short(str(a.Build.Fetch, "commit")))
|
||||
}
|
||||
if a.Build.FP != "" {
|
||||
parts = append(parts, "fp "+short(a.Build.FP))
|
||||
}
|
||||
case a.Compose != nil:
|
||||
parts = append(parts, "compose", str(a.Compose.Fetch, "repository")+"@"+short(str(a.Compose.Fetch, "commit")))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// endRunning writes step ended failed with why, only while its key is still at revision.
|
||||
func endRunning(scan Scan, key string, step delivery.Step, revision uint64, why string) error {
|
||||
step.State, step.Reason, step.Ended, step.Result = delivery.StepFailed, why, now().UTC(), nil
|
||||
raw, err := delivery.EncodeStep(step)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = scan.Update(key, json.RawMessage(raw), revision)
|
||||
return err
|
||||
}
|
||||
|
||||
// EndStale ends this node's running steps that no process of this module runs any more, and answers their ids. Only keys
|
||||
// of node are read; a value that is no step is left alone.
|
||||
// - A hand-started step with an owner (delivery.Step.Owner) that is not this process (Owner) is ended failed with
|
||||
// RestartReason at once, whatever its age: its process is gone and nothing redelivers it, so nothing else would
|
||||
// write its end (issue 1890, note 21).
|
||||
// - A pulled ask's step with an owner that is not this process is deleted, not ended (novox/hq ADR 0525 §6): the
|
||||
// stream redelivers an ask whose node stopped (the puller neither ends nor acknowledges one at its stop), and its
|
||||
// asker takes the first end it reads, so an end written here would fail a check the redelivery would still run. Its
|
||||
// asker's watch takes puts only and keeps waiting for the redelivery's key, its bound the backstop; the stale running
|
||||
// key leaves the steps state, and with it the delivery view's and the checker's count of what runs (note 21: it
|
||||
// held one of a build node's two slots).
|
||||
// - A step owned by this process is left: it may run now. A redelivered ask begun on this node is owned by it.
|
||||
// - A step with no owner (written by an older build) keeps the earlier rule: a hand-started step started before
|
||||
// `before` (the start of this process) is ended; a pulled ask is left to the stream's redelivery (isAsk) until its
|
||||
// start is older than delivery.StepBound, and then ended "past the step bound: its asker stopped waiting at <time>".
|
||||
//
|
||||
// Each end and delete is logged, naming the step, its owner and why. A key written by another writer since it was read
|
||||
// (a redelivered ask's start, a real end) is skipped.
|
||||
func EndStale(scan Scan, node string, before time.Time, logf func(string, ...any)) ([]string, error) {
|
||||
keys, err := scan.Keys()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("buildstep: listing the steps of %s: %w", node, err)
|
||||
}
|
||||
var ended []string
|
||||
for _, k := range keys {
|
||||
n, _, perr := delivery.ParseStepKey(k)
|
||||
if perr != nil || n != node {
|
||||
continue
|
||||
}
|
||||
raw, rev, err := scan.Get(k)
|
||||
if err != nil {
|
||||
return ended, fmt.Errorf("buildstep: reading the step %s: %w", k, err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
step, err := delivery.DecodeStep(raw)
|
||||
if err != nil || step.State != delivery.StepRunning {
|
||||
continue
|
||||
}
|
||||
why, said := staleWhy(k, step, before)
|
||||
if why == "" {
|
||||
continue
|
||||
}
|
||||
done := "ended"
|
||||
if step.Owner != "" && isAsk(k, step) {
|
||||
done = "deleted, left to the stream's redelivery"
|
||||
if scan.Delete == nil {
|
||||
err = fmt.Errorf("the steps state takes no delete")
|
||||
} else {
|
||||
err = scan.Delete(k, rev)
|
||||
}
|
||||
} else {
|
||||
err = endRunning(scan, k, step, rev, why)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
continue
|
||||
}
|
||||
return ended, fmt.Errorf("buildstep: ending the step %s: %w", k, err)
|
||||
}
|
||||
if logf != nil {
|
||||
logf("the step %s (%s, started %s) was left running and is %s: %s; %s", k, step.Tool,
|
||||
step.Started.UTC().Format(time.RFC3339), done, why, said)
|
||||
}
|
||||
ended = append(ended, step.ID)
|
||||
}
|
||||
return ended, nil
|
||||
}
|
||||
|
||||
// staleWhy is why a running step is ended (or, for an ask with an owner, deleted) by EndStale and what the log says of
|
||||
// its owner, or "" to leave it.
|
||||
func staleWhy(key string, step delivery.Step, before time.Time) (why, said string) {
|
||||
switch {
|
||||
case step.Owner == Owner:
|
||||
return "", ""
|
||||
case step.Owner != "":
|
||||
return RestartReason, "its owner " + step.Owner + " is not this process " + Owner
|
||||
case isAsk(key, step):
|
||||
if !step.Started.Add(delivery.StepBound).Before(now()) {
|
||||
return "", ""
|
||||
}
|
||||
return "past the step bound: its asker stopped waiting at " + step.Started.Add(delivery.StepBound).UTC().Format("2006-01-02T15:04:05Z"),
|
||||
"it names no owner (an older build wrote it)"
|
||||
case step.Started.Before(before):
|
||||
return RestartReason, "it names no owner (an older build wrote it) and started before this process"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// EndOne ends this node's running step id (or the key <node>.<id>) as failed with why. A step that is not running is
|
||||
// refused, and so is one this process owns (Owner; for a step with no owner, one started since Boot: it may still run,
|
||||
// whether a pulled ask or a step a tool started), one live reports as held, and one whose key changed since it was read.
|
||||
func EndOne(scan Scan, node, id, why string, live func(id string) bool) (delivery.Step, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if n, rest, ok := strings.Cut(id, "."); ok {
|
||||
if n != node {
|
||||
return delivery.Step{}, fmt.Errorf("the step %q is of node %q, this module runs on %q", id, n, node)
|
||||
}
|
||||
id = rest
|
||||
}
|
||||
if strings.TrimSpace(why) == "" {
|
||||
return delivery.Step{}, fmt.Errorf("say why the step is ended (why)")
|
||||
}
|
||||
key, err := delivery.StepKey(node, id)
|
||||
if err != nil {
|
||||
return delivery.Step{}, err
|
||||
}
|
||||
raw, rev, err := scan.Get(key)
|
||||
if err != nil {
|
||||
return delivery.Step{}, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return delivery.Step{}, fmt.Errorf("no step %s in the steps state", key)
|
||||
}
|
||||
step, err := delivery.DecodeStep(raw)
|
||||
if err != nil {
|
||||
return delivery.Step{}, err
|
||||
}
|
||||
if step.State != delivery.StepRunning {
|
||||
return delivery.Step{}, fmt.Errorf("the step %s is %s, not running", key, step.State)
|
||||
}
|
||||
if step.Owner == Owner || (step.Owner == "" && !step.Started.Before(Boot)) || (live != nil && live(id)) {
|
||||
return delivery.Step{}, fmt.Errorf("the step %s was started by this process and may run now; only a step a previous process left is ended", key)
|
||||
}
|
||||
if err := endRunning(scan, key, step, rev, why); err != nil {
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
return delivery.Step{}, fmt.Errorf("the step %s changed while it was being ended; read it again", key)
|
||||
}
|
||||
return delivery.Step{}, err
|
||||
}
|
||||
step.State, step.Reason = delivery.StepFailed, why
|
||||
return step, nil
|
||||
}
|
||||
|
||||
// StaleAttempts and StaleRetry bound EndStaleAtStart's tries: the steps state is readable once the runtime serves.
|
||||
var (
|
||||
StaleAttempts = 6
|
||||
StaleRetry = 5 * time.Second
|
||||
)
|
||||
|
||||
// StaleServed is what EndStaleAtStart waits for before it reads the steps state: the runtime serving (stdio.Served).
|
||||
// The conformance kit's left-running case (conformance.LeftRunning) hands it a closed channel, to run a module's own
|
||||
// start sweep in its test.
|
||||
var StaleServed = stdio.Served
|
||||
|
||||
// EndStaleAtStart is what a build module runs once at its start, in a goroutine: it waits for the runtime to serve,
|
||||
// then ends this node's steps a previous process left running (EndStale), trying again when the state cannot be read
|
||||
// yet. A step names its owner, so `before` (the start of this process) matters only for a step with no owner, written
|
||||
// by an older build: such a step started before it is a previous process's. When the node's boot id cannot be read it
|
||||
// says so once: Owner then tells processes apart by their pid and start only.
|
||||
func EndStaleAtStart(ctx context.Context, scan Scan, node string, before time.Time, logf func(string, ...any)) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-StaleServed():
|
||||
}
|
||||
if bootID == noBootID && logf != nil {
|
||||
logf("the boot id of %s cannot be read (/proc/sys/kernel/random/boot_id): its steps' owner is %s, told apart by pid and start only", node, Owner)
|
||||
}
|
||||
for attempt := 1; ; attempt++ {
|
||||
ended, err := EndStale(scan, node, before, logf)
|
||||
if err == nil {
|
||||
if logf != nil {
|
||||
logf("%d steps of %s left running by a previous process ended", len(ended), node)
|
||||
}
|
||||
return
|
||||
}
|
||||
if logf != nil {
|
||||
logf("ending the steps left running (try %d of %d): %v", attempt, StaleAttempts, err)
|
||||
}
|
||||
if attempt >= StaleAttempts {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(StaleRetry):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EndStaleRound is EndStale as the module's periodic round runs it: at once, and only when the runtime serves (a round
|
||||
// before that is skipped, the start's EndStaleAtStart and the next round cover it). It ends the steps a previous
|
||||
// process left and the asks past delivery.StepBound.
|
||||
func EndStaleRound(scan Scan, node string, logf func(string, ...any)) {
|
||||
select {
|
||||
case <-stdio.Served():
|
||||
default:
|
||||
return
|
||||
}
|
||||
if _, err := EndStale(scan, node, Boot, logf); err != nil && logf != nil {
|
||||
logf("ending the steps left running: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// scanOf is the steps a keys double holds, last value per key, its revision the count of its puts; a write through
|
||||
// Update lands only at the revision read, as the state's does. race, when set, runs once before the next Update's check.
|
||||
func scanOf(k *keys, race func(key string)) Scan {
|
||||
return Scan{
|
||||
Keys: func() ([]string, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
var out []string
|
||||
for key := range k.puts {
|
||||
out = append(out, key)
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
Get: func(key string) (json.RawMessage, uint64, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
s, ok := k.puts[key]
|
||||
if !ok {
|
||||
return nil, 0, nil
|
||||
}
|
||||
raw, err := delivery.EncodeStep(s[len(s)-1])
|
||||
return raw, uint64(len(s)), err
|
||||
},
|
||||
Delete: func(key string, revision uint64) error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if uint64(len(k.puts[key])) != revision {
|
||||
return stdio.ErrChanged
|
||||
}
|
||||
delete(k.puts, key)
|
||||
return nil
|
||||
},
|
||||
Update: func(key string, value any, revision uint64) (uint64, error) {
|
||||
if race != nil {
|
||||
race(key)
|
||||
}
|
||||
k.mu.Lock()
|
||||
n := uint64(len(k.puts[key]))
|
||||
k.mu.Unlock()
|
||||
if n != revision {
|
||||
return 0, stdio.ErrChanged
|
||||
}
|
||||
return k.put(key, value)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 1890 and 1890/AC6: EndStale ends this node's hand-started running steps of another owner at once, whatever their age,
|
||||
// and deletes its asks of another owner (the stream redelivers them, ADR 0525 §6); of
|
||||
// the steps with no owner (an older build's), the hand-started ones left running before the process began and the asks
|
||||
// past the step bound (a younger ask is the stream's to redeliver); never a step this process owns or a key written
|
||||
// meanwhile. EndOne ends one by hand, never one this process owns or started.
|
||||
func TestEndStaleAndEndOne(t *testing.T) {
|
||||
k := newKeys()
|
||||
boot := time.Now()
|
||||
oldBoot := Boot
|
||||
Boot = boot
|
||||
defer func() { Boot = oldBoot }()
|
||||
gone := ownerOf("b00t", 4242, boot.Add(-time.Hour))
|
||||
put := func(node, id, tool, state string, started time.Time, owner string) {
|
||||
key, _ := delivery.StepKey(node, id)
|
||||
_, _ = k.put(key, json.RawMessage(mustStep(t, delivery.Step{ID: id, Tool: tool, Node: node, State: state, Started: started, Owner: owner})))
|
||||
}
|
||||
put("ace", "old1", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour), "")
|
||||
put("ace", "mjk3l9x0abcdef", "check ask", delivery.StepRunning, boot.Add(-time.Hour), "")
|
||||
put("ace", "mjk3l9x0abcdee", "check ask", delivery.StepRunning, time.Now().Add(-5*time.Minute), "")
|
||||
put("ace", "mjk3l9x0abcded", "check ask", delivery.StepRunning, time.Now().Add(-5*time.Minute), gone) // note 21's ghost
|
||||
put("ace", "gone2", "build_go_test", delivery.StepRunning, boot.Add(time.Second), gone)
|
||||
put("ace", "mine1", "check ask", delivery.StepRunning, boot.Add(-time.Hour), Owner)
|
||||
put("ace", "raced1", "build_go_test", delivery.StepRunning, boot.Add(-time.Hour), "")
|
||||
put("ace", "new1", "build_go_fetch", delivery.StepRunning, boot.Add(time.Second), "")
|
||||
put("ace", "done1", "build_go_fetch", delivery.StepPassed, boot.Add(-time.Hour), gone)
|
||||
put("g14", "other1", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour), gone)
|
||||
// raced1 is ended by its own run between the read and the write.
|
||||
raced := false
|
||||
scan := scanOf(k, func(key string) {
|
||||
if key == "ace.raced1" && !raced {
|
||||
raced = true
|
||||
put("ace", "raced1", "build_go_test", delivery.StepPassed, boot.Add(-time.Hour), "")
|
||||
}
|
||||
})
|
||||
var said []string
|
||||
ended, err := EndStale(scan, "ace", boot, func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) })
|
||||
if err != nil || len(ended) != 4 {
|
||||
t.Fatalf("ended %v, %v", ended, err)
|
||||
}
|
||||
if _, held := k.puts["ace.mjk3l9x0abcded"]; held {
|
||||
t.Errorf("the ghost ask's key is still held: %+v", k.last("ace.mjk3l9x0abcded"))
|
||||
}
|
||||
for _, key := range []string{"ace.old1", "ace.gone2"} {
|
||||
if s := k.last(key); s.State != delivery.StepFailed || s.Reason != RestartReason || s.Ended.IsZero() {
|
||||
t.Errorf("%s = %+v", key, s)
|
||||
}
|
||||
}
|
||||
if s := k.last("ace.mjk3l9x0abcdef"); s.State != delivery.StepFailed || !strings.HasPrefix(s.Reason, "past the step bound: its asker stopped waiting at ") {
|
||||
t.Errorf("the old ask = %+v", s)
|
||||
}
|
||||
if log := strings.Join(said, "\n"); !strings.Contains(log, "ace.mjk3l9x0abcded") || !strings.Contains(log, "deleted, left to the stream's redelivery") || !strings.Contains(log, "its owner "+gone+" is not this process "+Owner) {
|
||||
t.Errorf("the log does not name the ghost and its owner:\n%s", log)
|
||||
}
|
||||
for key, state := range map[string]string{"ace.mjk3l9x0abcdee": delivery.StepRunning, "ace.mine1": delivery.StepRunning, "ace.raced1": delivery.StepPassed,
|
||||
"ace.new1": delivery.StepRunning, "ace.done1": delivery.StepPassed, "g14.other1": delivery.StepRunning} {
|
||||
if s := k.last(key); s.State != state {
|
||||
t.Errorf("%s = %s, want %s", key, s.State, state)
|
||||
}
|
||||
}
|
||||
// EndOne: refuses without why, a held step, one this process owns or (with no owner) started, a step not running and
|
||||
// another node's; ends a step a previous process left.
|
||||
for _, c := range []struct{ id, why string }{{"old1", ""}, {"new1", "stuck"}, {"mine1", "stuck"}, {"done1", "x"}, {"g14.other1", "x"}} {
|
||||
if _, err := EndOne(scan, "ace", c.id, c.why, nil); err == nil {
|
||||
t.Errorf("ended %s with why %q", c.id, c.why)
|
||||
}
|
||||
}
|
||||
put("ace", "old2", "build_go_fetch", delivery.StepRunning, boot.Add(time.Second), gone)
|
||||
if _, err := EndOne(scan, "ace", "old2", "x", func(string) bool { return true }); err == nil {
|
||||
t.Error("ended a held step")
|
||||
}
|
||||
if _, err := EndOne(scan, "ace", "ace.old2", "stuck for good", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := k.last("ace.old2"); s.State != delivery.StepFailed || s.Reason != "stuck for good" || s.Owner != gone {
|
||||
t.Errorf("old2 = %+v", s)
|
||||
}
|
||||
if a, b := ownerOf("b", 1, boot), ownerOf("b", 1, boot.Add(time.Second)); a == b {
|
||||
t.Errorf("two starts of one pid share the owner %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func mustStep(t *testing.T, s delivery.Step) []byte {
|
||||
t.Helper()
|
||||
raw, err := delivery.EncodeStep(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// 1890: AskWhat names the repository, head and levels of a check ask and the module of a build ask.
|
||||
func TestAskWhat(t *testing.T) {
|
||||
check := delivery.StepAsk{Kind: "check", Check: &delivery.CheckJob{Fetch: map[string]any{"repository": "novox/mesh-sdk", "commit": "0123456789abcdef0123"}, Levels: []string{"unit", "integration"}}}
|
||||
if got, want := AskWhat(check), "check ask, novox/mesh-sdk@0123456789ab, levels unit,integration"; got != want {
|
||||
t.Errorf("check = %q", got)
|
||||
}
|
||||
build := delivery.StepAsk{Kind: "delivery", Build: &delivery.BuildJob{Module: "mesh-x", FP: "abcdef0123456789"}}
|
||||
if got, want := AskWhat(build), "delivery ask, module mesh-x, fp abcdef012345"; got != want {
|
||||
t.Errorf("build = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// Package buildstep is what both build modules share: the runner of a long step that answers a tool call at once
|
||||
// and ends on its key in the module's `steps` state (novox/hq ADR 0469 §2; issue 1471), the toolchain guard
|
||||
// (issue 1473), the fetch of one commit over SSH (issue 1474), and the failure contract of a test level with the parsers
|
||||
// of go test -json and node --test's TAP that fill it (failure.go, issue 1901; its conformance kit is package conformance).
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
var now = time.Now
|
||||
|
||||
// NewID is a step id: the time in milliseconds in base 36 and six random hex digits, lower-case letters and
|
||||
// digits only, so it is one key token and differs between two steps of one node even in one millisecond.
|
||||
func NewID() string {
|
||||
var b [3]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return strconv.FormatInt(now().UnixMilli(), 36) + hex.EncodeToString(b[:])
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// keys is a steps state in memory: every value put, in order, per key, and a signal for each end.
|
||||
type keys struct {
|
||||
mu sync.Mutex
|
||||
puts map[string][]delivery.Step
|
||||
ended chan string
|
||||
}
|
||||
|
||||
func newKeys() *keys { return &keys{puts: map[string][]delivery.Step{}, ended: make(chan string, 16)} }
|
||||
|
||||
func (k *keys) put(key string, value any) (uint64, error) {
|
||||
step, err := delivery.DecodeStep(value.(json.RawMessage))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
k.mu.Lock()
|
||||
k.puts[key] = append(k.puts[key], step)
|
||||
n := uint64(len(k.puts[key]))
|
||||
k.mu.Unlock()
|
||||
if step.State != delivery.StepRunning {
|
||||
k.ended <- key
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (k *keys) last(key string) delivery.Step {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
s := k.puts[key]
|
||||
return s[len(s)-1]
|
||||
}
|
||||
|
||||
func (k *keys) waitEnd(t *testing.T) string {
|
||||
t.Helper()
|
||||
select {
|
||||
case key := <-k.ended:
|
||||
return key
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("the step never ended on its key")
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// 1471/AC5: ids made in one and the same millisecond differ, and each is one key token. The clock is fixed
|
||||
// while no step runs, so no goroutine reads it meanwhile.
|
||||
func TestTwoIDsInOneMillisecondDiffer(t *testing.T) {
|
||||
was := now
|
||||
defer func() { now = was }()
|
||||
at := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)
|
||||
now = func() time.Time { return at }
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 50; i++ {
|
||||
id := NewID()
|
||||
if seen[id] {
|
||||
t.Fatalf("id %q handed out twice", id)
|
||||
}
|
||||
seen[id] = true
|
||||
if _, err := delivery.StepKey("ace", id); err != nil {
|
||||
t.Errorf("id %q: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package buildstep
|
||||
|
||||
// ModuleTree is what a merge holds of one module directory, as a build module's preview answers it (novox/hq ADR 0484
|
||||
// clause 3, as ADR 0593 §2 changes it): its git tree id, the sha256 of its module.json, the sha256 of each lock file it
|
||||
// has (go.sum, package-lock.json), and, of a module whose every artifact names its paths, each named path's git object
|
||||
// id at the commit. The indexer alone computes from them (the named tree, the fingerprint); the build module's runner
|
||||
// (mesh-build-go's buildstep.ModuleTrees) fills them.
|
||||
type ModuleTree struct {
|
||||
Tree string `json:"tree"`
|
||||
Manifest string `json:"manifest"`
|
||||
Locks map[string]string `json:"locks,omitempty"`
|
||||
Paths map[string]string `json:"paths,omitempty"`
|
||||
// Packages are no longer filled (ADR 0593 §2): kept so a preview written before still decodes.
|
||||
Packages []string `json:"packages,omitempty"`
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AccountsState is the grants part's state of wanted accounts, by bus user.
|
||||
const AccountsState = "accounts"
|
||||
|
||||
// GrantsAccounts is that state as a reader names it (`<owner>.<name>`).
|
||||
const GrantsAccounts = GrantsModule + "." + AccountsState
|
||||
|
||||
// AccountIssuedEvent is what the vault says when it minted an account: AccountIssued, never a value or a hash.
|
||||
const AccountIssuedEvent = "account-issued"
|
||||
|
||||
// AccountIssuedSubject is where it says it, as the runtime addresses mesh-vault's events.
|
||||
const AccountIssuedSubject = "mesh.mod." + VaultSeat + ".event." + AccountIssuedEvent
|
||||
|
||||
// ClaimedSeat is one seat a module claims, as its runtime needs it (the controller's seatClaimed): the name, the scope (a
|
||||
// node-scoped seat's verb carries the machine) and the verbs the runtime serves for it.
|
||||
type ClaimedSeat struct {
|
||||
Seat string `json:"seat"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves,omitempty"`
|
||||
}
|
||||
|
||||
// WantedAccount is one key of the grants part's state `accounts`: an account the mesh needs, without its password.
|
||||
type WantedAccount struct {
|
||||
User string `json:"user"`
|
||||
// Kind is KindModule (KindNodeTools for the runtime, named as a module is), KindProcess or KindProvisioner.
|
||||
Kind Kind `json:"kind"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
// Process is a KindProcess account's process id.
|
||||
Process string `json:"process,omitempty"`
|
||||
// URL is the bus's address as that node reaches it, `nats://<host>:<port>`; Fingerprint the bus certificate's.
|
||||
URL string `json:"url"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
// Claims are a module's claims with the verbs each seat promises: none for a process or a provisioner client.
|
||||
Claims []ClaimedSeat `json:"claims,omitempty"`
|
||||
// At is when the grants part wrote the key: not compared (Same).
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// mintedKinds are the kinds of account the vault mints (ADR 0561 §1).
|
||||
var mintedKinds = []Kind{KindModule, KindNodeTools, KindProcess, KindProvisioner}
|
||||
|
||||
// principal is the account as the composer names it.
|
||||
func (w WantedAccount) principal() Principal {
|
||||
return Principal{Kind: w.Kind, Node: w.Node, Module: w.Module, Process: w.Process}
|
||||
}
|
||||
|
||||
// Valid says why w is not an account the vault may mint, or nil: a kind it mints, every name a subject token, the user
|
||||
// the one the composer derives from them, and an address to reach the bus at.
|
||||
func (w WantedAccount) Valid() error {
|
||||
if !slices.Contains(mintedKinds, w.Kind) {
|
||||
return fmt.Errorf("%s is a %s account, which the vault never mints (ADR 0561 §4)", w.User, w.Kind)
|
||||
}
|
||||
for what, v := range map[string]string{"node": w.Node, "module": w.Module} {
|
||||
if !safeSubject.MatchString(v) {
|
||||
return fmt.Errorf("%s: the %s %q is not a name the mesh writes", w.User, what, v)
|
||||
}
|
||||
}
|
||||
if w.Kind == KindProcess && !safeSubject.MatchString(w.Process) {
|
||||
return fmt.Errorf("%s: the process %q is not a name the mesh writes", w.User, w.Process)
|
||||
}
|
||||
if w.Kind != KindProcess && w.Process != "" {
|
||||
return fmt.Errorf("%s: only a process's account names a process", w.User)
|
||||
}
|
||||
if want := w.principal().Username(); w.User != want {
|
||||
return fmt.Errorf("%s is not the user of a %s account of %s on %s (that is %s)", w.User, w.Kind, w.Module, w.Node, want)
|
||||
}
|
||||
if !strings.HasPrefix(w.URL, "nats://") || len(w.URL) == len("nats://") {
|
||||
return fmt.Errorf("%s names no bus address (url, nats://<host>:<port>)", w.User)
|
||||
}
|
||||
if len(w.Claims) > 0 && w.Kind != KindModule && w.Kind != KindNodeTools {
|
||||
return fmt.Errorf("%s: a %s account holds no seat, so it carries no claims", w.User, w.Kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Same says whether two wanted accounts want the same account: every field but when it was written.
|
||||
func (w WantedAccount) Same(o WantedAccount) bool {
|
||||
a, b := w, o
|
||||
a.At, b.At = time.Time{}, time.Time{}
|
||||
ra, _ := json.Marshal(a)
|
||||
rb, _ := json.Marshal(b)
|
||||
return string(ra) == string(rb)
|
||||
}
|
||||
|
||||
// OwnSecret is the module's own secret the account is held in: `broker`, `broker-<process id>` or `provisioner-broker`
|
||||
// (the controller's names, ADR 0436 §2, ADR 0349 §11).
|
||||
func (w WantedAccount) OwnSecret() string {
|
||||
switch w.Kind {
|
||||
case KindProcess:
|
||||
return "broker-" + w.Process
|
||||
case KindProvisioner:
|
||||
return "provisioner-broker"
|
||||
}
|
||||
return "broker"
|
||||
}
|
||||
|
||||
// ReadWantedAccount reads the key user of the state: refused when it is not a valid wanted account of that user.
|
||||
func ReadWantedAccount(user string, raw []byte) (WantedAccount, error) {
|
||||
var w WantedAccount
|
||||
if err := json.Unmarshal(raw, &w); err != nil {
|
||||
return WantedAccount{}, fmt.Errorf("%s %s is not a wanted account: %w", GrantsAccounts, user, err)
|
||||
}
|
||||
if w.User != user {
|
||||
return WantedAccount{}, fmt.Errorf("%s %s names the user %q", GrantsAccounts, user, w.User)
|
||||
}
|
||||
return w, w.Valid()
|
||||
}
|
||||
|
||||
// AccountIssued is the vault's account-issued: which account it minted, and its generation. Never a value or a hash.
|
||||
type AccountIssued struct {
|
||||
User string `json:"user"`
|
||||
Kind Kind `json:"kind"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Process string `json:"process,omitempty"`
|
||||
Generation int `json:"generation"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// HeldCredential is the value the account's holder reads, exactly as the controller builds it today (issueWith,
|
||||
// sealProcess and sealProvisionerClient): a module's `{url, fingerprint, node, module, user, password, claims}`, a
|
||||
// process's without claims, a provisioner client's without module or claims; an empty fingerprint or claims left out.
|
||||
func HeldCredential(w WantedAccount, password string) ([]byte, error) {
|
||||
if err := w.Valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if password == "" {
|
||||
return nil, fmt.Errorf("%s: an account is held with its password", w.User)
|
||||
}
|
||||
switch w.Kind {
|
||||
case KindProcess:
|
||||
return json.Marshal(struct {
|
||||
URL string `json:"url"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password})
|
||||
case KindProvisioner:
|
||||
return json.Marshal(struct {
|
||||
URL string `json:"url"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Node string `json:"node"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, User: w.User, Password: password})
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
URL string `json:"url"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
Claims []ClaimedSeat `json:"claims,omitempty"`
|
||||
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password,
|
||||
Claims: w.Claims})
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
)
|
||||
|
||||
// DeclarationFilesBucket is the prefix of the buckets' names, which mesh-sdk fixes for the node-engine and the controller alike.
|
||||
const DeclarationFilesBucket = declaration.FileBucketPrefix
|
||||
@@ -1,59 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
// A Consumer is a durable subscription the controller creates on a module's behalf. A module
|
||||
// declares what it reacts to, never how delivery works, so it does not name these and cannot
|
||||
// misconfigure them.
|
||||
type Consumer struct {
|
||||
Name string
|
||||
Stream string
|
||||
// Filters are the subjects this consumer receives. One consumer per module with several
|
||||
// filters, rather than one per consumed event: its ack subject is derived from its name, and
|
||||
// a module with five consumers would need five ack permissions to ack its own deliveries.
|
||||
Filters []string
|
||||
// Queue is the queue group, set for a seat's worker so that "exactly one holder" survives a
|
||||
// seat later being relaxed to several. Authority and delivery are kept separate on purpose.
|
||||
Queue string
|
||||
// Push asks the server to deliver to a subject rather than wait to be pulled.
|
||||
//
|
||||
// For the mesh's own consumer, where the controller wants every message to arrive in the one
|
||||
// loop it already runs: pulling would mean a second goroutine fetching batches and handing
|
||||
// them over, and a loop that acts on one message at a time is the property the store window
|
||||
// depends on. A queue group implies this, because a group has nothing to pull from.
|
||||
Push bool
|
||||
// TakesOverFrom names a consumer of the same stream whose filter this one takes over (issue 973): made while
|
||||
// that consumer still filters this one's subject, it starts after what that consumer has acknowledged, so a
|
||||
// message the old one held unacknowledged is not lost to the change. Otherwise it starts as FromNow says.
|
||||
TakesOverFrom string
|
||||
// DeliverSubject, when set, is where a push consumer delivers instead of DeliverSubjectFor's.
|
||||
DeliverSubject string
|
||||
// AckWaitSeconds before an unacknowledged delivery is redelivered.
|
||||
AckWaitSeconds int
|
||||
// MaxDeliver is how often a message is handed over before the consumer gives it up; zero for no
|
||||
// bound. What a consumer gives up is kept in DEAD_LETTERS by the controller (novox/hq issue 330).
|
||||
MaxDeliver int
|
||||
// MaxAckPending is how many deliveries the server lets stand unacknowledged at once; zero for
|
||||
// the server's default, which is many. **One, for a consumer handled one at a time**
|
||||
// (novox/hq issue 175): a handler that builds for minutes keeps its own message alive with a
|
||||
// heartbeat, but everything handed over behind it times out unacknowledged and comes back —
|
||||
// and a merge that came back rebuilt what it had just built, five times over on 2026-09-30.
|
||||
// With one outstanding, the server holds the rest, and the heartbeat is keeping the message.
|
||||
MaxAckPending int
|
||||
// FromNow makes a consumer that does not exist yet start at the stream's end rather than its
|
||||
// beginning (novox/hq issue 248). For a consumer that reacts to announcements — a merge, a build's
|
||||
// outcome — on a stream that keeps a week of them: the server's default, everything the stream
|
||||
// holds, replays every merge and every build of that week as if it had just happened. A consumer
|
||||
// that exists keeps where it is, whatever this says; only its making is decided here.
|
||||
FromNow bool
|
||||
Why string
|
||||
// Resettable says why this consumer may be re-made to deliver from now by the mesh itself, with
|
||||
// nobody asked (novox/hq to-be 45 §7, healer H4): what a reset drops, something else catches up.
|
||||
// Empty for every consumer where nothing would — a module's, whose events would be lost to it.
|
||||
// Not a property of the consumer on the bus: nothing here is sent to the server.
|
||||
Resettable string
|
||||
}
|
||||
|
||||
// ModuleConsumer is one module's durable consumer, with the module and node it is for.
|
||||
type ModuleConsumer struct {
|
||||
Node, Module string
|
||||
Consumer Consumer
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
// Package buscompose is the bus's names and shapes every part reads (novox/hq ADR 0593 §1): the mesh's streams and
|
||||
// dead letters, a module's log, stream and buckets as the bus names them, the account a part asks for, a principal's
|
||||
// shape, and the grants part's memberships key. The composer of the bus's user list, which reads these and makes every
|
||||
// user's permissions, is the grants part's own (mesh-delivery modules/mesh-grants internal/buscompose).
|
||||
package buscompose
|
||||
@@ -1,12 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
// EngineModule is the node-engine's manifest's module name, and EngineActsState its state the acts asked through the
|
||||
// node seat and its own asks are kept in (ADR 0563 §2, §3: "the engine's own state"): the bucket mesh-host_acts, keys
|
||||
// `<node>.act.<id>`, `<node>.open.<act>.<digest>` and `<node>.ask.<id>`, each written by that node's engine alone.
|
||||
const (
|
||||
EngineModule = "mesh-host"
|
||||
EngineActsState = "acts"
|
||||
)
|
||||
|
||||
// EngineActsBucket is that state's bucket.
|
||||
var EngineActsBucket = BucketName(EngineModule, EngineActsState)
|
||||
@@ -1,99 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// ControllerLeftoverConsumers are the ended controller's durable consumers left on the bus, one leftover of the module
|
||||
// mesh-delivery placed nowhere (issue 1126, milestone section 3), and mesh-catalog's of the controller's built-before,
|
||||
// which its manifest no longer consumes and no part composes; each `<stream>.<consumer>`.
|
||||
var ControllerLeftoverConsumers = []string{
|
||||
"EVENTS." + ControllerName, "EVENTS." + MergesConsumerName, "EVENTS.novox_mesh-delivery", "EVENTS.novox_mesh-catalog",
|
||||
"CONTROL." + ControllerName, DeadLetterNoticesStream + "." + ControllerName,
|
||||
}
|
||||
|
||||
// ControllerLeftoverConsumersOn are ControllerLeftoverConsumers and, for each node given, the consumer the controller
|
||||
// made for the node's engine (`EVENTS.<node>_mesh-host`, of restic's backed-up): the engine hears that event without a
|
||||
// durable consumer, and the grants part composes none for it, so nothing reads it (issue 1126).
|
||||
func ControllerLeftoverConsumersOn(nodes []string) []string {
|
||||
out := slices.Clone(ControllerLeftoverConsumers)
|
||||
for _, n := range nodes {
|
||||
if safeSubject.MatchString(n) {
|
||||
out = append(out, "EVENTS."+n+"_"+EngineModule)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ControllerLeftoverBuckets are the ended controller's buckets, each by its local name (`KV_mesh-controller_<name>`).
|
||||
var ControllerLeftoverBuckets = []string{"asked", "assignments", "builds", "calls", "checks-running", "composed",
|
||||
"condition-history", "conditions", "declared-config", "declared-packages", "delivery-merges", "grants", "hand-acts",
|
||||
"lease", "predefined", "rollouts", "states-announced"}
|
||||
|
||||
// ControllerLeftoverStreams are the ended controller's streams, by their names: CONTROL, ASSIGNMENTS, each of its buckets'
|
||||
// and, for each node given, the declaration files' object bucket the controller wrote (ADR 0486 §2).
|
||||
func ControllerLeftoverStreams(nodes []string) []string {
|
||||
out := []string{"CONTROL", AssignmentsStream}
|
||||
for _, b := range ControllerLeftoverBuckets {
|
||||
out = append(out, "KV_"+BucketName(catalogue.EndedControllerName, b))
|
||||
}
|
||||
for _, n := range nodes {
|
||||
if safeSubject.MatchString(n) {
|
||||
out = append(out, "OBJ_"+DeclarationFilesBucket+n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BusRemovalGrants are the whole authority of an account whose bus declares removals (ADR 0564 Limits 4, ADR 0590 clause
|
||||
// 6), each removal its grant: the node-engine's acts bucket read (STREAM.INFO with a subject filter lists what a removal
|
||||
// would take, so the tools read before they remove) and its keys removed, which nothing else may empty when it fills; and
|
||||
// the ended controller's leftovers, each consumer and each stream read and deleted by its name, for the nodes given (the
|
||||
// declaration files' object buckets). A removal outside the set is refused, as the indexer's judge refuses it in a
|
||||
// manifest (novox/hq ADR 0578 §1).
|
||||
func BusRemovalGrants(removes, nodes []string) ([]string, error) {
|
||||
var out []string
|
||||
for _, r := range removes {
|
||||
switch r {
|
||||
case catalogue.RemovesEngineActs:
|
||||
out = append(out, "$JS.API.STREAM.INFO.KV_"+EngineActsBucket, "$KV."+EngineActsBucket+".>")
|
||||
case catalogue.RemovesControllerLeftovers:
|
||||
for _, c := range ControllerLeftoverConsumersOn(nodes) {
|
||||
out = append(out, "$JS.API.CONSUMER.INFO."+c, "$JS.API.CONSUMER.DELETE."+c)
|
||||
}
|
||||
for _, s := range ControllerLeftoverStreams(nodes) {
|
||||
out = append(out, "$JS.API.STREAM.INFO."+s, "$JS.API.STREAM.DELETE."+s)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("a bus account removes %q, which is not one of %s (novox/hq ADR 0564 Limits 4, ADR 0590)", r,
|
||||
strings.Join([]string{catalogue.RemovesEngineActs, catalogue.RemovesControllerLeftovers}, ", "))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WatchmanHears are the core subjects the watchman subscribes, with no durable consumer (ADR 0564 clauses 4, 5 and
|
||||
// Limits 3, 11): the power seat's words (S1, ADR 0211), the providers' failing and recovered words (S8), each tool
|
||||
// runner's beat (S11), the self-check's checked (S10), a consumer deleted and the bus module's slow consumers and closed
|
||||
// connections (S9), the provisioner clients' reports (S21), the dead letters kept, and the lease write's events. Never the bus's API audit
|
||||
// (`$JS.EVENT.ADVISORY.API`): it carries every API call's request and answer in the account, the vault's hashes among
|
||||
// them (the lead, 2026-10-29).
|
||||
func WatchmanHears() []string {
|
||||
return []string{
|
||||
"mesh.mod.power.event.*",
|
||||
"mesh.mod.*.event.provisioner.failing",
|
||||
"mesh.mod.*.event.provisioner.recovered",
|
||||
"mesh.control.*.tools-alive",
|
||||
"mesh.seat.mesh-self-check.event." + health.EventChecked,
|
||||
"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>",
|
||||
"mesh.mod.nats.event.bus.slow-consumer",
|
||||
"mesh.mod.nats.event.bus.connection-closed",
|
||||
ProvAppliedSubjects,
|
||||
deadLetterPrefix + ">",
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// The mesh's caps on a log: what an entry's value may weigh, what a message on the log's stream may
|
||||
// weigh — the value and its headers, which the server counts in a message's size, so a value of the
|
||||
// full size still fits with the expected-last-sequence header an append carries — and what a log holds
|
||||
// when its module says nothing and at most.
|
||||
const (
|
||||
LogMaxEntryBytes = 256 * 1024
|
||||
LogMaxMessageBytes = LogMaxEntryBytes + 4*1024
|
||||
LogDefaultMiB = 1024
|
||||
)
|
||||
|
||||
// A Log is one module's declared log as the bus holds it.
|
||||
type Log struct {
|
||||
Module string
|
||||
Name string
|
||||
// MaxMiB is its cap in MiB; zero is LogDefaultMiB.
|
||||
MaxMiB int
|
||||
// MaxAgeDays is how long an entry is kept; zero is as long as the log (catalogue.LogDeclaration).
|
||||
MaxAgeDays int
|
||||
}
|
||||
|
||||
// LogStreamPrefix starts every log's stream name, which no other stream of the mesh's starts with.
|
||||
const LogStreamPrefix = "LOG_"
|
||||
|
||||
// LogStreamName is the stream a module's log lives in: `LOG_<module>_<name>`. The module and the
|
||||
// local name are each one token with no underscore, so two modules can never derive one stream.
|
||||
func LogStreamName(module, name string) string { return LogStreamPrefix + module + "_" + name }
|
||||
|
||||
// LogSubject is the subject a log's entries are published under, without the key: an entry for key K
|
||||
// is on `<LogSubject>.<K>`.
|
||||
func LogSubject(module, name string) string { return "mesh.log." + module + "." + name }
|
||||
|
||||
// Stream is this log's stream name.
|
||||
func (l Log) Stream() string { return LogStreamName(l.Module, l.Name) }
|
||||
|
||||
// Subject is this log's subject, without the key.
|
||||
func (l Log) Subject() string { return LogSubject(l.Module, l.Name) }
|
||||
|
||||
// MaxBytes is this log's cap in bytes.
|
||||
func (l Log) MaxBytes() int64 {
|
||||
mib := l.MaxMiB
|
||||
if mib <= 0 {
|
||||
mib = LogDefaultMiB
|
||||
}
|
||||
return int64(mib) * 1024 * 1024
|
||||
}
|
||||
|
||||
// Why is carried into the server's description of the stream, so somebody reading the server's own
|
||||
// state finds whose it is and why it is kept.
|
||||
func (l Log) Why() string {
|
||||
return fmt.Sprintf("%s's log %q (novox/hq ADR 0297): its record of operations, one entry per operation "+
|
||||
"under its key, appended by %s alone and kept as long as the log; never removed by the mesh, because "+
|
||||
"it is data", l.Module, l.Name, l.Module)
|
||||
}
|
||||
|
||||
// Config is the stream a log is, field by field as novox/hq ADR 0297 fixes it: a file stream kept by
|
||||
// limits, with no maximum age unless the log says one (ADR 0565 §10) and no cap per key, whose message is an entry's value and 4 KiB of
|
||||
// headers at most, that refuses a new entry when full rather than
|
||||
// drop an old one, and that refuses deleting an entry or purging it. Direct gets are allowed, which
|
||||
// is how the runtime reads it.
|
||||
func (l Log) Config() jetstream.StreamConfig {
|
||||
return jetstream.StreamConfig{
|
||||
Name: l.Stream(),
|
||||
Description: l.Why(),
|
||||
Subjects: []string{l.Subject() + ".>"},
|
||||
Storage: jetstream.FileStorage,
|
||||
Retention: jetstream.LimitsPolicy,
|
||||
MaxAge: time.Duration(l.MaxAgeDays) * 24 * time.Hour,
|
||||
MaxMsgs: -1,
|
||||
MaxMsgsPerSubject: -1,
|
||||
MaxBytes: l.MaxBytes(),
|
||||
MaxMsgSize: LogMaxMessageBytes,
|
||||
Discard: jetstream.DiscardNew,
|
||||
AllowDirect: true,
|
||||
DenyDelete: true,
|
||||
DenyPurge: true,
|
||||
Replicas: 1,
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// moduleStreamPrefix starts every subject a module's own stream takes.
|
||||
const moduleStreamPrefix = "mesh.stream."
|
||||
|
||||
// ModuleStreamSubject is where a module's event on its own stream lands: `mesh.stream.<module>.event.<event>`.
|
||||
// The token before `.event.` is the emitter, as on EVENTS, so a runtime reads the same key from it.
|
||||
func ModuleStreamSubject(module, event string) string {
|
||||
return moduleStreamPrefix + module + ".event." + event
|
||||
}
|
||||
|
||||
// A ModuleStream is one module's declared stream as the bus holds it.
|
||||
type ModuleStream struct {
|
||||
Module string
|
||||
// Name is its name on the bus, `MAIL`.
|
||||
Name string
|
||||
// Events are the events it carries, as the bus spells them (WirePattern).
|
||||
Events []string
|
||||
MaxAgeDays int
|
||||
MaxMiB int
|
||||
// Restricted says a trusted config value of the module grants who hears it (ADR 0363 §4).
|
||||
Restricted bool
|
||||
// WorkQueue says this stream holds ADR 0436 §6's work-queue retention: a message is removed once a
|
||||
// consumer acknowledges it, and the bus refuses a new one when full rather than dropping its oldest.
|
||||
WorkQueue bool
|
||||
// Duplicates is the work-queue's duplicate window in hours (novox/hq ADR 0436 §6); meaningless unless
|
||||
// WorkQueue.
|
||||
Duplicates int
|
||||
}
|
||||
|
||||
// Subjects are the subjects the stream takes.
|
||||
func (s ModuleStream) Subjects() []string {
|
||||
var out []string
|
||||
for _, e := range s.Events {
|
||||
out = append(out, ModuleStreamSubject(s.Module, e))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Stream is the stream the controller asserts: kept by limits, its age and size the module's, dropping
|
||||
// its oldest when full — a consumer that was away catches up within the age, and the module's own record
|
||||
// answers for anything older.
|
||||
func (s ModuleStream) Stream() Stream {
|
||||
why := fmt.Sprintf("%s's own stream (novox/hq ADR 0363): its events, kept %d days and at most %d MiB so a "+
|
||||
"consumer that was away catches up", s.Module, s.MaxAgeDays, s.MaxMiB)
|
||||
if s.Restricted {
|
||||
why += "; heard only by the consumers its trusted config value grants"
|
||||
}
|
||||
if s.WorkQueue {
|
||||
why += "; a work-queue (novox/hq ADR 0436 §6): a message is removed once its one consumer " +
|
||||
"acknowledges it, and a full stream refuses a new one rather than dropping what it holds"
|
||||
}
|
||||
out := Stream{
|
||||
Name: s.Name,
|
||||
Subjects: s.Subjects(),
|
||||
Retention: RetentionLimits,
|
||||
MaxAge: s.MaxAgeDays * 24 * 60 * 60,
|
||||
MaxBytes: int64(s.MaxMiB) * 1024 * 1024,
|
||||
Why: why,
|
||||
// Said on the bus, so a dead letter kept from it ages out with it (novox/hq ADR 0376 §3).
|
||||
Restricted: s.Restricted,
|
||||
}
|
||||
if s.WorkQueue {
|
||||
out.Retention = RetentionWorkQueue
|
||||
out.DiscardNew = true
|
||||
out.DuplicatesSeconds = s.Duplicates * 60 * 60
|
||||
// Said on the bus too, reusing the one flag a restricted stream's dead letters already age and hide
|
||||
// by (novox/hq ADR 0436 §6, ADR 0376 §3-4): a work-queue stream's grant is always empty (ADR 0436
|
||||
// §6 refuses one that carries a grant), so this never collides with a trusted-config restriction.
|
||||
out.Restricted = true
|
||||
// And its own metadata, apart from the bus's work-queue retention SEAT_* and other mesh-internal
|
||||
// streams share without being one of this clause's: only this flag says a dead letter kept from the
|
||||
// stream is drop-only (novox/hq ADR 0436 §6), never the stream's Retention alone.
|
||||
out.WorkQueue = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// QueueOwner is the one process of a module that is the durable consumer of one of its own work-queue
|
||||
// streams (novox/hq ADR 0436 §1, §2, §6): named in that process's bus.queues, and checked at registration
|
||||
// to be the only one (the indexer's judge, novox/hq ADR 0578 §1).
|
||||
type QueueOwner struct {
|
||||
// Process is the process resource's id — its bus account's name, with the node and module (novox/hq ADR
|
||||
// 0436 §2).
|
||||
Process string
|
||||
// Stream is the work-queue stream's name on the bus.
|
||||
Stream string
|
||||
// MaxDeliver bounds how many times a message is handed over before it is given up as a dead letter
|
||||
// (novox/hq ADR 0436 §6).
|
||||
MaxDeliver int
|
||||
}
|
||||
|
||||
// Heard is a module's stream another module hears, and the subjects it hears of it: its `consumes`
|
||||
// within the stream and, for a restricted stream, within what the provider's trusted config value grants it
|
||||
// (ADR 0363 §5). Composed only with at least one subject.
|
||||
type Heard struct {
|
||||
Stream string
|
||||
Emitter string
|
||||
Filters []string
|
||||
// Restricted says the stream is restricted by its provider's trusted config value (ADR 0363 §4): then it
|
||||
// is heard only by a module on an account of its own (ADR 0376 §1, ComposedHears).
|
||||
Restricted bool
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/membership"
|
||||
)
|
||||
|
||||
// The names this package keeps for the grants part's composer (mesh-grants internal/buscompose, ADR 0593 §1) agree with
|
||||
// what the runtime reads: the memberships bucket, and a log's stream and subject.
|
||||
func TestTheBusNamesAgree(t *testing.T) {
|
||||
if GrantsMembershipsBucket != membership.GrantsBucket {
|
||||
t.Fatalf("the memberships bucket is %q here and %q in membership", GrantsMembershipsBucket, membership.GrantsBucket)
|
||||
}
|
||||
if MembershipKey("ace", "netcheck") != "ace.netcheck" {
|
||||
t.Fatal("a membership's key is not <node>.<module>")
|
||||
}
|
||||
if LogStreamName("mesh-delivery-builder", "build-log") == "" || LogSubject("mesh-delivery-builder", "build-log") == "" {
|
||||
t.Fatal("a log has no stream or subject")
|
||||
}
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
// Composing the bus's own configuration.
|
||||
//
|
||||
// An account is *composed*, never called for: the controller writes accounts, users and
|
||||
// per-subject permissions into one file the host keeps current, and the server reloads it in
|
||||
// place (novox/hq ADR 0106 — never through a management API; design 25 §4).
|
||||
//
|
||||
// Everything here is pure. Given the principals, it returns the file's text — so the whole of the
|
||||
// mesh's authority model is testable as strings, with no server.
|
||||
//
|
||||
// **Permissions are per subject, so a module's own name is the server's to enforce.** ADR 0042
|
||||
// reserves a module's origin — it publishes only under its own name — and here that is a refusal
|
||||
// rather than something a library promises.
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A Kind is what a principal is, which decides the shape of its authority rather than its
|
||||
// contents: a module's comes from its declaration, a host's from its node, and the controller's
|
||||
// and the enrolment user's are fixed.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindModule Kind = "module"
|
||||
KindNode Kind = "node"
|
||||
KindEnrolment Kind = "enrolment"
|
||||
// KindPerson is somebody reaching the mesh's tools from a workstation (design 25 §7). Its
|
||||
// authority is a list of tools and nothing else — not control, not declarations, not builds,
|
||||
// and no ability to answer anything, because a person asks.
|
||||
KindPerson Kind = "person"
|
||||
// KindNodeTools is a machine's tool runtime (novox/hq ADR 0175, to-be 38): one process per
|
||||
// node, on the host side, serving every assigned module's tools and every held seat's verbs.
|
||||
// Its authority is the union of what the modules it carries would each have had for their
|
||||
// tools — and nothing of what they consume, because tools are what it runs, not reactions.
|
||||
KindNodeTools Kind = "node-tools"
|
||||
// KindProvisioner is a machine's provisioner client (novox/hq ADR 0349 clauses 6 and 11, design 57 P3): a
|
||||
// service of the module holding NodeProvisionerSeat, outside the tool runner, under an account of its own so
|
||||
// the vault can tell it from an agent. Its authority is provisionerGrants and nothing else.
|
||||
KindProvisioner Kind = "provisioner"
|
||||
// KindProcess is one process of a module that declares bus (novox/hq ADR 0436 §1, §2): a service of that
|
||||
// module, outside the tool runner and outside the module's own account, under a bus account scoped to
|
||||
// exactly what its bus says — never root, never the operator account, and never able to answer a tool
|
||||
// call or a seat's verb (§3). One per process and machine.
|
||||
KindProcess Kind = "process"
|
||||
)
|
||||
|
||||
// Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it
|
||||
// emits (novox/hq ADR 0118, design 29 §5).
|
||||
type Seat struct {
|
||||
Name string
|
||||
// Scope is where the seat has one holder. A node-scoped seat's tool carries the node in its
|
||||
// subject, because one subject reaching six machines' holders is not an address
|
||||
// (novox/hq ADR 0132, design 33 §4). Empty reads as mesh.
|
||||
Scope string
|
||||
Accepts []string
|
||||
Emits []string
|
||||
Serves []string
|
||||
Versions []string // protocol versions served beside the current one; empty for v1 only
|
||||
|
||||
// Kinded says the seat is a kinded bench (novox/hq ADR 0234 §2, ADR 0259 §3): each holder claims one
|
||||
// kind, and its verbs' subjects carry it. Kind is the kind this principal's claim names, for a seat it
|
||||
// holds.
|
||||
Kinded bool
|
||||
Kind string
|
||||
// ByCaller are the accepts and emits whose last token names the calling module (ADR 0259 §3).
|
||||
ByCaller []string
|
||||
// Proofs are the seat's proof verbs: core request and reply, never on a stream (ADR 0259 §3).
|
||||
Proofs []string
|
||||
// Records are the holder's buckets, by their full name, each user reads under its own name.
|
||||
Records []string
|
||||
// Capabilities are what this principal's claim of a kinded bench promises (ADR 0234 §2).
|
||||
Capabilities []string
|
||||
// DeclaredBy is the module that declares the seat. On a kinded bench it alone submits work to a kind
|
||||
// and answers its proofs (novox/hq ADR 0259 §8): the router, not any user or watcher of the bench.
|
||||
DeclaredBy string
|
||||
// PulledByKind says each accepted kind is a worker of its own on the seat's queue, filtered to that kind, which
|
||||
// every holder pulls in the order Accepts lists them: the build seats' (novox/hq ADR 0525 §2, ADR 0536 §4).
|
||||
PulledByKind bool
|
||||
}
|
||||
|
||||
// A Principal is one user of the bus. Its permissions are derived from what it declares and
|
||||
// nothing else (novox/hq ADR 0043), over the three namespaces of design 29 §2: its own, the seats
|
||||
// it holds, and the seats it uses.
|
||||
type Principal struct {
|
||||
Kind Kind
|
||||
Node string
|
||||
Module string
|
||||
// Process is the process resource's id, for a KindProcess principal alone (novox/hq ADR 0436 §2):
|
||||
// Username and inbox are <node>.<module>.<process id>.
|
||||
Process string
|
||||
|
||||
// FilesFor are the nodes whose declaration-file buckets (novox/hq ADR 0486 §2) this principal may remove, for the bus
|
||||
// module's tools removing the ended controller's leftovers alone (ADR 0590 clause 6): every node of the mesh, one
|
||||
// bucket each.
|
||||
FilesFor []string
|
||||
|
||||
Emits []string
|
||||
Consumes []string
|
||||
Serves []string
|
||||
|
||||
Holds []Seat
|
||||
Uses []Seat
|
||||
// Watches are seats whose events this principal consumes. Separate from Consumes because a
|
||||
// role's event lives under the seat's namespace and not a module's, and this package cannot tell
|
||||
// a seat's name from a module's by looking at it — whoever resolved the declaration can, and
|
||||
// does (novox/hq ADR 0121).
|
||||
//
|
||||
// **Found by a consumer reading nothing.** The catalogue consumes the build machine's outcome;
|
||||
// with that name read as a module's, its subscription pointed at `mesh.mod.mesh-build-machine.…`,
|
||||
// a namespace no such module owns. Every service started and the graph stayed empty.
|
||||
Watches []Seat
|
||||
|
||||
// Carries are the modules whose tools this principal serves, for a KindNodeTools principal
|
||||
// (novox/hq ADR 0175): every module assigned to its node, as each declares itself. Its
|
||||
// serving authority is the union of theirs — each module's own tool namespace and each held
|
||||
// seat's verbs on this node — derived from the same declarations the modules' own principals
|
||||
// are, so the runtime can serve nothing a module could not have served for itself.
|
||||
Carries []Declared
|
||||
|
||||
// Invokes are the tools this principal may call, as `<module>.<tool>`; a single `*` is every
|
||||
// tool. A person's whole authority (design 25 §7), and a module's only if its manifest says so
|
||||
// (novox/hq ADR 0152) — the console's does, and nothing else's.
|
||||
//
|
||||
// **A list, not a role.** A person is not a module and holds no seat: nothing is addressed
|
||||
// to them, nothing is delivered to them, and they have no durable consumer to acknowledge.
|
||||
// What they have is permission to ask. A module that invokes gains exactly the same
|
||||
// permission and nothing beside it.
|
||||
Invokes []string
|
||||
|
||||
// AsksIssued are, for the vault's holder, the tools it asks in its own name for a credential a provider's software
|
||||
// issued, each `<module>.<tool>` (novox/hq ADR 0305, issue 723, vaultCalls): on the subject that names its caller,
|
||||
// so the tool knows the vault asks.
|
||||
AsksIssued []string
|
||||
|
||||
// AsksTheDesk says this principal is the vault's holder, which asks the desks' secret prompt in its own name
|
||||
// (novox/hq ADR 0349 clause 9, issue 723): `node-launcher.secret` on any machine, on the subject that names
|
||||
// its caller, and nothing else of that seat. With AsksIssued on the same principal, the vault's whole reach
|
||||
// outside its own seat: it asks each issuing provider's tool in its own name (clause 10).
|
||||
AsksTheDesk bool
|
||||
|
||||
// State is the local names of the state this principal's module keeps, and Reads the state of
|
||||
// others it reads as `<module>.<name>` (novox/hq ADR 0201): a bucket each, kept by the owner's
|
||||
// instances and read by whoever declares it.
|
||||
State []string
|
||||
Reads []string
|
||||
// PerMachine is the local names of its state keyed by machine, reaching the key named for Node and
|
||||
// no other; KeyedReads the keys of others' state it reads one key at a time (novox/hq ADR 0260).
|
||||
PerMachine []string
|
||||
KeyedReads []KeyedRead
|
||||
// Logs is the local names of the logs this principal's module keeps (novox/hq ADR 0297).
|
||||
Logs []string
|
||||
// ReadLogs are, of Reads, those naming another module's log (novox/hq ADR 0571 clause 1).
|
||||
ReadLogs []string
|
||||
// Streams are the streams this principal's module owns, and Hears the module streams it hears with the
|
||||
// subjects it was granted (novox/hq ADR 0363).
|
||||
Streams []ModuleStream
|
||||
Hears []Heard
|
||||
// Queues are its own work-queue streams and the one process each is the consumer of (novox/hq ADR 0436
|
||||
// §1, §2, §6); for a KindProcess principal, its own alone.
|
||||
Queues []QueueOwner
|
||||
// Asks says a KindProcess principal reads the records of its module's own asks (novox/hq ADR 0436 §1),
|
||||
// on whichever seat of Uses keeps them (novox/hq ADR 0259 §3) — never set for any other kind.
|
||||
Asks bool
|
||||
|
||||
// SnapshotsTheBus is the bus's own module, the one holding mesh-broker (novox/hq ADR 0235). Its
|
||||
// whole authority is BusSnapshotGrants: it copies the streams for the night's backup and nothing
|
||||
// else — not its declarations, which the node's tool runtime serves for it.
|
||||
SnapshotsTheBus bool
|
||||
// Removes are, for a process or tools bundle account whose bus declares them, what it may remove from the bus
|
||||
// (novox/hq ADR 0564 Limits 4): its whole authority is then BusRemovalGrants of them.
|
||||
Removes []string
|
||||
// DeadLetterSources are, for the watchman, the streams a dead letter it keeps may come from, by name
|
||||
// (deadLetterSourcesOf, novox/hq ADR 0564 clause 5): never a bucket's.
|
||||
DeadLetterSources []string
|
||||
|
||||
// Checks are the tools a machine principal's node-engine asks as a declared health check, each
|
||||
// `<module>.<tool>` (novox/hq ADR 0240, to-be 48 §3): asked of the instance on its own machine and
|
||||
// nowhere else.
|
||||
Checks []string
|
||||
// MovesIntoTheVault is a machine principal whose node-engine may ask the vault's `move-in` in its own name (design
|
||||
// 57's V3, issue 676): set while the vault's holder serves it, so the engine can hand in the values sealed to its
|
||||
// machine, which nothing else can open.
|
||||
MovesIntoTheVault bool
|
||||
// StatesItsKeyToTheVault is a machine principal whose node-engine may ask the vault's `node-key` in its own name
|
||||
// (issue 786, novox/hq ADR 0372): set while the vault's holder serves it. That one verb, and nothing else new.
|
||||
StatesItsKeyToTheVault bool
|
||||
// AsksItsOwnSecrets is a machine principal whose node-engine may ask the vault's `own` in its own name (novox/hq
|
||||
// ADR 0547 §2, ADR 0550 §5, issue 1126): its modules' own secrets sealed to its key, for the compositions it makes
|
||||
// itself. Set while the vault's holder serves it.
|
||||
AsksItsOwnSecrets bool
|
||||
// AsksTheVaultToSign is a machine principal whose node-engine may ask the vault's `sign` in its own name, read the
|
||||
// vault's public signing halves (`signers`) and the controller's lease key (novox/hq ADR 0550 §2): its own node's
|
||||
// certificate, the keys it trusts, and the lease write that drops the controller's key. Set while the vault's holder
|
||||
// serves `sign`.
|
||||
AsksTheVaultToSign bool
|
||||
// ServedReads are, for a machine principal, the keys of mesh-config's resolved values that other nodes' modules serve
|
||||
// from (`<node>.<module>.<key>`, catalogue.ServedSettings): what every consumer is told, and so what a node composing
|
||||
// its own modules reads of a provider elsewhere (issue 1126), each key alone. Sorted, none twice.
|
||||
ServedReads []string
|
||||
// FollowsGrantsUserList is the machine principal of the node holding mesh-broker: its node-engine reads the grants
|
||||
// part's sealed user list, one key and an object by name (novox/hq ADR 0547 clause 7, GrantsUserListFollows).
|
||||
FollowsGrantsUserList bool
|
||||
// BoundBuckets are the providers' buckets of entries, `<module>_bound`, a provisioner client reads its own
|
||||
// machine's keys of (mesh-sdk go/provider, issue 679): every bucket named bound of a module the mesh places.
|
||||
BoundBuckets []string
|
||||
// CreatesStreams are the streams the grants part's principal alone may create (novox/hq ADR 0547 §5; the operator,
|
||||
// 2026-10-26 08:54): the stream of every bucket, log and stream a manifest of a module placed anywhere declares,
|
||||
// by name. Created only, never updated or deleted: a new module state is on the bus when the grants part hears its
|
||||
// manifest, not at the controller's next start (issue 1790).
|
||||
CreatesStreams []string
|
||||
// CreatesConsumers are the durable consumers the grants part's principal alone may create beside the controller
|
||||
// (novox/hq ADR 0561 §6): each placed module's on EVENTS and each process's on its work queue (AccountConsumers), by
|
||||
// stream and name. Created only, never updated or deleted.
|
||||
CreatesConsumers []ModuleConsumer
|
||||
|
||||
// Login is a node principal's login when it is not `node.<n>`: its second login, `node.<n>--b`, composed beside or
|
||||
// in place of the first as the vault's node-logins says (novox/hq ADR 0583 §1, issue 774). Each login is granted
|
||||
// the node's permissions and the node's inbox `_INBOX.node.<n>`; what it calls, it calls in its own name, so the
|
||||
// vault tells the login that confirms a rotation from the one it replaces (§3).
|
||||
Login string
|
||||
// RotatesItsLogin is a machine principal whose node-engine reads its own node's key of the vault's node-logins,
|
||||
// and no other key, and confirms a switch with node-account-confirm in its own name (novox/hq ADR 0583 §1 insight,
|
||||
// §3). Set while the vault's holder serves rotate-node.
|
||||
RotatesItsLogin bool
|
||||
|
||||
// Redeems is an enrolment principal that may call the seat mesh-enrolment's redeem in its own name, and nothing else
|
||||
// of it (novox/hq ADR 0587 §4): set while the seat's holder serves redeem. LeaseHandedAway drops the controller's
|
||||
// enrolment subject from it, which is served no more from the lease write (§6).
|
||||
Redeems bool
|
||||
LeaseHandedAway bool
|
||||
|
||||
// VaultEnrols says the vault's holder serves an enrolment verb (VerbsTheEnrolmentAsksTheVault): every principal but
|
||||
// the module mesh-enrolment and its processes is then denied them, however wide its grant (ADR 0587 §1).
|
||||
VaultEnrols bool
|
||||
|
||||
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
||||
// and never appears here: this file is written to a node's disk and read by a server, and a
|
||||
// secret that can be read from a configuration file is a secret with a wider blast radius
|
||||
// than the one it protects (novox/hq design 29 §10).
|
||||
PasswordHash string
|
||||
}
|
||||
|
||||
// enrolmentPrefix is the space every enrolling node's user and inbox live under, so the one place the
|
||||
// controller may answer an enrolment is derived from the same constant the user is named from.
|
||||
const enrolmentPrefix = "enrol"
|
||||
|
||||
// safeSubject refuses anything that would change the meaning of a subject rather than sit inside
|
||||
// one. A name carrying a dot would silently widen a permission by adding a token; a name carrying
|
||||
// `>` or `*` would widen it to a wildcard, which is the whole authority model gone.
|
||||
var safeSubject = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
// Username is how a principal is named to the server. The node is part of it, so the same module
|
||||
// on two machines holds two users, each sealed to its own — the rule management.go already
|
||||
// applies, kept.
|
||||
func (p Principal) Username() string {
|
||||
switch p.Kind {
|
||||
case KindPerson:
|
||||
return "person." + p.Module
|
||||
case KindModule, KindNodeTools:
|
||||
// The runtime is named exactly as the module it stands for would have been: the mesh
|
||||
// issues its credential through the same path a module's takes (`module issue`), and
|
||||
// that path knows the node and the module, not the kind.
|
||||
return p.Node + "." + p.Module
|
||||
case KindProcess:
|
||||
// One account per process and machine (novox/hq ADR 0436 §2): `<machine>.<module>.<process id>`,
|
||||
// never the module's own name alone, which is a different account with a different secret.
|
||||
return p.Node + "." + p.Module + "." + p.Process
|
||||
case KindNode:
|
||||
if p.Login != "" {
|
||||
return p.Login
|
||||
}
|
||||
return "node." + p.Node
|
||||
case KindProvisioner:
|
||||
// Named for the machine, as a node is: one client per machine, whichever module runs it, and never
|
||||
// `<node>.<module>`, which is the module's own account or the tool runner's.
|
||||
return "provisioner." + p.Node
|
||||
case KindEnrolment:
|
||||
// Per token, not one shared user. **The inbox is the reason**: with a single `enrolment`
|
||||
// user every machine enrolling at once could read every other's answer, and an answer
|
||||
// carries that node's credentials sealed to it. Design 25 §6 says the inbox a token
|
||||
// derives, and a permission belongs to a user, so the user is per token.
|
||||
//
|
||||
// Named after the node, which **is** the token's id: a token is issued for a node record,
|
||||
// the mesh holds one live claim per record, and the node's name is the one identifier both
|
||||
// sides already have before anything else is agreed. It is also exactly what the other
|
||||
// transport does, where the account is named after the node and the secret is its password.
|
||||
return enrolmentPrefix + "." + p.Node
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
|
||||
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
|
||||
// inbox is derived from its own identity and its permissions name that prefix and no other.
|
||||
//
|
||||
// A node's two logins share the node's inbox (novox/hq ADR 0583 §1): its answers reach it whichever login it dials as.
|
||||
func (p Principal) inbox() string {
|
||||
if p.Kind == KindNode {
|
||||
return "_INBOX.node." + p.Node + ".>"
|
||||
}
|
||||
return "_INBOX." + p.Username() + ".>"
|
||||
}
|
||||
|
||||
// ResponseTTL is how long the bus lets a principal answer a request it received. Its one answer has
|
||||
// to come inside this, and a seat's holder answers within link.AnswerWithin — inside it by design.
|
||||
// **A broker reloading its user list forgets every answer it was about to permit**, whatever this
|
||||
// says (novox/hq issue 265): a call that is still running when the list reloads has its answer
|
||||
// refused, which is why a holder answers before it does what can reload it.
|
||||
const ResponseTTL = time.Minute
|
||||
|
||||
// GrantsModule is the one module whose principal publishes the modules' memberships (novox/hq ADR 0547 §5): the grants
|
||||
// part, which composes them from the manifests and placement. It defines no stream and is granted no other subject here.
|
||||
const GrantsModule = "mesh-grants"
|
||||
|
||||
// The grants part's state memberships (novox/hq ADR 0571 clause 7): one key per assignment, `<node>.<module>`, its
|
||||
// membership (Membership) bare, as the assignments stream carries it, written only when it differs. A module's runtime
|
||||
// reads its own key, the tool runner its node's; the grants part alone writes it. Both sides go through
|
||||
// membership.EncodeGrantsMembership and membership.DecodeGrantsMembership (or BareGrantsMembership for a reader with a
|
||||
// type of its own), never json of a type of their own: the grants part once wrote its wrapper here and every reader
|
||||
// served nothing (issue 774). The decoder still reads that wrapper, for the transition.
|
||||
const (
|
||||
GrantsMembershipsState = "memberships"
|
||||
GrantsMembershipsBucket = GrantsModule + "_" + GrantsMembershipsState
|
||||
)
|
||||
|
||||
// MembershipKey is an assignment's key in the grants part's memberships: `<node>.<module>`.
|
||||
func MembershipKey(node, module string) string { return node + "." + module }
|
||||
@@ -1,8 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/membership"
|
||||
)
|
||||
|
||||
// AssignmentsStream holds every assignment's membership, the newest per subject.
|
||||
const AssignmentsStream = membership.AssignmentsStream
|
||||
@@ -1,7 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
// isNewTraffic says whether a seat carries any of the rules above, so a seat that carries none is
|
||||
// composed exactly as before them.
|
||||
func (s Seat) isNewTraffic() bool {
|
||||
return s.Kinded || len(s.ByCaller) > 0 || len(s.Proofs) > 0 || len(s.Records) > 0 || s.PulledByKind
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// The mesh's caps on a bucket, the same for every module: a value is a piece of state, not a file,
|
||||
// and a bucket that grew without bound would be one module filling the bus's disk for everyone.
|
||||
const (
|
||||
StateMaxValueBytes = 256 * 1024
|
||||
)
|
||||
|
||||
// A Bucket is one module's declared state as the bus holds it.
|
||||
type Bucket struct {
|
||||
Module string
|
||||
Name string
|
||||
// History is how many values a key keeps; zero is one.
|
||||
History int
|
||||
// TTLSeconds is how long a value lives; zero is until replaced or deleted.
|
||||
TTLSeconds int
|
||||
// PerMachine says each machine's instance writes only the key named for its machine (novox/hq ADR
|
||||
// 0260), and is granted that key and no other.
|
||||
PerMachine bool
|
||||
// KeyTTL says the bucket allows a time to live per key (novox/hq ADR 0433 §1): its owner writes each key with its
|
||||
// own, and the server writes a delete marker, kept KeyTTLMarkerTTL, when it ends.
|
||||
KeyTTL bool
|
||||
}
|
||||
|
||||
// BucketName is the bucket a module's state lives in: the module and the local name joined by an
|
||||
// underscore, which neither may contain, so two modules can never derive one bucket.
|
||||
func BucketName(module, name string) string { return module + "_" + name }
|
||||
|
||||
// Bucket is this bucket's name on the bus.
|
||||
func (b Bucket) Bucket() string { return BucketName(b.Module, b.Name) }
|
||||
|
||||
// Why is carried into the server's description of the bucket, so somebody reading the server's
|
||||
// own state finds whose it is and why it is kept.
|
||||
func (b Bucket) Why() string {
|
||||
return fmt.Sprintf("%s's state %q (novox/hq ADR 0201): its current value per key, written by %s, "+
|
||||
"read by whatever declares it reads it; kept when %s is unassigned, because it is data",
|
||||
b.Module, b.Name, b.Module, b.Module)
|
||||
}
|
||||
|
||||
// KeyedRead is one key of another module's state a principal reads, and no other key of it.
|
||||
type KeyedRead struct {
|
||||
Read string `json:"read"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// ResolvedBucket is mesh-config's state of resolved values, keyed `<node>.<module>.<key>`.
|
||||
const ResolvedBucket = "mesh-config_resolved"
|
||||
@@ -1,196 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Retention is how a stream decides what to keep, which is the whole of what distinguishes the
|
||||
// mesh's four relationships on the wire (design 29 §4).
|
||||
type Retention string
|
||||
|
||||
const (
|
||||
// RetentionWorkQueue: a message is removed once a consumer acknowledges it. Exactly one
|
||||
// worker does the work, and a worker that dies has its message redelivered.
|
||||
RetentionWorkQueue Retention = "workqueue"
|
||||
// RetentionLastPerSubject: only the newest message on each subject survives. This is the
|
||||
// state shape — a node that was away gets exactly the current declaration and nothing older.
|
||||
RetentionLastPerSubject Retention = "last_per_subject"
|
||||
// RetentionLimits: kept until it ages or the stream fills. Events, where a subscriber that
|
||||
// was down catches up and nobody is obliged to act.
|
||||
RetentionLimits Retention = "limits"
|
||||
)
|
||||
|
||||
// A Stream is one of the mesh's own, as the controller asserts it.
|
||||
type Stream struct {
|
||||
Name string
|
||||
Subjects []string
|
||||
Retention Retention
|
||||
// MaxAge in seconds, zero for unbounded. Per stream — JetStream has no per-subject age,
|
||||
// which is why differing retention between modules would mean a stream each.
|
||||
MaxAge int
|
||||
// MaxMsgsPerSubject caps each subject independently, so one noisy emitter cannot push
|
||||
// another's events out of a shared stream. Verified: with a cap of 3, ten messages on one
|
||||
// subject and one on another leave four in the stream, not three.
|
||||
MaxMsgsPerSubject int
|
||||
// Why is carried into the assertion so an operator reading the server's own state finds the
|
||||
// reason there, rather than only in a repository they may not have.
|
||||
Why string
|
||||
// Direct lets a client read a subject's last message without a consumer, which is how a
|
||||
// runtime reads its own membership with no JetStream API beyond one request (ADR 0160).
|
||||
Direct bool
|
||||
// MaxBytes bounds the stream's size, zero for unbounded. With DiscardNew a full stream refuses
|
||||
// what comes next rather than dropping what it holds: the publisher is told, and says so.
|
||||
MaxBytes int64
|
||||
DiscardNew bool
|
||||
// DuplicatesSeconds is the window in which a message id published twice is kept once; zero for the
|
||||
// server's default (two minutes).
|
||||
DuplicatesSeconds int
|
||||
// AllowMsgTTL lets a message carry a time to live of its own (`Nats-TTL`), for what must not outlive the
|
||||
// stream it came from (novox/hq ADR 0376 §3, §5).
|
||||
AllowMsgTTL bool
|
||||
// Restricted marks a module's stream restricted by a grant (novox/hq ADR 0363 §4), in the stream's own
|
||||
// metadata, so whoever keeps one of its messages reads it from the bus (ADR 0376 §3).
|
||||
Restricted bool
|
||||
// WorkQueue marks a module stream declared retention: work-queue (novox/hq ADR 0436 §6), in the
|
||||
// stream's own metadata, so a dead letter kept from it is read as drop-only from the bus, by the
|
||||
// declaration and not by the bus's work-queue retention alone — which SEAT_* and other mesh-internal
|
||||
// streams share without being one of ADR 0436's, and whose dead letters are still delivered again
|
||||
// (ADR 0264 §4).
|
||||
WorkQueue bool
|
||||
}
|
||||
|
||||
// RestrictedMetadata is the key a restricted stream's metadata says it under.
|
||||
const RestrictedMetadata = "mesh-restricted"
|
||||
|
||||
// WorkQueueMetadata is the key a module stream declared retention: work-queue (novox/hq ADR 0436 §6) says
|
||||
// it under: never set by a mesh-internal work-queue stream (a seat's, a check's), only by Stream.WorkQueue.
|
||||
const WorkQueueMetadata = "mesh-work-queue"
|
||||
|
||||
// What a durable consumer gave up on is kept (novox/hq issue 330, design 25 §3).
|
||||
//
|
||||
// **The server says it and keeps it; the controller copies it.** A consumer that handed a message over
|
||||
// as often as it may stops offering it and publishes `$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES` with
|
||||
// the stream and the message's sequence. DeadLetterNoticesStream captures those advisories as the
|
||||
// server publishes them, so one said while no controller listens is still there when one starts. The
|
||||
// controller's consumer on it fetches the given-up message by its sequence, while the source stream
|
||||
// still holds it, and keeps a copy in DeadLettersStream under DeadLetterSubject, with the consumer,
|
||||
// the subject, how often it was handed over and when it was given up. It stays there until a person
|
||||
// delivers it again or drops it, with why; a condition is open for as long as it does.
|
||||
const (
|
||||
DeadLetterNoticesStream = "DEAD_LETTER_NOTICES"
|
||||
DeadLettersStream = "DEAD_LETTERS"
|
||||
// MaxDeliveriesAdvisories is the subject the server says a given-up message on.
|
||||
MaxDeliveriesAdvisories = "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>"
|
||||
deadLetterPrefix = "mesh.events.dead."
|
||||
againPrefix = "mesh.again."
|
||||
// DeadLettersBytes bounds the kept copies. Full, the stream refuses the next copy, which is said
|
||||
// as the consumer's condition; it never drops one it holds.
|
||||
DeadLettersBytes = 256 << 20
|
||||
)
|
||||
|
||||
// DeadLetterSubject is where a message one consumer gave up on is kept: `mesh.events.dead.<stream>.<consumer>`.
|
||||
func DeadLetterSubject(stream, consumer string) string {
|
||||
return deadLetterPrefix + stream + "." + consumer
|
||||
}
|
||||
|
||||
// DeadLetterOf is the stream and consumer a kept message's subject names; false for any other subject.
|
||||
func DeadLetterOf(subject string) (stream, consumer string, ok bool) {
|
||||
rest, found := strings.CutPrefix(subject, deadLetterPrefix)
|
||||
if !found {
|
||||
return "", "", false
|
||||
}
|
||||
stream, consumer, ok = strings.Cut(rest, ".")
|
||||
return stream, consumer, ok && stream != "" && consumer != "" && !strings.Contains(consumer, ".")
|
||||
}
|
||||
|
||||
// MeshStreams is the foundation set, in the order a person reads it.
|
||||
//
|
||||
// **EVENTS filters on the `event` token**, which is the reason that token exists. A module's
|
||||
// namespace carries both its events and its tool calls; a filter of `mesh.mod.*.>` would persist
|
||||
// every tool invocation in the mesh, and a tool call must never be persisted (design 25 §3 keeps
|
||||
// tools on core NATS, where a lost call is a timeout the caller already handles).
|
||||
func MeshStreams() []Stream {
|
||||
return []Stream{
|
||||
// CONTROL, the ended controller's work queue of what nodes reported, is no longer the mesh's (novox/hq ADR 0590
|
||||
// clause 6): not declared here, which would make it again; the bus module's tools remove it.
|
||||
{
|
||||
Name: "NODES",
|
||||
Subjects: []string{"mesh.node.*.declare"},
|
||||
Retention: RetentionLastPerSubject,
|
||||
Why: "one declaration per node, always the newest; a node that sees sequence n refuses " +
|
||||
"n-1 by construction (issue 107)",
|
||||
},
|
||||
// The assignments stream (ADR 0160) is no longer the mesh's: a runtime reads its membership from the grants
|
||||
// part's state memberships (ADR 0571 clause 7), and the bus module's tools delete the stream at the lease write
|
||||
// (research 072 row 33), so it is not declared here, which would make it again.
|
||||
{
|
||||
Name: EventsStream,
|
||||
// A seat's own events ride here too: they are 1:many like any event, and the
|
||||
// `event` token keeps them clear of both the seat's work queue (`accept`) and its
|
||||
// tools (`tool`), which must not be persisted.
|
||||
// And an event given up on, delivered again to the one consumer that gave it up
|
||||
// (novox/hq issue 330): under `mesh.again.<consumer>.`, which only that consumer filters.
|
||||
Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>", againPrefix + ">"},
|
||||
Retention: RetentionLimits,
|
||||
MaxAge: 7 * 24 * 60 * 60,
|
||||
MaxMsgsPerSubject: 10000,
|
||||
// A restricted dead letter delivered again keeps what is left of its stream's age (ADR 0376 §5).
|
||||
AllowMsgTTL: true,
|
||||
Why: "a subscriber that was down catches up; tool traffic under the same prefix is " +
|
||||
"excluded by the event token; per-subject caps keep a noisy emitter from " +
|
||||
"evicting a quiet one without splitting the stream",
|
||||
},
|
||||
{
|
||||
Name: DeadLetterNoticesStream,
|
||||
Subjects: []string{MaxDeliveriesAdvisories},
|
||||
Retention: RetentionWorkQueue,
|
||||
MaxAge: 7 * 24 * 60 * 60,
|
||||
Why: "the server's word that a consumer gave up on a message, kept until the controller has " +
|
||||
"copied the message into DEAD_LETTERS (novox/hq issue 330); a week, the longest the source " +
|
||||
"streams keep what they are about",
|
||||
},
|
||||
{
|
||||
Name: DeadLettersStream,
|
||||
Subjects: []string{deadLetterPrefix + ">"},
|
||||
Retention: RetentionLimits,
|
||||
MaxBytes: DeadLettersBytes,
|
||||
DiscardNew: true,
|
||||
DuplicatesSeconds: 24 * 60 * 60,
|
||||
AllowMsgTTL: true,
|
||||
Why: "every message a consumer gave up on, with its consumer, subject, deliveries and when, kept " +
|
||||
"until a person delivers it again or drops it with why (novox/hq issue 330); no age but a restricted " +
|
||||
"stream's message, which ages out with its stream (ADR 0376), and full it refuses the next copy " +
|
||||
"rather than drop one it holds",
|
||||
},
|
||||
{
|
||||
Name: ProvAppliedStream,
|
||||
Subjects: []string{ProvAppliedSubjects},
|
||||
Retention: RetentionLastPerSubject,
|
||||
Direct: true,
|
||||
MaxAge: 7 * 24 * 60 * 60,
|
||||
Why: "each machine's provisioner client's newest word on each module's files, applied, waiting or " +
|
||||
"failed with the reason (novox/hq ADR 0349 §4): what a walk's end and a gate wait for (ADR 0362, " +
|
||||
"ADR 0349 §15), read by subject; a week, as the events are",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ProvAppliedStream keeps what each machine's provisioner client last said of each module's files, newest per
|
||||
// subject (novox/hq ADR 0349 §4, ADR 0362, design 57 P6): `prov.applied.<node>.<module>` lies outside `mesh.*`, so
|
||||
// no other stream captures it, and a report said while no controller listened is still there to be read.
|
||||
const ProvAppliedStream = "PROV_APPLIED"
|
||||
|
||||
// ProvAppliedSubjects is every machine's client's reports: `prov.applied.<node>.<module>`, a module's name dots and
|
||||
// all (ProvisionerAppliedSubject).
|
||||
const ProvAppliedSubjects = "prov.applied.>"
|
||||
|
||||
// The ended controller's consumers' names (novox/hq ADR 0590 clause 6): kept only so its leftovers are named for their
|
||||
// removal (ControllerLeftoverConsumers) and the watchman can read whether `controller` on DEAD_LETTER_NOTICES still
|
||||
// stands.
|
||||
const (
|
||||
ControllerName = "controller"
|
||||
MergesConsumerName = "controller-merges"
|
||||
)
|
||||
|
||||
// EventsStream holds every module's and every role's events, a build's log among them.
|
||||
const EventsStream = "EVENTS"
|
||||
@@ -1,105 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
// Declared is one module on one node, as composing its authority needs it.
|
||||
type Declared struct {
|
||||
Module string
|
||||
Emits []string
|
||||
Consumes []string
|
||||
Serves []string
|
||||
// Served are the config keys its served values and zone name fill from its settings (catalogue.ServedSettings): what
|
||||
// every node may read of its resolved values where it runs (Principal.ServedReads, issue 1126).
|
||||
Served []string
|
||||
// Holds are the seats this module claims, with the protocol each seat declares. A seat the
|
||||
// mesh defines for itself declares no protocol, so holding one grants nothing on the bus —
|
||||
// which is right: those seats are about who does a job, not about who may say what.
|
||||
Holds []Seat
|
||||
// Uses are the seats this module sends to.
|
||||
Uses []Seat
|
||||
// Issued are the tools that answer a credential its software issued, one per offer that says so (novox/hq
|
||||
// ADR 0305): the controller is granted to ask each in its own name, and no other tool so.
|
||||
Issued []string
|
||||
// Watches are the seats whose events it consumes.
|
||||
Watches []Seat
|
||||
// Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152).
|
||||
Invokes []string
|
||||
// Waits are the event families a session may wait on through the tool runner, the tool runner's module's
|
||||
// alone (novox/hq ADR 0367 §2): its account subscribes them for a call, through no consumer.
|
||||
Waits []Wait
|
||||
// State is the state it keeps, each a bucket its instances write (novox/hq ADR 0201).
|
||||
State []Bucket
|
||||
// Logs are the logs it keeps, each a stream its instances append to (novox/hq ADR 0297).
|
||||
Logs []Log
|
||||
// Streams are the streams it owns, each its events' one place (novox/hq ADR 0363).
|
||||
Streams []ModuleStream
|
||||
// Queues are its own work-queue streams and the one process each is the consumer of (novox/hq ADR 0436
|
||||
// §1, §2, §6): one durable consumer per entry, named `<node>_<module>_<process id>`.
|
||||
Queues []QueueOwner
|
||||
// Hears are the module streams it hears on this machine, each with the subjects it was granted
|
||||
// (novox/hq ADR 0363 §5); HearsAStream says its `consumes` names a module that owns a stream, granted
|
||||
// or not, so its membership is issued again when a grant changes.
|
||||
Hears []Heard
|
||||
HearsAStream bool
|
||||
// Reads are other modules' state it reads, each `<module>.<name>` (novox/hq ADR 0201).
|
||||
Reads []string
|
||||
// ReadsRefused are the reads its manifest names that the owner's announcement does not make readable (novox/hq ADR
|
||||
// 0495 clause 3, issue 1790): granted nothing, kept to be said.
|
||||
ReadsRefused []string
|
||||
// ReadLogs are, of its reads, those naming another module's log (`<module>.<log>`, novox/hq ADR 0571 clause 1): filled
|
||||
// by Users and PlacementsOf from the logs the modules placed anywhere declare, never by a caller.
|
||||
ReadLogs []string
|
||||
// KeyedReads are keys of other modules' state it reads, each one key alone: what a seat's holder is
|
||||
// granted for the pieces the modules beside it offer (novox/hq ADR 0260).
|
||||
KeyedReads []KeyedRead
|
||||
// NoAccount says the module declares no own secret named broker, so no account could ever be
|
||||
// delivered to it and nothing can connect as it (novox/hq issue 195). Said in the negative so a
|
||||
// record that does not say is composed as it always was.
|
||||
NoAccount bool
|
||||
// SnapshotsTheBus says the module holds mesh-broker — it is the bus — and so is the one module
|
||||
// granted the snapshot API, to copy the bus's streams for the night's backup (novox/hq ADR 0235).
|
||||
SnapshotsTheBus bool
|
||||
// Checks are the module's own tools its health asks, each `<module>.<tool>` (novox/hq ADR 0240, to-be
|
||||
// 48 §3): the machine's node-engine asks them of its own node tools, and is granted that and no more.
|
||||
Checks []string
|
||||
// Loads says the module has a compiled bundle whose entrypoints the machine's tool runtime loads and runs
|
||||
// (novox/hq ADR 0175; issue 1329): a helper that reaches the bus on the runner's account, so the runner
|
||||
// must carry the module although it declares no tools.
|
||||
Loads bool
|
||||
// RunsAs is the account the module runs as in a runtime of its own (novox/hq ADR 0259 §8): it is never
|
||||
// carried by the machine's runtime, and reaches the bus on its own account.
|
||||
RunsAs string
|
||||
// ProvisionsNode says the module runs the machine's provisioner client: it claims NodeProvisionerSeat and
|
||||
// declares the own secret ProvisionerSecret its account is delivered in (novox/hq ADR 0349 clause 11, design 57
|
||||
// P3). The client is given a bus account of its own, never the module's or the tool runner's.
|
||||
ProvisionsNode bool
|
||||
// Processes are this module's own processes that declare bus (novox/hq ADR 0436 §1), each getting a
|
||||
// principal of its own: granted exactly what its bus says, and nothing of this module's other grants —
|
||||
// composed whether or not the module itself has an account (NoAccount), because a process account is
|
||||
// delivered in its own secret, broker-<process id>, never the module's `broker`.
|
||||
Processes []ProcessPrincipal
|
||||
}
|
||||
|
||||
// ProcessPrincipal is one process of a module that declares bus (novox/hq ADR 0436 §1, §2), as the broker
|
||||
// composes its own account from: its own emits, its own work-queue consumers, its own state, whether it
|
||||
// reads the router's records of its module's own asks, and the tools and seat verbs it calls.
|
||||
type ProcessPrincipal struct {
|
||||
// Process is the process resource's id, naming the account <node>.<module>.<process id>.
|
||||
Process string
|
||||
// Emits are the events this process may publish, named as the module's own Emits are.
|
||||
Emits []string
|
||||
// Queues are the module's own work-queue streams this process is the one consumer of.
|
||||
Queues []QueueOwner
|
||||
// Removes are what its account may remove from the bus, as its bus declares them (the indexer's judge refuses any other; novox/hq
|
||||
// ADR 0564 Limits 4): a tools bundle's account, whose whole authority is then BusRemovalGrants.
|
||||
Removes []string
|
||||
// State is the module's own state, by local name, this process reads and writes.
|
||||
State []string
|
||||
// Asks says whether this process reads the records of its module's own asks (novox/hq ADR 0259 §3), on
|
||||
// whichever seats the module uses that keep them (the router's, operator-channel, among others).
|
||||
Asks bool
|
||||
// Invokes are the tools and seat verbs this process calls, each <module>.<tool> or a role's
|
||||
// seat:<seat>.<verb>, or * for every tool on the mesh.
|
||||
Invokes []string
|
||||
}
|
||||
|
||||
// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name.
|
||||
const VaultSeat = "mesh-vault"
|
||||
@@ -1,18 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
// Wait is one event family the tool runner's module declares: its name as the manifest gives it, and the
|
||||
// emitter — a seat or a module — and the event, as the inventory resolved them.
|
||||
type Wait struct {
|
||||
// Name is the family as the manifest names it, `<emitter>.<event>`: what a session asks for.
|
||||
Name string
|
||||
Emitter string
|
||||
// Seat says the emitter is a seat, whose events live under the seat's namespace (novox/hq ADR 0121).
|
||||
Seat bool
|
||||
// Event is the event, with any further token the subject carries: `decided.claude-code`, the asker of a
|
||||
// seat that names it last (ADR 0259 §3).
|
||||
Event string
|
||||
// Last names what the seat's event subject ends in — "kind" for a kinded bench, "asker" for a seat naming
|
||||
// its caller (ADR 0259 §3) — and is empty for an event that ends at its name. A family needing it is waited
|
||||
// on with it given, and refused without.
|
||||
Last string
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// Package busretry waits out a bus that answers nothing for a few seconds (issue 774, the sweep of 2026-10-30: bus
|
||||
// blackouts at every user-list reload). nats-server 2.11's reloadAuthorization rebuilds the accounts, and for a few
|
||||
// seconds the mesh's account has none of its imports, $JS.API.> among them: every request is answered "no responders"
|
||||
// and every JetStream call "jetstream not enabled". A write or a call made in that window failed, and was lost.
|
||||
//
|
||||
// Only an error that says the request reached no one is waited out (Undelivered): nothing was done, so asking again
|
||||
// is safe even for a write that is not idempotent. A timeout, or any answer, is never asked again: the request may
|
||||
// have been delivered. The wait is bounded (Bound, about 30 s, backing off), and said on standard error when it ends
|
||||
// without an answer.
|
||||
//
|
||||
// This is the quick fix. The thorough one is a reload that keeps the account's imports, in the bus's holder.
|
||||
package busretry
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Bound is how long a request that reached no one is asked again; FirstWait the first pause, doubled to MostWait.
|
||||
var (
|
||||
Bound = 30 * time.Second
|
||||
FirstWait = 250 * time.Millisecond
|
||||
MostWait = 4 * time.Second
|
||||
)
|
||||
|
||||
// Waits sets Bound, FirstWait and MostWait and returns what puts the earlier values back. They are state every caller
|
||||
// in a process shares, so a test sets them only through it, as t.Cleanup(busretry.Waits(...)): a test that set them
|
||||
// and left them set shortened the next test's bound to milliseconds, and a loaded check machine overran it (the check
|
||||
// of release sdk1, 2026-11-02). A bound a test waits on to succeed is seconds: no slow machine reaches it.
|
||||
func Waits(bound, first, most time.Duration) (restore func()) {
|
||||
was := [3]time.Duration{Bound, FirstWait, MostWait}
|
||||
Bound, FirstWait, MostWait = bound, first, most
|
||||
return func() { Bound, FirstWait, MostWait = was[0], was[1], was[2] }
|
||||
}
|
||||
|
||||
// Undelivered says err means the request reached no one: no responders, JetStream not enabled (for the account), or
|
||||
// no response from the stream after the client's own retries on no responders; also said in words by a runtime that
|
||||
// passed the error on.
|
||||
func Undelivered(err error) bool {
|
||||
var answered *Answered
|
||||
if err == nil || errors.As(err, &answered) {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, nats.ErrNoResponders) || errors.Is(err, jetstream.ErrJetStreamNotEnabled) ||
|
||||
errors.Is(err, jetstream.ErrJetStreamNotEnabledForAccount) || errors.Is(err, jetstream.ErrNoStreamResponse) ||
|
||||
errors.Is(err, nats.ErrJetStreamNotEnabled) || errors.Is(err, nats.ErrJetStreamNotEnabledForAccount) {
|
||||
return true
|
||||
}
|
||||
words := strings.ToLower(err.Error())
|
||||
return strings.Contains(words, "no responders") || strings.Contains(words, "jetstream not enabled")
|
||||
}
|
||||
|
||||
// Answered is an error a callee answered with: the request was delivered, whatever its words say ("jetstream not
|
||||
// enabled" in a callee's own answer), so Undelivered never counts it and Do never asks it again.
|
||||
type Answered struct{ Words string }
|
||||
|
||||
func (a *Answered) Error() string { return a.Words }
|
||||
|
||||
// Do runs try, and again while its error says the request reached no one, until Bound has passed. blackout, when
|
||||
// given, is asked after each such error and Do stops waiting when it says the bus is not blacked out: a call nobody
|
||||
// answers because its callee is not running fails at once, as before. what names the request in the error.
|
||||
func Do[T any](what string, try func() (T, error), blackout func() bool) (T, error) {
|
||||
v, err := try()
|
||||
if !Undelivered(err) || (blackout != nil && !blackout()) {
|
||||
return v, err
|
||||
}
|
||||
start, wait := time.Now(), FirstWait
|
||||
for time.Since(start) < Bound {
|
||||
time.Sleep(wait)
|
||||
wait = min(wait*2, MostWait)
|
||||
v, err = try()
|
||||
if !Undelivered(err) || (blackout != nil && !blackout()) {
|
||||
return v, err
|
||||
}
|
||||
}
|
||||
err = fmt.Errorf("%s: the bus answered nothing for %s, asked again until then (a user-list reload drops the "+
|
||||
"account's imports for a few seconds; this was longer): %w", what, time.Since(start).Round(time.Second), err)
|
||||
fmt.Fprintf(os.Stderr, "[mesh-sdk] %v\n", err)
|
||||
return v, err
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package busretry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Issue 774 (bus blackouts at every user-list reload): only a request that reached no one is asked again, within the
|
||||
// bound; a blackout probe that says the bus answers ends the wait at once.
|
||||
func TestABusThatAnswersNothingIsWaitedOut(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
errs []error // what each try answers; the last repeats
|
||||
blackout func() bool
|
||||
tries int // at least
|
||||
atMost int
|
||||
delivered bool
|
||||
}{
|
||||
{"no responders, then an answer", []error{nats.ErrNoResponders, nats.ErrNoResponders, nil}, nil, 3, 3, true},
|
||||
{"jetstream not enabled, then an answer", []error{jetstream.ErrJetStreamNotEnabled, nil}, nil, 2, 2, true},
|
||||
{"said in words by a runtime", []error{errors.New("nats: no responders available for request"), nil}, nil, 2, 2, true},
|
||||
{"a timeout may have been delivered: never asked again", []error{context.DeadlineExceeded}, nil, 1, 1, false},
|
||||
{"an answer that refuses is an answer", []error{errors.New("refused")}, nil, 1, 1, false},
|
||||
{"nobody answers on a bus that answers: the callee is not running", []error{nats.ErrNoResponders}, func() bool { return false }, 1, 1, false},
|
||||
{"blacked out past the bound: said", []error{jetstream.ErrJetStreamNotEnabledForAccount}, func() bool { return true }, 2, 1000, false},
|
||||
} {
|
||||
// A row that ends in an answer waits on a bound of seconds, which no slow machine reaches; only the row that
|
||||
// runs past it has a short one, and asks only that it was asked again at least once.
|
||||
bound := 10 * time.Second
|
||||
if strings.HasPrefix(row.name, "blacked out") {
|
||||
bound = 100 * time.Millisecond
|
||||
}
|
||||
t.Cleanup(Waits(bound, time.Millisecond, 4*time.Millisecond))
|
||||
tries := 0
|
||||
_, err := Do("the test", func() (int, error) {
|
||||
e := row.errs[min(tries, len(row.errs)-1)]
|
||||
tries++
|
||||
return tries, e
|
||||
}, row.blackout)
|
||||
if tries < row.tries || tries > row.atMost || (err == nil) != row.delivered {
|
||||
t.Errorf("%s: %d tries, %v", row.name, tries, err)
|
||||
}
|
||||
if strings.HasPrefix(row.name, "blacked out") && (!Undelivered(err) || !strings.Contains(fmt.Sprint(err), "the bus answered nothing")) {
|
||||
t.Errorf("%s: %v", row.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
// Package calladdress reads the key a module calls a tool or a seat verb by, as a module's invokes names it, with the
|
||||
// machine it is asked of after '@': `seat:<seat>.<verb>[@<node>]` or `<module>.<tool>[@<node>]` (novox/hq ADR 0159,
|
||||
// ADR 0469 §3). Every caller in the SDK parses its key here before it publishes anything, so a malformed key fails at
|
||||
// once and says why, never as a call nobody serves and a timeout (novox/hq issue 774, the lease write's stop asked on
|
||||
// `...call.stop@novox-be...`).
|
||||
package calladdress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Address is a parsed key: a seat and its verb, or a module and its tool, and the machine named, if any.
|
||||
type Address struct {
|
||||
Seat, Verb string
|
||||
Module, Tool string
|
||||
Node string
|
||||
}
|
||||
|
||||
// Key is the address as a module's invokes names it, without the machine.
|
||||
func (a Address) Key() string {
|
||||
if a.Seat != "" {
|
||||
return "seat:" + a.Seat + "." + a.Verb
|
||||
}
|
||||
return a.Module + "." + a.Tool
|
||||
}
|
||||
|
||||
// Parse reads a key, refusing one with more than one '@', a node that is not one subject token, or a seat, verb, module
|
||||
// or tool that holds a wildcard, white space or (but a tool) a dot.
|
||||
func Parse(key string) (Address, error) {
|
||||
name, node, err := cutNode(key)
|
||||
if err != nil {
|
||||
return Address{}, err
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(name, "seat:"); ok {
|
||||
seat, verb, ok := strings.Cut(rest, ".")
|
||||
if !ok || seat == "" || verb == "" {
|
||||
return Address{}, fmt.Errorf("%q names no seat and verb (seat:<seat>.<verb>)", key)
|
||||
}
|
||||
if err := tokens(key, seat, verb); err != nil {
|
||||
return Address{}, err
|
||||
}
|
||||
return Address{Seat: seat, Verb: verb, Node: node}, nil
|
||||
}
|
||||
module, tool, ok := strings.Cut(name, ".")
|
||||
if !ok || module == "" || tool == "" {
|
||||
return Address{}, fmt.Errorf("%q is not <module>.<tool>, and does not start seat:<seat>.<verb>", key)
|
||||
}
|
||||
if err := tokens(key, module); err != nil {
|
||||
return Address{}, err
|
||||
}
|
||||
if err := checkTool(key, tool); err != nil {
|
||||
return Address{}, err
|
||||
}
|
||||
return Address{Module: module, Tool: tool, Node: node}, nil
|
||||
}
|
||||
|
||||
// CheckOwn is Parse for a caller that may also name a tool of its own module by its bare name (the runtime's mesh/ask).
|
||||
func CheckOwn(key string) error {
|
||||
name, _, err := cutNode(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(name, "seat:") && !strings.Contains(name, ".") {
|
||||
return tokens(key, name)
|
||||
}
|
||||
_, err = Parse(key)
|
||||
return err
|
||||
}
|
||||
|
||||
// CheckNode refuses a node name that is not one subject token.
|
||||
func CheckNode(node string) error {
|
||||
if node == "" || strings.ContainsAny(node, ".*>@ \t\r\n") {
|
||||
return fmt.Errorf("%q is not a node name (one subject token: no '.', '*', '>', '@' or space)", node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cutNode(key string) (name, node string, err error) {
|
||||
name, node, at := strings.Cut(key, "@")
|
||||
if !at {
|
||||
return key, "", nil
|
||||
}
|
||||
if strings.Contains(node, "@") {
|
||||
return "", "", fmt.Errorf("%q holds more than one '@': a key names at most one machine", key)
|
||||
}
|
||||
if err := CheckNode(node); err != nil {
|
||||
return "", "", fmt.Errorf("%q: %w", key, err)
|
||||
}
|
||||
return name, node, nil
|
||||
}
|
||||
|
||||
func tokens(key string, parts ...string) error {
|
||||
for _, p := range parts {
|
||||
if p == "" || strings.ContainsAny(p, ".*> \t\r\n") {
|
||||
return fmt.Errorf("%q: %q is not one subject token (no '.', '*', '>' or space)", key, p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkTool refuses a tool name with a wildcard or white space; a dot is kept, as the tool runner serves it.
|
||||
func checkTool(key, tool string) error {
|
||||
if strings.ContainsAny(tool, "*> \t\r\n") || strings.HasPrefix(tool, ".") || strings.HasSuffix(tool, ".") {
|
||||
return fmt.Errorf("%q: %q is not one subject token (no '*', '>' or space)", key, tool)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package calladdress
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Issue 774 notes 84-87 (the lease write's stop asked of `seat:node-service-manager.stop@novox-be` published on a subject
|
||||
// nobody serves and timed out after 30 s): a key names its machine after '@', and every other malformed key is refused
|
||||
// before anything is published, naming why.
|
||||
func TestAKeyIsParsedOrRefusedBeforeItIsPublished(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
key, want string // want: "seat|<seat>|<verb>|<node>", "mod|<module>|<tool>|<node>", or a refusal's words
|
||||
}{
|
||||
{"seat:node-service-manager.stop@novox", "seat|node-service-manager|stop|novox"},
|
||||
{"seat:operator-channel.tell", "seat|operator-channel|tell|"},
|
||||
{"mesh-build-go.fetch@ace", "mod|mesh-build-go|fetch|ace"},
|
||||
{"nats.nats_users", "mod|nats|nats_users|"},
|
||||
{"seat:node-service-manager.stop@novox@g14", "more than one '@'"},
|
||||
{"seat:node-service-manager.stop@", "not a node name"},
|
||||
{"seat:node-service-manager.stop@nov.ox", "not a node name"},
|
||||
{"seat:node-files.*", "not one subject token"},
|
||||
{"mesh-build-go.fe tch", "not one subject token"},
|
||||
{"mesh>build.fetch", "not one subject token"},
|
||||
{"seat:noverb", "names no seat and verb"},
|
||||
{"nodot", "is not <module>.<tool>"},
|
||||
{"", "is not <module>.<tool>"},
|
||||
} {
|
||||
a, err := Parse(row.key)
|
||||
got := ""
|
||||
switch {
|
||||
case err != nil:
|
||||
got = err.Error()
|
||||
if !strings.Contains(got, row.want) {
|
||||
t.Errorf("Parse(%q) refused %q, want %q", row.key, got, row.want)
|
||||
}
|
||||
continue
|
||||
case a.Seat != "":
|
||||
got = "seat|" + a.Seat + "|" + a.Verb + "|" + a.Node
|
||||
default:
|
||||
got = "mod|" + a.Module + "|" + a.Tool + "|" + a.Node
|
||||
}
|
||||
if got != row.want {
|
||||
t.Errorf("Parse(%q) = %s, want %s", row.key, got, row.want)
|
||||
}
|
||||
}
|
||||
if a, err := Parse("seat:node-files.read@g14"); err != nil || a.Key() != "seat:node-files.read" {
|
||||
t.Errorf("the key without its machine: %q, %v", a.Key(), err)
|
||||
}
|
||||
if err := CheckOwn("list@ace"); err != nil {
|
||||
t.Errorf("a module's own tool by its bare name was refused: %v", err)
|
||||
}
|
||||
if err := CheckOwn("li*st"); err == nil {
|
||||
t.Error("a bare name that is not one token was accepted")
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
// callWith serves one tool that answers the caller CallerOf reads from its arguments, sends it one tools/call
|
||||
// with the params given, and returns the caller it answered.
|
||||
func callWith(t *testing.T, params map[string]any) string {
|
||||
servesAlone(t)
|
||||
t.Helper()
|
||||
inR, inW := io.Pipe()
|
||||
outR, outW := io.Pipe()
|
||||
@@ -76,21 +75,6 @@ func TestACallerInTheArgumentsIsNeverTheCaller(t *testing.T) {
|
||||
if got != "shanks.node-tools" {
|
||||
t.Fatalf("with a caller in the arguments the tool read %q, want the runtime's", got)
|
||||
}
|
||||
// A module's trusted list of callers (issue 774, ADR 0563 §8) grants the callers it names as the runtime proved them:
|
||||
// a module on any machine, <machine>.<module> exactly, person.<name> exactly; no wildcard, never one unproven.
|
||||
callers, err := ParseCallers([]byte(`{"callers":["node-tools","ace.claude-code","person.jo","*"]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for caller, want := range map[string]bool{"shanks.node-tools": true, "ace.claude-code": true, "g14.claude-code": false,
|
||||
"person.jo": true, "person.node-tools": false, Unproven: false, "": false, "novox.mesh-vault": false} {
|
||||
if callers.Allows(caller) != want {
|
||||
t.Errorf("%q allowed: %v, want %v", caller, !want, want)
|
||||
}
|
||||
}
|
||||
if _, err := ParseCallers([]byte(`{"callers":["x"],"more":1}`)); err == nil {
|
||||
t.Error("a list with a field it does not know was read")
|
||||
}
|
||||
}
|
||||
|
||||
// A runtime that names no caller — one from before issue 365, or a call on the subject that names none — is
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package stdio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Callers is a module's trusted list of who may call a verb of its (novox/hq ADR 0459 §2, ADR 0563 §8), as the mesh
|
||||
// writes a list setting into a file the module reads: `{"callers": [...]}`. An entry is a module's name (that module on
|
||||
// any machine), exactly <machine>.<module>, or exactly person.<name>. Each caller is the runtime's proof (CallerOf), never
|
||||
// an argument; nobody beyond the list is granted, a wildcard is no entry, and an unproven caller never matches.
|
||||
type Callers []string
|
||||
|
||||
// ParseCallers reads a callers file: the list under `callers`, and nothing else.
|
||||
func ParseCallers(raw []byte) (Callers, error) {
|
||||
var f struct {
|
||||
Callers Callers `json:"callers"`
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&f); err != nil {
|
||||
return nil, fmt.Errorf("the callers setting is not a list of callers: %v", err)
|
||||
}
|
||||
return f.Callers, nil
|
||||
}
|
||||
|
||||
// Allows says whether caller, as the runtime proved it, is one the list names.
|
||||
func (c Callers) Allows(caller string) bool {
|
||||
if caller == "" || caller == Unproven {
|
||||
return false
|
||||
}
|
||||
for _, entry := range c {
|
||||
if entry == "" || strings.ContainsAny(entry, "*> ") {
|
||||
continue
|
||||
}
|
||||
if entry == caller {
|
||||
return true
|
||||
}
|
||||
if !strings.Contains(entry, ".") {
|
||||
if _, module, ok := strings.Cut(caller, "."); ok && module == entry && !strings.HasPrefix(caller, "person.") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq issue 213 (ADR 0188 §1, §3): a module's own Go service is a bundle the host runs as a
|
||||
// process, not an image. Each test holds one thing that had to change in the composer for the
|
||||
// controller to be declared that way.
|
||||
|
||||
const aServiceDigest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
|
||||
// aServiceModule is the controller's shape in miniature: it answers tools of its own, its code is a
|
||||
// Go bundle a process runs as an account it declares, its secrets belong to that account, it
|
||||
// prepares its state, and its process replaces the container it used to run as.
|
||||
func aServiceModule(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
raw := `{
|
||||
"module": "svc", "version": "1", "tools": ["status"], "prepares": true,
|
||||
"own-secrets": {"store": "${dir:state}/store"},
|
||||
"secrets-owner": "svc",
|
||||
"resources": [
|
||||
{"id": "state", "type": "directory", "mode": "0700", "place": "mesh", "owner": "svc"},
|
||||
{"id": "service", "type": "process", "name": "svc", "artifact": "code",
|
||||
"run": ["./svc", "serve"], "user": "svc", "replaces": ["server"],
|
||||
"env": {"SVC_STORE_FILE": "${dir:state}/store", "SVC_STORE_PORT": "${seat:mesh-store:5432}"}},
|
||||
{"id": "account", "type": "user", "name": "svc", "shell": "/usr/bin/nologin", "home": "/var/lib/svc"}
|
||||
],
|
||||
"build": {"artifacts": [{"name": "code", "kind": "bundle", "language": "go", "system": "arch",
|
||||
"from": "cmd/svc", "binary": "svc"}]}
|
||||
}`
|
||||
m, err := decodeForTest([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("the service's manifest is refused: %v", err)
|
||||
}
|
||||
resolved, err := m.Resolve([]Built{{Name: "code", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "svc/code@" + aServiceDigest, Digest: aServiceDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func indexOf(out []map[string]any, id string) int {
|
||||
for i, r := range out {
|
||||
if r["id"] == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// A module that declares tools has every bundle served by the node's runtime unless it says
|
||||
// otherwise — and the controller declares the verbs it answers as tools. Its service bundle is run
|
||||
// by its own process; launched a second time by the runtime it would be a second controller
|
||||
// pretending to be an MCP server.
|
||||
func TestABundleItsOwnProcessRunsIsNotServedByTheRuntime(t *testing.T) {
|
||||
m := aServiceModule(t)
|
||||
if len(m.Bundles) != 1 {
|
||||
t.Fatalf("the service's bundle was not kept: %+v", m.Bundles)
|
||||
}
|
||||
if loads := m.Bundles[0].Loads; len(loads) != 0 {
|
||||
t.Fatalf("the runtime would launch the service's own bundle as tools: %v", loads)
|
||||
}
|
||||
// And a bundle no resource runs still is served, as a module declaring tools always had it.
|
||||
tools := Manifest{Module: "t", Version: "1", Tools: []string{"x"},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/t"}}}}
|
||||
resolved, err := tools.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "t/tools@" + aServiceDigest, Digest: aServiceDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loads := resolved.Bundles[0].Loads; len(loads) != 1 || loads[0] != "t" {
|
||||
t.Fatalf("a tools bundle nothing runs is no longer served: %v", loads)
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AdoptionPrefix is the id prefix of what the mesh declares of its own on an adopted node. It is
|
||||
// never a module's, so none of it is ever held as found.
|
||||
const AdoptionPrefix = "adoption."
|
||||
|
||||
// Where an opening admits from, on the wire.
|
||||
const (
|
||||
OpeningFromEverywhere = "everywhere"
|
||||
OpeningFromMesh = "mesh"
|
||||
)
|
||||
|
||||
// The two paths a packet reaches a port by: received by the machine, or forwarded to a
|
||||
// container that publishes it.
|
||||
const (
|
||||
PathIncoming = "incoming"
|
||||
PathForwarded = "forwarded"
|
||||
)
|
||||
|
||||
// Guard resources: the refusal-only table, the unit that loads it, and that unit running.
|
||||
const (
|
||||
GuardPath = "/etc/mesh/guard.nft"
|
||||
GuardUnit = "mesh-guard.service"
|
||||
// GuardUnitPath is where the unit is written.
|
||||
GuardUnitPath = "/etc/systemd/system/" + GuardUnit
|
||||
)
|
||||
|
||||
// GuardPackage is the package that carries nft.
|
||||
const GuardPackage = "nftables"
|
||||
|
||||
// Published is every port the given containers publish on the machine, by protocol and machine
|
||||
// port, mapped to the container's own port. A mapping bound to loopback is left out: nothing off
|
||||
// the machine reaches it, forwarded or not.
|
||||
func Published(resources []map[string]any) map[string]map[int]int {
|
||||
out := map[string]map[int]int{}
|
||||
for _, r := range resources {
|
||||
if fmt.Sprint(r["type"]) != "container" {
|
||||
continue
|
||||
}
|
||||
listed, _ := r["ports"].([]any)
|
||||
for _, entry := range listed {
|
||||
written := strings.TrimSpace(fmt.Sprint(entry))
|
||||
protocol := "tcp"
|
||||
if cut := strings.LastIndex(written, "/"); cut >= 0 {
|
||||
protocol = written[cut+1:]
|
||||
}
|
||||
// Indexed from the end, so an IPv6 address's own colons never shift the ports.
|
||||
outer, inner, address, ok := mapping(written)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.Trim(address, "[]") {
|
||||
case "127.0.0.1", "localhost", "::1":
|
||||
continue
|
||||
}
|
||||
if out[protocol] == nil {
|
||||
out[protocol] = map[int]int{}
|
||||
}
|
||||
out[protocol][outer] = inner
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0100: on an adopted node the found firewall stays in force. The mesh declares
|
||||
// openings where it would have loaded a filter, and guards its own ports in a table that only
|
||||
// refuses.
|
||||
|
||||
// hub is the private network's generator on the hub: it opens the hub's port from anywhere.
|
||||
type hub struct{}
|
||||
|
||||
// anAdoptedAnchor is the control-node's set: the store, the bus, the registry, the private network,
|
||||
// a served module and the filter module.
|
||||
func anAdoptedAnchor() Resolution {
|
||||
return Resolution{Node: "anchor", Modules: []Manifest{
|
||||
{Module: "network", Computed: "overlay"},
|
||||
{Module: "postgres", Guards: []int{5432},
|
||||
Listens: []Listening{{Port: 5432, From: FromMesh}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store",
|
||||
"ports": []any{"5432:5432"}}}},
|
||||
{Module: "lavinmq", Guards: []int{15672},
|
||||
Listens: []Listening{{Port: 5671, From: FromMesh}, {Port: 5672, From: FromMesh}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-broker",
|
||||
"ports": []any{"5671:5671", "5672:5672", "127.0.0.1:15672:15672"}}}},
|
||||
{Module: "distribution",
|
||||
Listens: []Listening{{Port: 5000, From: FromMesh}},
|
||||
Resources: []map[string]any{{"id": "store", "type": "container", "name": "registry",
|
||||
"ports": []any{"5000"}}}},
|
||||
{Module: "hello-web",
|
||||
Listens: []Listening{{Port: 8080, From: FromEverywhere}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web",
|
||||
"ports": []any{"8080"}}}},
|
||||
{Module: "helper", Listens: []Listening{{Port: 9000, From: FromMachine}}},
|
||||
{Module: "nftables", Filtering: &Filtering{Into: "/etc/nftables.conf"},
|
||||
Resources: []map[string]any{{"id": "load", "type": "service", "unit": "mesh-filter.service",
|
||||
"state": "running", "restart-on": []any{"filtering"}}}},
|
||||
}}
|
||||
}
|
||||
|
||||
func byID(resources []map[string]any) map[string]map[string]any {
|
||||
out := map[string]map[string]any{}
|
||||
for _, r := range resources {
|
||||
out[r["id"].(string)] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A mapping bound to loopback is not published to anything off the machine — in either address
|
||||
// family — and an address's own colons never shift the ports.
|
||||
func TestPublishedLeavesOutLoopbackInBothFamilies(t *testing.T) {
|
||||
got := Published([]map[string]any{{"type": "container", "ports": []any{
|
||||
"127.0.0.1:15672:15672", "[::1]:8080:80", "localhost:9090:90",
|
||||
"[::]:8443:443", "0.0.0.0:5000:5000", "5353:53/udp"}}})
|
||||
want := map[string]map[int]int{"tcp": {8443: 443, 5000: 5000}, "udp": {5353: 53}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("published is %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/internal/beside"
|
||||
)
|
||||
|
||||
// **The word does not come back through a manifest** (novox/hq ADR 0131). A module that wants
|
||||
// messaging wants the mesh's bus, reached through the sdk and named by the `mesh-broker` seat. Naming
|
||||
// the old wire protocol asks for the one server being retired, so both directions are refused at the
|
||||
// parser — this is judged from the manifest alone, no store needed.
|
||||
|
||||
// And the catalogue (internal/beside) names it nowhere — the three modules that did are removed under
|
||||
// design 28 task 5.4, not converted.
|
||||
func TestNoCatalogueManifestNamesAmqp(t *testing.T) {
|
||||
modules, err := filepath.Glob(filepath.Join(beside.Catalogue(t), "*", "module.json"))
|
||||
if err != nil || len(modules) == 0 {
|
||||
t.Fatalf("no manifests in the catalogue, so this proved nothing: %v", err)
|
||||
}
|
||||
for _, path := range modules {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `"amqp"`) {
|
||||
t.Errorf("%s names amqp, which is not a provision (novox/hq ADR 0131)",
|
||||
filepath.Base(filepath.Dir(path)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
// ArtifactSweepSeat is the seat artifact-store-tools holds. Not the seat mesh-artifact-store, the store itself held by
|
||||
// distribution, and not the provision artifact-store it delivers.
|
||||
const ArtifactSweepSeat = "mesh-artifact-sweep"
|
||||
|
||||
// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks.
|
||||
func artifactSweepVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " +
|
||||
"repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " +
|
||||
"newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " +
|
||||
"unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.",
|
||||
Input: schema(map[string]string{
|
||||
"repository": "only this repository's references, as <module>/<artifact> (the counts stay whole)",
|
||||
"collected": "\"true\": list the collected references too (counted always)",
|
||||
}, nil, "collected")},
|
||||
{Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " +
|
||||
"held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. " +
|
||||
"Reads only.",
|
||||
Input: listed(schema(map[string]string{
|
||||
"references": "the artifacts' references, artifact-store://<module>/<artifact>/blobs/sha256:<hex>: a list, " +
|
||||
"or one text separated by commas",
|
||||
}, []string{"references"}), "references")},
|
||||
{Name: "collect", Description: "The artifact store's sweep, now: what is eligible is let go of. Without confirm a " +
|
||||
"dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " +
|
||||
"then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " +
|
||||
"names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " +
|
||||
"seconds; what is left is said. Never touches a digest the mesh did not make.",
|
||||
Input: schema(map[string]string{
|
||||
"confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done",
|
||||
"why": "with confirm: why, required and recorded",
|
||||
"most": "let go of at most this many (500 by default, at most 5000)",
|
||||
}, nil, "confirm")},
|
||||
{Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " +
|
||||
"it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " +
|
||||
"it: never removed), by what the builder's log says each build stood on. Records nothing by hand.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "images", Description: "Every container image one machine's want and have name, each with the resources " +
|
||||
"naming it: what the machine keeps when it prunes its images. Reads only.",
|
||||
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ArtifactStoreScheme marks a reference to something in the mesh's artifact store, kept without
|
||||
// the store's address.
|
||||
const ArtifactStoreScheme = ArtifactStoreProvision + "://"
|
||||
|
||||
// Recorded is a reference as the mesh records it: the artifact store's address, if the builder wrote
|
||||
// one, taken off.
|
||||
//
|
||||
// For a reference the builder announced — one it pushed to the store — which is the only kind
|
||||
// this is called on. An image the builder named `<host>/<path>@sha256:…` is kept as its path; an
|
||||
// archive it named `http://<host>/v2/<path>/blobs/<digest>` likewise. A reference with no address
|
||||
// in it — an image id from a genesis build that had nowhere to publish, a package version — is
|
||||
// what it was.
|
||||
func Recorded(reference string) string {
|
||||
if strings.HasPrefix(reference, ArtifactStoreScheme) {
|
||||
return reference
|
||||
}
|
||||
if rest, isURL := strings.CutPrefix(reference, "http://"); isURL {
|
||||
if _, path, ok := strings.Cut(rest, "/v2/"); ok && strings.Contains(path, "/blobs/") {
|
||||
return ArtifactStoreScheme + path
|
||||
}
|
||||
return reference
|
||||
}
|
||||
host, path, ok := strings.Cut(reference, "/")
|
||||
if !ok || !isRegistryHost(host) || !strings.Contains(path, "@sha256:") {
|
||||
return reference
|
||||
}
|
||||
return ArtifactStoreScheme + path
|
||||
}
|
||||
|
||||
// isRegistryHost is the runtime's own rule for reading the first component of a reference as a
|
||||
// registry rather than as a namespace: it has a dot or a port in it, or it is localhost.
|
||||
func isRegistryHost(component string) bool {
|
||||
return component == "localhost" || strings.ContainsAny(component, ".:")
|
||||
}
|
||||
|
||||
// InArtifactStore reports whether a reference is a kept one, and what it names there.
|
||||
func InArtifactStore(reference string) (path string, kept bool) {
|
||||
return strings.CutPrefix(reference, ArtifactStoreScheme)
|
||||
}
|
||||
|
||||
// Routed is a kept reference as a machine fetches it, through the artifact store at `address`
|
||||
// (host:port). A reference that is not a kept one is what it was.
|
||||
func Routed(reference, address string) string {
|
||||
path, kept := InArtifactStore(reference)
|
||||
if !kept {
|
||||
return reference
|
||||
}
|
||||
if strings.Contains(path, "/blobs/") {
|
||||
return "http://" + address + "/v2/" + path
|
||||
}
|
||||
return address + "/" + path
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A build is recorded by what it is; where it is pushed is composed where it is used (novox/hq
|
||||
// 04-ISSUES/102).
|
||||
|
||||
var digest = "sha256:" + strings.Repeat("d", 64)
|
||||
|
||||
func TestAReferenceIsRecordedWithoutTheStoresAddress(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"anchor.internal:5100/gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest,
|
||||
"localhost:5000/gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest,
|
||||
"http://anchor.internal:5100/v2/gitea/config/blobs/" + digest: ArtifactStoreScheme + "gitea/config/blobs/" + digest,
|
||||
ArtifactStoreScheme + "gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest,
|
||||
digest: digest,
|
||||
"@novox/sdk@1.2.3": "@novox/sdk@1.2.3",
|
||||
"gitea/gitea@" + digest: "gitea/gitea@" + digest,
|
||||
"https://registry.example/v2/gitea/config/blobs/" + digest: "https://registry.example/v2/gitea/config/blobs/" + digest,
|
||||
}
|
||||
for announced, want := range cases {
|
||||
if got := Recorded(announced); got != want {
|
||||
t.Errorf("Recorded(%q) = %q, want %q", announced, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
// BackupNamespace is the placeholder kind of the machine's backup lines and data.
|
||||
const BackupNamespace = "backup"
|
||||
@@ -1,10 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
// A machine's backup lines are placement (design 57 L3, issue 794): the holder of node-backup names them as
|
||||
// `${backup:lines}` and `${backup:data}`, which a handed holder has its client fill from the plan and an unhanded
|
||||
// one has the controller fill as it fills `${contribution:node-backup:backup}` and `${...:data}`: the same text.
|
||||
|
||||
func dataModule(name string) string {
|
||||
return `{"module":"` + name + `","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}],
|
||||
"data":{"own":[{"id":"d","path":"${dir:d}","class":"irreplaceable","why":"the ` + name + ` data"}]}}`
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Naming the image directly is the way out, and must stay accepted.
|
||||
func TestAnArtifactStoreThatNamesItsImageIsAccepted(t *testing.T) {
|
||||
_, err := decodeForTest([]byte(`{"module":"registry","version":"1",` +
|
||||
`"provides":[{"name":"artifact-store","scope":"mesh"}],` +
|
||||
`"resources":[{"id":"store","type":"container","name":"mesh-registry",` +
|
||||
`"image":"registry@sha256:` +
|
||||
`266f282fabd7cd3df053ee7c658c77b42380d1a2f0d8e5a1c0d7a6d5b5c4a3b2",` +
|
||||
`"ports":["5000:5000"]}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("the one way an artifact store can be delivered was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// And an ordinary module still builds whatever it likes.
|
||||
func TestAModuleThatDoesNotProvideTheStoreStillBuilds(t *testing.T) {
|
||||
_, err := decodeForTest([]byte(`{"module":"forge","version":"1",` +
|
||||
`"build":{"artifacts":[{"name":"forge","kind":"upstream","from":"gitea/gitea:1.22"}]},` +
|
||||
`"resources":[{"id":"run","type":"container","name":"forge","artifact":"forge"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("an ordinary module was caught by a rule about the artifact store: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A mapping that names an address still names the port, and the machine side is still the middle.
|
||||
//
|
||||
// The foundation bundle writes "127.0.0.1:5432:5432" today, so the shape is not hypothetical.
|
||||
// Found in review: the first cut split on the first colon, read "127.0.0.1" as the machine port,
|
||||
// failed to parse it, and silently skipped the mapping — which put the filter back on the
|
||||
// declared port, the exact fault MachineSide was written to end.
|
||||
func TestAnAddressedMappingStillNamesThePort(t *testing.T) {
|
||||
m := Manifest{Module: "store", Resources: []map[string]any{
|
||||
{"type": "container", "id": "server", "ports": []any{"127.0.0.1:8080:80"}},
|
||||
}}
|
||||
for _, named := range []int{8080, 80} {
|
||||
at, mayAssign := m.MachineSide(named)
|
||||
if mayAssign {
|
||||
t.Fatalf("%d was reassigned though the manifest published it explicitly", named)
|
||||
}
|
||||
if at != 8080 {
|
||||
t.Fatalf("naming %d gave %d; the machine side of 127.0.0.1:8080:80 is 8080", named, at)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// KeptBinding is one consumer this resolution would have moved, and did not.
|
||||
type KeptBinding struct {
|
||||
// Machine is where the consumer runs, and Consumer the module there that is bound.
|
||||
Machine string
|
||||
Consumer string
|
||||
// Provision is what it is bound for.
|
||||
Provision string
|
||||
// Bound is the provider it was recorded at, and still is; Would is the one the resolution chose.
|
||||
Bound Chosen
|
||||
Would Chosen
|
||||
}
|
||||
|
||||
// String is the condition's sentence: the move, where the data is, and the act that confirms it.
|
||||
func (k KeptBinding) String() string {
|
||||
return fmt.Sprintf("would move %s's %s from %s to %s — its data is on %s; kept there. "+
|
||||
"`pin %s %s %s %s` to confirm a move (and move the data first)",
|
||||
k.Consumer, k.Provision, k.Bound, k.Would, k.Bound, k.Machine, k.Provision, k.Would.Node, k.Would.Module)
|
||||
}
|
||||
|
||||
// Unbound is one consumer that still asks for a provision and whose own resolution binds it to
|
||||
// another provider than the one a pair credential on record was made with (novox/hq issue 274).
|
||||
//
|
||||
// **A provider is granted exactly the consumers bound to it.** The credential from the old provider
|
||||
// stays on record — it is the key to a login that provider keeps, disabled, with the consumer's data,
|
||||
// until a person deletes it with `cleanup delete` (ADR 0230), and a pin back must find it — but it is
|
||||
// no longer granted, so the provider stops being asked for it and retires it. Said on every plan and
|
||||
// push of the provider, so a credential the mesh keeps and does not use is never kept silently.
|
||||
type Unbound struct {
|
||||
// Provision is what was required, Provider the machine the credential on record is from.
|
||||
Provision string `json:"provision"`
|
||||
Provider string `json:"provider"`
|
||||
// Consumer is the machine, Module the module on it that requires it, Local the credential's
|
||||
// name inside it where it keeps several (ADR 0094).
|
||||
Consumer string `json:"consumer"`
|
||||
Module string `json:"module"`
|
||||
Local string `json:"local,omitempty"`
|
||||
// BoundTo is every provider the consumer's resolution binds this credential to now; empty when
|
||||
// it binds it nowhere — the module asks for the provision under other local names.
|
||||
BoundTo []string `json:"bound_to,omitempty"`
|
||||
}
|
||||
|
||||
func (u Unbound) String() string {
|
||||
who := u.Module
|
||||
if u.Local != "" {
|
||||
who += " (as " + u.Local + ")"
|
||||
}
|
||||
now := "is bound to no provider under that name"
|
||||
if len(u.BoundTo) > 0 {
|
||||
now = "is bound to " + strings.Join(u.BoundTo, ", ")
|
||||
}
|
||||
return fmt.Sprintf("%s on %s %s for %s, not to %s — %s no longer grants it, so it retires that "+
|
||||
"login and keeps its data until `cleanup delete` (ADR 0230); the credential from %s stays on "+
|
||||
"record while that login does", who, u.Consumer, now, u.Provision, u.Provider, u.Provider, u.Provider)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
func orNothing(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return "nothing"
|
||||
}
|
||||
return join(names)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A name too long to stay distinct is refused before anything is created.
|
||||
func TestAnIdentityTooLongToBeDistinctIsRefused(t *testing.T) {
|
||||
if err := CheckIdentity("anchor", "keycloak"); err != nil {
|
||||
t.Fatalf("an ordinary consumer was refused: %v", err)
|
||||
}
|
||||
if err := CheckIdentity(strings.Repeat("n", 40), strings.Repeat("m", 40)); err == nil {
|
||||
t.Fatal("an identity that providers would shorten was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// Two modules on one node get two identities, which is the whole point of both issues.
|
||||
func TestTwoModulesOnOneNodeAreTwoIdentities(t *testing.T) {
|
||||
if ConsumerIdentity("anchor", "gitea") == ConsumerIdentity("anchor", "keycloak") {
|
||||
t.Fatal("two consumers on one machine share an identity")
|
||||
}
|
||||
// And one module on two nodes, likewise.
|
||||
if ConsumerIdentity("anchor", "gitea") == ConsumerIdentity("laptop", "gitea") {
|
||||
t.Fatal("one module on two machines shares an identity")
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A consumer of a provision that keeps its data stays bound where its data is (novox/hq ADR 0232,
|
||||
// issue 273).
|
||||
//
|
||||
// The incident, exactly: a machine runs its own store and five consumers of it; the mesh's store seat
|
||||
// is held by the store on another machine. Issue 258's rule — the seat's holder elsewhere answers
|
||||
// before this machine's own provider — re-bound all five to the holder, each was made a fresh, empty
|
||||
// database there, and nothing said so.
|
||||
|
||||
func TestAnOfferSaysWhetherItKeepsConsumerData(t *testing.T) {
|
||||
var o Offer
|
||||
if err := json.Unmarshal([]byte(`{"name":"wildcard-resolution","scope":"mesh","keeps-consumer-data":false}`), &o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o.KeepsConsumerData == nil || *o.KeepsConsumerData {
|
||||
t.Fatalf("read %+v", o)
|
||||
}
|
||||
out, err := json.Marshal(o)
|
||||
if err != nil || !strings.Contains(string(out), `"keeps-consumer-data":false`) {
|
||||
t.Fatalf("wrote %s, %v", out, err)
|
||||
}
|
||||
yes, no := true, false
|
||||
granting := Manifest{Module: "g", Provides: []Offer{{Name: "p", Scope: ScopeMesh}}, Grants: map[string]string{"p": "/g"}}
|
||||
if !granting.KeepsConsumerData("p") {
|
||||
t.Error("a provider granting each consumer a credential keeps what it writes, unsaid")
|
||||
}
|
||||
if (Manifest{Module: "r", Provides: []Offer{{Name: "p", Scope: ScopeMesh}}}).KeepsConsumerData("p") {
|
||||
t.Error("a provider granting nothing keeps nothing, unsaid")
|
||||
}
|
||||
granting.Provides[0].KeepsConsumerData = &no
|
||||
if granting.KeepsConsumerData("p") {
|
||||
t.Error("what an offer says, it gets")
|
||||
}
|
||||
said := Manifest{Module: "s", Provides: []Offer{{Name: "p", Scope: ScopeMesh, KeepsConsumerData: &yes}}}
|
||||
if !said.KeepsConsumerData("p") || !KeepsConsumerData(map[string]Manifest{"s": said, "r": {Module: "r",
|
||||
Provides: []Offer{{Name: "p", Scope: ScopeMesh, KeepsConsumerData: &no}}}}, "p") {
|
||||
t.Error("a name any provider says keeps data keeps data")
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The old broker is gone from the catalogue (novox/hq ADR 0131, design 28 task 5.4), so it is no
|
||||
// longer a fixture here. That two eligible holders stand beside each other with one on record is
|
||||
// pinned in holdings_test.go against manifests this package owns.
|
||||
|
||||
// **A seat and the interface it delivers are different names, and renaming one must not rename
|
||||
// the other** (novox/hq ADR 0118). This nearly went wrong: the seats were renamed to the `mesh-*`
|
||||
// prefix, and a blanket search-and-replace also renamed `npm-package-registry` and `git` where
|
||||
// they are *provisions* — which a consumer requires and a provider offers. The tests failed with
|
||||
// "the package registry is served on <nil>", which does not say "you renamed an interface".
|
||||
func TestRenamingASeatDidNotRenameTheInterfaceItDelivers(t *testing.T) {
|
||||
for _, pair := range []struct{ seat, delivers string }{
|
||||
{"git", "git"},
|
||||
{"npm-package-registry", "npm-package-registry"},
|
||||
{"mesh-artifact-store", "artifact-store"},
|
||||
{"mesh-store", "postgres-database"},
|
||||
{"mesh-broker", "mesh-bus"},
|
||||
} {
|
||||
s, known := SeatNamed(pair.seat)
|
||||
if !known {
|
||||
t.Fatalf("%q is not a seat", pair.seat)
|
||||
}
|
||||
if s.Delivers != pair.delivers {
|
||||
t.Errorf("the %s seat delivers %q, expected %q — renaming the seat moved the "+
|
||||
"interface with it, and every consumer requiring it would stop resolving",
|
||||
pair.seat, s.Delivers, pair.delivers)
|
||||
}
|
||||
// **Three of these deliberately share a name with what they deliver**, and that is not an
|
||||
// incomplete rename. Renaming a seat that delivers a provision cascades to every consumer
|
||||
// requiring it, with a mesh-wide window where a holder stops resolving mid-flight — so the
|
||||
// trunk deferred exactly those three (novox/hq ADR 0121) while renaming the node-scoped ones.
|
||||
// What this test is for is the other direction: that renaming a seat never moves the
|
||||
// interface, which once produced "the package registry is served on <nil>".
|
||||
}
|
||||
}
|
||||
|
||||
// A manifest written against an old seat name is told what it became rather than refused as
|
||||
// unknown. **That map is the controller's store now, not this package** (novox/hq ADR 0122): a
|
||||
// rename is a row, so the courtesy survives a rename nobody recompiled for. Checked where the
|
||||
// table is read, not here, where there is no longer a hardcoded list to check against.
|
||||
@@ -1,35 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Where the answer to a requirement is allowed to live.
|
||||
//
|
||||
// A shell, a display server and a private network have to be on the machine that needs them. A
|
||||
// database does not — it runs somewhere and is reached over the network. Both were written
|
||||
// `requires`, so both were answered the same way, and the second answer was to install PostgreSQL
|
||||
// on every machine that runs a web application.
|
||||
|
||||
// onNetwork is a set of providers, all of them reachable. Written as a helper because a machine
|
||||
// that cannot reach the one answering its requirement is its own case, tested separately.
|
||||
// reachable is this machine, on the private network.
|
||||
func reachable() Node {
|
||||
n := workstation()
|
||||
n.At = "workstation.internal"
|
||||
return n
|
||||
}
|
||||
|
||||
func TestTheShortFormStillMeansHere(t *testing.T) {
|
||||
// `"provides": ["shell"]` must keep meaning what it meant, or every existing manifest
|
||||
// silently changes meaning.
|
||||
m, err := decodeForTest([]byte(`{"module":"zsh","version":"1","provides":["shell"]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Provides) != 1 || m.Provides[0].At() != ScopeNode {
|
||||
t.Fatalf("a plain name is no longer node-scoped: %v", m.Provides)
|
||||
}
|
||||
}
|
||||
|
||||
// Being told, which is the difference between knowing and being able to.
|
||||
@@ -1,83 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// BucketShare is one entry of `shares` on a module's requirement of `s3-bucket` (novox/hq ADR 0468 §8): who else may
|
||||
// put or get in the consumer's bucket, and with which methods. Not the NFS shares of shares.go (ADR 0263).
|
||||
//
|
||||
// "requires": [{"name": "s3-bucket", "shares": [{"to": "seat:node-files", "methods": ["put"]},
|
||||
// {"to": "print-and-scan", "methods": ["get"]}]}]
|
||||
//
|
||||
// `to` is a module name, or `seat:<seat name>`. `methods` is a non-empty subset of get and put, without a repeat.
|
||||
// Shares are allowed on `s3-bucket` only, and no two entries name the same `to`.
|
||||
type BucketShare struct {
|
||||
To string `json:"to"`
|
||||
Methods []string `json:"methods"`
|
||||
}
|
||||
|
||||
// BucketRequirement is the requirement `shares` may be written on.
|
||||
const BucketRequirement = "s3-bucket"
|
||||
|
||||
// RequireSharesKey is where a stored manifest keeps the shares of its requirements: beside `requires`, which stays a
|
||||
// plain list of names, for the reason OptionalRequiresKey is beside it. An older controller sets the unknown key
|
||||
// aside instead of failing the catalogue.
|
||||
const RequireSharesKey = "requires-shares"
|
||||
|
||||
// SharesValue is the key of the value a consumer gives the provider of s3-bucket in its grant: the list of
|
||||
// {to, methods} sorted by `to`. A consumer's own `contributes` may not name it.
|
||||
const SharesValue = "shares"
|
||||
|
||||
var shareTarget = regexp.MustCompile(`^(seat:)?[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
// checkBucketShares refuses a malformed `shares` of one requirement, naming the entry and ADR 0468 §8.
|
||||
func checkBucketShares(requirement string, shares []BucketShare) error {
|
||||
if len(shares) == 0 {
|
||||
return nil
|
||||
}
|
||||
if requirement != BucketRequirement {
|
||||
return fmt.Errorf("requires %s: `shares` is allowed on %s only (novox/hq ADR 0468 §8)", requirement, BucketRequirement)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, s := range shares {
|
||||
at := fmt.Sprintf("requires %s: shares[%d] (to %q)", requirement, i, s.To)
|
||||
if !shareTarget.MatchString(s.To) {
|
||||
return fmt.Errorf("%s: `to` is a module name or seat:<seat name> (novox/hq ADR 0468 §8)", at)
|
||||
}
|
||||
if seen[s.To] {
|
||||
return fmt.Errorf("%s: a second entry for the same `to` (novox/hq ADR 0468 §8)", at)
|
||||
}
|
||||
seen[s.To] = true
|
||||
if len(s.Methods) == 0 {
|
||||
return fmt.Errorf("%s: `methods` is empty; name get, put or both (novox/hq ADR 0468 §8)", at)
|
||||
}
|
||||
used := map[string]bool{}
|
||||
for _, method := range s.Methods {
|
||||
if method != "get" && method != "put" {
|
||||
return fmt.Errorf("%s: method %q is not get or put (novox/hq ADR 0468 §8)", at, method)
|
||||
}
|
||||
if used[method] {
|
||||
return fmt.Errorf("%s: method %q twice (novox/hq ADR 0468 §8)", at, method)
|
||||
}
|
||||
used[method] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readRequireShares reads the stored form's side key.
|
||||
func readRequireShares(raw json.RawMessage) (map[string][]BucketShare, error) {
|
||||
var out map[string][]BucketShare
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("%s: an object of requirement to a list of {to, methods}: %w", RequireSharesKey, err)
|
||||
}
|
||||
for requirement, shares := range out {
|
||||
if err := checkBucketShares(requirement, shares); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Issue 1591: `shares` on a requirement of s3-bucket (novox/hq ADR 0468 §8).
|
||||
|
||||
const filesWithShares = `{"module":"files","version":"1","requires":["shell",
|
||||
{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["get","put"]}]}]}`
|
||||
|
||||
// 1591/AC1: parsed, the shares are on the manifest; stored, `requires` is plain names and the side key holds them.
|
||||
func TestSharesParseAndAreStoredBesideRequires(t *testing.T) {
|
||||
m := mustParse(t, filesWithShares)
|
||||
want := []BucketShare{{To: "seat:node-files", Methods: []string{"put"}}, {To: "print-and-scan", Methods: []string{"get", "put"}}}
|
||||
if !reflect.DeepEqual(m.RequireShares["s3-bucket"], want) {
|
||||
t.Fatalf("the shares read as %+v", m.RequireShares)
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var keys map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &keys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(keys["requires"]) != `["shell","s3-bucket"]` {
|
||||
t.Errorf("the stored requires is not plain names: %s", keys["requires"])
|
||||
}
|
||||
if !strings.Contains(string(keys[RequireSharesKey]), `"seat:node-files"`) {
|
||||
t.Errorf("the side key does not hold the shares: %s", raw)
|
||||
}
|
||||
var again Manifest
|
||||
if err := json.Unmarshal(raw, &again); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(again, m) {
|
||||
t.Fatalf("not lossless:\n%+v\n%+v", again, m)
|
||||
}
|
||||
}
|
||||
|
||||
// 1591/AC2: a malformed share is refused, naming the entry and ADR 0468 §8.
|
||||
func TestAMalformedShareIsRefused(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"unknown method": `{"name":"s3-bucket","shares":[{"to":"a","methods":["delete"]}]}`,
|
||||
"empty to": `{"name":"s3-bucket","shares":[{"to":"","methods":["get"]}]}`,
|
||||
"malformed to": `{"name":"s3-bucket","shares":[{"to":"Seat:X y","methods":["get"]}]}`,
|
||||
"empty methods": `{"name":"s3-bucket","shares":[{"to":"a","methods":[]}]}`,
|
||||
"duplicate to": `{"name":"s3-bucket","shares":[{"to":"a","methods":["get"]},{"to":"a","methods":["put"]}]}`,
|
||||
"duplicate verb": `{"name":"s3-bucket","shares":[{"to":"a","methods":["get","get"]}]}`,
|
||||
"other require": `{"name":"route","shares":[{"to":"a","methods":["get"]}]}`,
|
||||
}
|
||||
for name, entry := range cases {
|
||||
_, err := decodeForTest([]byte(`{"module":"files","version":"1","requires":[` + entry + `]}`))
|
||||
if err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ADR 0468 §8") || !strings.Contains(err.Error(), "requires") {
|
||||
t.Errorf("%s: the error does not name the entry and the ADR: %v", name, err)
|
||||
}
|
||||
}
|
||||
// The stored form is held to the same rules.
|
||||
_, err := decodeForTest([]byte(`{"module":"files","version":"1","requires":["s3-bucket"],
|
||||
"requires-shares":{"s3-bucket":[{"to":"a","methods":[]}]}}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") {
|
||||
t.Errorf("the stored form was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Built is one artifact after it exists: where it is and what it hashes to.
|
||||
type Built struct {
|
||||
// Name is what the manifest called it.
|
||||
Name string
|
||||
// Kind is "image" or "archive".
|
||||
Kind string
|
||||
// Reference is what a machine uses to get it — an image reference for an image, a URL for an
|
||||
// archive. Both already carry the digest for an image; an archive carries it separately.
|
||||
Reference string
|
||||
// Digest is "sha256:<hex>", for an archive. An image reference already ends in one.
|
||||
Digest string
|
||||
// Launchers are, for a bundle in an interpreted language, the executable the build wrote beside
|
||||
// each entrypoint, by entrypoint (novox/hq ADR 0193): what the node's runtime starts to serve it.
|
||||
Launchers map[string]string
|
||||
// Texts are, for the archive a module packs its templates into (TemplatesArtifact), each file's text as the
|
||||
// build read it back from the packed archive, by its path inside it (novox/hq ADR 0349 §2). Never carried on
|
||||
// the wire or kept: Resolve reads them into the files' content, which is what the mesh holds.
|
||||
Texts map[string]string `json:"-"`
|
||||
}
|
||||
|
||||
// Resolve fills a manifest's resources in from what was built.
|
||||
//
|
||||
// Every resource naming an artifact is rewritten to name the thing itself, and the `artifact` key
|
||||
// is removed — because it is a build-time word and the host has never heard of it. A resource
|
||||
// naming an artifact nothing produced is refused: it would otherwise reach a machine with an
|
||||
// empty image or an unpinned archive, which is the shape of failure that looks like success.
|
||||
func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
if m.Build == nil && len(built) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
by := map[string]Built{}
|
||||
for _, b := range built {
|
||||
by[b.Name] = b
|
||||
}
|
||||
// Declared and not produced is a build that did not do what the manifest asked, and saying so
|
||||
// here beats a machine reporting it later.
|
||||
var missing []string
|
||||
if m.Build != nil {
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if _, ok := by[a.Name]; !ok {
|
||||
missing = append(missing, a.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
return Manifest{}, fmt.Errorf(
|
||||
"%s says it builds %s and the build did not produce %s — the build did not do what "+
|
||||
"the manifest asked, which is a different fault from a resource asking for the "+
|
||||
"wrong thing",
|
||||
m.Module, strings.Join(missing, " and "), oneOrOther(len(missing)))
|
||||
}
|
||||
|
||||
// **Each template's text, as the build read it back from its templates archive** (novox/hq ADR 0349 §2): into
|
||||
// the file's content, so what the mesh holds and composes is what the same text inline would be.
|
||||
var handed *Handed
|
||||
if m.Templated() {
|
||||
name := TemplatesArtifact(m)
|
||||
texts := by[name].Texts
|
||||
if name == "" || texts == nil {
|
||||
return Manifest{}, fmt.Errorf("%s names templates, and the build carried no text read back from the "+
|
||||
"archive it packs %s/ into (%q): the builder that built it does not read templates",
|
||||
m.Module, m.TemplatesDir(), name)
|
||||
}
|
||||
dir := m.TemplatesDir()
|
||||
filled, err := fillTemplates(m.Module, dir, m.Resources, func(template string) (string, bool) {
|
||||
t, ok := texts[strings.TrimPrefix(template, dir+"/")]
|
||||
return t, ok
|
||||
}, fmt.Sprintf("the archive %q", name))
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
items, err := fillItemTemplates(m, m.Items, func(template string) (string, bool) {
|
||||
t, ok := texts[strings.TrimPrefix(template, dir+"/")]
|
||||
return t, ok
|
||||
}, fmt.Sprintf("the archive %q", name))
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
// And, for a moved module, where its templates are and which one each file and item is filled from
|
||||
// (handed.go): read while its resources and items still name their templates.
|
||||
handed = handedBy(m, by[name])
|
||||
m.Resources = filled
|
||||
m.Items = items
|
||||
}
|
||||
|
||||
out := m
|
||||
out.Handed = handed
|
||||
out.Build = nil
|
||||
// Read with the build, like Build: the manifest the mesh holds carries each file's text (issue 731).
|
||||
out.Templates = ""
|
||||
out.Resources = nil
|
||||
// What the build compiled, kept on the resolved manifest (novox/hq ADR 0175): a tools bundle is
|
||||
// named by no resource of the module's own — the node's runtime loads it — so this is the only
|
||||
// place the mesh would otherwise not have it. In artifact order, so two resolutions of one
|
||||
// build compare equal.
|
||||
out.Bundles = nil
|
||||
if m.Build != nil {
|
||||
run := runByAResource(m)
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a.Kind != ArtifactBundle {
|
||||
continue
|
||||
}
|
||||
made := by[a.Name]
|
||||
loads := BundleLoads(m, a, run)
|
||||
// **Kept, never routed** (ADR 0155): the builder publishes to the store at the address
|
||||
// it reached it by, and a manifest carrying that address names an installation —
|
||||
// registration refused node-tools for exactly this on 2026-10-02. The build record
|
||||
// already keeps the store-relative form; the resolved manifest keeps the same, and
|
||||
// composition routes it through the store a machine reaches (Routed).
|
||||
out.Bundles = append(out.Bundles, Bundle{
|
||||
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
|
||||
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
|
||||
Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers),
|
||||
Binary: BinaryOf(a), Bus: a.Bus,
|
||||
})
|
||||
}
|
||||
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
named, _ := r["artifact"].(string)
|
||||
if named == "" {
|
||||
out.Resources = append(out.Resources, r)
|
||||
continue
|
||||
}
|
||||
artifact, ok := by[named]
|
||||
if !ok {
|
||||
return Manifest{}, fmt.Errorf(
|
||||
"%s: %v uses the artifact %q, and this module builds no such thing",
|
||||
m.Module, r["id"], named)
|
||||
}
|
||||
|
||||
filled := map[string]any{}
|
||||
for k, v := range r {
|
||||
filled[k] = v
|
||||
}
|
||||
delete(filled, "artifact")
|
||||
switch artifact.Kind {
|
||||
case ArtifactPackage:
|
||||
// A package is not a resource on any machine; it is consumed by other builds. A
|
||||
// resource that names one is a manifest error, named here rather than shipped.
|
||||
return Manifest{}, fmt.Errorf(
|
||||
"%s: %v uses %q, which is a package — a build input, not a resource a machine runs",
|
||||
m.Module, r["id"], named)
|
||||
case ArtifactImage, ArtifactUpstream:
|
||||
filled["image"] = artifact.Reference
|
||||
// An image is not unpacked anywhere, so it has no directory to be named for its
|
||||
// version and `${version}` has nothing to mean. Refused rather than left as literal
|
||||
// text in a path, which is how it would reach a machine and be created as a directory
|
||||
// called `${version}`.
|
||||
for key, value := range filled {
|
||||
if text, isText := value.(string); isText && strings.Contains(text, versionRef) {
|
||||
return Manifest{}, fmt.Errorf(
|
||||
"%s: %v says %s in %q, and %q is an image — an image is not unpacked, so "+
|
||||
"it has no versioned place. %s is for an archive or a bundle",
|
||||
m.Module, r["id"], versionRef, key, named, versionRef)
|
||||
}
|
||||
}
|
||||
case ArtifactArchive, ArtifactBundle:
|
||||
// The same on the wire: both are bytes fetched by digest and unpacked. They differ in
|
||||
// how they were made — one packed as it stood, the other compiled first — and a
|
||||
// machine has no reason to care which.
|
||||
// Kept, not routed, for the reason the bundles above are (ADR 0155).
|
||||
filled["source"] = Recorded(artifact.Reference)
|
||||
filled["digest"] = artifact.Digest
|
||||
// **And `${version}`, so a resource can name a place that is this build's alone**
|
||||
// (novox/hq ADR 0141, 04-ISSUES/142). A component is unpacked into a directory named
|
||||
// for its version so it can read its own version from its path — and until this,
|
||||
// nothing could compose that path: an archive named a fixed one in the manifest and
|
||||
// nothing interpolated the build into it, so nothing could ask for
|
||||
// `…/versions/<version>/` and every machine took a hand-placed fallback.
|
||||
//
|
||||
// The version is the artifact's own digest, short. Not the commit: two builds of one
|
||||
// commit are meant to be the same bytes (the toolchains are `-trimpath` for this), and
|
||||
// a content-addressed version means an unchanged build resolves to the path it already
|
||||
// had — so re-composing a declaration moves nothing, where a commit would move the
|
||||
// path of an identical binary and recreate everything that reads it.
|
||||
for key, value := range filled {
|
||||
filled[key] = withVersion(value, versionOf(artifact.Digest))
|
||||
}
|
||||
default:
|
||||
return Manifest{}, fmt.Errorf("%s: %q is a %q, and an artifact is %q, %q, %q or %q",
|
||||
m.Module, named, artifact.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream,
|
||||
ArtifactBundle)
|
||||
}
|
||||
out.Resources = append(out.Resources, filled)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// withVersion is a resource's value with `${version}` filled: in a string, and in each string of a map —
|
||||
// a process's env, where the controller is told which build it is (novox/hq issue 352). Anything else is
|
||||
// left as it is.
|
||||
func withVersion(value any, version string) any {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if strings.Contains(v, versionRef) {
|
||||
return strings.ReplaceAll(v, versionRef, version)
|
||||
}
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(v))
|
||||
for k, x := range v {
|
||||
out[k] = withVersion(x, version)
|
||||
}
|
||||
return out
|
||||
case map[string]string:
|
||||
out := make(map[string]string, len(v))
|
||||
for k, x := range v {
|
||||
out[k] = strings.ReplaceAll(x, versionRef, version)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// NamedPaths are the paths a module's fingerprint takes its tree from (novox/hq ADR 0499 clause 2): the union of the
|
||||
// paths its artifacts name, cleaned and sorted, and every true only when the module builds something and every artifact
|
||||
// names paths; otherwise none, and the module keeps its directory's tree.
|
||||
//
|
||||
// **Read by the core alone** (ADR 0495 clause 1, the lead's decision L8 of R4e): the indexer and its merge preview
|
||||
// (buildstep.ModuleTrees) call it. A build module's fetch step is handed the paths by the builder, from the indexer's
|
||||
// kept inputs (delivery.KeptInputs2.Paths), and reads no manifest for them.
|
||||
func (m Manifest) NamedPaths() (paths []string, every bool) {
|
||||
if m.Build == nil || len(m.Build.Artifacts) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if len(a.Paths) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
for _, p := range a.Paths {
|
||||
seen[path.Clean(p)] = true
|
||||
}
|
||||
}
|
||||
for p := range seen {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths, true
|
||||
}
|
||||
|
||||
// oneOrOther keeps the message readable for one artifact and for several, because a message that
|
||||
// says "neither" about one thing reads as a bug in the message.
|
||||
func oneOrOther(n int) string {
|
||||
if n == 1 {
|
||||
return "it"
|
||||
}
|
||||
return "them"
|
||||
}
|
||||
|
||||
// compilesToABinary is whether this language's bundle is a binary for one operating system rather
|
||||
// than code that runs wherever its interpreter does.
|
||||
//
|
||||
// **Asked of the language, not of the artifact.** A module says what it is written in; what that
|
||||
// implies is the mesh's to know, exactly as the compiler is (novox/hq ADR 0142). Asking the artifact
|
||||
// would let two artifacts in one language disagree about whether they are portable.
|
||||
func compilesToABinary(language string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(language)) {
|
||||
case "go":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// versionRef is how a resource names the version of the artifact it uses: ${version}.
|
||||
//
|
||||
// No artifact name in it, because the resource already says which artifact it is for — a second
|
||||
// name would be a second thing to keep in step with the first.
|
||||
const versionRef = "${version}"
|
||||
|
||||
// versionOf is an artifact's version as a path names it: its digest, short.
|
||||
//
|
||||
// **Content-addressed on purpose.** The alternative is the commit a build came from, and two builds
|
||||
// of one commit are meant to produce the same bytes — every toolchain here is `-trimpath` for that
|
||||
// reason. A commit-named path would move for an identical binary, and everything reading that path
|
||||
// would be recreated for a change that is not one. A digest-named path moves exactly when the bytes
|
||||
// do.
|
||||
//
|
||||
// Twelve hex characters: enough that two of this mesh's builds will not collide, short enough to
|
||||
// read in a path and in a journal line. The `sha256:` prefix goes, because a directory name carrying
|
||||
// a colon is a directory name people quote wrong.
|
||||
func versionOf(digest string) string {
|
||||
hex := strings.TrimPrefix(strings.TrimSpace(digest), "sha256:")
|
||||
if len(hex) > 12 {
|
||||
return hex[:12]
|
||||
}
|
||||
return hex
|
||||
}
|
||||
|
||||
func copyWords(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BinaryOf is what a bundle compiled to a binary is called once built: what the artifact names, else
|
||||
// the package it is built from, else the artifact's own name (novox/hq 04-ISSUES/142). Empty for a
|
||||
// language that does not compile to one. The builder writes the binary under this name, and the
|
||||
// composer runs it by it, so both ask here.
|
||||
func BinaryOf(a Artifact) string {
|
||||
if !compilesToABinary(a.Language) {
|
||||
return ""
|
||||
}
|
||||
if name := strings.TrimSpace(a.Binary); name != "" {
|
||||
return name
|
||||
}
|
||||
if from := strings.Trim(a.From, "./"); from != "" {
|
||||
return path.Base(from)
|
||||
}
|
||||
return a.Name
|
||||
}
|
||||
|
||||
// runByAResource is the artifacts one of a module's own resources names — a process that runs it,
|
||||
// a step, an archive that unpacks it — by name.
|
||||
func runByAResource(m Manifest) map[string]bool {
|
||||
named := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if a, ok := r["artifact"].(string); ok && a != "" {
|
||||
named[a] = true
|
||||
}
|
||||
}
|
||||
return named
|
||||
}
|
||||
|
||||
// BundleLoads is what the runtime loads of the bundle artifact a of m (run: the bundles one of m's own resources runs,
|
||||
// runByAResource): what the artifact said, else every entrypoint of a module that declares tools, else nothing; a
|
||||
// bundle compiled to a binary is served by its binary. The rule a build's resolution keeps on Bundle.Loads, and what a
|
||||
// module composed as what runs of it reads again when the runtime already dropped it (issue 1126).
|
||||
func BundleLoads(m Manifest, a Artifact, run map[string]bool) []string {
|
||||
// What the runtime loads: what the artifact said, else every entrypoint of a module
|
||||
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
|
||||
//
|
||||
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
|
||||
// A process the host runs is the module's service, not its tools: the controller declares
|
||||
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
|
||||
// have been launched a second time by the node's runtime, as an MCP child it is not.
|
||||
loads := append([]string(nil), a.Loads...)
|
||||
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
|
||||
loads = append([]string(nil), a.Entrypoints...)
|
||||
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
|
||||
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
|
||||
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
|
||||
if bin := BinaryOf(a); bin != "" {
|
||||
loads = []string{bin}
|
||||
}
|
||||
}
|
||||
return loads
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
// bundleFor is a manifest whose one artifact is a bundle in the given language and system.
|
||||
func bundleFor(language, system string) Manifest {
|
||||
return Manifest{Module: "a-component", Build: &Build{Artifacts: []Artifact{
|
||||
{Name: "binary", Kind: ArtifactBundle, Language: language, System: system},
|
||||
}}}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The manifest in a repository and the manifest the mesh holds are two documents.
|
||||
//
|
||||
// A digest is not knowable until something is built, so a repository carrying one would be a
|
||||
// repository whose file is wrong the moment anybody edits anything — and the mesh would be pinning
|
||||
// a value nobody could have checked.
|
||||
|
||||
func buildable() Manifest {
|
||||
return Manifest{
|
||||
Module: "meshboard", Version: "1",
|
||||
Build: &Build{Artifacts: []Artifact{
|
||||
{Name: "server", Kind: ArtifactImage, From: "Dockerfile"},
|
||||
{Name: "theme", Kind: ArtifactArchive, From: "files"},
|
||||
}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "svc", "type": "container", "name": "meshboard", "artifact": "server"},
|
||||
{"id": "look", "type": "archive", "path": "/opt/meshboard", "artifact": "theme"},
|
||||
{"id": "dir", "type": "directory", "path": "/opt/meshboard"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func wasBuilt() []Built {
|
||||
return []Built{
|
||||
{Name: "server", Kind: ArtifactImage,
|
||||
Reference: "registry.invalid/meshboard@sha256:" + strings.Repeat("a", 64)},
|
||||
{Name: "theme", Kind: ArtifactArchive,
|
||||
Reference: "https://store.invalid/theme.tar.gz",
|
||||
Digest: "sha256:" + strings.Repeat("b", 64)},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAResourceNamingAnArtifactBecomesOneNamingTheThing(t *testing.T) {
|
||||
got, err := buildable().Resolve(wasBuilt())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range got.Resources {
|
||||
if r["artifact"] != nil {
|
||||
// A build-time word the host has never heard of. Leaving it would be a field the
|
||||
// strict decoder refuses on the machine, at the worst moment.
|
||||
t.Fatalf("%v still names an artifact: %v", r["id"], r)
|
||||
}
|
||||
}
|
||||
if got.Resources[0]["image"] != "registry.invalid/meshboard@sha256:"+strings.Repeat("a", 64) {
|
||||
t.Fatalf("the image was not filled in: %v", got.Resources[0])
|
||||
}
|
||||
if got.Resources[1]["source"] != "https://store.invalid/theme.tar.gz" ||
|
||||
got.Resources[1]["digest"] != "sha256:"+strings.Repeat("b", 64) {
|
||||
t.Fatalf("the archive was not filled in: %v", got.Resources[1])
|
||||
}
|
||||
// And a resource that names nothing is untouched.
|
||||
if got.Resources[2]["path"] != "/opt/meshboard" || len(got.Resources[2]) != 3 {
|
||||
t.Fatalf("an ordinary resource was changed: %v", got.Resources[2])
|
||||
}
|
||||
// The built manifest carries no build section: it is the derived document, and something
|
||||
// holding both would invite somebody to build from it again.
|
||||
if got.Build != nil {
|
||||
t.Fatal("the built manifest still says how to build itself")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnArtifactDeclaredAndNotBuiltIsRefused(t *testing.T) {
|
||||
// A build that did not do what the manifest asked. Said here rather than by a machine later
|
||||
// reporting an empty image.
|
||||
_, err := buildable().Resolve(wasBuilt()[:1])
|
||||
if err == nil {
|
||||
t.Fatal("a manifest resolved with an artifact missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "theme") {
|
||||
t.Fatalf("the refusal does not name what is missing: %v", err)
|
||||
}
|
||||
// And it must blame the build rather than the resource. A resource asking for something that
|
||||
// does not exist is a manifest fault; a build not producing what the manifest declared is a
|
||||
// build fault, and telling somebody to fix the wrong one costs an afternoon.
|
||||
if !strings.Contains(err.Error(), "the build did not produce") {
|
||||
t.Fatalf("the refusal blames the wrong thing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAResourceNamingSomethingTheModuleDoesNotBuildIsRefused(t *testing.T) {
|
||||
m := buildable()
|
||||
m.Resources = append(m.Resources,
|
||||
map[string]any{"id": "other", "type": "archive", "path": "/x", "artifact": "nothing"})
|
||||
if _, err := m.Resolve(wasBuilt()); err == nil {
|
||||
t.Fatal("a resource naming an artifact nobody builds was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleThatBuildsNothingIsOrdinary(t *testing.T) {
|
||||
// Most of what a person installs is configuration. Requiring an empty build section would be
|
||||
// a field that exists to be left blank.
|
||||
m := Manifest{Module: "shell", Version: "1", Resources: []map[string]any{
|
||||
{"id": "rc", "type": "file", "path": "/etc/zsh/zshrc", "content": "setopt"},
|
||||
}}
|
||||
got, err := m.Resolve(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Resources) != 1 {
|
||||
t.Fatalf("got %v", got.Resources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAResourceNamingAPackageIsRefused(t *testing.T) {
|
||||
m, err := decodeForTest([]byte(`{"module":"a","version":"1","slug":"a",
|
||||
"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]},
|
||||
"resources":[{"id":"svc","type":"container","name":"a","artifact":"lib"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
_, err = m.Resolve([]Built{{Name: "lib", Kind: ArtifactPackage, Reference: "@novox/a@1.0.0"}})
|
||||
if err == nil {
|
||||
t.Fatal("a resource backed by a package was accepted; a package is not a resource")
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/internal/beside"
|
||||
)
|
||||
|
||||
// The catalogue's bus protects its streams by the snapshot, not as live files: its streams' item is a
|
||||
// dump into its snapshots, it has the account the dump runs as, and the dump runs the snapshot
|
||||
// program in the bus's own container.
|
||||
func TestTheCataloguesBusIsBackedUpBySnapshot(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(beside.Catalogue(t), "nats", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := decodeForTest(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, account := m.OwnSecrets["broker"]; !account {
|
||||
t.Fatal("the bus declares no account, so its snapshot could not reach it")
|
||||
}
|
||||
var found bool
|
||||
if m.Data == nil {
|
||||
t.Fatal("the bus declares no data")
|
||||
}
|
||||
for _, it := range m.Data.Own {
|
||||
if it.ID != "jetstream" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !it.Backup.IsDump() || it.Backup.Into != "snapshots" {
|
||||
t.Fatalf("the bus's streams are protected by %+v, not a snapshot into its snapshots", it.Backup)
|
||||
}
|
||||
if !strings.Contains(it.Backup.Dump, "mesh-nats-snapshot snapshot") {
|
||||
t.Fatalf("the dump does not run the snapshot program: %s", it.Backup.Dump)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("the bus declares no item for its streams")
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BusyField is the resource field.
|
||||
const BusyField = "busy"
|
||||
|
||||
// The bounds of one ask (ADR 0335), the node-engine's own, held here near the author.
|
||||
const (
|
||||
BusyTimeoutDefault = 10 * time.Second
|
||||
BusyTimeoutCeiling = time.Minute
|
||||
)
|
||||
|
||||
// Busy is one container's busy, read.
|
||||
type Busy struct {
|
||||
Command string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// ReadBusy reads a resource's `busy` field, its default applied. False when it carries none.
|
||||
func ReadBusy(r map[string]any) (Busy, bool, []string) {
|
||||
raw, present := r[BusyField]
|
||||
if !present {
|
||||
return Busy{}, false, nil
|
||||
}
|
||||
id := fmt.Sprint(r["id"])
|
||||
fields, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return Busy{}, true, []string{fmt.Sprintf("%s: busy is %T; it is an object with a command", id, raw)}
|
||||
}
|
||||
var problems []string
|
||||
keys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if k != "command" && k != "timeout" {
|
||||
problems = append(problems, fmt.Sprintf("%s: its busy says %q; busy has a command and a timeout", id, k))
|
||||
}
|
||||
}
|
||||
b := Busy{Timeout: BusyTimeoutDefault}
|
||||
if v, has := fields["command"]; has {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
problems = append(problems, fmt.Sprintf("%s: its busy's command is %T; it is text", id, v))
|
||||
}
|
||||
b.Command = strings.TrimSpace(s)
|
||||
}
|
||||
if v, has := fields["timeout"]; has {
|
||||
s, _ := v.(string)
|
||||
d, err := time.ParseDuration(strings.TrimSpace(s))
|
||||
switch {
|
||||
case err != nil:
|
||||
problems = append(problems, fmt.Sprintf("%s: its busy's timeout is %v; it is a duration such as \"10s\"", id, v))
|
||||
case d <= 0 || d > BusyTimeoutCeiling:
|
||||
problems = append(problems, fmt.Sprintf("%s: an ask of its busy takes more than nothing and at most %s, not %s",
|
||||
id, BusyTimeoutCeiling, d))
|
||||
default:
|
||||
b.Timeout = d
|
||||
}
|
||||
}
|
||||
return b, true, problems
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/internal/beside"
|
||||
)
|
||||
|
||||
// TestEveryCatalogueManifestParses runs the real catalogue through the real gate.
|
||||
//
|
||||
// Not a fixture: the point is whether the manifests as written are accepted by the control plane that
|
||||
// will read them, and a copy of one manifest proves nothing about the other seventy-one.
|
||||
// catalogueRoot is the catalogue these checks run over: in a merge check the clone the build seat put
|
||||
// beside this one, elsewhere the copy captured in testdata/beside (internal/beside). A check that only
|
||||
// ran when somebody remembered a variable was a check nobody ran (novox/hq issue 134), and one that read
|
||||
// whatever checkout sat beside judged the machine, not the change (novox/hq issue 432): it never skips.
|
||||
func catalogueRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
return beside.Dir(t, "mesh-catalog")
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
// readManifest is one manifest of the catalogue as ReadTreeManifest read it.
|
||||
type readManifest struct {
|
||||
path string // the module.json
|
||||
manifest Manifest
|
||||
err error
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
// CheckerSeat is the seat mesh-delivery-checker holds.
|
||||
const CheckerSeat = "mesh-delivery-checker"
|
||||
|
||||
// checkerVerbs are the checker seat's verbs: ask a try, read one, end one by hand. Optional until the checker serves them
|
||||
// everywhere.
|
||||
func checkerVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "try", Optional: true, Description: "Try a pushed head before its pull request is opened: the checker " +
|
||||
"judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration " +
|
||||
"levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read " +
|
||||
"reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most " +
|
||||
"4 wait or run at once, mesh-wide.",
|
||||
Input: schema(map[string]string{"repository": "<owner>/<name>",
|
||||
"head": "the full commit to try, 40 or 64 hex digits",
|
||||
"trunk": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent",
|
||||
"tests": "a go test -run pattern: only those tests run, and nothing is kept",
|
||||
"level": "unit or integration: only this level; both when absent"},
|
||||
[]string{"repository", "head"}),
|
||||
Replaces: []string{"sh merge-check.sh", "docker run postgres"}},
|
||||
{Name: "try-read", Optional: true, Description: "Where a try stands, as stored: queued, running, or its end " +
|
||||
"(pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit " +
|
||||
"it was merged onto, the time it took and what failed first. Read-only.",
|
||||
Input: schema(map[string]string{"id": "the try's id, try-<digits>"}, []string{"id"})},
|
||||
{Name: "try-end", Optional: true, Description: "End a try that waits or runs, as error, by hand: say why (kept in " +
|
||||
"what it says). Its place among the tries is free at once and the next try of its head asks anew with a new " +
|
||||
"id. A try that ended already is refused.",
|
||||
Input: schema(map[string]string{"id": "the try's id, try-<digits>", "why": "why it is ended, in words"},
|
||||
[]string{"id", "why"})},
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
// Chosen is the provider somebody named for a provision: the module, and the node it runs on. Both,
|
||||
// always (novox/hq #258) — a provision comes from a module, and the same module on two machines is
|
||||
// two answers, so neither half alone says which. Module is empty only on a record made before this
|
||||
// was asked, and such a record is honoured exactly as long as it is unambiguous.
|
||||
type Chosen struct {
|
||||
Node string
|
||||
Module string
|
||||
}
|
||||
|
||||
func (c Chosen) String() string {
|
||||
if c.Module == "" {
|
||||
return c.Node
|
||||
}
|
||||
return c.Node + "/" + c.Module
|
||||
}
|
||||
|
||||
func oneOf(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Item is one item a module contributes to a collection: its template in the module's repository, or, in the
|
||||
// manifest the mesh holds, the template's text, read back from the build's templates archive like a file's.
|
||||
//
|
||||
// **Attributes, a capability and a name** (design 57 L3, issue 777): an item may carry `attributes` (string keys and
|
||||
// values; `order`, a whole number 0–99 defaulting to 50, orders the items of one placeholder) and `if-capability`,
|
||||
// the capability a machine must report for the item to be placed on it. A module may give several items to one
|
||||
// collection, each then with a `name` unique within the module and the collection. A collector takes only the items
|
||||
// whose attributes match, `${collected:<collection>:<k>=<v>,…}`, and all of them without the selector.
|
||||
type Item struct {
|
||||
Collection string `json:"collection"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Template string `json:"template,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Attributes map[string]string `json:"attributes,omitempty"`
|
||||
IfCapability string `json:"if-capability,omitempty"`
|
||||
}
|
||||
|
||||
// ItemKey is an item's key among its module's: the collection, and `/<name>` for a named item.
|
||||
func ItemKey(collection, name string) string {
|
||||
if name == "" {
|
||||
return collection
|
||||
}
|
||||
return collection + "/" + name
|
||||
}
|
||||
|
||||
func (it Item) key() string { return ItemKey(it.Collection, it.Name) }
|
||||
|
||||
// OrderAttribute is the attribute that orders items.
|
||||
const OrderAttribute = "order"
|
||||
|
||||
// Collection is one collection a module collects, and the comment its tool writes a line in: each contributor's item
|
||||
// is introduced by `<comment> <contributor>`, and by nothing without one.
|
||||
type Collection struct {
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// CollectedNamespace is a collection's placeholder namespace, `${collected:<collection>}`.
|
||||
const CollectedNamespace = "collected"
|
||||
|
||||
// EnvironmentCollection is the one collection of the session environment (design 57 L3, issue 795): its items are
|
||||
// typed by attributes, a variable (`kind=variable`, `name`) or a PATH entry (`kind=path`, `at=start|end`), and the
|
||||
// template's text is the value or the entry. A collector names the rendering, `${collected:environment:posix}` or
|
||||
// `${collected:environment:systemd}`, which is not a selector.
|
||||
const EnvironmentCollection = "environment"
|
||||
|
||||
// itemTemplateProblem is what is wrong with an item's template path, or "".
|
||||
func itemTemplateProblem(m Manifest, name string) string {
|
||||
dir := m.TemplatesDir()
|
||||
if strings.HasPrefix(name, "/") || path.Clean(name) != name || !strings.HasPrefix(name, dir+"/") ||
|
||||
strings.Contains(name, "\\") {
|
||||
return fmt.Sprintf("the template %q is not a file under %s/ of its own repository: a template is %q, a "+
|
||||
"plain relative path, and the build packs only %s/", name, dir, dir+"/<name>", dir)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
// An item may carry attributes and a capability, a module may give several items to one collection, and a
|
||||
// placeholder may select by attributes (design 57 L3, issue 777). The controller leaves out of a plan an item whose
|
||||
// capability the machine lacks, accepts a selecting placeholder where it accepts the collection, and renders a
|
||||
// selected moved item into an old placeholder exactly as the contribution it replaces.
|
||||
|
||||
const (
|
||||
barsText = "top\n${collected:blocks:bar=top,place=status}bottom\n${collected:blocks:bar=bottom,place=status}${collected:blocks}"
|
||||
barContribute = `{"module":"power","version":"1","moved":true,
|
||||
"items":[{"collection":"blocks","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"20"},"template":"files/battery"},
|
||||
{"collection":"blocks","name":"clock","attributes":{"bar":"top","place":"status"},"template":"files/clock"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
|
||||
)
|
||||
@@ -1,32 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A collection is declared and filled by the client (novox/hq ADR 0349 §13, design 57 L1, issue 684): a collector
|
||||
// names a collection it collects, a contributor an item from a template of its own, and a handed composition plans
|
||||
// both for the node's provisioner client and renders neither. The three old namespaces read as collections of their
|
||||
// own name. Each file is filled one way: a piece of the other form on the machine is said as not placed.
|
||||
|
||||
const (
|
||||
collectorZsh = `{"module":"zsh","version":"1","moved":true,
|
||||
"claims":[{"name":"node-login-shell","scope":"node"}],
|
||||
"collections":[{"name":"widgets","comment":"#"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"rc","type":"file","path":"${machine:account-home}/.zshrc","owner":"${machine:account}",
|
||||
"mode":"0644","template":"files/zshrc"}]}`
|
||||
zshrcText = "# zsh\n${shell:zsh:normal}${collected:widgets}"
|
||||
contributorText = "source ${machine:account-home}/.p10k.zsh\n"
|
||||
contributorP10k = `{"module":"prompt","version":"1","moved":true,
|
||||
"items":[{"collection":"shell:zsh:normal","template":"files/zsh"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
|
||||
oldFormAliases = `{"module":"aliases","version":"1",
|
||||
"shell":[{"for":"zsh","slot":"normal","code":"alias ll='ls -l'\n"}]}`
|
||||
)
|
||||
|
||||
func builtWith(module string, texts map[string]string) []Built {
|
||||
return []Built{{Name: "files", Kind: ArtifactArchive,
|
||||
Reference: ArtifactStoreScheme + module + "/files/blobs/sha256:" + strings.Repeat("e", 64),
|
||||
Digest: "sha256:" + strings.Repeat("e", 64), Texts: texts}}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// ConfigField is the manifest key a module declares its config values under.
|
||||
const ConfigField = "config"
|
||||
|
||||
// TakesConfigField is the key a mergeable file names the config values it takes by.
|
||||
const TakesConfigField = "takes-config"
|
||||
|
||||
// SettingsField is the old name of ConfigField, read as its alias.
|
||||
const SettingsField = "settings"
|
||||
|
||||
// configRef is how a file asks for a config value: ${config:<key>}, the same keys as settingRef.
|
||||
var configRef = regexp.MustCompile(`\$\{config:([a-z0-9][a-z0-9_.-]*)\}`)
|
||||
|
||||
// takesConfigKey and takesSettingsKey are the two spellings as a key of a JSON object. A string value is never
|
||||
// followed by a colon, and a quote inside a string is escaped, so these match only keys.
|
||||
var (
|
||||
takesConfigKey = regexp.MustCompile(`"` + TakesConfigField + `"(\s*):`)
|
||||
takesSettingsKey = regexp.MustCompile(`"` + TakesSettingsField + `"(\s*):`)
|
||||
)
|
||||
|
||||
// The types a config value may have (ADR 0348 §4).
|
||||
const (
|
||||
ConfigNumber = "number"
|
||||
ConfigString = "string"
|
||||
ConfigBoolean = "boolean"
|
||||
ConfigObject = "object"
|
||||
ConfigList = "list"
|
||||
)
|
||||
|
||||
// accountFacts are what the mesh places for a module's account, never config values (ADR 0348 §11).
|
||||
var accountFacts = map[string]bool{"puid": true, "pgid": true}
|
||||
|
||||
// configForms is which of the two forms a manifest was written in, by the words of each it uses.
|
||||
type configForms struct {
|
||||
config, settings []string
|
||||
}
|
||||
|
||||
// DeclaresConfig is whether the manifest is written in the config form: `config`, `takes-config` or
|
||||
// `${config:<key>}` (ADR 0348 §1, §11).
|
||||
func (m Manifest) DeclaresConfig() bool { return len(m.forms.config) > 0 }
|
||||
|
||||
// toSettingsForm is raw with the config form's placeholders and takes-config read as the settings form's, and
|
||||
// which words of each form it used. `config` itself is a top-level key, renamed where the keys are read
|
||||
// (UnmarshalJSON).
|
||||
func toSettingsForm(raw []byte) ([]byte, configForms) {
|
||||
var f configForms
|
||||
if configRef.Match(raw) {
|
||||
f.config = append(f.config, "${config:…}")
|
||||
}
|
||||
if takesConfigKey.Match(raw) {
|
||||
f.config = append(f.config, TakesConfigField)
|
||||
}
|
||||
if settingRef.Match(raw) {
|
||||
f.settings = append(f.settings, "${setting:…}")
|
||||
}
|
||||
if takesSettingsKey.Match(raw) {
|
||||
f.settings = append(f.settings, TakesSettingsField)
|
||||
}
|
||||
if len(f.config) == 0 {
|
||||
return raw, f
|
||||
}
|
||||
raw = configRef.ReplaceAllFunc(raw, func(ref []byte) []byte {
|
||||
return []byte("${setting:" + string(configRef.FindSubmatch(ref)[1]) + "}")
|
||||
})
|
||||
raw = takesConfigKey.ReplaceAllFunc(raw, func(key []byte) []byte {
|
||||
return []byte(`"` + TakesSettingsField + `"` + string(takesConfigKey.FindSubmatch(key)[1]) + ":")
|
||||
})
|
||||
return raw, f
|
||||
}
|
||||
|
||||
// toConfigForm is a manifest written in the settings form's words, as JSON, in the config form's: what
|
||||
// toSettingsForm and UnmarshalJSON read, written back.
|
||||
func toConfigForm(raw []byte) ([]byte, error) {
|
||||
var keys map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &keys); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if settings, has := keys[SettingsField]; has {
|
||||
keys[ConfigField] = settings
|
||||
delete(keys, SettingsField)
|
||||
var err error
|
||||
if raw, err = json.Marshal(keys); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
raw = settingRef.ReplaceAllFunc(raw, func(ref []byte) []byte {
|
||||
return []byte("${config:" + string(settingRef.FindSubmatch(ref)[1]) + "}")
|
||||
})
|
||||
raw = takesSettingsKey.ReplaceAllFunc(raw, func(key []byte) []byte {
|
||||
return []byte(`"` + TakesConfigField + `"` + string(takesSettingsKey.FindSubmatch(key)[1]) + ":")
|
||||
})
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// configDeclaration is the config form's declaration as written: nothing of the settings form's, and nothing
|
||||
// unsaid.
|
||||
type configDeclaration struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enum []any `json:"enum,omitempty"`
|
||||
Minimum *float64 `json:"minimum,omitempty"`
|
||||
Maximum *float64 `json:"maximum,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
Trusted bool `json:"trusted,omitempty"`
|
||||
Predefined string `json:"predefined,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Runtime bool `json:"runtime,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON writes a settings declaration exactly as before, and a config declaration without the settings
|
||||
// form's kind and why, and without a default it does not have.
|
||||
func (d SettingDeclaration) MarshalJSON() ([]byte, error) {
|
||||
type plain SettingDeclaration
|
||||
if d.Kind != "" || d.Why != "" {
|
||||
return json.Marshal(plain(d))
|
||||
}
|
||||
return json.Marshal(configDeclaration{Type: d.Type, Description: d.Description, Enum: d.Enum,
|
||||
Minimum: d.Minimum, Maximum: d.Maximum, Pattern: d.Pattern, Trusted: d.Trusted, Predefined: d.Predefined,
|
||||
Default: d.Default, Required: d.Required, Runtime: d.Runtime})
|
||||
}
|
||||
|
||||
// validationProblems is what is wrong with a declaration's validation, and its default against it.
|
||||
func validationProblems(d SettingDeclaration) []string {
|
||||
var problems []string
|
||||
isNumber := d.Type == ConfigNumber
|
||||
if len(d.Enum) > 0 && d.Type != ConfigNumber && d.Type != ConfigString {
|
||||
problems = append(problems, fmt.Sprintf("an enum is for a number or a string, and this is a %s", d.Type))
|
||||
}
|
||||
if (d.Minimum != nil || d.Maximum != nil) && !isNumber {
|
||||
problems = append(problems, fmt.Sprintf("a minimum or maximum is for a number, and this is a %s", d.Type))
|
||||
}
|
||||
if d.Minimum != nil && d.Maximum != nil && *d.Minimum > *d.Maximum {
|
||||
problems = append(problems, fmt.Sprintf("its minimum %v is above its maximum %v", *d.Minimum, *d.Maximum))
|
||||
}
|
||||
var pattern *regexp.Regexp
|
||||
if d.Pattern != "" {
|
||||
if d.Type != ConfigString {
|
||||
problems = append(problems, fmt.Sprintf("a pattern is for a string, and this is a %s", d.Type))
|
||||
}
|
||||
var err error
|
||||
if pattern, err = regexp.Compile(`^(?:` + d.Pattern + `)$`); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("its pattern %q is not a regular expression: %v", d.Pattern, err))
|
||||
}
|
||||
}
|
||||
for _, v := range d.Enum {
|
||||
if !ofType(v, d.Type) {
|
||||
problems = append(problems, fmt.Sprintf("its enum holds %v, which is not a %s", v, d.Type))
|
||||
}
|
||||
}
|
||||
if d.Default == nil {
|
||||
return problems
|
||||
}
|
||||
if !ofType(d.Default, d.Type) {
|
||||
if d.Type != "" {
|
||||
problems = append(problems, fmt.Sprintf("its default %v is not a %s", d.Default, d.Type))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
if len(d.Enum) > 0 && !slices.ContainsFunc(d.Enum, func(v any) bool { return v == d.Default }) {
|
||||
problems = append(problems, fmt.Sprintf("its default %v is not one of %v", d.Default, d.Enum))
|
||||
}
|
||||
if n, ok := d.Default.(float64); ok {
|
||||
if d.Minimum != nil && n < *d.Minimum {
|
||||
problems = append(problems, fmt.Sprintf("its default %v is below its minimum %v", n, *d.Minimum))
|
||||
}
|
||||
if d.Maximum != nil && n > *d.Maximum {
|
||||
problems = append(problems, fmt.Sprintf("its default %v is above its maximum %v", n, *d.Maximum))
|
||||
}
|
||||
}
|
||||
if s, ok := d.Default.(string); ok && pattern != nil && !pattern.MatchString(s) {
|
||||
problems = append(problems, fmt.Sprintf("its default %q does not match its pattern %q", s, d.Pattern))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// ofType is whether a value read from JSON is of a config type.
|
||||
func ofType(v any, kind string) bool {
|
||||
switch v.(type) {
|
||||
case float64:
|
||||
return kind == ConfigNumber
|
||||
case string:
|
||||
return kind == ConfigString
|
||||
case bool:
|
||||
return kind == ConfigBoolean
|
||||
case map[string]any:
|
||||
return kind == ConfigObject
|
||||
case []any:
|
||||
return kind == ConfigList
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import "strings"
|
||||
|
||||
// ValueProblems is what is wrong with a value for a declaration: its type, and its enum, bounds or pattern.
|
||||
func ValueProblems(d SettingDeclaration, v any) []string {
|
||||
if v == nil {
|
||||
return []string{"no value"}
|
||||
}
|
||||
d.Default = v
|
||||
d.Required = false
|
||||
var problems []string
|
||||
for _, p := range validationProblems(d) {
|
||||
problems = append(problems, strings.ReplaceAll(p, "its default", "the value"))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A takes-config entry may lay a value under the name the file's software reads (issue 751): the key stays a
|
||||
// config key, lower-case, and the file names its own variable, such as ATLASSIAN_URL.
|
||||
|
||||
// renaming is inConfigForm with its rules file taking `url` as NOTIFIER_URL, a value the code reading the file
|
||||
// falls back on when the file lacks it.
|
||||
func renaming() map[string]any {
|
||||
m := inConfigForm()
|
||||
m["config"].(map[string]any)["url"] = map[string]any{"type": "string",
|
||||
"description": "the address the notifier posts to, a URL; unset, the notifier's own environment says it",
|
||||
"default": ""}
|
||||
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme",
|
||||
map[string]any{"key": "url", "as": "NOTIFIER_URL"}}
|
||||
return m
|
||||
}
|
||||
|
||||
func listHolds(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestARenamedEntryIsWrittenBackAsItWasSaid(t *testing.T) {
|
||||
m, err := parseRaw(t, renaming())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"takes-config":["position","theme",{"as":"NOTIFIER_URL","key":"url"}]`) {
|
||||
t.Fatalf("written back as %s", raw)
|
||||
}
|
||||
if _, err := decodeForTest(raw); err != nil {
|
||||
t.Fatalf("read again: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module declares its config (novox/hq ADR 0348 §1, §4, §5, §6, §8, §11; design 57 package K1, issue 660):
|
||||
// one registration refusal per rule of design 57's K1, the old names read as aliases, and a module rewritten
|
||||
// from settings to config composing byte-identically.
|
||||
|
||||
// inConfigForm is a module in the config form that registers: every value typed, described, defaulted or
|
||||
// required, placed in a file or read at runtime, and its mergeable file naming its keys.
|
||||
func inConfigForm() map[string]any {
|
||||
return map[string]any{
|
||||
"module": "notifier",
|
||||
"version": "1",
|
||||
"config": map[string]any{
|
||||
"font-size": map[string]any{"type": "number", "description": "the notification's font size, in points",
|
||||
"minimum": 6, "maximum": 40, "default": 10},
|
||||
"width": map[string]any{"type": "number", "description": "the notification's width, in pixels",
|
||||
"default": 250},
|
||||
"position": map[string]any{"type": "string", "description": "the corner it shows in, a word",
|
||||
"enum": []any{"top-right", "top-left"}, "default": "top-right"},
|
||||
"theme": map[string]any{"type": "string", "description": "the colour theme, a lower-case name",
|
||||
"pattern": "[a-z]+", "default": "dark"},
|
||||
"history": map[string]any{"type": "number", "description": "how many notifications it keeps, a count",
|
||||
"default": 20, "runtime": true},
|
||||
"sender": map[string]any{"type": "string", "description": "the address it mails a digest from",
|
||||
"required": true},
|
||||
},
|
||||
"resources": []any{
|
||||
map[string]any{"id": "configuration", "type": "file", "path": "/x/notifierrc",
|
||||
"content": "font = Inter ${config:font-size}\nwidth = ${config:width}\nfrom = ${config:sender}\n"},
|
||||
map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json",
|
||||
"takes-config": []any{"position", "theme"}, "content": "{\"position\": \"top-right\"}\n"},
|
||||
map[string]any{"id": "notifier", "type": "service", "unit": "notifier.service",
|
||||
"reload-on": []any{"configuration"}, "restart-on": []any{"rules"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// declaration is one value of inConfigForm's config, to change in place.
|
||||
func declaration(m map[string]any, key string) map[string]any {
|
||||
return m["config"].(map[string]any)[key].(map[string]any)
|
||||
}
|
||||
|
||||
func resourceOf(m map[string]any, id string) map[string]any {
|
||||
for _, r := range m["resources"].([]any) {
|
||||
if r.(map[string]any)["id"] == id {
|
||||
return r.(map[string]any)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRaw(t *testing.T, m map[string]any) (Manifest, error) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return decodeForTest(raw)
|
||||
}
|
||||
|
||||
// refused is that the manifest is refused with every one of these words.
|
||||
func refused(t *testing.T, m map[string]any, words ...string) {
|
||||
t.Helper()
|
||||
_, err := parseRaw(t, m)
|
||||
if err == nil {
|
||||
t.Fatalf("registered; wanted a refusal saying %q", words)
|
||||
}
|
||||
for _, w := range words {
|
||||
if !strings.Contains(err.Error(), w) {
|
||||
t.Fatalf("refused without %q:\n%v", w, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleInTheConfigFormRegisters(t *testing.T) {
|
||||
m, err := parseRaw(t, inConfigForm())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.DeclaresConfig() {
|
||||
t.Fatal("a module declaring config is not read as in the config form")
|
||||
}
|
||||
d := m.Settings["font-size"]
|
||||
if d.Type != "number" || d.Description == "" || d.Minimum == nil || *d.Minimum != 6 || d.Default != float64(10) {
|
||||
t.Fatalf("font-size read as %+v", d)
|
||||
}
|
||||
if s := m.Settings["sender"]; !s.Required || s.Default != nil {
|
||||
t.Fatalf("sender read as %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// withPredefined is the list of predefined keys the mesh published, for the length of a test.
|
||||
func withPredefined(t *testing.T, keys PredefinedList) {
|
||||
t.Helper()
|
||||
was := ReplacePublished(keys)
|
||||
t.Cleanup(func() { ReplacePublished(was) })
|
||||
}
|
||||
|
||||
// What the mesh holds is what was written: a module in the config form is written back in it, and read again
|
||||
// the same.
|
||||
func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) {
|
||||
m, err := parseRaw(t, inConfigForm())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, said := range []string{`"config":`, `${config:font-size}`, `"takes-config":`, `"required":true`} {
|
||||
if !strings.Contains(string(raw), said) {
|
||||
t.Fatalf("written back without %s: %s", said, raw)
|
||||
}
|
||||
}
|
||||
for _, unsaid := range []string{`"settings":`, `${setting:`, `"takes-settings"`, `"kind"`, `"why"`, `"default":null`} {
|
||||
if strings.Contains(string(raw), unsaid) {
|
||||
t.Fatalf("written back with %s: %s", unsaid, raw)
|
||||
}
|
||||
}
|
||||
again, err := decodeForTest(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
twice, _ := json.Marshal(again)
|
||||
if !bytes.Equal(raw, twice) {
|
||||
t.Fatalf("read again it is written\n%s\nwhere it was\n%s", twice, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// A module in the settings form is written back byte for byte as before.
|
||||
func TestASettingsManifestIsWrittenBackAsBefore(t *testing.T) {
|
||||
d := SettingDeclaration{Kind: KindPreference, Default: float64(10), Why: "readable"}
|
||||
raw, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != `{"kind":"preference","default":10,"why":"readable"}` {
|
||||
t.Fatalf("written as %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// 1658/AC1: a value whose only placement is ${config:key} in an item's text is placed.
|
||||
func TestConfigCountsAnItemsTextAsAPlacement(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"module": "m", "version": "1",
|
||||
"config": map[string]any{
|
||||
"k": map[string]any{"type": "string", "description": "a word the item says", "default": "x"},
|
||||
},
|
||||
"items": []any{map[string]any{"collection": "a", "content": "k = ${config:k}\n"}},
|
||||
}
|
||||
if _, err := parseRaw(t, m); err != nil {
|
||||
t.Fatalf("a value placed only in an item's text is refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"sort"
|
||||
)
|
||||
|
||||
func sortedAnyKeys(values map[string]any) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for k := range values {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
// ConsumersNamespace is a provider's consumers' credentials' placeholder namespace, `${consumers:<provision>}`.
|
||||
const ConsumersNamespace = "consumers"
|
||||
@@ -1,24 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1279/AC4 (ADR 0443 §4): no module is given a way to ask where its consumer starts. A consumes entry is an event's
|
||||
// name; one that carries a key asking for the stream's history is refused by name, so the safe start is the only one.
|
||||
func TestAConsumesEntryAskingWhereItStartsIsRefused(t *testing.T) { // 1279/AC4
|
||||
for _, key := range []string{"from", "start", "deliver"} {
|
||||
raw := `{"module":"audit","version":"1","consumes":[{"event":"shop.order.placed","` + key + `":"beginning"}]}`
|
||||
_, err := decodeForTest([]byte(raw))
|
||||
if err == nil {
|
||||
t.Fatalf("a consumes entry with %q was accepted", key)
|
||||
}
|
||||
if !strings.Contains(err.Error(), `"`+key+`"`) || !strings.Contains(err.Error(), "consumes") {
|
||||
t.Errorf("the refusal for %q does not name the key and the field: %v", key, err)
|
||||
}
|
||||
}
|
||||
if _, err := decodeForTest([]byte(`{"module":"audit","version":"1","consumes":["shop.order.placed"]}`)); err != nil {
|
||||
t.Errorf("a plain consumes entry was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The other half of an edge.
|
||||
//
|
||||
// `requires` says a thing must be there. It never said what to do with it — a web application
|
||||
// requiring a reverse proxy has to say *which name, which port*, and there was nowhere to put
|
||||
// that. The two modules that needed it most, the proxy and the VPN, went round the outside and
|
||||
// opened a connection to the control plane's database, which is why every node held a credential
|
||||
// to it permanently.
|
||||
|
||||
// A provision answered from anywhere in the mesh has consumers on other machines, and the
|
||||
// provider has to know who they are. Contributions were node-local until this, which meant the
|
||||
// one case that most needed them was the one they did not reach.
|
||||
|
||||
func provider() Manifest {
|
||||
return Manifest{Module: "postgres", Version: "1",
|
||||
Provides: FromAnywhere("postgres-database"),
|
||||
Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"},
|
||||
Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"},
|
||||
}
|
||||
}
|
||||
|
||||
func grantedTo(t *testing.T, out []map[string]any) []Contribution {
|
||||
t.Helper()
|
||||
for _, r := range out {
|
||||
if r["path"] != "/var/lib/postgres/grants/mesh.json" {
|
||||
continue
|
||||
}
|
||||
var parsed struct {
|
||||
Given []Contribution `json:"given"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parsed.Given
|
||||
}
|
||||
t.Fatalf("the provider was given no manifest: %v", out)
|
||||
return nil
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
// The two seats.
|
||||
const (
|
||||
WantsSeat = "mesh-wants"
|
||||
HandActsSeat = "mesh-hand-acts"
|
||||
)
|
||||
|
||||
// controlRecordsDecision is the record that made the two seats.
|
||||
const controlRecordsDecision = "novox/hq ADR 0565"
|
||||
|
||||
// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15).
|
||||
func wantsVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " +
|
||||
"and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " +
|
||||
"rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " +
|
||||
"with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.",
|
||||
Input: schema(map[string]string{
|
||||
"module": "one module",
|
||||
"policy": "with module: roll, together, record, or default to take a person's choice back",
|
||||
"why": "with policy: why, required and kept",
|
||||
"compare": "\"true\": each node's own composition, by its digest, and the assigner's findings",
|
||||
}, nil, "compare")},
|
||||
{Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " +
|
||||
"indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " +
|
||||
"the step itself is the put on the operator's warrant.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
}
|
||||
}
|
||||
|
||||
// handActsVerbs are mesh-hand-acts' verbs (ADR 0565 clause 10), as the controller's took them.
|
||||
func handActsVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "hand-act", Description: "Record one act done by hand: what, why, its cause in a word or a condition's kind " +
|
||||
"(the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the " +
|
||||
"runtime proves, never said by the caller. Said as act-recorded.",
|
||||
Input: schema(map[string]string{
|
||||
"what": "what was done, in a line",
|
||||
"why": "why it had to be done by hand",
|
||||
"cause": "the cause in a word, or a condition's kind",
|
||||
"condition": "the key of the condition it addressed, if any (optional)",
|
||||
"verb": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own",
|
||||
}, []string{"what", "why"})},
|
||||
{Name: "hand-acts", Description: "What was done by hand lately, over this log and the controller's: who, why and the " +
|
||||
"cause of each, and the causes recorded twice within a fortnight (a healer is wanted).",
|
||||
Input: schema(map[string]string{"days": "how many days back (default 14)", "cause": "only this cause",
|
||||
"who": "only acts whose who contains this", "warrants": "\"true\": every warrant recorded"}, nil, "warrants")},
|
||||
{Name: "warranted", Description: "Record what a module did on the operator's warrant: who chose, through which channel, " +
|
||||
"with which proofs and which answer read from the router's own record of that module's ask, never from the " +
|
||||
"caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.",
|
||||
Input: schema(map[string]string{"asker": "the module that asked", "ask": "its ask's id"}, []string{"asker", "ask"})},
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EndedControllerName is the name the ended controller had, as its module and as its seat: no manifest may name it.
|
||||
const EndedControllerName = "mesh-controller"
|
||||
|
||||
// The kinds of a declaration that names the controller.
|
||||
const (
|
||||
DependsRead = "read"
|
||||
DependsInvoke = "invoke"
|
||||
DependsConsume = "consume"
|
||||
DependsUses = "uses"
|
||||
DependsWaits = "waits"
|
||||
DependsClaims = "claims"
|
||||
DependsRequires = "requires"
|
||||
DependsIs = "is"
|
||||
)
|
||||
|
||||
// Dependency is one declaration of a manifest that names the controller.
|
||||
type Dependency struct {
|
||||
Module string `json:"module"`
|
||||
// By is the account declaring it when it is not the module's own: `process <id>` or `tools <binary>`.
|
||||
By string `json:"by,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
What string `json:"what"`
|
||||
}
|
||||
|
||||
// onController says whether a name `<owner>.<rest>` is the controller's.
|
||||
func onController(name string) bool { return strings.HasPrefix(name, EndedControllerName+".") }
|
||||
|
||||
// invokesController says whether an invoke names a verb of the controller's seat or a tool of its module. `*` names none:
|
||||
// it is the console's grant to every tool.
|
||||
func invokesController(invoke string) bool {
|
||||
if seat, _, ok := SeatInvoke(invoke); ok {
|
||||
return seat == EndedControllerName
|
||||
}
|
||||
return onController(invoke)
|
||||
}
|
||||
|
||||
// Dependencies are every declaration naming the controller in the manifests given, by module.
|
||||
func Dependencies(manifests map[string]Manifest) []Dependency {
|
||||
var out []Dependency
|
||||
add := func(module, by, kind, what string) {
|
||||
if d := (Dependency{Module: module, By: by, Kind: kind, What: what}); !slices.Contains(out, d) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
for name, m := range manifests {
|
||||
if name == EndedControllerName || m.Module == EndedControllerName {
|
||||
add(name, "", DependsIs, EndedControllerName)
|
||||
}
|
||||
for _, r := range m.Reads {
|
||||
if onController(r) {
|
||||
add(name, "", DependsRead, r)
|
||||
}
|
||||
}
|
||||
for _, i := range m.Invokes {
|
||||
if invokesController(i) {
|
||||
add(name, "", DependsInvoke, i)
|
||||
}
|
||||
}
|
||||
for _, c := range m.Consumes {
|
||||
if onController(c) {
|
||||
add(name, "", DependsConsume, c)
|
||||
}
|
||||
}
|
||||
for _, w := range m.Waits {
|
||||
if onController(w) {
|
||||
add(name, "", DependsWaits, w)
|
||||
}
|
||||
}
|
||||
for _, u := range m.Uses {
|
||||
if u == EndedControllerName {
|
||||
add(name, "", DependsUses, u)
|
||||
}
|
||||
}
|
||||
for _, c := range m.Claims {
|
||||
if c.Name == EndedControllerName {
|
||||
add(name, "", DependsClaims, c.Name)
|
||||
}
|
||||
}
|
||||
for _, r := range m.Requires {
|
||||
if r == EndedControllerName || onController(r) {
|
||||
add(name, "", DependsRequires, r)
|
||||
}
|
||||
}
|
||||
for _, a := range m.BusAccounts() {
|
||||
by := "process " + a.ID
|
||||
if a.Tools {
|
||||
by = "tools " + a.ID
|
||||
}
|
||||
for _, i := range a.Bus.Invokes {
|
||||
if invokesController(i) {
|
||||
add(name, by, DependsInvoke, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
a, b := out[i], out[j]
|
||||
if a.Module != b.Module {
|
||||
return a.Module < b.Module
|
||||
}
|
||||
if a.Kind != b.Kind {
|
||||
return a.Kind < b.Kind
|
||||
}
|
||||
if a.What != b.What {
|
||||
return a.What < b.What
|
||||
}
|
||||
return a.By < b.By
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ManifestRefusals is the check over a repository's manifests, by module name: one reason per declaration naming the
|
||||
// controller, none when it passes. None given fails it, since nothing read can hide a declaration.
|
||||
func ManifestRefusals(manifests map[string]Manifest) []string {
|
||||
if len(manifests) == 0 {
|
||||
return []string{"no manifest was read: with nothing read, nothing naming the controller can be judged absent"}
|
||||
}
|
||||
var out []string
|
||||
for _, d := range Dependencies(manifests) {
|
||||
who := d.Module
|
||||
if d.By != "" {
|
||||
who += " (" + d.By + ")"
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s declares %s %s: the controller ended by its unassignment and nothing may name it "+
|
||||
"(ADR 0590 clause 5)", who, d.Kind, d.What))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Issue 1126 B1/AC1, B2/AC1 (ADR 0590 clause 5): the seat set holds no mesh-controller, and the dependence check refuses
|
||||
// every manifest that reads (the lease too), invokes, consumes, waits on, uses, claims, requires or is mesh-controller.
|
||||
func TestNothingMayNameTheController(t *testing.T) {
|
||||
for _, s := range DefaultSeats() {
|
||||
if s.Name == EndedControllerName {
|
||||
t.Errorf("the seat set still holds %s", s.Name)
|
||||
}
|
||||
}
|
||||
if _, ok := SeatNamed(EndedControllerName); ok {
|
||||
t.Errorf("SeatNamed still finds %s", EndedControllerName)
|
||||
}
|
||||
for _, row := range []struct {
|
||||
name, manifest string
|
||||
want []string // "<kind> <what>"
|
||||
}{
|
||||
{"a read of the lease", `{"module":"mod-l","version":"1","reads":["mesh-controller.lease"]}`, []string{"read mesh-controller.lease"}},
|
||||
{"the controller's own manifest", `{"module":"mesh-controller","version":"1"}`, []string{"is mesh-controller"}},
|
||||
{"a claim of its seat", `{"module":"a-controller","version":"1","claims":[{"name":"mesh-controller","scope":"mesh"}]}`,
|
||||
[]string{"claims mesh-controller"}},
|
||||
{"each declaration kind; every tool (*) is none",
|
||||
`{"module":"mod-i","version":"1","invokes":["seat:mesh-controller.root-free","mesh-controller.status","*","nats.nats_users"],` +
|
||||
`"consumes":["mesh-controller.artifacts-gone","mod-x.thing"],"uses":["mesh-controller"],"waits":["mesh-controller.checked"],` +
|
||||
`"requires":["mesh-controller"]}`,
|
||||
[]string{"consume mesh-controller.artifacts-gone", "invoke mesh-controller.status", "invoke seat:mesh-controller.root-free",
|
||||
"requires mesh-controller", "uses mesh-controller", "waits mesh-controller.checked"}},
|
||||
{"a process's own invoke", `{"module":"mod-p","version":"1","resources":[{"id":"account","type":"user","name":"mod-p"},` +
|
||||
`{"id":"worker","type":"process","run":["./w"],"user":"mod-p","bus":{"invokes":["seat:mesh-controller.held"]}}]}`,
|
||||
[]string{"invoke seat:mesh-controller.held"}},
|
||||
{"a module on its own seats", `{"module":"mod-ok","version":"1","reads":["mesh-grants.accounts"],"invokes":["*"]}`, nil},
|
||||
} {
|
||||
m, err := Decode([]byte(row.manifest))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", row.name, err)
|
||||
}
|
||||
var got []string
|
||||
for _, d := range Dependencies(map[string]Manifest{m.Module: m}) {
|
||||
got = append(got, d.Kind+" "+d.What)
|
||||
}
|
||||
if !slices.Equal(got, row.want) {
|
||||
t.Errorf("%s: dependencies %q, want %q", row.name, got, row.want)
|
||||
}
|
||||
refused := ManifestRefusals(map[string]Manifest{m.Module: m})
|
||||
if len(refused) != len(row.want) || len(refused) > 0 && !strings.Contains(refused[0], "ADR 0590") {
|
||||
t.Errorf("%s: refused %q", row.name, refused)
|
||||
}
|
||||
}
|
||||
if got := ManifestRefusals(map[string]Manifest{}); len(got) != 1 || !strings.Contains(got[0], "no manifest") {
|
||||
t.Errorf("no manifest given: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// CoreCredentialModules are the modules whose own secrets are the core's credentials: the bus, mesh-provisioner and
|
||||
// its client. The node-engine and the controller hold no own secret of a module's. **Fixed here, in the controller's
|
||||
// code, as the clause says** — and the vault keeps the same list (mesh-vault's CoreModules), answering the controller
|
||||
// for these alone.
|
||||
var CoreCredentialModules = []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}
|
||||
|
||||
// VaultSealedField is the field of a file the vault sealed to its machine (mesh-host's File.VaultSealed).
|
||||
const VaultSealedField = "vault-sealed"
|
||||
|
||||
// ForeseenVaultBox stands for a core credential's box in a composition asked ahead of the send, when none is kept
|
||||
// yet: the send asks the vault for it. Never sent.
|
||||
var ForeseenVaultBox = json.RawMessage(`{"v":1,"eph":"","iv":"","tag":"","ct":"","foreseen":"asked of the vault by the next send"}`)
|
||||
@@ -1,26 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The core's own credentials travel sealed by the vault to their machine (novox/hq ADR 0349 §16, issue 755): the
|
||||
// controller carries the vault's box and cannot open it. The list is fixed here, and the composition carries the box
|
||||
// in place of the controller's own sealed value only where the rendering was given one.
|
||||
|
||||
const provisionerClient = `{"module":"mesh-provisioner-client","version":"1",
|
||||
"own-secrets":{"provisioner-broker":"${dir:state}/broker"},
|
||||
"resources":[{"id":"state","type":"directory","mode":"0700","place":"."}]}`
|
||||
|
||||
func brokerFile(t *testing.T, c Composed) map[string]any {
|
||||
t.Helper()
|
||||
for _, r := range c.Resources {
|
||||
if r["id"] == "mesh-provisioner-client.needs-provisioner-broker" {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var aVaultBox = json.RawMessage(`{"v":1,"eph":"ZQ==","iv":"aQ==","tag":"dA==","ct":"Yw=="}`)
|
||||
@@ -1,392 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The classes: how precious the data is. A fixed vocabulary, ranked by the operator (2026-10-06): a
|
||||
// class is what the protections are derived from, so a new one is a decision, not a manifest's choice.
|
||||
const (
|
||||
// ClassIrreplaceable is what must never be lost: the operator names it (the media library, the
|
||||
// photo sites' storage). Protected by a backup or by a declared redundancy — one is required —
|
||||
// retired rather than removed, and every alert about it is urgent.
|
||||
ClassIrreplaceable = "irreplaceable"
|
||||
// ClassValuable is anybody's work that would be painful to lose: in the nightly backup by default,
|
||||
// retired rather than removed, and every alert about it a warning.
|
||||
ClassValuable = "valuable"
|
||||
// ClassRebuildable can be made again from something kept elsewhere — a clone, an index, a
|
||||
// download, a night's dump — at a cost in time, not in data. In the nightly backup by default;
|
||||
// forgotten when its module goes, and never alerted on.
|
||||
ClassRebuildable = "rebuildable"
|
||||
// ClassCache may be emptied at any moment with nothing lost but speed: never backed up, never
|
||||
// measured, never alerted on.
|
||||
ClassCache = "cache"
|
||||
// ClassNone is a provision that keeps nothing of its consumers' (consumers only): a resolver, a
|
||||
// certificate authority, an artifact store.
|
||||
ClassNone = "none"
|
||||
)
|
||||
|
||||
// classRank orders the classes by how precious they are, so the stricter of two is chosen.
|
||||
var classRank = map[string]int{ClassNone: 0, ClassCache: 1, ClassRebuildable: 2, ClassValuable: 3, ClassIrreplaceable: 4}
|
||||
|
||||
// StricterClass is the more precious of two classes.
|
||||
func StricterClass(a, b string) string {
|
||||
if classRank[b] > classRank[a] {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// Retires is whether a class's data is retired, not forgotten, when its machine no longer declares it.
|
||||
func Retires(class string) bool { return class == ClassIrreplaceable || class == ClassValuable }
|
||||
|
||||
// Watched is whether a class's data raises conditions: irreplaceable and valuable.
|
||||
func Watched(class string) bool { return Retires(class) }
|
||||
|
||||
// DefaultBackupWithin is how old the last good backup of an item may be before the self-check says so
|
||||
// (novox/hq ADR 0214: a machine with data and no good backup in 48 hours).
|
||||
const DefaultBackupWithin = 48 * time.Hour
|
||||
|
||||
// Data is a manifest's `data` section.
|
||||
type Data struct {
|
||||
// Own is the data this module keeps itself.
|
||||
Own []DataItem `json:"own,omitempty"`
|
||||
// Consumers is what it keeps for its consumers, per provision it grants.
|
||||
Consumers map[string]ConsumerData `json:"consumers,omitempty"`
|
||||
// KeptBy is what of its own lives with the provider of a provision it requires — its rows, its
|
||||
// objects — and how precious that is. The provider's protections follow the stricter of its own
|
||||
// class for its consumers and this.
|
||||
KeptBy map[string]KeptData `json:"kept-by,omitempty"`
|
||||
}
|
||||
|
||||
// DataItem is one piece of a module's own data.
|
||||
type DataItem struct {
|
||||
// ID names it within the module: what `data`, `cleanup` and a condition call it.
|
||||
ID string `json:"id"`
|
||||
// Path is one of the module's directories, `${dir:<id>}`, or an operator's path it was given,
|
||||
// `${access:<id>}`, or a path beneath either. Never a machine path (novox/hq ADR 0112).
|
||||
Path string `json:"path"`
|
||||
Class string `json:"class"`
|
||||
// Backup is how it is copied: "copy" (the holder reads it as it stands), "none", or a dump — a
|
||||
// command that writes a consistent copy into another item, which is copied. Unsaid, anything but a
|
||||
// cache is copied, unless it says it is protected by redundancy instead.
|
||||
Backup *DataBackup `json:"backup,omitempty"`
|
||||
// Redundancy says it is protected by the redundancy of the storage it lives on rather than by a
|
||||
// copy, and why that is enough: the media library on an array there is no room to copy. The
|
||||
// backup holder then watches that array, and a degraded one is said.
|
||||
Redundancy string `json:"redundancy,omitempty"`
|
||||
// Within is how old its last good backup may be; unsaid, DefaultBackupWithin.
|
||||
Within string `json:"within,omitempty"`
|
||||
// Measure is how the backup holder measures it: `walk` (the default — every file, at most daily and
|
||||
// bounded, for small items), `dataset` (a ZFS dataset's own counters, hourly, nothing walked) or
|
||||
// `shallow` (its top-level entries only, no size). A large item never says walk.
|
||||
Measure string `json:"measure,omitempty"`
|
||||
// Active is how long it may go unwritten before that is a fault — for data something is
|
||||
// expected to write all the time. Unsaid, a quiet item is not a fault.
|
||||
Active string `json:"active,omitempty"`
|
||||
// Why is a line for the reviewer: why this class.
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// ConsumerData is what a provider keeps for the consumers of one provision.
|
||||
type ConsumerData struct {
|
||||
Class string `json:"class"`
|
||||
// In is where it lives: one of the module's own items (whose protection covers it), or a
|
||||
// provision this module requires (whose provider keeps it, as its consumer). Unsaid only for none
|
||||
// and cache.
|
||||
In string `json:"in,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// KeptData is how precious what a module keeps with a provider is.
|
||||
type KeptData struct {
|
||||
Class string `json:"class"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// DataBackup is how an item is copied.
|
||||
type DataBackup struct {
|
||||
Copy bool
|
||||
None bool
|
||||
// Dump is the command writing a consistent copy into the item Into.
|
||||
Dump string
|
||||
Into string
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads "copy", "none" or {"dump": "...", "into": "<item>"}.
|
||||
func (b *DataBackup) UnmarshalJSON(raw []byte) error {
|
||||
var word string
|
||||
if err := json.Unmarshal(raw, &word); err == nil {
|
||||
switch word {
|
||||
case "copy":
|
||||
*b = DataBackup{Copy: true}
|
||||
case "none":
|
||||
*b = DataBackup{None: true}
|
||||
default:
|
||||
return fmt.Errorf("a data item's backup is \"copy\", \"none\" or {dump, into}, not %q", word)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var full struct {
|
||||
Dump string `json:"dump"`
|
||||
Into string `json:"into"`
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&full); err != nil {
|
||||
return fmt.Errorf("a data item's backup is \"copy\", \"none\" or {dump, into}: %w", typedUnknown(err))
|
||||
}
|
||||
*b = DataBackup{Dump: full.Dump, Into: full.Into}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON writes it back in the form it was written.
|
||||
func (b DataBackup) MarshalJSON() ([]byte, error) {
|
||||
switch {
|
||||
case b.Copy:
|
||||
return json.Marshal("copy")
|
||||
case b.None:
|
||||
return json.Marshal("none")
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
Dump string `json:"dump"`
|
||||
Into string `json:"into"`
|
||||
}{b.Dump, b.Into})
|
||||
}
|
||||
|
||||
// IsDump is whether the item is copied by a dump.
|
||||
func (b *DataBackup) IsDump() bool { return b != nil && b.Dump != "" }
|
||||
|
||||
// BackedUp is whether the holder keeps restore points of this item: anything but a cache by default —
|
||||
// the nightly backup is the standard plan — unless it says "none", or says it is protected by
|
||||
// redundancy and says nothing of a backup.
|
||||
func (it DataItem) BackedUp() bool {
|
||||
switch {
|
||||
case it.Backup == nil:
|
||||
return it.Class != ClassCache && it.Redundancy == ""
|
||||
case it.Backup.None:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Protection is how the item is protected, in one word: "backup", "redundancy", both joined by "+",
|
||||
// or "none".
|
||||
func (it DataItem) Protection() string {
|
||||
var by []string
|
||||
if it.BackedUp() {
|
||||
by = append(by, "backup")
|
||||
}
|
||||
if it.Redundancy != "" {
|
||||
by = append(by, "redundancy")
|
||||
}
|
||||
if len(by) == 0 {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join(by, "+")
|
||||
}
|
||||
|
||||
// The ways an item is measured.
|
||||
const (
|
||||
MeasureWalk = "walk"
|
||||
MeasureDataset = "dataset"
|
||||
MeasureShallow = "shallow"
|
||||
)
|
||||
|
||||
// MeasuredBy is how the item is measured, with the default applied.
|
||||
func (it DataItem) MeasuredBy() string {
|
||||
if it.Measure == "" {
|
||||
return MeasureWalk
|
||||
}
|
||||
return it.Measure
|
||||
}
|
||||
|
||||
// BackupWithin is the item's bound on its last good backup.
|
||||
func (it DataItem) BackupWithin() time.Duration {
|
||||
if d, err := ParseDataDuration(it.Within); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
return DefaultBackupWithin
|
||||
}
|
||||
|
||||
// ActiveWithin is how long the item may go unwritten; zero when quiet is not a fault.
|
||||
func (it DataItem) ActiveWithin() time.Duration {
|
||||
d, _ := ParseDataDuration(it.Active)
|
||||
return d
|
||||
}
|
||||
|
||||
// ParseDataDuration reads "48h", "90m" or "7d"; empty is zero.
|
||||
func ParseDataDuration(s string) (time.Duration, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
if days, ok := strings.CutSuffix(s, "d"); ok {
|
||||
n, err := strconv.Atoi(days)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, fmt.Errorf("%q is not a number of days", s)
|
||||
}
|
||||
return time.Duration(n) * 24 * time.Hour, nil
|
||||
}
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil || d <= 0 {
|
||||
return 0, fmt.Errorf("%q is not a duration (48h, 90m, 7d)", s)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// DataItems is the module's own data, in the order declared.
|
||||
func (m Manifest) DataItems() []DataItem {
|
||||
if m.Data == nil {
|
||||
return nil
|
||||
}
|
||||
return m.Data.Own
|
||||
}
|
||||
|
||||
// DataItem is one own item by id.
|
||||
func (m Manifest) DataItem(id string) (DataItem, bool) {
|
||||
for _, it := range m.DataItems() {
|
||||
if it.ID == id {
|
||||
return it, true
|
||||
}
|
||||
}
|
||||
return DataItem{}, false
|
||||
}
|
||||
|
||||
// ConsumerDataOf is what this module says it keeps for a provision's consumers, and whether it says.
|
||||
func (m Manifest) ConsumerDataOf(provision string) (ConsumerData, bool) {
|
||||
if m.Data == nil {
|
||||
return ConsumerData{}, false
|
||||
}
|
||||
c, ok := m.Data.Consumers[provision]
|
||||
return c, ok
|
||||
}
|
||||
|
||||
// keepsByClass is whether a class keeps something a binding must not move away from.
|
||||
func keepsByClass(class string) bool {
|
||||
return class == ClassIrreplaceable || class == ClassValuable || class == ClassRebuildable
|
||||
}
|
||||
|
||||
// KeepsConsumerData is whether this module, providing a provision, keeps what each consumer writes
|
||||
// there (novox/hq ADR 0232, ADR 0233): whether a consumer bound to it is bound to its data.
|
||||
//
|
||||
// **What the data section says, it gets**: irreplaceable, valuable or rebuildable keeps; cache and none
|
||||
// do not — a cache lost in a move costs speed, not data. Before the section, an offer said it with
|
||||
// `keeps-consumer-data`, which is still read; unsaid in both, a provider that grants each consumer a
|
||||
// credential of its own keeps that consumer's data (ADR 0232 §1). The catalogue check refuses the
|
||||
// unsaid case for a provider that grants (DataProblems), so the inference is what an older definition
|
||||
// on the shelf gets, never a new one.
|
||||
func (m Manifest) KeepsConsumerData(provision string) bool {
|
||||
if c, ok := m.ConsumerDataOf(provision); ok {
|
||||
return keepsByClass(c.Class)
|
||||
}
|
||||
for _, o := range m.Provides {
|
||||
if o.Name == provision && o.KeepsConsumerData != nil {
|
||||
return *o.KeepsConsumerData
|
||||
}
|
||||
}
|
||||
_, grants := m.Grants[provision]
|
||||
return grants
|
||||
}
|
||||
|
||||
// NeedsBackupHolder is whether a module's data needs the machine's backup holder to be there: it keeps
|
||||
// something irreplaceable — backed up by the holder, or protected by an array the holder watches. A
|
||||
// module keeping only valuable or rebuildable data is backed up where a holder is, and refused nowhere
|
||||
// for want of one: the standard plan, not a requirement.
|
||||
func (m Manifest) NeedsBackupHolder() bool {
|
||||
for _, it := range m.DataItems() {
|
||||
if it.Class == ClassIrreplaceable {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// The node-backup seat's kinds (novox/hq to-be 43, ADR 0233): `backup` is what to run and which paths
|
||||
// to keep, `data` every item with its class and protection, for the holder to measure and watch.
|
||||
const (
|
||||
BackupKindBackup = "backup"
|
||||
BackupKindData = "data"
|
||||
)
|
||||
|
||||
// derivedContributions is what a module's data section gives the backup holder: its backup lines and
|
||||
// its items. Every item is listed, so a valuable one on a machine is measured whether or not it is
|
||||
// copied; a cache is listed and not measured.
|
||||
func (m Manifest) derivedContributions() []SeatContribution {
|
||||
items := m.DataItems()
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
var lines []string
|
||||
kept := map[string]bool{}
|
||||
keep := func(path string) {
|
||||
if !kept[path] {
|
||||
kept[path] = true
|
||||
lines = append(lines, "path "+path)
|
||||
}
|
||||
}
|
||||
for _, it := range items {
|
||||
if !it.BackedUp() {
|
||||
continue
|
||||
}
|
||||
if it.Backup.IsDump() {
|
||||
lines = append(lines, "run "+strings.TrimSpace(it.Backup.Dump))
|
||||
if into, ok := m.DataItem(it.Backup.Into); ok {
|
||||
keep(into.Path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
keep(it.Path)
|
||||
}
|
||||
var described []string
|
||||
for _, it := range items {
|
||||
covered := "-"
|
||||
switch {
|
||||
case it.Backup.IsDump():
|
||||
if into, ok := m.DataItem(it.Backup.Into); ok {
|
||||
covered = into.Path
|
||||
}
|
||||
case it.BackedUp():
|
||||
covered = it.Path
|
||||
}
|
||||
described = append(described, fmt.Sprintf("item %s %s %s %s %s %s", it.ID, it.Class, it.Path, covered,
|
||||
it.Protection(), it.MeasuredBy()))
|
||||
}
|
||||
var out []SeatContribution
|
||||
if len(lines) > 0 {
|
||||
out = append(out, SeatContribution{Seat: BackupSeat, Kind: BackupKindBackup, Content: strings.Join(lines, "\n") + "\n"})
|
||||
}
|
||||
return append(out, SeatContribution{Seat: BackupSeat, Kind: BackupKindData, Content: strings.Join(described, "\n") + "\n"})
|
||||
}
|
||||
|
||||
// allContributions is every contribution a module makes to a seat's holder: what it wrote, and what
|
||||
// its data section derives. **One list**: a module that declares its data has its backup lines
|
||||
// composed from it, and a backup line it still writes by hand is not placed — the catalogue check
|
||||
// refuses it — so the holder never copies two lists that disagree.
|
||||
func (m Manifest) allContributions() []SeatContribution {
|
||||
if m.Data == nil {
|
||||
return m.Contributions
|
||||
}
|
||||
derived := m.derivedContributions()
|
||||
out := make([]SeatContribution, 0, len(m.Contributions)+len(derived))
|
||||
for _, c := range m.Contributions {
|
||||
if s, known := SeatNamed(c.Seat); known && s.Name == BackupSeat {
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return append(out, derived...)
|
||||
}
|
||||
|
||||
// volumeMount is a container's volume, `<source>:<target>[:<options>]`, its source possibly a
|
||||
// `${dir:<id>}` — whose own colon is not the separator.
|
||||
var volumeMount = regexp.MustCompile(`^(\$\{(?:dir|access):[a-z0-9][a-z0-9-]*\}[^:]*|[^:]+):([^:]+)(?::(.*))?$`)
|
||||
|
||||
// dirPlain is a whole directory, `${dir:<id>}` and nothing after it.
|
||||
var dirPlain = regexp.MustCompile(`^\$\{dir:([a-z0-9][a-z0-9-]*)\}$`)
|
||||
@@ -1,66 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PlacedDataItem is one data item a module on a node declares, as placed on that node.
|
||||
type PlacedDataItem struct {
|
||||
Module string `json:"module"`
|
||||
ID string `json:"item"`
|
||||
Class string `json:"class"`
|
||||
// Path is the item's place on the node; CoveredBy the path whose snapshot keeps it (the item, or the item its dump
|
||||
// writes into), empty when it is not backed up.
|
||||
Path string `json:"path"`
|
||||
CoveredBy string `json:"covered_by,omitempty"`
|
||||
// Protection is backup, redundancy, both or none; Measure walk, dataset or shallow.
|
||||
Protection string `json:"protection,omitempty"`
|
||||
Measure string `json:"measure,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
dataModuleHeader = regexp.MustCompile(`^#\s*([a-z0-9][a-z0-9-]*)$`)
|
||||
dataSafePath = regexp.MustCompile(`^/[^\s'"\\$` + "`" + `]*$`)
|
||||
)
|
||||
|
||||
// ParseDataItems reads the node-backup holder's data text (`${backup:data}`: a `# <module>` line, then one line per item,
|
||||
// `item <id> <class> <path> <covered-by or -> <protection> <measure>`) into each module's items, in order. A line it does
|
||||
// not read is refused, naming the module: a path with a space or a quote is never handed to a measuring shell.
|
||||
func ParseDataItems(text string) (map[string][]PlacedDataItem, error) {
|
||||
out := map[string][]PlacedDataItem{}
|
||||
module := ""
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if m := dataModuleHeader.FindStringSubmatch(line); m != nil {
|
||||
module = m[1]
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "#") || module == "" {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 5 || len(f) > 7 || f[0] != "item" || !dataSafePath.MatchString(f[3]) || (f[4] != "-" && !dataSafePath.MatchString(f[4])) {
|
||||
return nil, fmt.Errorf("%s declares a data line that is not read: %s", module, line)
|
||||
}
|
||||
it := PlacedDataItem{Module: module, ID: f[1], Class: f[2], Path: f[3], Measure: MeasureWalk}
|
||||
if len(f) >= 6 {
|
||||
it.Protection = f[5]
|
||||
}
|
||||
if len(f) == 7 {
|
||||
it.Measure = f[6]
|
||||
}
|
||||
if it.Measure != MeasureWalk && it.Measure != MeasureDataset && it.Measure != MeasureShallow {
|
||||
return nil, fmt.Errorf("%s declares a data line that is not read: %s", module, line)
|
||||
}
|
||||
if f[4] != "-" {
|
||||
it.CoveredBy = f[4]
|
||||
}
|
||||
out[module] = append(out[module], it)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A store as a module now declares it (novox/hq ADR 0233): its own data by directory with a class and a
|
||||
// dump, and what it keeps for its consumers.
|
||||
const declaredStore = `{"module":"pg","version":"1",
|
||||
"provides":[{"name":"postgres-database","scope":"mesh"}],
|
||||
"grants":{"postgres-database":"${dir:grants}"},
|
||||
"data":{
|
||||
"own":[
|
||||
{"id":"store","path":"${dir:store}","class":"irreplaceable","backup":{"dump":"dump-all > ${dir:dumps}/all","into":"dumps"},"active":"1d"},
|
||||
{"id":"dumps","path":"${dir:dumps}","class":"rebuildable","backup":"none"}],
|
||||
"consumers":{"postgres-database":{"class":"irreplaceable","in":"store"}}},
|
||||
"resources":[
|
||||
{"id":"grants","type":"directory","mode":"0700"},
|
||||
{"id":"store","type":"directory","path":"/srv/store","mode":"0700"},
|
||||
{"id":"dumps","type":"directory","path":"${dir:store}/dumps","mode":"0700"},
|
||||
{"id":"server","type":"container","image":"x@sha256:aa","volumes":["${dir:store}:/var/lib/postgresql/data"]}]}`
|
||||
|
||||
func mustParse(t *testing.T, raw string) Manifest {
|
||||
t.Helper()
|
||||
m, err := decodeForTest([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("refused: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// What a binding is sticky by follows the class (ADR 0232, generalised by 0233): a cache's consumers
|
||||
// move freely, a store's do not; an older definition saying nothing still follows its grant.
|
||||
func TestKeepingConsumerDataFollowsTheClass(t *testing.T) {
|
||||
for class, keeps := range map[string]bool{"irreplaceable": true, "valuable": true, "rebuildable": true, "cache": false, "none": false} {
|
||||
in := `,"in":"d"`
|
||||
own := `"own":[{"id":"d","path":"${dir:d}","class":"irreplaceable"}],`
|
||||
if !keeps {
|
||||
in, own = "", ""
|
||||
}
|
||||
m := mustParse(t, `{"module":"p","version":"1","provides":[{"name":"q","scope":"mesh"}],"grants":{"q":"${dir:d}"},
|
||||
"resources":[{"id":"d","type":"directory","mode":"0700"}],
|
||||
"data":{`+own+`"consumers":{"q":{"class":"`+class+`"`+in+`}}}}`)
|
||||
if m.KeepsConsumerData("q") != keeps {
|
||||
t.Errorf("class %s keeps consumer data: %v, want %v", class, !keeps, keeps)
|
||||
}
|
||||
shelf := map[string]Manifest{"p": m}
|
||||
if KeepsConsumerData(shelf, "q") != keeps {
|
||||
t.Errorf("class %s: the provision by name keeps: %v", class, !keeps)
|
||||
}
|
||||
}
|
||||
older := mustParse(t, `{"module":"p","version":"1","provides":[{"name":"q","scope":"mesh"}],"grants":{"q":"${dir:d}"},
|
||||
"resources":[{"id":"d","type":"directory","mode":"0700"}]}`)
|
||||
if !older.KeepsConsumerData("q") {
|
||||
t.Fatal("an older definition that grants no longer keeps its consumers' data")
|
||||
}
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SettingsBy is the layers that apply to each module, keyed by module name.
|
||||
type SettingsBy map[string][]Layer
|
||||
|
||||
// Generator works out a module's resources for one node, where they cannot be written in advance.
|
||||
type Generator interface {
|
||||
// Resources for this node. Absent means the node is not part of whatever this generates,
|
||||
// which is an ordinary answer rather than a failure — a machine assigned the module before it
|
||||
// has an address on the network is in exactly that state.
|
||||
Resources(node string) ([]map[string]any, bool, error)
|
||||
}
|
||||
|
||||
// OpensPorts is a generator that also says what its resources accept connections on.
|
||||
//
|
||||
// **Separate from Generator, because most generators have nothing to say here** and requiring an
|
||||
// empty method of each would be a cost paid everywhere for one caller.
|
||||
//
|
||||
// It exists because a static field cannot express this. A hub accepts connections from every node
|
||||
// at other sites; a machine that is not a hub dials out and needs nothing open — and they are the
|
||||
// same module. `listens` in a manifest is one answer for every machine that runs it, so the
|
||||
// machine that most needs filtering, the one facing the public internet, was the one whose rule
|
||||
// set would have closed its own overlay.
|
||||
type OpensPorts interface {
|
||||
// Listens is what this node accepts on because of what was computed for it. Nothing is the
|
||||
// ordinary answer: most machines running a computed module open no port at all.
|
||||
Listens(node string) ([]Listening, error)
|
||||
}
|
||||
|
||||
// Grant is one consumer's credential, on the machine that must create it.
|
||||
type Grant struct {
|
||||
// Provision is what was required.
|
||||
Provision string
|
||||
// Consumer is the node that will use it.
|
||||
Consumer string
|
||||
// From is the module on that machine which asked.
|
||||
//
|
||||
// **Part of who this credential is for, not a label** (novox/hq 04-ISSUES/022). Together with
|
||||
// Consumer it names one consumer; the node alone does not, because a machine routinely runs
|
||||
// several modules wanting the same thing. It is also what the provider names the role or the
|
||||
// bucket or the client after, so withdrawing one consumer does not take another's away.
|
||||
From string
|
||||
// Values are what that module contributed — the name it wants, and anything else the
|
||||
// provision's own vocabulary defines.
|
||||
Values map[string]any
|
||||
// Local is the name the credential goes by inside the consumer where it keeps several for one
|
||||
// provision (ADR 0094); empty for the ordinary one. The provider sees it as a holder of its own.
|
||||
Local string
|
||||
// Slug is the consumer module's identity slug, if it declared one — carried on the grant so the
|
||||
// provider side derives the same login the consumer does, even across nodes where the consumer's
|
||||
// manifest is not in view (novox/hq ADR 0049). Empty means "use the module name".
|
||||
Slug string
|
||||
// At is where the consuming machine is on the private network, empty if it is not on one.
|
||||
//
|
||||
// Passed in with the grant because it is a fact about another machine, and resolution answers
|
||||
// questions about one. A provider that must reach back to its consumer — a reverse proxy is
|
||||
// the whole reason this exists — otherwise has to know how the mesh names machines.
|
||||
At string
|
||||
// Sealed is the credential, closed to the providing node.
|
||||
Sealed string
|
||||
}
|
||||
|
||||
// Rendering is everything needed to turn a resolution into the declaration a node is sent.
|
||||
type Rendering struct {
|
||||
// Certificate is what the mesh issued for this machine's internal name, and the mesh's own
|
||||
// certificate. Both public — the key they belong to never left the machine.
|
||||
Certificate string
|
||||
Authority string
|
||||
|
||||
// Needed is each module's own secrets, sealed to this node, keyed by module and then by the
|
||||
// name the module gave it.
|
||||
Needed map[string]map[string]string
|
||||
// Foreseen is each own secret not made yet that the next send WILL make, keyed like Needed: a
|
||||
// composition asked ahead of the send (the self-check's D1) composes it with ForeseenSealed in
|
||||
// its place and lists it in Composed.Foreseen, rather than failing for a value only a send
|
||||
// makes. Nil on every composition that is sent, and on every other question, which then refuse
|
||||
// a secret not made with a *NotMadeError.
|
||||
Foreseen map[string]map[string]bool
|
||||
// VaultSealed is, for a module on the core's list (CoreCredentialModules), each own secret as the vault sealed it
|
||||
// to this machine (the mesh's sealed box, as the vault answered it), keyed like Needed (novox/hq ADR 0349 §16,
|
||||
// issue 755). Given only for a machine whose engine opens the box (VaultBoxContract): its file then carries the
|
||||
// box and never the controller's own sealed value. Nil everywhere else, and the composition is as it was.
|
||||
VaultSealed map[string]map[string]json.RawMessage
|
||||
// NotPlaced is each core module left out of this machine's declaration because the vault gave nothing for its
|
||||
// credential and no box was kept, with why (issue 755): the machine keeps what it holds of it, and a module not
|
||||
// yet there is not placed. Nil everywhere else.
|
||||
NotPlaced map[string]string
|
||||
|
||||
// Mesh is every node's address on the private network, which is what a rule saying "from the
|
||||
// mesh" resolves to. Passed in for the same reason grants are: who else is on the network is
|
||||
// a fact about the mesh, and resolution answers questions about one machine.
|
||||
Mesh []string
|
||||
// Suffix is what a machine's internal name ends in, as the control plane composed Names —
|
||||
// `internal` unless the operator chose another — so a fact writing those names does not
|
||||
// compose it a second time.
|
||||
Suffix string
|
||||
|
||||
// BusUsers is the mesh's composed user list, for the module holding `mesh-broker`. Empty on
|
||||
// every other node, and on this one until the controller has composed it.
|
||||
//
|
||||
// **Only the users, never the server's own settings**: those are the module's, in its image and
|
||||
// its mounts (Manifest.BusUsers).
|
||||
BusUsers string
|
||||
// BusMembership is this machine's membership for the bus the mesh is moving to, sealed to it
|
||||
// (design 28, task 5.2). Empty for a machine not being moved. Written as a file the host reads
|
||||
// after the declaration has applied, so the bus it names is standing before the machine leaves
|
||||
// the one it is on.
|
||||
BusMembership string
|
||||
|
||||
// MeshRange is the private network's CIDR (the range node addresses are allocated from), for a
|
||||
// module that must name the whole mesh rather than one machine — an intrusion filter that must
|
||||
// never ban a tunnel peer, say. A per-mesh value the module cannot know, so it is carried here
|
||||
// and offered as ${machine:mesh-range}, the same way one machine's address is.
|
||||
MeshRange string
|
||||
|
||||
// Accounts is each machine's operator account, by the same internal name Names uses (novox/hq
|
||||
// to-be 29). What an ssh Host block's `User` line is composed from; empty for a machine no
|
||||
// operator account is known on.
|
||||
Accounts map[string]string
|
||||
|
||||
// Kept is every operator-sealed secret in the mesh, for a module that `keeps` them. Nil when
|
||||
// nothing on this node keeps them, or the mesh has no operator key.
|
||||
Kept *KeptExport
|
||||
// KeptRuns is, by module, the file of the export as the node runs it, for a composer that holds no export (a node
|
||||
// composing its own: the export is the controller's store's, issue 1126, C6): carried as it runs when Kept is nil.
|
||||
KeptRuns map[string]map[string]any
|
||||
|
||||
// OutwardLinks is the links this machine reported as facing outside it, which the filter is
|
||||
// written around (novox/hq ADR 0140). Empty means the machine has not said, and the mesh
|
||||
// composes no filter for it rather than writing a rule around a link with no name.
|
||||
OutwardLinks []string
|
||||
|
||||
// LocalNetworks is this machine's local networks (novox/hq ADR 0340): the networks it reported on a
|
||||
// link facing outside it that lie wholly in a private range (JudgeLocalNetworks), which a port
|
||||
// reached from `local-network` is admitted from beside the private network. Empty when it reported
|
||||
// none, and the rule set then says so rather than widening the port or dropping it.
|
||||
LocalNetworks []string
|
||||
|
||||
// TunnelInterface is the interface the mesh's private network runs on, named here rather than
|
||||
// imported because the overlay package rests on this one. Traffic arriving on it is the mesh's,
|
||||
// not this machine's own guest, so the filter admits it only by a rule.
|
||||
TunnelInterface string
|
||||
|
||||
// Foundation is the ports the mesh itself needs reachable on every machine, which no module
|
||||
// declares because the foundation is not a module (novox/hq 04-ISSUES/051 and 052). The broker
|
||||
// is the one that matters: a machine dials it to enrol, and a firewall derived only from
|
||||
// modules closes it.
|
||||
Foundation []int
|
||||
|
||||
// Names is every machine's internal name and its address, for containers to be given.
|
||||
//
|
||||
// **A container does not inherit the machine's names**, so every internal name the mesh wrote
|
||||
// is invisible to what the machine runs. Given here rather than looked up on the machine,
|
||||
// because which machines exist is a fact about the mesh.
|
||||
Names map[string]string
|
||||
|
||||
// Machines is only the machines, by the same internal name — the subset of Names that is a
|
||||
// node of this mesh rather than a name it was told to serve. Both matter and they are not the
|
||||
// same set: a container's hosts wants every name, so a routed name resolves to the proxy that
|
||||
// serves it, while a resolver told the mesh's suffix is authoritative for it answers from what
|
||||
// it is given and forwards nothing — so a routed name written there is a name nobody asks for,
|
||||
// standing beside the machines and looking as real as they do.
|
||||
Machines map[string]string
|
||||
|
||||
// Zones is every zone a module in the mesh answers itself, where it is answered (novox/hq ADR
|
||||
// 0199): the mesh's resolver forwards each one there.
|
||||
Zones []ZoneAt
|
||||
|
||||
// Holders is, for each replicated mesh seat, every machine holding it, by internal name and
|
||||
// private address (novox/hq ADR 0223) — the same shape as Machines. What a machine's resolver
|
||||
// file lists: every holder of the mesh's resolver, this machine first if it is one.
|
||||
Holders map[string]map[string]string
|
||||
|
||||
Settings SettingsBy
|
||||
Generators map[string]Generator
|
||||
// Grants are the credentials this node must create, for the provisions it offers. Passed in
|
||||
// rather than resolved, because who consumes a node is a fact about the rest of the mesh and
|
||||
// resolution answers questions about one machine.
|
||||
Grants []Grant
|
||||
// Withheld is every consumer left out of Grants because its identity overflows the provision's
|
||||
// bound (novox/hq ADR 0225). Composed into nothing; carried so the machine's declaration can say
|
||||
// whom it does not serve, and why, beside what it does.
|
||||
Withheld []Overflow
|
||||
// Unbound is every consumer whose pair credential from this node is on record and whose own
|
||||
// resolution binds it elsewhere (novox/hq issue 274): never granted, carried to be said.
|
||||
Unbound []Unbound
|
||||
|
||||
// Ports is where this machine puts what each module needs reachable, by module and by the
|
||||
// port the software itself uses (novox/hq ADR 0038).
|
||||
//
|
||||
// **The one place the number now lives.** A module used to write it three times — for the rule
|
||||
// set, for what a consumer is told, and for what the runtime publishes — and nothing checked
|
||||
// that the three agreed. They are all derived from this.
|
||||
Ports map[string]map[int]int
|
||||
|
||||
// Adopted says the node is adopted (novox/hq ADR 0100): the firewall found on it stays in
|
||||
// force, so no module that loads a filter is declared there, and what the mesh needs
|
||||
// reachable is declared as openings, with its own ports guarded by a table that only refuses.
|
||||
Adopted bool
|
||||
|
||||
// Given is the machine ports this node was given for its modules' ports, by module and by the
|
||||
// port the software uses (novox/hq ADR 0100) — the foundation's ports, as genesis chose them.
|
||||
// They win over anything the mesh would assign and over a manifest's own long-form mapping.
|
||||
Given map[string]map[int]int
|
||||
|
||||
// Planned says this rendering composes the plans the seat mesh-provisioner keeps (compose.Planning). A contribution a
|
||||
// plan carries is then refused where its port is none the module listens on: no machine port stands for it, and its
|
||||
// provider dials what the plan says (issue 774, research 072 row 34).
|
||||
Planned bool
|
||||
|
||||
// Taken is the modules taken on this adopted node (novox/hq ADR 0100). The guard is derived
|
||||
// from these only (ADR 0103): a port of a module assigned but not taken may still be the
|
||||
// predecessor's.
|
||||
Taken map[string]bool
|
||||
|
||||
// Seats is where this machine put each mesh-scoped seat's holder, by seat and by the port the
|
||||
// holder's software uses (novox/hq 04-ISSUES/102) — read from the node's settings and
|
||||
// assignments for whichever module claims the seat, whether or not it is in this node's set.
|
||||
// What ${seat:…} answers with; see seat_into.go for why the answer may be absent.
|
||||
Seats map[string]map[int]int
|
||||
|
||||
// ArtifactStore is the mesh's artifact store as this network reaches it (host:port) — the
|
||||
// node holding it and the port that node put it on — or empty when the mesh has none on its
|
||||
// network yet. Composed into every image and archive the mesh built, at this moment and never
|
||||
// stored (novox/hq 04-ISSUES/102).
|
||||
ArtifactStore string
|
||||
|
||||
// SeatReach is where this machine reaches the holder of each mesh-scoped seat it may be asked
|
||||
// about (host:port), by seat: what ${seat:<seat>:reach} answers with (novox/hq ADR 0222). Absent
|
||||
// when no holder is reachable yet; see seat_into.go for which seats are answered.
|
||||
SeatReach map[string]string
|
||||
|
||||
// Built is every `<module>/<artifact>` the mesh has built. What tells a reference recorded
|
||||
// with an address — before references were kept without one — from an image a module runs
|
||||
// straight from a public registry.
|
||||
Built map[string]bool
|
||||
|
||||
// DataRoot is where this node keeps the directories the mesh places for its modules
|
||||
// (novox/hq ADR 0112, to-be 27): a directory resource that states no path resolves to
|
||||
// <DataRoot>/<module>/<id>, and ${dir:<id>} names that place from the module's own files,
|
||||
// mounts and environment. A node setting fixed at installation; empty means the default,
|
||||
// /var/lib — see dir_into.go.
|
||||
DataRoot string
|
||||
|
||||
// ReadsHealth says this machine's node-engine reads a resource's `health` (novox/hq ADR 0240 Phase B:
|
||||
// its statement's contract is link.ReadinessContract or later). An older engine parses strictly and
|
||||
// refuses a field it does not know, whole — so to it the field is not sent, and what it runs is
|
||||
// judged by liveness alone.
|
||||
ReadsHealth bool
|
||||
|
||||
// JudgesRoot says this machine's node-engine judges a user's declared `root` (novox/hq ADR 0266: its
|
||||
// statement's contract is link.RootContract or later). To an older, strict engine the field is not
|
||||
// sent, and the account it names is not judged — which the self-check says, as not judged.
|
||||
JudgesRoot bool
|
||||
|
||||
// ReadsBusy says this machine's node-engine reads a container's `busy` (novox/hq ADR 0335: its statement's
|
||||
// contract is link.BusyContract or later). To an older, strict engine the field is not sent, and the
|
||||
// container is replaced at once, as before.
|
||||
ReadsBusy bool
|
||||
|
||||
// Hands says this machine's node-engine writes a filled file on the provisioner client's word alone (its
|
||||
// statement's contract is link.HandedContract or later, design 57 P7), and Provisioner is the account the
|
||||
// provisioner's client runs as here, empty where none runs (novox/hq ADR 0349 §4). With both, a moved module's
|
||||
// templated files are handed to the client (handed.go); without either, it composes as before.
|
||||
Hands bool
|
||||
Provisioner string
|
||||
// Assigned is, by module, whether the vault judged it handed (AssignmentHands of the plan in its assignment), as the
|
||||
// vault answered the node composing itself; where it names a module, Handing follows it alone (issue 774). Nil, or a
|
||||
// module it does not name, is judged by the manifest and the machine, as the composer of the plans does.
|
||||
Assigned map[string]bool
|
||||
}
|
||||
|
||||
// RootField is a user resource's field saying the account must never become root without a person
|
||||
// (novox/hq ADR 0266).
|
||||
const RootField = "root"
|
||||
|
||||
// Composed is a declaration's resources and which module each came from.
|
||||
//
|
||||
// Owner is kept beside the resources because a resource id cannot be split back into its module:
|
||||
// a module's name may itself contain a dot. What the mesh adds of its own — an opening, the guard —
|
||||
// has no owner.
|
||||
//
|
||||
// Received is what each module on the machine is given for each requirement it receives — the same
|
||||
// contributions its received file is written from, kept beside it so the mesh can also issue them
|
||||
// on the bus in the module's membership (novox/hq ADR 0167). By module, then requirement.
|
||||
type Composed struct {
|
||||
Resources []map[string]any
|
||||
Owner map[string]string
|
||||
Received map[string]map[string][]Contribution
|
||||
// LeftOut is every module of this machine's set that was left out of its declaration, and
|
||||
// why (novox/hq ADR 0163, rule 6): a setting stored for it that its definition can no longer
|
||||
// compose. Its held things are kept and its containers untouched — the machine is told so —
|
||||
// and it is told everything else.
|
||||
LeftOut map[string]string
|
||||
// Foreseen is every own secret composed with ForeseenSealed in its place (Rendering.Foreseen),
|
||||
// as `module/name`, sorted: what the next send will make. Never set on a declaration that is
|
||||
// sent — a placeholder in a sealed file is a credential the process cannot read.
|
||||
Foreseen []string
|
||||
// Unplaced is every contribution the holder's template could not render into something, each
|
||||
// naming its module and why (novox/hq ADR 0255): that piece is left out and the machine is told
|
||||
// everything else. Push and plan say it; the merge gate refuses a change that adds one.
|
||||
Unplaced []string
|
||||
// HandedOnly is each file left out because only the provisioner's client fills it and the module is not handed on
|
||||
// this machine (a provider's consumers file, ADR 0373), said once in the plan. Not an unplaced contribution, which
|
||||
// the merge gate refuses: a module not handed composes as it did.
|
||||
HandedOnly []string
|
||||
// Filled is the path of every file handed to the provisioner, in the order composed: the declaration's
|
||||
// filled files (novox/hq ADR 0349 §4, §5). Plans is each handed module's plan on this machine, by module.
|
||||
// Both empty where nothing is handed, and the declaration is then what it was.
|
||||
Filled []string
|
||||
Plans map[string]Plan
|
||||
}
|
||||
|
||||
// ForeseenSealed is what stands for an own secret a send will make, in a composition asked ahead of
|
||||
// the send. Not a sealed value: nothing composed with it may be sent.
|
||||
const ForeseenSealed = "foreseen: made by the next send"
|
||||
|
||||
const BusMembershipPath = "/var/lib/mesh/membership-next.json"
|
||||
|
||||
// mapping reads a container's port mapping — `[address:]outer:inner[/protocol]`, the address
|
||||
// possibly an IPv6 one in brackets — indexing from the end, so an address's own colons never
|
||||
// shift the ports. Not ok for a short form or anything that is not a mapping.
|
||||
func mapping(written string) (outer, inner int, address string, ok bool) {
|
||||
written = strings.TrimSpace(written)
|
||||
if cut := strings.LastIndex(written, "/"); cut >= 0 {
|
||||
written = written[:cut]
|
||||
}
|
||||
parts := strings.Split(written, ":")
|
||||
if len(parts) < 2 {
|
||||
return 0, 0, "", false
|
||||
}
|
||||
inner, err := strconv.Atoi(parts[len(parts)-1])
|
||||
if err != nil {
|
||||
return 0, 0, "", false
|
||||
}
|
||||
outer, err = strconv.Atoi(parts[len(parts)-2])
|
||||
if err != nil {
|
||||
return 0, 0, "", false
|
||||
}
|
||||
return outer, inner, strings.Join(parts[:len(parts)-2], ":"), true
|
||||
}
|
||||
|
||||
// Contribution is one module telling the answer to a requirement what it needs from it.
|
||||
type Contribution struct {
|
||||
// From is the module that said it, so the provider and a person reading the file can tell
|
||||
// which route belongs to what.
|
||||
From string `json:"from"`
|
||||
// Node is the machine it said it from, empty when that is this one.
|
||||
//
|
||||
// A provision answered from anywhere in the mesh has consumers on other machines, and the
|
||||
// provider has to know who they are — a database told to create a password and not who for
|
||||
// cannot do anything with it. Contributions were node-local until this, which meant the one
|
||||
// case that most needed them was the one they did not reach.
|
||||
Node string `json:"node,omitempty"`
|
||||
// At is where that machine is on the private network, empty when it is not on one or when it
|
||||
// is this machine.
|
||||
//
|
||||
// The mesh knows it and a provider should not have to derive it. A reverse proxy is told
|
||||
// *send traffic to this consumer* and has to open a connection — so without this every
|
||||
// provider that reaches back to a consumer would have to know how the mesh names machines,
|
||||
// which is a convention leaking into every module that implements a provision.
|
||||
At string `json:"at,omitempty"`
|
||||
// As is who this consumer is: what the provider should call the login it creates.
|
||||
//
|
||||
// **Said by the mesh rather than invented by the provisioner** (novox/hq 04-ISSUES/023). It
|
||||
// used to be neither — the provisioner made a name, and the consumer, which has to present it
|
||||
// to authenticate, had no way to learn it. One derivation reaches both ends, so they agree by
|
||||
// construction.
|
||||
// Omitted when there is none. A contribution that is not a credential grant — a module
|
||||
// offering something to another on its own machine — has nobody to be identified to, and a
|
||||
// field that is always present and usually empty teaches a reader to ignore it.
|
||||
As string `json:"as,omitempty"`
|
||||
// Secret is the file on this machine holding that consumer's credential, sealed to it.
|
||||
//
|
||||
// Named rather than carried, for the same reason the private network's key is: the mesh
|
||||
// discarded the value and could not put it here if it wanted to. What is here is where to
|
||||
// find it.
|
||||
Secret string `json:"secret,omitempty"`
|
||||
// Values are the module's own, with settings applied. What the keys mean is agreed by the
|
||||
// requirement's name — everything providing `reverse-proxy` understands the same shape, which
|
||||
// is what makes swapping one for another cost nothing.
|
||||
Values map[string]any `json:"values"`
|
||||
// Derived is what this provider's own definition said it derives for this consumer, already
|
||||
// derived (novox/hq ADR 0202).
|
||||
//
|
||||
// **The provider is told, rather than recomputing it.** A served value may name the consumer's
|
||||
// identity — a bucket named for who is asking, a database prefixed with it — and before this
|
||||
// the rule lived twice: once in the provisioner's code, once transcribed into every consumer's
|
||||
// definition. The mesh fills the provider's own statement here and delivers the same filled
|
||||
// value to the consumer, so the two cannot disagree: there is no second computation to
|
||||
// disagree with.
|
||||
//
|
||||
// Only the keys that are per-consumer. The rest of what the provider serves is the same for
|
||||
// everyone and is in its own definition, where it already is.
|
||||
Derived map[string]any `json:"derived,omitempty"`
|
||||
}
|
||||
|
||||
// Kept is one secret as the operator can recover it: where it belongs, and the value sealed to the
|
||||
// operator's key. Never a node's blob, and never a value.
|
||||
//
|
||||
// Two kinds, addressed the same way — by the node and module that hold it and the name they know
|
||||
// it by. An `own` secret is one a module has for itself; a `pair` secret is a credential the
|
||||
// module was granted for a provision it requires (`name` is the provision), and Provider says which
|
||||
// node grants it.
|
||||
type Kept struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Name string `json:"name"`
|
||||
// Kind is `own` or `pair`.
|
||||
Kind string `json:"kind"`
|
||||
// Provider is the node granting a pair credential; empty for an own secret.
|
||||
Provider string `json:"provider,omitempty"`
|
||||
// Origin is `made` or `accepted` — whether the mesh minted it or a person supplied it.
|
||||
Origin string `json:"origin"`
|
||||
// Sealed is the value, sealed to the operator key named in Key.
|
||||
Sealed string `json:"sealed"`
|
||||
Key string `json:"key"`
|
||||
MadeAt time.Time `json:"made-at"`
|
||||
// Local is the credential's name inside the consumer where it holds several (ADR 0094).
|
||||
Local string `json:"local,omitempty"`
|
||||
}
|
||||
|
||||
// KeptExport is what a person keeps beside the operator key, and what a vault keeps on its disk:
|
||||
// every operator-sealed copy, and the honest list of what has none.
|
||||
type KeptExport struct {
|
||||
Export int `json:"export"`
|
||||
OperatorKey string `json:"operator-key"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
// Kept is sealed to OperatorKey. EarlierKey is sealed to a key the mesh has since replaced —
|
||||
// recoverable with that key, if the person still has it, and with nothing else. Unrecoverable
|
||||
// has no operator copy at all.
|
||||
Kept []Kept `json:"kept"`
|
||||
EarlierKey []Kept `json:"sealed-to-earlier-key,omitempty"`
|
||||
Unrecoverable []Kept `json:"unrecoverable,omitempty"`
|
||||
}
|
||||
|
||||
// sortedKeys is map iteration made repeatable, which everything written to a machine needs.
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func asPort(v any) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n, true
|
||||
case float64:
|
||||
return int(n), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// PreparationArgument is how the mesh asks a module to prepare its state: one word, to the module's
|
||||
// own program, whatever that program is (novox/hq ADR 0135).
|
||||
//
|
||||
// **One word for every kind of module.** A module built as a Go binary receives it as its argument;
|
||||
// one built as a bundle receives it through the runtime, whose entry takes the same word. So the
|
||||
// mesh has one way of asking and a module has one way of answering, and neither learns the other's
|
||||
// shape.
|
||||
const PreparationArgument = "prepare"
|
||||
|
||||
// endpointPorts is a module's endpoint names against the ports they listen on.
|
||||
func endpointPorts(m Manifest) map[string]int {
|
||||
out := map[string]int{}
|
||||
for _, l := range m.Listens {
|
||||
if name := strings.TrimSpace(l.Name); name != "" {
|
||||
out[name] = l.Port
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Decode reads a manifest leniently, as every part but the judge reads one (novox/hq ADR 0578 §3): every field the
|
||||
// contract knows as it declares it, and a field it does not know set aside and named (SetAside), never refused. It
|
||||
// judges nothing: no name, seat, bus or catalogue rule is applied, since the indexer judged the manifest once and its
|
||||
// verdict is in the indexer's state (delivery.VerdictsBucket); a reader takes the verdict beside it
|
||||
// (delivery.TakeVerdict). An error is only bytes that are no manifest: not JSON, or a field of the wrong shape.
|
||||
func Decode(raw []byte) (Manifest, error) {
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// SetAside names what Decode set aside of this manifest: each field it does not know, as the decoder words the first
|
||||
// and with the path of every one it read without, or "" when it knew every field. A reader says it in its own words
|
||||
// (its journal, its finding) and reads the rest; the part that needs the field takes the newer package (ADR 0578 §6).
|
||||
func (m Manifest) SetAside() string { return m.unknown }
|
||||
|
||||
// ReadNoFurther is whether Decode could not read past a field it does not know, so the manifest holds its name and
|
||||
// version alone: a reader holds such a module, as it holds a refused one, and names why (SetAside).
|
||||
func (m Manifest) ReadNoFurther() bool { return strings.Contains(m.unknown, readNoFurther) }
|
||||
|
||||
// readNoFurther marks, in what a manifest set aside, that it was not read past it.
|
||||
const readNoFurther = "(read no further: "
|
||||
|
||||
// Predefined is the list of predefined keys the manifest was read with (ReadTreeManifestWritten, or the judge's
|
||||
// SetPredefined), nil when none: data of the contract a judge reads, never a rule.
|
||||
func (m Manifest) Predefined() PredefinedList { return m.predefined }
|
||||
|
||||
// SetPredefined keeps the list of predefined keys a manifest is read with, for what reads it after (the judge).
|
||||
func (m *Manifest) SetPredefined(list PredefinedList) { m.predefined = list }
|
||||
|
||||
// FormsWritten is the fields the manifest's config was written with, in the config form (`config`, `takes-config`,
|
||||
// `${config:…}`) and in the settings form (novox/hq ADR 0348 §1): data a judge reads to say a manifest mixes them.
|
||||
func (m Manifest) FormsWritten() (config, settings []string) { return m.forms.config, m.forms.settings }
|
||||
@@ -1,12 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
// decodeForTest builds a manifest for a test as every reader reads one (Decode); the judge's own tests are in
|
||||
// mesh-delivery's judge (novox/hq ADR 0578 §4).
|
||||
func decodeForTest(raw []byte) (Manifest, error) { return Decode(raw) }
|
||||
|
||||
// decodeWithForTest is decodeForTest keeping the list of predefined keys it was read with.
|
||||
func decodeWithForTest(raw []byte, list PredefinedList) (Manifest, error) {
|
||||
m, err := Decode(raw)
|
||||
m.SetPredefined(list)
|
||||
return m, err
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
// DeliverySeat is the seat mesh-delivery holds.
|
||||
const DeliverySeat = "mesh-delivery"
|
||||
|
||||
// RemovedDeliveryVerbs are the delivery seat's verbs release 6 removed (novox/hq ADR 0539 clause 17): with no plan the
|
||||
// gate keeps the merge's states and derives nothing after the merge. Each stays in deliveryVerbs as an optional row
|
||||
// with no input, so the holder of before and the holder of release 6 both hold the seat and a caller is answered by
|
||||
// name with its replacement, given here.
|
||||
var RemovedDeliveryVerbs = map[string]string{
|
||||
"times": "the delivery view (nox-delivery): each machine's want and have, and since when",
|
||||
"what-if": "the merge check's composition, which composes every machine a change touches",
|
||||
"release": "the assigner's asks (hold, restore, backup, record), answered on the phone or in the delivery view",
|
||||
"stop": "a bad fingerprint is held and its canary set back by the assigner (ADR 0429 §6, ADR 0539 clause 18)",
|
||||
"retire-history": "nothing to retire: the gate keeps no delivery after its merge",
|
||||
}
|
||||
|
||||
// deliveryRemovedInR6 is the row of a verb release 6 removed from the delivery seat.
|
||||
func deliveryRemovedInR6(name string) Verb {
|
||||
return Verb{Name: name, Description: "Removed in R6 (ADR 0539): refused by name, naming its replacement: " +
|
||||
RemovedDeliveryVerbs[name], Input: schema(nil, nil), Optional: true}
|
||||
}
|
||||
|
||||
// deliveryVerbs are the delivery seat's tools: what reads the merges and checks, and the acts of a person and healer H2
|
||||
// on a merge (ADR 0539 clause 17).
|
||||
func deliveryVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "deliveries", Description: "Every delivery not final, and those that ended in the last day, one line " +
|
||||
"each: its id (owner/repository@commit), its state, its group, what it waits for and since when. Narrowed " +
|
||||
"by state, repository or group.",
|
||||
Input: schema(map[string]string{"state": "one state, e.g. delivering or held",
|
||||
"repository": "owner/repository", "group": "a group's id (its branch name)",
|
||||
"all": "\"true\": the final ones of the last thirty days too"}, nil, "all")},
|
||||
deliveryRemovedInR6("times"),
|
||||
{Name: "show", Description: "One delivery or group whole: its delivery plan (what it builds, what each " +
|
||||
"machine receives, what is not an ordinary send), every transition with when and why, the machine " +
|
||||
"steps of its walk, its group and its order.",
|
||||
Input: schema(map[string]string{"id": "a delivery's id, or a group's"}, []string{"id"})},
|
||||
{Name: "groups", Description: "Every delivery group: its members in order, why each pair is ordered " +
|
||||
"(declared, built by, version skew, engine before controller, by name), its composed check and its " +
|
||||
"state, derived from its members.",
|
||||
Input: schema(map[string]string{"all": "\"true\": the groups that ended too"}, nil, "all")},
|
||||
deliveryRemovedInR6("what-if"),
|
||||
{Name: "checks", Description: "What the mesh's checks said of a pull request's head or of one commit: each " +
|
||||
"of the commit's mesh statuses (mesh/check, the one a main requires from R4e, hq's mesh/repo-check, " +
|
||||
"mesh/delivery, …) with its state, " +
|
||||
"description and when it was set; the merge check's full verdict as the controller said it — each " +
|
||||
"layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's " +
|
||||
"protection would let it merge, every required status being success.",
|
||||
Input: schema(map[string]string{"repository": "owner/repository",
|
||||
"number": "a pull request's number: its head is read",
|
||||
"commit": "a commit's sha, or the start of one, instead of a pull request"}, []string{"repository"}),
|
||||
// Added after the seat's first holder shipped: optional until mesh-delivery serves it everywhere.
|
||||
Optional: true},
|
||||
{Name: "table", Description: "The state table every delivery runs by: each transition with its guard, " +
|
||||
"each state's bound and what healer H2 may do once it has passed; and the machine steps' table.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "stalled", Description: "Every delivery held past its state's bound, with the transition the table " +
|
||||
"lets healer H2 take for it, or none.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "recheck", Description: "Check a rejected or ready delivery again: it goes back to proposed and the " +
|
||||
"controller is asked for its check. With why.",
|
||||
Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"},
|
||||
[]string{"id", "why"})},
|
||||
deliveryRemovedInR6("release"),
|
||||
deliveryRemovedInR6("stop"),
|
||||
{Name: "close", Description: "Healer H2's verb: take the one transition the table names for a delivery held " +
|
||||
"past its bound. Refused for any other.",
|
||||
Input: schema(map[string]string{"id": "the delivery's id", "why": "the condition it answers"},
|
||||
[]string{"id", "why"})},
|
||||
deliveryRemovedInR6("retire-history"),
|
||||
{Name: "settle", Description: "Ask the operator to settle deliveries held because their merge reached the " +
|
||||
"trunk without a passing check: one ask on the operator's channel for all of them, as an Approve is asked. " +
|
||||
"The verb moves nothing; on the operator's answer each goes to merged, and whether it is on the machines " +
|
||||
"is derived as for any merge (novox/hq ADR 0556). Refused for a delivery not held, or held for another " +
|
||||
"reason, naming why.",
|
||||
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"ids", "why"},
|
||||
"properties": map[string]any{
|
||||
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1,
|
||||
"description": "the held deliveries' ids (owner/repository@commit)"},
|
||||
"why": map[string]any{"type": "string", "description": "why they may be settled, shown to the operator"},
|
||||
}},
|
||||
// Added after the seat's release 6 holder shipped: optional until the gate serves it everywhere.
|
||||
Optional: true},
|
||||
}
|
||||
}
|
||||
|
||||
// SeatSays is whether a consumed `<seat>.<event>` names one of the seat's own events: the seat emits it,
|
||||
// exactly or by one of its patterns (`log.*`). A consumed name whose emitter is a seat and whose event the
|
||||
// seat does not emit is the event of the module of that name (novox/hq ADR 0239): mesh-delivery is a seat
|
||||
// and the module holding it, and what the module says it says as itself, through the runtime that launches
|
||||
// it — read as the seat's, a consumer would subscribe a subject nothing may publish.
|
||||
func SeatSays(emits []string, event string) bool {
|
||||
for _, e := range emits {
|
||||
if e == event || topicMatches(e, event) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// topicMatches is whether an event's name matches a declared pattern, `*` one dot-separated segment and
|
||||
// `>` or `**` the rest.
|
||||
func topicMatches(pattern, event string) bool {
|
||||
p, e := splitDots(pattern), splitDots(event)
|
||||
for i, part := range p {
|
||||
if part == ">" || part == "**" {
|
||||
return len(e) > i
|
||||
}
|
||||
if i >= len(e) || (part != "*" && part != e[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(p) == len(e)
|
||||
}
|
||||
|
||||
func splitDots(s string) []string {
|
||||
var out []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '.' {
|
||||
out = append(out, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
return append(out, s[start:])
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The delivery seat answers "what did the mesh's checks say of this pull request?" as a verb of its own,
|
||||
// `checks` (novox/hq ADR 0239): read by repository and a pull request's number or a commit.
|
||||
func TestTheDeliverySeatPromisesChecks(t *testing.T) {
|
||||
seat, ok := SeatNamed(DeliverySeat)
|
||||
if !ok {
|
||||
t.Fatal("the delivery seat is not in the set")
|
||||
}
|
||||
var checks *Verb
|
||||
for i := range seat.Serves {
|
||||
if seat.Serves[i].Name == "checks" {
|
||||
checks = &seat.Serves[i]
|
||||
}
|
||||
}
|
||||
if checks == nil {
|
||||
t.Fatalf("the delivery seat promises %v and not checks", VerbNames(seat.Serves))
|
||||
}
|
||||
props, _ := checks.Input["properties"].(map[string]any)
|
||||
for _, arg := range []string{"repository", "number", "commit"} {
|
||||
if _, has := props[arg]; !has {
|
||||
t.Errorf("checks takes no %q", arg)
|
||||
}
|
||||
}
|
||||
if req, _ := checks.Input["required"].([]string); !reflect.DeepEqual(req, []string{"repository"}) {
|
||||
t.Errorf("checks requires %v, wanted only the repository", req)
|
||||
}
|
||||
|
||||
// Added after the seat's holder shipped, it is optional: the holder serving the ten verbs before it still
|
||||
// holds the seat, and one serving all eleven does too — so the controller and the catalogue can move in
|
||||
// either order, and no check of the catalogue fails on a module nobody touched between the two.
|
||||
if !checks.Optional {
|
||||
t.Fatal("checks is a condition of holding before its holder serves it")
|
||||
}
|
||||
var before []string
|
||||
for _, v := range VerbNames(seat.Serves) {
|
||||
if v != "checks" {
|
||||
before = append(before, v)
|
||||
}
|
||||
}
|
||||
m := Manifest{Module: "mesh-delivery", Claims: []Claim{{Name: DeliverySeat, Scope: ScopeMesh, Serves: before}}}
|
||||
if err := CanHold(m, seat); err != nil {
|
||||
t.Fatalf("a holder without checks yet: %v", err)
|
||||
}
|
||||
m.Claims[0].Serves = VerbNames(seat.Serves)
|
||||
if err := CanHold(m, seat); err != nil {
|
||||
t.Fatalf("a holder serving every verb: %v", err)
|
||||
}
|
||||
// Every other verb is still a condition of holding.
|
||||
m.Claims[0].Serves = append([]string{"checks"}, before[1:]...)
|
||||
if err := CanHold(m, seat); err == nil || !strings.Contains(err.Error(), before[0]) {
|
||||
t.Fatalf("a holder without %s: %v", before[0], err)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
func namesOfDirs(dirs map[string]string) []string {
|
||||
var names []string
|
||||
for id := range dirs {
|
||||
names = append(names, fmt.Sprintf("%q", id))
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user