Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1363a2fe27 | ||
|
|
17b8f14fe1 | ||
|
|
42c394acc2 | ||
|
|
b9ad7a2948 | ||
|
|
cde22ff627 | ||
|
|
79993fb498 | ||
|
|
aec55b7072 | ||
|
|
c7884f5a72 | ||
|
|
580c4d66a7 | ||
|
|
63bfc5fda5 | ||
|
|
02e3482eb5 | ||
|
|
294e83dab1 | ||
|
|
b5438bb331 | ||
|
|
c23be73d4d | ||
|
|
d2d171f2d2 | ||
|
|
73fa64ea68 | ||
|
|
1a13dbeb17 | ||
|
|
e11caecdad | ||
|
|
7bb9e55d0b | ||
|
|
00037608ae | ||
|
|
cea59428b1 | ||
|
|
5c832f2d19 | ||
|
|
cdebb7d1a5 | ||
|
|
6a803ea5b3 | ||
|
|
9745c1ab31 | ||
|
|
c0c3c3fed4 | ||
|
|
c1449fffe9 | ||
|
|
f873c97db5 | ||
|
|
b0b3d87fe2 | ||
|
|
ba189e6943 | ||
|
|
cadf74a176 | ||
|
|
74efe8e2e7 | ||
|
|
68af9eff44 | ||
|
|
518eeb7941 | ||
|
|
bf2da878a0 | ||
|
|
50cf253a43 | ||
|
|
980a0dee93 | ||
|
|
ac9c2d57be | ||
|
|
8b016cc62b | ||
|
|
cf2bb3b87d | ||
|
|
473376259b | ||
|
|
e1293fb0ad | ||
|
|
6784efae75 | ||
|
|
d86baebe9a | ||
|
|
82481099b7 | ||
|
|
b127f005c3 | ||
|
|
58b4fcb8c8 | ||
|
|
796f6410c1 | ||
|
|
e67c58cd98 | ||
|
|
85873b19e1 | ||
|
|
2ebbb79937 | ||
|
|
9204190445 | ||
|
|
06ea2168d8 | ||
|
|
2b149dd43e | ||
|
|
17dba2a34c | ||
|
|
11e4bc0ba1 | ||
|
|
e56f3aa1cb | ||
|
|
f966693583 | ||
|
|
5acc763992 | ||
|
|
4f009fff83 | ||
|
|
f27e31954f | ||
|
|
62650cd48c | ||
|
|
408f6dbad9 | ||
|
|
eae0577567 | ||
|
|
de26918c52 | ||
|
|
e01d18e548 | ||
|
|
59166b1031 | ||
|
|
c294949f2a | ||
|
|
f86f6a74f0 |
+8
-2
@@ -1,5 +1,11 @@
|
||||
ARG GO_BASE=golang:1.25-alpine
|
||||
# The control plane's image.
|
||||
# The Go it builds with, pinned here because genesis builds this file with no arguments (novox/hq
|
||||
# issue 223) — the Makefile passes the same digest. A tag older than go.mod asks for is how
|
||||
# `make image` broke once before (issue 146).
|
||||
ARG GO_BASE=golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c
|
||||
# The control plane's image — for genesis and the lab only. The mesh runs the controller as a Go
|
||||
# bundle the host starts as a process (module.json; novox/hq issue 213), and builds no image of it.
|
||||
# Genesis builds this file and raises it as the container the process replaces on the first push
|
||||
# (mesh-host internal/bootstrap, novox/hq issue 223).
|
||||
#
|
||||
# novox/hq ADR 0006: this image is pinned by digest in the bundle the host carries, fetched on a
|
||||
# machine where no mesh exists yet, and run before there is anything to check it against. So it
|
||||
|
||||
@@ -27,17 +27,21 @@ build:
|
||||
IMAGE ?= mesh-controller:$(VERSION)
|
||||
DEV_TAG ?= mesh-controller:development
|
||||
|
||||
# The base the module declares, read from the manifest rather than written here twice.
|
||||
# The Go base the image is built on.
|
||||
#
|
||||
# **`make image` was broken and stayed broken**, because the Dockerfile's fallback base was a Go
|
||||
# older than go.mod asks for: every build died at `go mod download` with "go.mod requires go >=
|
||||
# 1.26.0", and the pipeline never saw it because the pipeline passes the declared base in. Anybody
|
||||
# building the image by hand hit it and had to find the digest themselves (novox/hq 04-ISSUES/146,
|
||||
# what it cost).
|
||||
GO_BASE ?= $(shell python3 -c "import json;print(next(o['image'] for o in json.load(open('module.json'))['build']['on'] if o['arg']=='GO_BASE'))" 2>/dev/null)
|
||||
#
|
||||
# **Pinned here since the manifest stopped building an image** (novox/hq issue 213): the mesh builds
|
||||
# the controller as a Go bundle with its own toolchain, and only `make image` — genesis and the lab —
|
||||
# still needs a Go base. The digest is the one the manifest declared until then.
|
||||
GO_BASE ?= golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c
|
||||
|
||||
image:
|
||||
@test -n "$(GO_BASE)" || { echo "module.json declares no GO_BASE; pass GO_BASE=<image> or fix the manifest"; exit 1; }
|
||||
@test -n "$(GO_BASE)" || { echo "no GO_BASE; pass GO_BASE=<image>"; exit 1; }
|
||||
docker build --build-arg GO_BASE=$(GO_BASE) --build-arg VERSION=$(VERSION) -t $(IMAGE) -t $(DEV_TAG) .
|
||||
@echo
|
||||
@docker image inspect $(IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||
@@ -48,7 +52,7 @@ BUILDER_IMAGE ?= mesh-builder:$(VERSION)
|
||||
BUILDER_DEV_TAG ?= mesh-builder:development
|
||||
|
||||
builder-image:
|
||||
@test -n "$(GO_BASE)" || { echo "module.json declares no GO_BASE; pass GO_BASE=<image> or fix the manifest"; exit 1; }
|
||||
@test -n "$(GO_BASE)" || { echo "no GO_BASE; pass GO_BASE=<image>"; exit 1; }
|
||||
docker build --build-arg GO_BASE=$(GO_BASE) -f cmd/mesh-builder/Dockerfile -t $(BUILDER_IMAGE) -t $(BUILDER_DEV_TAG) .
|
||||
@echo
|
||||
@docker image inspect $(BUILDER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||
|
||||
@@ -193,6 +193,13 @@ passes every check that only looks at the message.
|
||||
|
||||
## The image
|
||||
|
||||
**The mesh no longer runs the controller from it** (novox/hq issue 213). The module declares a Go
|
||||
bundle, `controller`, which the host on the controller's machine unpacks and runs as the process
|
||||
`mesh-controller` under the account of the same name (ADR 0188 §1, §3). The image stays for what
|
||||
still runs a container of the controller: genesis, which raises the first controller from it and
|
||||
installs the module from its manifest (mesh-host `internal/bootstrap`), and the lab. Neither is the
|
||||
mesh's own build any more — `make image` builds it.
|
||||
|
||||
`FROM scratch`, holding one statically linked binary and nothing else — no shell, no package
|
||||
manager, no libc, no CA certificates.
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ func TestTheRegistryTrustAndEveryImageFollowThePortTheNodeGaveTheStore(t *testin
|
||||
//
|
||||
// Composed from the control plane's own manifest against a real inventory: the store's module is
|
||||
// given 6852 on this node the way genesis or an operator gives it, and the control plane's
|
||||
// container is told so beside the sealed connection genesis wrote.
|
||||
// process is told so beside the sealed connection genesis wrote.
|
||||
func TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
@@ -157,8 +157,8 @@ func TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
control, err := withSeatPorts(m).Resolve([]catalogue.Built{{Name: "server", Kind: catalogue.ArtifactImage,
|
||||
Reference: "registry.example/control@" + aDigest}})
|
||||
control, err := withSeatPorts(m).Resolve([]catalogue.Built{{Name: "controller", Kind: catalogue.ArtifactBundle,
|
||||
Reference: "https://registry.example/mesh-controller/controller.tar.gz", Digest: aDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -200,12 +200,12 @@ func TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker(t *testing.T)
|
||||
|
||||
var env map[string]any
|
||||
for _, r := range composed(t, open, "anchor").Resources {
|
||||
if r["id"] == "mesh-controller.server" {
|
||||
if r["id"] == "mesh-controller.controller" {
|
||||
env, _ = r["env"].(map[string]any)
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
t.Fatal("the control plane's container is not in its own node's declaration")
|
||||
t.Fatal("the control plane's process is not in its own node's declaration")
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"MESH_STORE_INVENTORY_PORT": "6852",
|
||||
@@ -238,7 +238,7 @@ func withSeatPorts(m catalogue.Manifest) catalogue.Manifest {
|
||||
out := m
|
||||
out.Resources = nil
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] != "container" {
|
||||
if r["type"] != "container" && r["type"] != "process" {
|
||||
out.Resources = append(out.Resources, r)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -148,6 +148,11 @@ func buildFrom(result link.BuildResult) inventory.Build {
|
||||
// rebuild the graph rather than a list of names.
|
||||
Path: result.Path,
|
||||
}
|
||||
// When it was asked, which is what orders it against another build of the same module
|
||||
// (novox/hq 04-ISSUES/219) — not when it was heard.
|
||||
if asked, ok := link.BuildAskedAt(result.ID); ok {
|
||||
kept.Asked = asked
|
||||
}
|
||||
for _, ref := range result.Against {
|
||||
kept.Against = append(kept.Against, catalogue.Recorded(ref))
|
||||
}
|
||||
@@ -409,7 +414,7 @@ func buildOne(ctx context.Context, source buildSource, path, ref string, wait ti
|
||||
// Correlated by something the control plane makes, not by the module's name: two builds of one
|
||||
// module can be in flight, and the second answer is not the first one's.
|
||||
request := link.BuildRequest{
|
||||
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()),
|
||||
ID: link.NewBuildID(time.Now()),
|
||||
Repository: repository,
|
||||
Path: path,
|
||||
Ref: ref,
|
||||
@@ -526,17 +531,37 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
||||
BuiltFrom: result.Commit, Head: result.Commit,
|
||||
// What it stood on, so registration can judge a built manifest's base (to-be 38 WP2.4).
|
||||
Against: kept.Against,
|
||||
// When it was asked, so an older request heard later does not replace a newer one
|
||||
// (novox/hq 04-ISSUES/219).
|
||||
Asked: kept.Asked,
|
||||
}
|
||||
if result.Source != nil && result.Source.Seat != "" {
|
||||
recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat
|
||||
}
|
||||
// **A build at a commit does not change the branch a module follows** (novox/hq 04-ISSUES/215):
|
||||
// the commit is built and recorded as what it was built from, and the module keeps following
|
||||
// what it followed before — the repository's default branch for one new to the catalogue.
|
||||
if followedBranch(result.Ref) == "" && result.Ref != "" {
|
||||
recorded.Ref = ""
|
||||
if was, err := inv.SourceOf(ctx, manifest.Module); err == nil {
|
||||
recorded.Ref = followedBranch(was.Ref)
|
||||
}
|
||||
}
|
||||
if err := namesNoInstallation(manifest); err != nil {
|
||||
return manifest, kept, fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w",
|
||||
result.On, result.Repository, short(result.Commit), err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, manifest, recorded); err != nil {
|
||||
if errors.Is(err, inventory.ErrSuperseded) {
|
||||
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w",
|
||||
result.On, manifest.Module, short(result.Commit), err)
|
||||
}
|
||||
return manifest, kept, err
|
||||
}
|
||||
// The keep set just moved, and new bytes just landed (novox/hq ADR 0189). Asked here rather
|
||||
// than on a timer of its own: this is the only moment either is true. Never fatal — the build
|
||||
// worked and the module is registered.
|
||||
collect(ctx, inv)
|
||||
return manifest, kept, nil
|
||||
}
|
||||
|
||||
@@ -564,7 +589,7 @@ func buildAndShow(ctx context.Context, source buildSource, path, ref string, wai
|
||||
defer ask.Close()
|
||||
|
||||
result, err := ask.Submit(ctx, link.BuildRequest{
|
||||
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()),
|
||||
ID: link.NewBuildID(time.Now()),
|
||||
Repository: repository, Path: path, Ref: ref,
|
||||
Held: heldBy(ctx), Seats: seatBases(ctx),
|
||||
}, wait)
|
||||
|
||||
@@ -2,9 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -63,3 +66,87 @@ func TestABuildHeardIsRecordedAndRegistered(t *testing.T) {
|
||||
t.Fatalf("a failure is said in the builder's words: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/215: a build asked at a commit is recorded as built from that commit, and the
|
||||
// module keeps following the branch it followed — a new one, the default branch.
|
||||
func TestABuildAtACommitKeepsTheBranchTheModuleFollows(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
manifest, _ := json.Marshal(map[string]any{"module": "unifi", "version": "1"})
|
||||
result := func(id, ref, commit string) link.BuildResult {
|
||||
return link.BuildResult{ID: id, Repository: "http://forge.internal:20000/novox/mesh-catalog.git",
|
||||
Path: "modules/unifi", Ref: ref, On: "anchor", Commit: commit, Manifest: manifest,
|
||||
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
|
||||
}
|
||||
if _, _, err := takeIn(ctx, open.inventory, result("b-1", "main", "1111111aaaa")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := takeIn(ctx, open.inventory, result("b-2", "9c97a8a", "9c97a8a1d2c3")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src, err := open.inventory.SourceOf(ctx, "unifi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if src.Ref != "main" || src.BuiltFrom != "9c97a8a1d2c3" {
|
||||
t.Errorf("after a build at a commit the module follows %q, built from %q; want main, 9c97a8a1d2c3", src.Ref, src.BuiltFrom)
|
||||
}
|
||||
|
||||
// One new to the catalogue, first built at a commit, follows the default branch.
|
||||
other, _ := json.Marshal(map[string]any{"module": "letta", "version": "1"})
|
||||
r := result("b-3", "deadbeef", "deadbeefcafe")
|
||||
r.Manifest, r.Path = other, "modules/letta"
|
||||
if _, _, err := takeIn(ctx, open.inventory, r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if src, _ := open.inventory.SourceOf(ctx, "letta"); src.Ref != "" {
|
||||
t.Errorf("a module first built at a commit follows %q, want the default branch", src.Ref)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/219: an older request heard after a newer one is recorded and not registered,
|
||||
// so a push sends what the newer request built.
|
||||
func TestAnOlderBuildHeardLaterDoesNotReplaceTheNewer(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
older := time.Date(2026, 10, 3, 21, 33, 45, 0, time.UTC)
|
||||
newer := time.Date(2026, 10, 3, 21, 51, 57, 0, time.UTC)
|
||||
result := func(asked time.Time, image string) link.BuildResult {
|
||||
manifest, _ := json.Marshal(map[string]any{"module": "postgres", "version": image})
|
||||
return link.BuildResult{ID: link.NewBuildID(asked), Repository: "http://forge.internal:20000/novox/mesh-catalog.git",
|
||||
Path: "modules/postgres", Ref: "main", On: "anchor", Commit: "efff5415", Manifest: manifest,
|
||||
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
|
||||
}
|
||||
if _, _, err := takeIn(ctx, open.inventory, result(newer, "4bcd5f73")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err := takeIn(ctx, open.inventory, result(older, "0ab07fa9"))
|
||||
if !errors.Is(err, inventory.ErrSuperseded) {
|
||||
t.Fatalf("the older request's outcome was taken in as current: %v", err)
|
||||
}
|
||||
shelf, err := open.inventory.Catalogue(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := shelf["postgres"].Version; got != "4bcd5f73" {
|
||||
t.Errorf("postgres is %q; want the newer request's 4bcd5f73", got)
|
||||
}
|
||||
if builds, _ := open.inventory.Builds(ctx, "postgres", 5); len(builds) != 2 {
|
||||
t.Errorf("the late build was not recorded: %v", builds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABuildIDSaysWhenItWasAsked(t *testing.T) {
|
||||
at := time.Date(2026, 10, 3, 21, 51, 57, 392539762, time.UTC)
|
||||
if got, ok := link.BuildAskedAt(link.NewBuildID(at)); !ok || !got.Equal(at) {
|
||||
t.Errorf("read back %v %v; want %v", got, ok, at)
|
||||
}
|
||||
if got, ok := link.BuildAskedAt("build-1791064317392539762"); !ok || got.Format(time.TimeOnly) != "21:51:57" {
|
||||
t.Errorf("the incident's id reads as %v %v", got, ok)
|
||||
}
|
||||
for _, id := range []string{"b-1", "build-2", "build-", "build-x", ""} {
|
||||
if _, ok := link.BuildAskedAt(id); ok {
|
||||
t.Errorf("%q read as a request time", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
@@ -90,6 +91,17 @@ func moduleCheck(paths []string, out io.Writer) error {
|
||||
if len(m.Invokes) > 0 {
|
||||
fmt.Fprintf(out, ", invokes %s", joinInvokes(m.Invokes))
|
||||
}
|
||||
// The state it keeps and reads (novox/hq ADR 0201), so a reviewer sees what lands on the bus.
|
||||
if len(m.State) > 0 {
|
||||
kept := make([]string, 0, len(m.State))
|
||||
for _, s := range m.State {
|
||||
kept = append(kept, s.Name)
|
||||
}
|
||||
fmt.Fprintf(out, ", keeps state %s", strings.Join(kept, ", "))
|
||||
}
|
||||
if len(m.Reads) > 0 {
|
||||
fmt.Fprintf(out, ", reads %s", strings.Join(m.Reads, ", "))
|
||||
}
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
if failed > 0 {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/artifacts"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// Letting the artifact store go of what the mesh no longer keeps (novox/hq ADR 0189, issue 108).
|
||||
//
|
||||
// **Run where the records change.** A build is the moment new bytes landed in the store and the
|
||||
// moment the keep set moved, so it is the moment to say what may go — and it needs no timer of
|
||||
// its own. Reclaiming the bytes is the store's own nightly step; this only decides.
|
||||
//
|
||||
// Never fatal to a build. The build succeeded, the module is registered, and a store that could
|
||||
// not be reached is a thing to say rather than a reason to undo any of that. The next build asks
|
||||
// again, and the references it could not collect are still uncollected, so nothing is lost by
|
||||
// having failed.
|
||||
|
||||
// collect asks the store to let go of everything the mesh made and no longer keeps, and records
|
||||
// what it let go of. Says what it did and what it could not; returns nothing, because nothing
|
||||
// upstream should branch on it.
|
||||
func collect(ctx context.Context, inv *inventory.Inventory) {
|
||||
references, err := inv.ToCollect(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "could not work out what the artifact store may let go of: %v\n", err)
|
||||
return
|
||||
}
|
||||
if len(references) == 0 {
|
||||
return
|
||||
}
|
||||
shelf, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "could not read the catalogue to find the artifact store: %v\n", err)
|
||||
return
|
||||
}
|
||||
// As the mesh reaches it from the network. Empty means the store is not on the network — on a
|
||||
// mesh being raised it is not yet, and there the store holds one build of anything and has
|
||||
// nothing to collect.
|
||||
address, err := artifactStoreAddress(ctx, inv, shelf, "")
|
||||
if err != nil || address == "" {
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "could not find the artifact store to collect from: %v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// **Bounded, because this runs inside somebody's build.** The first sweep of a mesh that has
|
||||
// never collected has the whole history to get through, and a person waiting on `build` should
|
||||
// not pay for it. Two bounds, and what is left over is simply offered again next time —
|
||||
// builds are frequent, and the point is that the store stops growing, not that it empties
|
||||
// tonight.
|
||||
within, stop := context.WithTimeout(ctx, sweepBudget)
|
||||
defer stop()
|
||||
store := artifacts.Store{Address: address}
|
||||
|
||||
var done []string
|
||||
var left int
|
||||
for i, reference := range references {
|
||||
if i >= mostPerSweep || within.Err() != nil {
|
||||
left = len(references) - i
|
||||
break
|
||||
}
|
||||
err := store.LetGo(within, reference)
|
||||
if err == nil || errors.Is(err, artifacts.Gone) {
|
||||
// Gone is the outcome wanted, already true. Recorded so the next sweep does not ask
|
||||
// again for ever.
|
||||
done = append(done, reference)
|
||||
continue
|
||||
}
|
||||
// **Stopped at the first refusal, not pushed through.** A store that refuses one refuses
|
||||
// all of them — deletion disabled, the store down, the network gone — so going on would
|
||||
// be a hundred identical failures and a hundred identical log lines in front of whoever
|
||||
// was building something.
|
||||
fmt.Fprintf(os.Stderr, "the artifact store kept %s, so nothing more was asked of it: %v\n",
|
||||
reference, err)
|
||||
left = len(references) - i
|
||||
break
|
||||
}
|
||||
|
||||
if len(done) > 0 {
|
||||
// Recorded outside `within`: the deletions happened, and losing the record of them because
|
||||
// the sweep ran out of budget would mean asking about them again for ever.
|
||||
if err := inv.MarkCollected(ctx, done); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "the store let go of %d artifact(s) and the record of it did not keep: %v\n",
|
||||
len(done), err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "the artifact store let go of %d artifact(s) the mesh no longer keeps\n",
|
||||
len(done))
|
||||
}
|
||||
if left > 0 {
|
||||
fmt.Fprintf(os.Stderr, "%d more to collect; the next build asks again\n", left)
|
||||
}
|
||||
}
|
||||
|
||||
// mostPerSweep is how many artifacts one sweep will ask about. Enough that a mesh building
|
||||
// several times a day converges within days of this landing; small enough that no single build
|
||||
// waits on the whole backlog.
|
||||
const mostPerSweep = 200
|
||||
|
||||
// sweepBudget is the longest a sweep will keep a build waiting.
|
||||
const sweepBudget = 60 * time.Second
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// A declaration composed earlier is numbered lower than one composed later, whatever order the two
|
||||
// are sent in (novox/hq issue 204). The number used to be taken at send time, after composing, so a
|
||||
// declaration composed before an assignment changed and sent after a newer one carried the higher
|
||||
// number — and the machine, which refuses a lower number, took the older content as the mesh's
|
||||
// newest word. Taken before the composition reads anything, the order of numbers is the order of
|
||||
// compositions, and the host's refusal does what it is for.
|
||||
func TestADeclarationComposedEarlierIsNumberedLowerWhateverOrderItIsSent(t *testing.T) {
|
||||
allot := numbered()
|
||||
var composed []string
|
||||
compose := func(stamp string) func(string) (sendable, error) {
|
||||
return func(node string) (sendable, error) {
|
||||
composed = append(composed, stamp)
|
||||
return sendable{Resources: []map[string]any{{"id": node + "." + stamp}}}, nil
|
||||
}
|
||||
}
|
||||
// Composed first — before an assignment changed — and sent last.
|
||||
stale, _ := composeEach([]string{"anchor"}, allot, compose("before"))
|
||||
// Composed after the change, sent first.
|
||||
fresh, _ := composeEach([]string{"anchor"}, allot, compose("after"))
|
||||
|
||||
if stale[0].declared.Sequence != 1 || fresh[0].declared.Sequence != 2 {
|
||||
t.Fatalf("the numbers do not follow the compositions: before=%d after=%d",
|
||||
stale[0].declared.Sequence, fresh[0].declared.Sequence)
|
||||
}
|
||||
// Sent in the other order, the numbers do not change — so the machine that has applied the
|
||||
// fresh one (2) refuses the stale one (1) when it arrives late.
|
||||
if !(stale[0].declared.Sequence < fresh[0].declared.Sequence) {
|
||||
t.Fatal("a declaration composed earlier must carry the lower number, however late it is sent")
|
||||
}
|
||||
if len(composed) != 2 || composed[0] != "before" {
|
||||
t.Fatalf("compositions happened in an unexpected order: %v", composed)
|
||||
}
|
||||
}
|
||||
|
||||
// The number is taken before the first read of the composition, not after it: an allotter that
|
||||
// fails leaves nothing composed for that machine, and the others are still composed.
|
||||
func TestTheNumberIsTakenBeforeComposingAndItsFailureIsARefusal(t *testing.T) {
|
||||
calls := 0
|
||||
allot := func(node string) (int64, error) {
|
||||
if node == "anchor" {
|
||||
return 0, context.DeadlineExceeded
|
||||
}
|
||||
return 7, nil
|
||||
}
|
||||
sending, refusals := composeEach([]string{"anchor", "laptop"}, allot, func(node string) (sendable, error) {
|
||||
calls++
|
||||
if node == "anchor" {
|
||||
t.Fatal("anchor was composed although its number could not be taken")
|
||||
}
|
||||
return sendable{}, nil
|
||||
})
|
||||
if calls != 1 || len(sending) != 1 || sending[0].node != "laptop" || sending[0].declared.Sequence != 7 {
|
||||
t.Fatalf("laptop should be composed with its number and anchor refused: %v / %v", sending, refusals)
|
||||
}
|
||||
if len(refusals) != 1 {
|
||||
t.Fatalf("anchor's failed number should be a refusal naming it: %v", refusals)
|
||||
}
|
||||
}
|
||||
|
||||
// What was sent is written down even when the sender's context is already cancelled (issue 204): a
|
||||
// controller replaced mid-send had told the machine and never recorded it, so status read "applied,
|
||||
// current" over a machine that had just been sent something else.
|
||||
func TestASendIsRecordedEvenWhenTheSenderIsBeingCancelled(t *testing.T) {
|
||||
inv := inventory.ForTest(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cancel() // the sender is going away: its context is cancelled between the send and the record
|
||||
body := []byte(`{"declaration":1,"resources":[]}`)
|
||||
digest, err := recordSent(ctx, inv, "anchor", body)
|
||||
if err != nil {
|
||||
// NodeByName on the cancelled context may itself refuse; the record must still be possible
|
||||
// through the detached context, so look the node up again on a live one.
|
||||
t.Fatalf("recording a send after cancellation failed: %v", err)
|
||||
}
|
||||
outstanding, err := inv.Outstanding(t.Context(), "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if outstanding != digest || digest != digestOf(body) {
|
||||
t.Fatalf("the send was not recorded: outstanding %q, sent %q", outstanding, digest)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -253,25 +254,34 @@ func parseAround(set *flag.FlagSet, args []string) ([]string, error) {
|
||||
// became of a build nobody was watching.
|
||||
func (b builds) Built(ctx context.Context, result link.BuildResult) error {
|
||||
manifest, _, err := takeIn(ctx, b.inv, result)
|
||||
// When it was asked, so a plan takes as its outcome only a build asked for it or after it
|
||||
// (novox/hq 04-ISSUES/219). Zero when the id does not say.
|
||||
asked, _ := link.BuildAskedAt(result.ID)
|
||||
switch {
|
||||
case err != nil && result.Failed != "":
|
||||
fmt.Printf("%s: %v\n", result.ID, err)
|
||||
if result.Module != "" {
|
||||
planBuilt(ctx, b.open, result.Module, result.Commit, result.Failed)
|
||||
planBuilt(ctx, b.open, result.Module, result.Commit, result.Failed, asked)
|
||||
} else {
|
||||
planFailedBuild(ctx, b.open, result)
|
||||
}
|
||||
return nil
|
||||
case errors.Is(err, inventory.ErrSuperseded):
|
||||
// Not a failure: the module is already at what a later request built. A plan that asked
|
||||
// before that later request is answered by it; one that asked after it ignores this.
|
||||
fmt.Printf("%s: %v\n", result.ID, err)
|
||||
planBuilt(ctx, b.open, manifest.Module, result.Commit, "", asked)
|
||||
return nil
|
||||
case err != nil:
|
||||
fmt.Printf("%s: heard and recorded, and not registered: %v\n", result.ID, err)
|
||||
if manifest.Module != "" {
|
||||
planBuilt(ctx, b.open, manifest.Module, result.Commit, err.Error())
|
||||
planBuilt(ctx, b.open, manifest.Module, result.Commit, err.Error(), asked)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%s: %s %s registered, built on %s from %s\n",
|
||||
result.ID, manifest.Module, manifest.Version, result.On, short(result.Commit))
|
||||
saysWhenThePolicyActs(ctx, b.inv, manifest.Module)
|
||||
planBuilt(ctx, b.open, manifest.Module, result.Commit, "")
|
||||
planBuilt(ctx, b.open, manifest.Module, result.Commit, "", asked)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -147,6 +147,40 @@ func moduleCommand(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// **The same list, for something other than a person** (novox/hq ADR 0195): what each module
|
||||
// is, where it runs, whether it is current, and what it says of itself.
|
||||
if len(args) > 1 && args[1] == "--json" {
|
||||
type listed struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Built string `json:"built,omitempty"`
|
||||
Head string `json:"head,omitempty"`
|
||||
Current bool `json:"current"`
|
||||
Provided bool `json:"provided,omitempty"`
|
||||
// Tools says whether the module answers tools anywhere it runs: a list of its own,
|
||||
// a bundle the runtime serves, or a seat's verbs it claims (novox/hq ADR 0197) —
|
||||
// what the console checks the bus's answers against.
|
||||
Tools bool `json:"tools"`
|
||||
On []string `json:"on"`
|
||||
Provides []string `json:"provides,omitempty"`
|
||||
Requires []string `json:"requires,omitempty"`
|
||||
Claims []string `json:"claims,omitempty"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
}
|
||||
out := make([]listed, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
m := e.Manifest
|
||||
l := listed{Module: m.Module, Version: m.Version, Built: e.Source.BuiltFrom, Head: e.Source.Head,
|
||||
Current: e.Provided || e.Source.Repository == "" || e.Source.Current(), Provided: e.Provided,
|
||||
On: append([]string{}, e.On...), Provides: m.Offers(), Requires: m.Requires,
|
||||
Capabilities: m.Capabilities, Tools: declaresTools(m)}
|
||||
for _, c := range m.Claims {
|
||||
l.Claims = append(l.Claims, c.At()+"/"+c.Name)
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return printJSON(out)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
fmt.Println("this mesh knows about no modules yet")
|
||||
return nil
|
||||
@@ -733,3 +767,23 @@ func claimsFor(ctx context.Context, inv *inventory.Inventory, m catalogue.Manife
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// declaresTools is whether a module answers tools wherever it runs (novox/hq ADR 0197): it names
|
||||
// tools of its own, its build delivers a bundle the node's runtime serves, or it claims a seat
|
||||
// whose verbs it serves. A module with none is never expected to announce anything.
|
||||
func declaresTools(m catalogue.Manifest) bool {
|
||||
if len(m.Tools) > 0 {
|
||||
return true
|
||||
}
|
||||
for _, b := range m.Bundles {
|
||||
if len(b.Loads) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, c := range m.Claims {
|
||||
if len(c.Serves) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -265,6 +266,12 @@ func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *t
|
||||
if _, err := assign(ctx, open, "anchor", "dnsmasq"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Its bus credential, as assigning issues it where the bus is reachable (novox/hq issue 203):
|
||||
// no bus is known to this test, so it is minted here, or composing refuses the placeholder.
|
||||
if _, err := open.inventory.MintBusPassword(ctx, inventory.BusUser{
|
||||
Username: "anchor.dnsmasq", Kind: inventory.BusModule, Node: "anchor", Module: "dnsmasq"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zones := func() string {
|
||||
t.Helper()
|
||||
for _, r := range composed(t, open, "anchor").Resources {
|
||||
@@ -303,3 +310,28 @@ func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *t
|
||||
t.Fatalf("a machine that left the network is still a wildcard, or the one that stayed is not:\n%s", after)
|
||||
}
|
||||
}
|
||||
|
||||
// The roster is the machines and nothing else (novox/hq ADR 0191): each node's internal domain covers
|
||||
// every route on it, and a node's public domains are public DNS's. A routed name in `.Names` was a
|
||||
// private answer for a public name, handed by a resolver serving a LAN to a phone that could not use it.
|
||||
func TestTheRosterNamesOnlyTheMachines(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
gens, err := generators(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, node := range []string{"anchor", "laptop"} {
|
||||
plan, settings, err := planFor(ctx, open, node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
with, _, err := renderingFor(ctx, open, node, plan, settings, gens, Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(with.Names, with.Machines) {
|
||||
t.Fatalf("%s's roster names more than the machines:\n names %v\n machines %v", node, with.Names, with.Machines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -44,6 +45,21 @@ func nodeCommand(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// **The same list, for something other than a person** — the console's discovery reads it
|
||||
// (novox/hq ADR 0195), and a reader that parses a printed column breaks when it is reworded.
|
||||
if len(args) > 1 && args[1] == "--json" {
|
||||
type listed struct {
|
||||
Name string `json:"name"`
|
||||
Heard string `json:"heard"`
|
||||
Mode string `json:"mode"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
out := make([]listed, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
out = append(out, listed{Name: n.Name, Heard: heardFrom(n), Mode: modeOf(n), ID: n.ID})
|
||||
}
|
||||
return printJSON(out)
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
// Said rather than printed as nothing: an empty list and a failed read must never
|
||||
// look the same, and this command answering "none" is only honest because getting
|
||||
@@ -500,3 +516,13 @@ func orNotReported(s string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// printJSON prints a value as indented JSON, the shape every `--json` answers in.
|
||||
func printJSON(v any) error {
|
||||
body, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(body))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -211,3 +211,27 @@ func TestWhatAHandedOverModuleRecordsAboutItsSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/215: a module once built at a commit still follows its branch — a merge into it
|
||||
// matches the module, and a plan re-asks the branch, not the old commit.
|
||||
func TestAModuleBuiltAtACommitStillFollowsItsBranch(t *testing.T) {
|
||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main"}
|
||||
pinned := inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Ref: "9c97a8a"}
|
||||
if !sourceIs(pinned, m) {
|
||||
t.Error("a module whose record names a commit is left out of a merge into its branch")
|
||||
}
|
||||
full := inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Ref: "9c97a8a1d2c3b4a5f60718293a4b5c6d7e8f9012"}
|
||||
if !sourceIs(full, m) {
|
||||
t.Error("a full commit hash is read as a branch")
|
||||
}
|
||||
if got := followedBranch("9c97a8a"); got != "" {
|
||||
t.Errorf("a plan would re-ask the old commit %q", got)
|
||||
}
|
||||
if got := followedBranch("release"); got != "release" {
|
||||
t.Errorf("a branch is not followed as named: %q", got)
|
||||
}
|
||||
// A module that follows another branch is still not this merge's.
|
||||
if sourceIs(inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Ref: "release"}, m) {
|
||||
t.Error("a module following another branch was matched")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,23 +645,17 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
}
|
||||
}
|
||||
|
||||
// And every routed name → the node that serves it (novox/hq ADR 0066). Alongside the
|
||||
// `<node>.internal` names above, so a container — or an internal ACME validator — resolves a
|
||||
// routed name to the proxy that serves it, mesh-wide. The mesh publishes the names it was told
|
||||
// to serve and knows nothing about what they mean.
|
||||
// Kept apart from the machines, because a fact about the machines must not be handed the names
|
||||
// the mesh merely serves (novox/hq 04-ISSUES/111).
|
||||
// **The roster is the machines and nothing else** (novox/hq ADR 0191). Each node has one internal
|
||||
// domain, `<node>.internal`, and every route on it is a name under that domain (ADR 0151), which
|
||||
// the resolver answers with one wildcard per machine — so no route needs a line of its own. A
|
||||
// node's public domains are the operator's and public DNS answers them; the mesh gives no private
|
||||
// answer for any of them. The roster once carried every routed name, public ones included, and a
|
||||
// resolver that also serves a LAN handed a phone a tunnel address for the mail server.
|
||||
// `.Names` and `.Machines` stay two fields so a module's template keeps rendering (issue 111).
|
||||
machines := make(map[string]string, len(names))
|
||||
for name, at := range names {
|
||||
machines[name] = at
|
||||
}
|
||||
routes, err := routeNamesInTheMesh(ctx, open)
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
for name, at := range routes {
|
||||
names[name] = at
|
||||
}
|
||||
|
||||
// **The bus is never public** (novox/hq ADR 0169). It was a foundation port — widened from the
|
||||
// broker's own `from: mesh` to from-anywhere on the broker's host, so a machine could enrol
|
||||
@@ -740,76 +734,6 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
}, record, nil
|
||||
}
|
||||
|
||||
// routeNamesInTheMesh is every routed name and the address of the node that serves it (novox/hq
|
||||
// ADR 0066).
|
||||
//
|
||||
// **Mesh-wide, so any container resolves any routed name to its proxy** — including an internal
|
||||
// ACME validator, which cannot complete a challenge for a name it cannot reach. A routed name is
|
||||
// composed on the consumer's node (from its label and that node's public domain) and served by the
|
||||
// node answering the consumer's route requirement; this gathers both.
|
||||
//
|
||||
// It reads route names off resolutions rather than a table because there is no table: a route is a
|
||||
// contribution, computed from what each node runs. Name-agnostic — a contribution counts as a
|
||||
// routed name only because it carried a label the mesh composed, never because the mesh knows what
|
||||
// "route" means. A node that does not resolve is skipped, so one machine's broken set does not cost
|
||||
// the rest their names.
|
||||
//
|
||||
// **A node that could not be READ is a different matter and is raised.** Skipping one states, to
|
||||
// every machine at once, that its names do not exist — and since the roster is part of every
|
||||
// container's identity, that withdraws them and replaces every container (novox/hq 04-ISSUES/152,
|
||||
// 151). So every failure here says which machine and which read, because the alternative is a
|
||||
// mesh-wide refusal with nothing named in it.
|
||||
func routeNamesInTheMesh(ctx context.Context, open *stores) (map[string]string, error) {
|
||||
inv := open.inventory
|
||||
places, err := inv.Overlays(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("where the machines are cannot be read: %w", err)
|
||||
}
|
||||
address := map[string]string{}
|
||||
for _, p := range places {
|
||||
if strings.TrimSpace(p.Address) != "" {
|
||||
address[p.Name] = p.Address
|
||||
}
|
||||
}
|
||||
|
||||
nodes, err := inv.Nodes(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("which machines the mesh has cannot be read: %w", err)
|
||||
}
|
||||
|
||||
// Every machine's resolution first, then the names across them at once: which node serves a
|
||||
// name is a question about the graph — the consumer on one machine, the provider on another —
|
||||
// and answered wrongly by looking at one contribution at a time (novox/hq issue 178).
|
||||
plans := map[string]catalogue.Resolution{}
|
||||
settings := map[string]catalogue.SettingsBy{}
|
||||
for _, n := range nodes {
|
||||
plan, layers, err := planFor(ctx, open, n.Name)
|
||||
switch {
|
||||
case unresolvable(err):
|
||||
// Their set does not compose, so they serve no names. Passed over, so one machine's
|
||||
// broken set does not cost the rest theirs.
|
||||
continue
|
||||
case err != nil:
|
||||
// The mesh could not be asked. Returning the roster without this machine's names would
|
||||
// state that they do not exist — to every machine, and indistinguishably from the
|
||||
// operator having withdrawn them (novox/hq 04-ISSUES/152).
|
||||
return nil, fmt.Errorf("the names %s serves cannot be read: %w", n.Name, err)
|
||||
}
|
||||
plans[n.Name], settings[n.Name] = plan, layers
|
||||
}
|
||||
served, err := catalogue.NamesServed(plans, settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]string{}
|
||||
for name, node := range served {
|
||||
if at := address[node]; at != "" {
|
||||
out[name] = at
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// certificateFor is what the mesh certifies about one machine's internal name.
|
||||
//
|
||||
// It reaches across two contexts and reads neither one's store from the other: `inventory` knows
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// novox/hq issue 213: for the moment a machine hands its controller over, the container and the
|
||||
// process both run the plan timer on one store. Only the one holding the plans moves them; the other
|
||||
// leaves them alone, and moves them once they are let go.
|
||||
func TestAControllerLeavesThePlansToTheOneHoldingThem(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
now := time.Now().UTC()
|
||||
// Every tier done: the next step is the plan's last, and needs nothing but the store.
|
||||
plan := inventory.Plan{ID: "plan-213", Repository: "r", Commit: "abc", Created: now, Updated: now,
|
||||
State: inventory.PlanRolling, Tier: 1, Tiers: [][]string{{"app"}},
|
||||
Modules: map[string]*inventory.PlanModule{"app": {State: "built"}}}
|
||||
if err := open.inventory.SavePlan(ctx, plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The other controller: its own connections to the same store, holding the plans.
|
||||
other, err := inventory.Open(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(other.Close)
|
||||
release, err := other.HoldPlans(ctx, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(release) // before the close above: a pool waits for a connection still held
|
||||
|
||||
advancePlans(ctx, open)
|
||||
if p, err := open.inventory.PlanByID(ctx, "plan-213"); err != nil || !p.Open() {
|
||||
t.Fatalf("a controller moved a plan another held: %+v %v", p, err)
|
||||
}
|
||||
|
||||
release()
|
||||
advancePlans(ctx, open)
|
||||
if p, err := open.inventory.PlanByID(ctx, "plan-213"); err != nil || p.State != inventory.PlanDone {
|
||||
t.Fatalf("the plan did not move once it was let go: %+v %v", p, err)
|
||||
}
|
||||
}
|
||||
+88
-39
@@ -151,6 +151,12 @@ func serve(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
defer stopServing()
|
||||
// And says so on the bus (novox/hq ADR 0197): what it serves, as the NATS services protocol asks.
|
||||
stopAnnouncing, err := bus.Announce(seatAnnouncement(handlers), log.New(os.Stdout, "", log.LstdFlags))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stopAnnouncing()
|
||||
|
||||
return server.Serve(ctx)
|
||||
}
|
||||
@@ -205,6 +211,12 @@ func declare(ctx context.Context, args []string) error {
|
||||
if err := link.Declare(ctx, server.Bus(), ident, node, raw, 15*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
// Written down like every other send (novox/hq issue 204): a declaration a person sent by hand
|
||||
// is still what the machine was last told, and status must not read it as current for the one
|
||||
// the mesh would compose.
|
||||
if _, err := recordSent(ctx, inv, node, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("sent %s a signed declaration (%d bytes)\n", node, len(raw))
|
||||
return nil
|
||||
}
|
||||
@@ -348,7 +360,7 @@ func pushCommand(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sending, refusals := composeEach(asked, func(node string) (sendable, error) {
|
||||
sending, refusals := composeEach(asked, allotting(held, inv), func(node string) (sendable, error) {
|
||||
plan, settings, err := planFor(held, open, node)
|
||||
if err != nil {
|
||||
return sendable{}, err
|
||||
@@ -370,12 +382,8 @@ func pushCommand(ctx context.Context, args []string) error {
|
||||
sentDigest := map[string]string{}
|
||||
defer release()
|
||||
for _, s := range sending {
|
||||
// Numbered under the hold, one higher than the last, before the body exists — the number is
|
||||
// inside the signed bytes, so a replayed older declaration cannot borrow a newer one's
|
||||
// (novox/hq 04-ISSUES/107).
|
||||
if err := number(ctx, inv, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
// The number is inside the signed bytes, so a replayed older declaration cannot borrow a
|
||||
// newer one's (novox/hq 04-ISSUES/107); it was taken when the composition began (issue 204).
|
||||
body, err := s.declared.Body()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -385,14 +393,10 @@ func pushCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
// After it is away, not before. A digest recorded for something that failed to send would
|
||||
// make the machine look current for a declaration it never received.
|
||||
record, err := inv.NodeByName(ctx, s.node)
|
||||
digest, err := recordSent(ctx, inv, s.node, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
digest := digestOf(body)
|
||||
if err := inv.RecordSent(ctx, record.ID, digest); err != nil {
|
||||
return err
|
||||
}
|
||||
sentDigest[s.node] = digest
|
||||
fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
||||
}
|
||||
@@ -476,11 +480,7 @@ func pushCommand(ctx context.Context, args []string) error {
|
||||
15*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
record, err := inv.NodeByName(ctx, s.node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := inv.RecordSent(ctx, record.ID, digestOf(body)); err != nil {
|
||||
if _, err := recordSent(ctx, inv, s.node, body); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
||||
@@ -569,17 +569,31 @@ type readyNode struct {
|
||||
//
|
||||
// The all-or-nothing rule is kept where it means something — sendTo, which rotates a credential
|
||||
// across two machines that must agree — and dropped here, where it never did.
|
||||
func composeEach(names []string,
|
||||
func composeEach(names []string, allot func(node string) (int64, error),
|
||||
compose func(node string) (sendable, error)) ([]readyNode, []string) {
|
||||
|
||||
var sending []readyNode
|
||||
var refusals []string
|
||||
for _, name := range names {
|
||||
// **Numbered before it is composed, not before it is sent** (novox/hq issue 204). The
|
||||
// number says where this declaration stands against every other the mesh composed for the
|
||||
// machine, and the host refuses one lower than the last it applied. Taken at send time, as
|
||||
// it was, a declaration composed a minute ago — before an assignment changed — went out with
|
||||
// a number higher than one composed after the change and sent before it, and the machine
|
||||
// took the older content as the newer word: on 2026-10-02 a runtime assigned and applied on
|
||||
// two machines was undone two seconds later by exactly that. Taken here, before the first
|
||||
// read, what was composed earlier is numbered lower whatever order the sends happen in.
|
||||
seq, err := allot(name)
|
||||
if err != nil {
|
||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||
continue
|
||||
}
|
||||
declared, err := compose(name)
|
||||
if err != nil {
|
||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||
continue
|
||||
}
|
||||
declared.Sequence = seq
|
||||
if len(declared.Resources) == 0 {
|
||||
// Sent, not skipped (novox/hq issue 127). A node whose declaration composes to
|
||||
// nothing may have HELD something before — the broker opening a placement gave it,
|
||||
@@ -607,13 +621,10 @@ func sendRound(ctx context.Context, open *stores, names []string,
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
sending, refused := composeEach(names, func(node string) (sendable, error) {
|
||||
sending, refused := composeEach(names, allotting(held, open.inventory), func(node string) (sendable, error) {
|
||||
return compose(held, node)
|
||||
})
|
||||
for _, s := range sending {
|
||||
if err := number(ctx, open.inventory, &s); err != nil {
|
||||
return refused, err
|
||||
}
|
||||
body, err := s.declared.Body()
|
||||
if err != nil {
|
||||
return refused, err
|
||||
@@ -670,6 +681,12 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
||||
var sending []readyNode
|
||||
var refusals []string
|
||||
for _, name := range names {
|
||||
// Numbered before composing, for the reason composeEach gives (novox/hq issue 204).
|
||||
seq, err := allot(ctx, inv, name)
|
||||
if err != nil {
|
||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||
continue
|
||||
}
|
||||
plan, settings, err := planFor(ctx, open, name)
|
||||
if err != nil {
|
||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||
@@ -681,6 +698,7 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||
continue
|
||||
}
|
||||
declared.Sequence = seq
|
||||
reportLeftOut(name, declared)
|
||||
sending = append(sending, readyNode{name, declared})
|
||||
}
|
||||
@@ -696,9 +714,6 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
||||
defer server.Close()
|
||||
|
||||
for _, s := range sending {
|
||||
if err := number(ctx, inv, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := s.declared.Body()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -706,11 +721,7 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
||||
if err := link.Declare(ctx, server.Bus(), ident, s.node, body, 15*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
record, err := inv.NodeByName(ctx, s.node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := inv.RecordSent(ctx, record.ID, digestOf(body)); err != nil {
|
||||
if _, err := recordSent(ctx, inv, s.node, body); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
||||
@@ -887,6 +898,21 @@ func raiseTheBus(ctx context.Context, inv *inventory.Inventory, address string)
|
||||
if err := broker.RaiseSeats(js, inventory.MeshSeats(), holders); err != nil {
|
||||
return err
|
||||
}
|
||||
// Every module's state (novox/hq ADR 0201), from the catalogue: a bucket exists from
|
||||
// registration, so a module reading one may watch it before its owner runs anywhere. One that
|
||||
// nothing declares any more is said and kept — what it holds is data.
|
||||
buckets, err := inv.DeclaredBuckets(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
undeclared, err := broker.RaiseBuckets(js, buckets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(undeclared) > 0 {
|
||||
fmt.Printf("the bus holds state nothing declares any more, kept because it is data: %s — "+
|
||||
"removing it is a person's act\n", strings.Join(undeclared, ", "))
|
||||
}
|
||||
// And how every module hears what it consumes. Derived from the same records the user list is
|
||||
// composed from, so a module the mesh grants a consumer's subjects has that consumer waiting.
|
||||
// Done on every raise, not only when a credential is issued: every module moved onto this bus
|
||||
@@ -910,8 +936,8 @@ func raiseTheBus(ctx context.Context, inv *inventory.Inventory, address string)
|
||||
}
|
||||
hearing++
|
||||
}
|
||||
fmt.Printf("the bus at %s has its streams, %d machine(s) can hear a declaration, and %d module(s) "+
|
||||
"can hear what they consume\n", broker.BareAddress(address), len(names), hearing)
|
||||
fmt.Printf("the bus at %s has its streams, %d machine(s) can hear a declaration, %d module(s) "+
|
||||
"can hear what they consume, and %d bucket(s) of state\n", broker.BareAddress(address), len(names), hearing, len(buckets))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -951,15 +977,38 @@ func seatHolders(ctx context.Context, inv *inventory.Inventory) (map[string]brok
|
||||
}
|
||||
|
||||
// number gives one send the next sequence for its node (novox/hq 04-ISSUES/107).
|
||||
func number(ctx context.Context, inv *inventory.Inventory, s *readyNode) error {
|
||||
record, err := inv.NodeByName(ctx, s.node)
|
||||
// allotting is allot over one inventory, in the shape composeEach takes.
|
||||
func allotting(ctx context.Context, inv *inventory.Inventory) func(node string) (int64, error) {
|
||||
return func(node string) (int64, error) { return allot(ctx, inv, node) }
|
||||
}
|
||||
|
||||
// allot takes the next sequence for a machine — the number its next declaration carries.
|
||||
func allot(ctx context.Context, inv *inventory.Inventory, node string) (int64, error) {
|
||||
record, err := inv.NodeByName(ctx, node)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
seq, err := inv.NextSequence(ctx, record.ID)
|
||||
return inv.NextSequence(ctx, record.ID)
|
||||
}
|
||||
|
||||
// recordSent writes down what a machine was just sent, and returns the digest.
|
||||
//
|
||||
// **On a context that outlives the caller's** (novox/hq issue 204). The record is written after the
|
||||
// declaration is away, so a send that failed is never recorded as current — and a controller being
|
||||
// replaced mid-send had its context cancelled between the two, so the machine was told and the mesh
|
||||
// never wrote it down: status read "applied, current" over a machine that had just been sent
|
||||
// something else. What was sent was sent; the record of it must not depend on the sender living
|
||||
// another second. Bounded, so a store that is away does not hold a dying process open for ever.
|
||||
func recordSent(ctx context.Context, inv *inventory.Inventory, node string, body []byte) (string, error) {
|
||||
kept, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
record, err := inv.NodeByName(kept, node)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
s.declared.Sequence = seq
|
||||
return nil
|
||||
digest := digestOf(body)
|
||||
if err := inv.RecordSent(kept, record.ID, digest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// the wrong machine no longer refuses the whole node), applied one level up.
|
||||
func TestOneUnresolvableNodeStillLetsTheRestBeSent(t *testing.T) {
|
||||
sending, refusals := composeEach(
|
||||
[]string{"anchor", "home-server", "laptop"},
|
||||
[]string{"anchor", "home-server", "laptop"}, numbered(),
|
||||
func(node string) (sendable, error) {
|
||||
if node == "anchor" {
|
||||
return sendable{}, errors.New(`nothing provides "acme-ca", wanted by route-proxy`)
|
||||
@@ -43,7 +43,7 @@ func TestOneUnresolvableNodeStillLetsTheRestBeSent(t *testing.T) {
|
||||
// (novox/hq issue 127): it may have held something before, and only sending the empty
|
||||
// declaration tells it to drop what the mesh owned. It is never a refusal.
|
||||
func TestAnEmptyDeclarationIsSentSoTheNodeDropsWhatItHeld(t *testing.T) {
|
||||
sending, refusals := composeEach([]string{"spare"},
|
||||
sending, refusals := composeEach([]string{"spare"}, numbered(),
|
||||
func(string) (sendable, error) { return sendable{}, nil })
|
||||
if len(sending) != 1 || len(refusals) != 0 {
|
||||
t.Errorf("an empty declaration must be sent, not skipped or refused: %v / %v", sending, refusals)
|
||||
@@ -74,3 +74,9 @@ func TestASkippedMachineIsStillAnError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// numbered is an allotter for tests: one higher per call, as the inventory's is per machine.
|
||||
func numbered() func(string) (int64, error) {
|
||||
var n int64
|
||||
return func(string) (int64, error) { n++; return n, nil }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"sort"
|
||||
@@ -272,7 +273,8 @@ func askTier(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan) e
|
||||
}
|
||||
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
||||
fmt.Printf(" tier %d: ", p.Tier)
|
||||
if err := buildOne(ctx, source, e.Source.Path, e.Source.Ref, 0); err != nil {
|
||||
// The branch it follows, never a commit a build once named (novox/hq 04-ISSUES/215).
|
||||
if err := buildOne(ctx, source, e.Source.Path, followedBranch(e.Source.Ref), 0); err != nil {
|
||||
state.State = "failed"
|
||||
state.Why = err.Error()
|
||||
p.State = inventory.PlanFailed
|
||||
@@ -287,8 +289,23 @@ func askTier(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan) e
|
||||
|
||||
// planBuilt marks a module built (or failed) in every open plan whose current tier holds it, and
|
||||
// advances what that completes. Called from the daemon's take-in of every outcome.
|
||||
func planBuilt(ctx context.Context, open *stores, module, commit, failed string) {
|
||||
//
|
||||
// **Only a build asked at or after the plan's ask is its outcome** (novox/hq 04-ISSUES/219). Two
|
||||
// plans a few minutes apart both ask for a module; the earlier plan's build, finishing late, is not
|
||||
// the later plan's answer — it stood on the bases from before the later plan's merge, and taking it
|
||||
// would send machines, and the next tier, what the later merge replaced. asked is zero when the
|
||||
// build's request time is not known, and such an outcome is taken as before.
|
||||
func planBuilt(ctx context.Context, open *stores, module, commit, failed string, asked time.Time) {
|
||||
inv := open.inventory
|
||||
// One controller works the plans at a time (novox/hq issue 213); an outcome waits its turn rather
|
||||
// than write over what the holder is about to save. Not taken, it is still in the build records,
|
||||
// which the holder settles the plan from (issue 214).
|
||||
release, err := inv.HoldPlans(ctx, true)
|
||||
if err != nil {
|
||||
fmt.Printf("plans: %s's outcome is left to the build records: %v\n", module, err)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
plans, err := inv.OpenPlans(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("plans: cannot read them: %v\n", err)
|
||||
@@ -314,6 +331,9 @@ func planBuilt(ctx context.Context, open *stores, module, commit, failed string)
|
||||
state = &inventory.PlanModule{}
|
||||
p.Modules[module] = state
|
||||
}
|
||||
if askedBefore(asked, state.AskedAt) {
|
||||
continue
|
||||
}
|
||||
if failed != "" {
|
||||
state.State = "failed"
|
||||
state.Why = failed
|
||||
@@ -332,13 +352,30 @@ func planBuilt(ctx context.Context, open *stores, module, commit, failed string)
|
||||
fmt.Printf("%s: %s; the tiers after it are not asked\n", p.ID, p.Note)
|
||||
}
|
||||
}
|
||||
advancePlans(ctx, open)
|
||||
advanceHeld(ctx, open)
|
||||
}
|
||||
|
||||
// advancePlans moves every open plan as far as the facts allow: a tier whose modules are all built
|
||||
// and whose gates are applied gives way to the next; the last tier done is the plan done. Called
|
||||
// after every outcome and on a timer, so a plan waiting on a machine's report moves when it comes.
|
||||
//
|
||||
// **One controller at a time** (novox/hq issue 213). A plan is read, changed and saved whole; two
|
||||
// controllers — the old and the new while a machine hands its controller over — would each ask a
|
||||
// tier the other had just asked. Taken without waiting: whoever holds the plans is moving them.
|
||||
func advancePlans(ctx context.Context, open *stores) {
|
||||
release, err := open.inventory.HoldPlans(ctx, false)
|
||||
if err != nil {
|
||||
if !errors.Is(err, inventory.ErrPlansBusy) {
|
||||
fmt.Printf("plans: cannot hold them: %v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
advanceHeld(ctx, open)
|
||||
}
|
||||
|
||||
// advanceHeld is advancePlans for a caller already holding the plans.
|
||||
func advanceHeld(ctx context.Context, open *stores) {
|
||||
inv := open.inventory
|
||||
plans, err := inv.OpenPlans(ctx)
|
||||
if err != nil {
|
||||
@@ -399,6 +436,24 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
// **Asked: settle from the build records first** (novox/hq 04-ISSUES/214). An outcome is taken
|
||||
// in by whichever controller hears it, and a merge to the controller's own repository replaces
|
||||
// the controller in its first tier: the build that produced the new one is recorded, and the
|
||||
// plan never hears it. The record is the fact; a build recorded after the ask is that tier's
|
||||
// outcome, whoever was listening.
|
||||
recorded := map[string][]inventory.Build{}
|
||||
for _, m := range tier {
|
||||
if s := p.Modules[m]; s != nil && s.State == "asked" {
|
||||
builds, err := inv.Builds(ctx, m, 5)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
recorded[m] = builds
|
||||
}
|
||||
}
|
||||
if settleFromRecords(p, tier, recorded) {
|
||||
return true, nil
|
||||
}
|
||||
// Asked: wait for every build.
|
||||
var latest time.Time
|
||||
for _, m := range tier {
|
||||
@@ -530,7 +585,8 @@ func planFailedBuild(ctx context.Context, open *stores, result link.BuildResult)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if repositoryMatches(e.Source.Repository, result.Repository) && e.Source.Path == result.Path {
|
||||
planBuilt(ctx, open, e.Manifest.Module, result.Commit, result.Failed)
|
||||
asked, _ := link.BuildAskedAt(result.ID)
|
||||
planBuilt(ctx, open, e.Manifest.Module, result.Commit, result.Failed, asked)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -647,6 +703,19 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
p.State = inventory.PlanFailed
|
||||
p.Note = "stopped by hand at tier " + fmt.Sprint(p.Tier)
|
||||
release, err := inv.HoldPlans(ctx, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
if p, err = inv.PlanByID(ctx, positionals[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
if !p.Open() {
|
||||
return fmt.Errorf("%s is already %s", p.ID, p.State)
|
||||
}
|
||||
p.State = inventory.PlanFailed
|
||||
p.Note = "stopped by hand at tier " + fmt.Sprint(p.Tier)
|
||||
if err := inv.SavePlan(ctx, p); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -755,3 +824,52 @@ func splitList(s string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// settleFromRecords marks every module of the tier still `asked` built — or failed — from a build
|
||||
// recorded after it was asked, and says whether it changed anything (novox/hq 04-ISSUES/214).
|
||||
// Newest first, as Builds answers: the first record after the ask is the outcome of that ask.
|
||||
func settleFromRecords(p *inventory.Plan, tier []string, recorded map[string][]inventory.Build) bool {
|
||||
changed := false
|
||||
for _, m := range tier {
|
||||
s := p.Modules[m]
|
||||
if s == nil || s.State != "asked" || s.AskedAt == nil {
|
||||
continue
|
||||
}
|
||||
var outcome *inventory.Build
|
||||
for i := range recorded[m] {
|
||||
b := recorded[m][i]
|
||||
if b.At.Before(*s.AskedAt) {
|
||||
break
|
||||
}
|
||||
// Recorded after the ask and asked before it: an earlier ask's late outcome, not this
|
||||
// one's (novox/hq 04-ISSUES/219).
|
||||
if askedBefore(b.Asked, s.AskedAt) {
|
||||
continue
|
||||
}
|
||||
outcome = &b
|
||||
}
|
||||
if outcome == nil {
|
||||
continue
|
||||
}
|
||||
at := outcome.At
|
||||
if outcome.Worked() {
|
||||
s.State = "built"
|
||||
s.BuiltAt = &at
|
||||
s.Commit = outcome.Commit
|
||||
} else {
|
||||
s.State = "failed"
|
||||
s.Why = outcome.Failed
|
||||
p.State = inventory.PlanFailed
|
||||
p.Note = fmt.Sprintf("%s failed to build in tier %d", m, p.Tier)
|
||||
}
|
||||
fmt.Printf("%s: %s settled from the build records as %s (%s)\n", p.ID, m, s.State, outcome.ID)
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// askedBefore is whether a build asked at asked was asked before a plan asked for its module — and
|
||||
// so is not that plan's outcome (novox/hq 04-ISSUES/219). False when either time is not known.
|
||||
func askedBefore(asked time.Time, planAsked *time.Time) bool {
|
||||
return !asked.IsZero() && planAsked != nil && asked.Before(*planAsked)
|
||||
}
|
||||
|
||||
@@ -115,3 +115,69 @@ func TestACycleIsOneLastTierAndSaidSo(t *testing.T) {
|
||||
t.Fatalf("a cycle should be one tier of two, said: %v", tiers)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/211: a merge moving the toolchain and a bundle compiled in it builds the
|
||||
// bundle a tier after the toolchain, not beside it.
|
||||
func TestABundleIsPlannedAfterTheToolchainItIsCompiledIn(t *testing.T) {
|
||||
edges := []inventory.Edge{{From: "node-tools", To: "mesh-tools", Kind: inventory.EdgeStandsOn}}
|
||||
p := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-tools", Commit: "abc"},
|
||||
[]string{"mesh-tools", "node-tools"}, edges)
|
||||
if len(p.Tiers) != 2 || p.Tiers[0][0] != "mesh-tools" || p.Tiers[1][0] != "node-tools" {
|
||||
t.Fatalf("the toolchain, then the bundle: %v", p.Tiers)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/214: a plan whose build outcome was recorded while no controller followed it —
|
||||
// the controller rebuilding itself — settles from the build records instead of waiting for ever.
|
||||
func TestAPlanSettlesAnAskedBuildFromTheRecords(t *testing.T) {
|
||||
asked := time.Date(2026, 10, 3, 19, 20, 0, 0, time.UTC)
|
||||
p := inventory.Plan{ID: "plan-1", Tiers: [][]string{{"mesh-controller", "builder"}, {"route-proxy"}},
|
||||
Modules: map[string]*inventory.PlanModule{
|
||||
"mesh-controller": {State: "asked", AskedAt: &asked},
|
||||
"builder": {State: "asked", AskedAt: &asked},
|
||||
}}
|
||||
records := map[string][]inventory.Build{
|
||||
// Newest first, as Builds answers: the build after the ask is the outcome.
|
||||
"mesh-controller": {
|
||||
{ID: "build-2", Commit: "2ebbb799", At: asked.Add(4 * time.Minute)},
|
||||
{ID: "build-1", Commit: "06ea2168", At: asked.Add(-10 * time.Minute)},
|
||||
},
|
||||
// Only a build from before the ask: not this ask's outcome.
|
||||
"builder": {{ID: "build-0", Commit: "06ea2168", At: asked.Add(-time.Hour)}},
|
||||
}
|
||||
if !settleFromRecords(&p, p.Tiers[0], records) {
|
||||
t.Fatal("nothing settled, though the controller's build is recorded after the ask")
|
||||
}
|
||||
if s := p.Modules["mesh-controller"]; s.State != "built" || s.Commit != "2ebbb799" || s.BuiltAt == nil {
|
||||
t.Errorf("the controller's ask is %+v, want built from 2ebbb799", s)
|
||||
}
|
||||
if s := p.Modules["builder"]; s.State != "asked" {
|
||||
t.Errorf("an ask with no record after it was settled: %+v", s)
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/219: a build recorded after the ask but asked before it — an earlier
|
||||
// plan's late outcome — is not this ask's, built or failed.
|
||||
r := inventory.Plan{ID: "plan-3", Tiers: [][]string{{"postgres"}},
|
||||
Modules: map[string]*inventory.PlanModule{"postgres": {State: "asked", AskedAt: &asked}}}
|
||||
late := map[string][]inventory.Build{"postgres": {
|
||||
{ID: "build-old", Commit: "efff5415", Asked: asked.Add(-18 * time.Minute), At: asked.Add(12 * time.Minute)},
|
||||
}}
|
||||
if settleFromRecords(&r, r.Tiers[0], late) || r.Modules["postgres"].State != "asked" {
|
||||
t.Errorf("an earlier ask's late outcome settled this ask: %+v", r.Modules["postgres"])
|
||||
}
|
||||
// Newest heard first: the earlier ask's late outcome, then this ask's own, heard before it.
|
||||
late["postgres"] = append(late["postgres"], inventory.Build{ID: "build-mine", Commit: "4bcd5f73",
|
||||
Asked: asked.Add(time.Second), At: asked.Add(5 * time.Minute)})
|
||||
if !settleFromRecords(&r, r.Tiers[0], late) || r.Modules["postgres"].State != "built" ||
|
||||
r.Modules["postgres"].Commit != "4bcd5f73" {
|
||||
t.Errorf("this ask's own outcome, heard before the earlier ask's, did not settle it: %+v", r.Modules["postgres"])
|
||||
}
|
||||
|
||||
// A failure recorded after the ask fails the plan, as hearing it would have.
|
||||
q := inventory.Plan{ID: "plan-2", Tiers: [][]string{{"x"}},
|
||||
Modules: map[string]*inventory.PlanModule{"x": {State: "asked", AskedAt: &asked}}}
|
||||
settleFromRecords(&q, q.Tiers[0], map[string][]inventory.Build{"x": {{ID: "b", Failed: "no", At: asked.Add(time.Minute)}}})
|
||||
if q.State != inventory.PlanFailed || q.Modules["x"].State != "failed" {
|
||||
t.Errorf("a recorded failure did not fail the plan: %+v %+v", q, q.Modules["x"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A node's own set failing to compose, and the mesh being unable to answer at all, are different
|
||||
// things, and only the first may be passed over when something is gathered across every machine
|
||||
// (novox/hq 04-ISSUES/152). These pin that distinction where the three gatherers rely on it.
|
||||
// (novox/hq 04-ISSUES/152). These pin that distinction where the gatherers rely on it.
|
||||
|
||||
func TestASetThatDoesNotComposeIsMarkedAsTheNodesOwnProblem(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
@@ -45,55 +44,3 @@ func TestAStoreThatCannotBeReadIsNotANodeThatDoesNotCompose(t *testing.T) {
|
||||
t.Fatalf("a question the mesh could not answer was read as a node that runs nothing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneIncoherentNodeDoesNotCostTheRestTheirNames(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
one, two := rivals()
|
||||
register(t, open, one)
|
||||
register(t, open, two)
|
||||
for _, m := range []string{one.Module, two.Module} {
|
||||
if _, err := open.inventory.Assign(t.Context(), "laptop", m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// laptop cannot compose. That is laptop's problem and nobody else's: the roster is still
|
||||
// answerable, and anchor keeps whatever it serves.
|
||||
if _, err := routeNamesInTheMesh(t.Context(), open); err != nil {
|
||||
t.Fatalf("one node's broken set cost the whole mesh its roster: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARosterIsNeverReturnedWithNamesItCouldNotRead(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
|
||||
stopped, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
names, err := routeNamesInTheMesh(stopped, open)
|
||||
if err == nil {
|
||||
t.Fatalf("a roster was composed from a store that could not be read: %v", names)
|
||||
}
|
||||
// The failure must be raised, not turned into an absence. A roster missing a machine's names
|
||||
// is indistinguishable, on every machine that receives it, from the operator withdrawing them —
|
||||
// and because the roster is part of every container's identity, it replaces all of them.
|
||||
if names != nil {
|
||||
t.Fatalf("a partial roster was returned beside the error: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Kept so the reason survives the next person reading it: the message the gatherer raises must say
|
||||
// which machine could not be read, or the operator is left with a mesh-wide failure and no name.
|
||||
func TestTheRaisedFailureNamesTheMachineItCouldNotRead(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
stopped, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
_, err := routeNamesInTheMesh(stopped, open)
|
||||
if err == nil {
|
||||
t.Fatal("no failure was raised")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot be read") {
|
||||
t.Fatalf("the failure does not say the mesh could not be read: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/nats-io/nats.go/micro"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
@@ -66,14 +68,14 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
||||
case "status":
|
||||
return []string{"status", "--json"}, nil
|
||||
case "nodes":
|
||||
return []string{"node", "list"}, nil
|
||||
return []string{"node", "list", "--json"}, nil
|
||||
case "node":
|
||||
if err := need("node"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{"node", "show", str("node")}, nil
|
||||
case "modules":
|
||||
return []string{"module", "list"}, nil
|
||||
return []string{"module", "list", "--json"}, nil
|
||||
case "seats":
|
||||
return []string{"seats", "--json"}, nil
|
||||
case "builds":
|
||||
@@ -379,3 +381,46 @@ func splitCommandLine(line string) ([]string, error) {
|
||||
}
|
||||
return words, nil
|
||||
}
|
||||
|
||||
// seatAnnouncement is what the controller says it serves on the bus (novox/hq ADR 0197): the
|
||||
// mesh-controller seat, one endpoint per verb it answers, each with the seat's own description and
|
||||
// argument schema — the same facts `tools` answers from the records, as NATS's services format.
|
||||
func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info {
|
||||
about := map[string]catalogue.Verb{}
|
||||
for _, s := range catalogue.SeatsWithAProtocol() {
|
||||
if s.Name == catalogue.ControllerSeatName {
|
||||
for _, v := range s.Serves {
|
||||
about[v.Name] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
verbs := make([]string, 0, len(handlers))
|
||||
for verb := range handlers {
|
||||
verbs = append(verbs, verb)
|
||||
}
|
||||
sort.Strings(verbs)
|
||||
var endpoints []micro.EndpointInfo
|
||||
for _, verb := range verbs {
|
||||
schema, _ := json.Marshal(about[verb].Input)
|
||||
// The same shape every tool runtime announces in (node-tools' announce package): the name is
|
||||
// `<seat>__<verb>`, as the protocol's characters allow; the metadata is what identifies it.
|
||||
endpoints = append(endpoints, micro.EndpointInfo{
|
||||
Name: catalogue.ControllerSeatName + "__" + verb,
|
||||
Subject: link.SeatToolSubject(catalogue.ControllerSeatName, verb),
|
||||
QueueGroup: "seat." + catalogue.ControllerSeatName,
|
||||
Metadata: map[string]string{
|
||||
"kind": "seat", "module": catalogue.ControllerSeatName, "tool": verb,
|
||||
"seat": catalogue.ControllerSeatName, "scope": "mesh", "interchangeable": "false",
|
||||
"description": about[verb].Description, "schema": string(schema),
|
||||
},
|
||||
})
|
||||
}
|
||||
return micro.Info{
|
||||
ServiceIdentity: micro.ServiceIdentity{
|
||||
Name: catalogue.ControllerSeatName, ID: "controller", Version: "0.1.0",
|
||||
Metadata: map[string]string{"seat": catalogue.ControllerSeatName, "scope": "mesh"},
|
||||
},
|
||||
Description: "the mesh's own verbs, answered by the holder of the mesh-controller seat",
|
||||
Endpoints: endpoints,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -249,3 +251,48 @@ func TestARowAheadOfThisBuildIsServedAnyway(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0195: the console's discovery reads the machines and the modules; they answer as JSON,
|
||||
// as status and seats do, so nothing parses a printed column.
|
||||
func TestTheNodesAndModulesVerbsAnswerAsJSON(t *testing.T) {
|
||||
for verb, want := range map[string]string{"nodes": "[node list --json]", "modules": "[module list --json]"} {
|
||||
argv, err := argvFor(verb, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(argv) != want {
|
||||
t.Errorf("%s runs %v, want %s", verb, argv, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0197: the controller announces exactly the verbs it serves, each on the subject and
|
||||
// queue it serves it on, with the seat's own description and schema, in NATS's services format.
|
||||
func TestTheControllerAnnouncesTheVerbsItServes(t *testing.T) {
|
||||
handlers, _, err := seatToolHandlers()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info := seatAnnouncement(handlers)
|
||||
if info.Name != catalogue.ControllerSeatName || info.ID == "" || info.Version == "" {
|
||||
t.Fatalf("the service is not named for the seat: %+v", info.ServiceIdentity)
|
||||
}
|
||||
if len(info.Endpoints) != len(handlers) {
|
||||
t.Fatalf("%d endpoints announced for %d verbs served", len(info.Endpoints), len(handlers))
|
||||
}
|
||||
for _, e := range info.Endpoints {
|
||||
verb := e.Metadata["tool"]
|
||||
if _, served := handlers[verb]; !served || e.Name != catalogue.ControllerSeatName+"__"+verb {
|
||||
t.Errorf("%s (%s) is announced and not served under that name", e.Name, verb)
|
||||
}
|
||||
if e.Metadata["kind"] != "seat" || e.Metadata["seat"] != catalogue.ControllerSeatName {
|
||||
t.Errorf("%s is not announced as the seat's verb: %v", e.Name, e.Metadata)
|
||||
}
|
||||
if e.Subject != link.SeatToolSubject(catalogue.ControllerSeatName, verb) || e.QueueGroup != "seat."+catalogue.ControllerSeatName {
|
||||
t.Errorf("%s is announced on %s/%s, not where it is served", e.Name, e.Subject, e.QueueGroup)
|
||||
}
|
||||
if e.Metadata["description"] == "" || e.Metadata["schema"] == "" || e.Metadata["scope"] != "mesh" {
|
||||
t.Errorf("%s is announced without its description, schema or scope: %v", e.Name, e.Metadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -283,6 +284,14 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
||||
if isHistory(m.MergedAt, lastLookAt(entries, m)) {
|
||||
packaging = nil
|
||||
}
|
||||
// Said, never silent (novox/hq 04-ISSUES/215): a module built from this repository that follows
|
||||
// another branch is not part of this merge, and whoever is waiting for its change should read why.
|
||||
for _, e := range entries {
|
||||
if sameRepository(e.Source.Repository, m) && !sourceIs(e.Source, m) {
|
||||
fmt.Printf(" %s is built from %s/%s and follows %s, not %s; this merge leaves it out\n",
|
||||
e.Manifest.Module, m.Owner, m.Repo, e.Source.Ref, m.Base)
|
||||
}
|
||||
}
|
||||
touched := whatTheMergeTouched(from, entries, m)
|
||||
for _, e := range touched {
|
||||
if err := inv.SourceMoved(ctx, e.Manifest.Module, m.Commit); err != nil {
|
||||
@@ -306,6 +315,13 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
||||
for _, e := range moved {
|
||||
movedNames = append(movedNames, e.Manifest.Module)
|
||||
}
|
||||
// Written and its first tier asked as one act on the plans (novox/hq issue 213): a timer on
|
||||
// another controller reading it between the two would ask the tier again.
|
||||
release, err := inv.HoldPlans(ctx, true)
|
||||
if err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
defer release()
|
||||
plan := planOfMerge(m, movedNames, edges)
|
||||
if hasCycle(plan.Tiers, edges) {
|
||||
fmt.Printf(" the last tier depends on itself: %s — built together, in no order\n",
|
||||
@@ -345,7 +361,24 @@ func sourceIs(s inventory.Source, m link.SourceMoved) bool {
|
||||
if !sameRepository(s.Repository, m) {
|
||||
return false
|
||||
}
|
||||
return s.Ref == "" || s.Ref == m.Base
|
||||
ref := followedBranch(s.Ref)
|
||||
return ref == "" || ref == m.Base
|
||||
}
|
||||
|
||||
// commitRef is a ref that names a commit rather than a branch: what `build --ref <commit>` asks for.
|
||||
var commitRef = regexp.MustCompile(`^[0-9a-f]{7,40}$`)
|
||||
|
||||
// followedBranch is the branch a recorded ref means a module follows (novox/hq 04-ISSUES/215). **A
|
||||
// commit is never a branch to follow.** A build asked at a commit — to try one, or to pin it during a
|
||||
// fix — recorded that commit as the module's ref; every merge after it then failed to match the
|
||||
// module, its plan left it out without saying so, and every plan that rebuilt it asked for that same
|
||||
// old commit again. A commit recorded so is read as the repository's default branch, which is what
|
||||
// the module followed before it; a branch is followed as named.
|
||||
func followedBranch(ref string) string {
|
||||
if commitRef.MatchString(strings.TrimSpace(ref)) {
|
||||
return ""
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
// sameRepository is whether a recorded repository is the one a merge names, in either spelling it
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A backend behind the proxy learns the client used TLS and which name it asked for, so the addresses
|
||||
// it writes into its own pages are the ones a client can use (2026-10-03: a forge's Go import tag
|
||||
// named an http clone URL, and Go refused the module path).
|
||||
func TestABackendIsToldTheRequestWasHTTPSAndForWhichName(t *testing.T) {
|
||||
var proto, host, fwdHost, fwdFor string
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
proto, host, fwdHost, fwdFor = r.Header.Get("X-Forwarded-Proto"), r.Host, r.Header.Get("X-Forwarded-Host"), r.Header.Get("X-Forwarded-For")
|
||||
}))
|
||||
defer backend.Close()
|
||||
where, _ := url.Parse(backend.URL)
|
||||
req := httptest.NewRequest(http.MethodGet, "https://git.example.org/novox/mesh-sdk/go?go-get=1", nil)
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
req.Host = "git.example.org"
|
||||
req.RemoteAddr = "192.0.2.7:51000"
|
||||
towards(where).ServeHTTP(httptest.NewRecorder(), req)
|
||||
if proto != "https" || fwdHost != "git.example.org" || host != "git.example.org" || fwdFor != "192.0.2.7" {
|
||||
t.Errorf("the backend was told proto=%q host=%q forwarded-host=%q for=%q", proto, host, fwdHost, fwdFor)
|
||||
}
|
||||
}
|
||||
@@ -273,7 +273,7 @@ func (t *table) set(routes map[string][]rule, public map[string]bool) {
|
||||
log.Printf("route %s points at %q, which is not a URL: %v", host, r.target, err)
|
||||
continue
|
||||
}
|
||||
r.to = httputil.NewSingleHostReverseProxy(where)
|
||||
r.to = towards(where)
|
||||
if r.insecure {
|
||||
r.to.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
|
||||
}
|
||||
@@ -1060,3 +1060,17 @@ func asPort(v any) (int, bool) {
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// towards proxies to one backend and tells it what the client asked: **X-Forwarded-Proto, -Host and
|
||||
// -For**, set from the request this proxy received. A backend that builds its own addresses — a forge
|
||||
// writing its clone URL into a page, a login redirect — otherwise sees the plain HTTP hop from this
|
||||
// proxy and writes `http://`, though every client reached it over TLS: Go refused the forge's module
|
||||
// path for exactly that on 2026-10-03, its import tag naming an http clone URL.
|
||||
// The standard library's NewSingleHostReverseProxy sets only X-Forwarded-For.
|
||||
func towards(where *url.URL) *httputil.ReverseProxy {
|
||||
return &httputil.ReverseProxy{Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.SetURL(where)
|
||||
pr.Out.Host = pr.In.Host
|
||||
pr.SetXForwarded()
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Package artifacts speaks to the mesh's artifact store over its own door.
|
||||
//
|
||||
// Only what the mesh needs that nothing else does: letting go of something it put there
|
||||
// (novox/hq ADR 0189, issue 108). Pushing is the builder's, through the container runtime; reading
|
||||
// is every machine's, through its runtime. This is the one operation that belongs to the thing
|
||||
// holding the records, because it is the only one that is a decision rather than a transfer.
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// Store is the artifact store at an address, as this machine reaches it.
|
||||
type Store struct {
|
||||
// Address is `host:port` — the store as the caller reaches it now, composed and never
|
||||
// recorded (novox/hq 04-ISSUES/102).
|
||||
Address string
|
||||
// HTTP is the client used; nil is a client with a modest timeout.
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// Gone is the answer when the store does not hold it: the outcome wanted, already true.
|
||||
var Gone = fmt.Errorf("the store does not hold it")
|
||||
|
||||
// LetGo asks the store to drop one artifact the mesh recorded making.
|
||||
//
|
||||
// Takes a reference as the mesh records it — `artifact-store://<module>/<artifact>@sha256:…` for
|
||||
// an image, `…/blobs/sha256:…` for an archive — because that is the identity every record uses,
|
||||
// and composes the address here at the moment of use.
|
||||
//
|
||||
// Returns Gone when the store answers that it does not have it. That is not a failure: the sweep
|
||||
// wants the artifact absent, and it is. It is distinguished from success only so a caller can say
|
||||
// which of the two happened.
|
||||
func (s Store) LetGo(ctx context.Context, reference string) error {
|
||||
path, kept := catalogue.InArtifactStore(reference)
|
||||
if !kept {
|
||||
// Nothing the mesh put in its own store. Refused rather than attempted: composing a
|
||||
// delete for a reference of unknown shape is how a sweep reaches something that is not
|
||||
// the mesh's.
|
||||
return fmt.Errorf("%s is not a reference into the mesh's artifact store", reference)
|
||||
}
|
||||
if s.Address == "" {
|
||||
return fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference)
|
||||
}
|
||||
repository, kind, digest, err := split(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url := "http://" + s.Address + "/v2/" + repository + "/" + kind + "/" + digest
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := s.HTTP
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
switch response.StatusCode {
|
||||
case http.StatusAccepted, http.StatusOK, http.StatusNoContent:
|
||||
return nil
|
||||
case http.StatusNotFound:
|
||||
return Gone
|
||||
case http.StatusMethodNotAllowed:
|
||||
// The registry was started without deletion enabled. Said plainly, because the remedy is
|
||||
// a setting on the store's module and not anything about this artifact.
|
||||
return fmt.Errorf(
|
||||
"the artifact store refuses deletion: its server was started without it enabled "+
|
||||
"(REGISTRY_STORAGE_DELETE_ENABLED), so nothing can be collected until the store "+
|
||||
"module is applied again (novox/hq ADR 0189). Asking about %s", reference)
|
||||
default:
|
||||
return fmt.Errorf("the artifact store answered %s for %s", response.Status, reference)
|
||||
}
|
||||
}
|
||||
|
||||
// split reads a recorded path into the repository, which endpoint names the thing, and the digest.
|
||||
//
|
||||
// Two shapes, which are the two the mesh records: `<repository>@sha256:<hex>` is a manifest, and
|
||||
// `<repository>/blobs/sha256:<hex>` is a blob.
|
||||
func split(path string) (repository, kind, digest string, err error) {
|
||||
if before, after, ok := strings.Cut(path, "@sha256:"); ok {
|
||||
return before, "manifests", "sha256:" + after, nil
|
||||
}
|
||||
if before, after, ok := strings.Cut(path, "/blobs/sha256:"); ok {
|
||||
return before, "blobs", "sha256:" + after, nil
|
||||
}
|
||||
return "", "", "", fmt.Errorf("%q names nothing the store holds by digest", path)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// Asking the store to let go of what the mesh no longer keeps (novox/hq ADR 0189, issue 108).
|
||||
//
|
||||
// A fake store records what it was asked to delete, so what is asserted is the mesh's decision
|
||||
// and the shape of the request — not the registry's behaviour, which is the registry's to test.
|
||||
|
||||
func fakeStore(t *testing.T, answer int) (Store, *[]string) {
|
||||
t.Helper()
|
||||
var asked []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
t.Errorf("the store was asked %s %s; collecting is a delete", r.Method, r.URL.Path)
|
||||
}
|
||||
asked = append(asked, r.URL.Path)
|
||||
w.WriteHeader(answer)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return Store{Address: strings.TrimPrefix(server.URL, "http://")}, &asked
|
||||
}
|
||||
|
||||
func TestAnImageAndAnArchiveAreAskedForAtTheirOwnEndpoints(t *testing.T) {
|
||||
// The two shapes the mesh records: a manifest by digest, and a blob by digest. They are
|
||||
// different endpoints, and asking at the wrong one answers 404 — which this would then
|
||||
// record as collected, leaving the bytes on disk for ever while the record says otherwise.
|
||||
store, asked := fakeStore(t, http.StatusAccepted)
|
||||
ctx := context.Background()
|
||||
|
||||
image := catalogue.ArtifactStoreScheme + "web/app@sha256:abc123"
|
||||
archive := catalogue.ArtifactStoreScheme + "web/config/blobs/sha256:def456"
|
||||
if err := store.LetGo(ctx, image); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.LetGo(ctx, archive); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"/v2/web/app/manifests/sha256:abc123", "/v2/web/config/blobs/sha256:def456"}
|
||||
if len(*asked) != 2 || (*asked)[0] != want[0] || (*asked)[1] != want[1] {
|
||||
t.Fatalf("the store was asked %v; want %v", *asked, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStoreThatDoesNotHaveItAnswersGone(t *testing.T) {
|
||||
// The outcome wanted, already true. Told apart from success only so the sweep can say which
|
||||
// happened; both are recorded, because retrying for ever is the thing to avoid.
|
||||
store, _ := fakeStore(t, http.StatusNotFound)
|
||||
err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@sha256:abc123")
|
||||
if !errors.Is(err, Gone) {
|
||||
t.Fatalf("a store that does not hold it answered %v, want Gone", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStoreWithDeletionOffSaysSoAndNamesTheRemedy(t *testing.T) {
|
||||
// The registry answers 405 when it was started without deletion enabled. The remedy is a
|
||||
// setting on the store's module, and saying "405" would send somebody to the wrong place.
|
||||
store, _ := fakeStore(t, http.StatusMethodNotAllowed)
|
||||
err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@sha256:abc123")
|
||||
if err == nil {
|
||||
t.Fatal("a store that refuses deletion was read as success")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "REGISTRY_STORAGE_DELETE_ENABLED") {
|
||||
t.Fatalf("the refusal does not name the remedy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReferenceThatIsNotTheMeshsOwnIsNeverAsked(t *testing.T) {
|
||||
// The whole safety of the sweep is that it names only what the mesh recorded putting there.
|
||||
// A reference of another shape — a vendor's image, a package version — is refused rather
|
||||
// than composed into a delete somewhere that is not the mesh's store.
|
||||
store, asked := fakeStore(t, http.StatusAccepted)
|
||||
for _, reference := range []string{
|
||||
"docker.io/library/registry@sha256:abc123",
|
||||
"registry@sha256:abc123",
|
||||
"1.4.2",
|
||||
} {
|
||||
if err := store.LetGo(context.Background(), reference); err == nil {
|
||||
t.Errorf("%s was asked about; it is not a reference into the mesh's store", reference)
|
||||
}
|
||||
}
|
||||
if len(*asked) != 0 {
|
||||
t.Fatalf("the store was asked about %v", *asked)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// The JetStream side of the controller: the one place the mesh's streams and consumers are
|
||||
@@ -235,14 +237,22 @@ func (j *JetStream) EnsureConsumer(c Consumer) error {
|
||||
return fmt.Errorf("asking about stream %s to re-make consumer %s: %w", c.Stream, c.Name, err)
|
||||
}
|
||||
if info.Config.Retention != nats.WorkQueuePolicy {
|
||||
j.note("consumer %s on %s is %s and should be %s; not re-made, because %s keeps its history "+
|
||||
"and a re-made consumer replays what this one acknowledged (novox/hq issue 156). Re-make it by hand",
|
||||
c.Name, c.Stream, shape(havePush), shape(wantPush), c.Stream)
|
||||
return nil
|
||||
}
|
||||
// **A stream that keeps its history is re-made from now on, never from the start.**
|
||||
// Left for a hand, the hand re-makes it with the server's default — everything the
|
||||
// stream holds — which on 2026-10-03 replayed every build ask since 1 October and
|
||||
// re-registered nine modules from the past (novox/hq issue 207). What this consumer
|
||||
// had not yet acknowledged is lost with it, and said: on a history stream that is
|
||||
// the smaller cost, and the asks in flight are visible to whoever asked.
|
||||
j.note("consumer %s on %s changes from %s to %s delivery on a stream that keeps its history: "+
|
||||
"re-made to deliver from now on, so nothing this one acknowledged comes back (novox/hq issue "+
|
||||
"207); %d ask(s) it had not acknowledged are not carried over and must be asked again",
|
||||
c.Name, c.Stream, shape(havePush), shape(wantPush), have.NumPending+uint64(have.NumAckPending))
|
||||
want.DeliverPolicy = nats.DeliverNewPolicy
|
||||
} else {
|
||||
j.note("consumer %s on %s changes from %s to %s delivery: re-made where it left off, nothing "+
|
||||
"acknowledged comes back and nothing pending is lost (novox/hq issue 206); a holder bound to "+
|
||||
"the old shape binds again", c.Name, c.Stream, shape(havePush), shape(wantPush))
|
||||
}
|
||||
if err := j.js.DeleteConsumer(c.Stream, c.Name); err != nil {
|
||||
return fmt.Errorf("re-making consumer %s on %s as %s: %w", c.Name, c.Stream, shape(wantPush), err)
|
||||
}
|
||||
@@ -308,3 +318,50 @@ func retentionOf(r Retention) nats.RetentionPolicy {
|
||||
return nats.LimitsPolicy
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBucket creates a module's bucket if it is absent and brings its options to match if it is
|
||||
// present (novox/hq ADR 0201).
|
||||
//
|
||||
// **An update, never a delete and recreate**, for the reason a stream is updated: recreating
|
||||
// discards what the bucket holds, and what a module's state holds is data. The mesh's caps are
|
||||
// asserted with the owner's options, so a bucket made by hand converges to them.
|
||||
func (j *JetStream) EnsureBucket(b Bucket) error {
|
||||
history := b.History
|
||||
if history == 0 {
|
||||
history = 1
|
||||
}
|
||||
js, err := jetstream.New(j.conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||
Bucket: b.Bucket(),
|
||||
Description: b.Why(),
|
||||
History: uint8(history),
|
||||
TTL: time.Duration(b.TTLSeconds) * time.Second,
|
||||
MaxValueSize: StateMaxValueBytes,
|
||||
MaxBytes: StateMaxBytes,
|
||||
Storage: jetstream.FileStorage,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("asserting bucket %s: %w", b.Bucket(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BucketNames is every key-value bucket on the server, the mesh's and anybody else's.
|
||||
func (j *JetStream) BucketNames() ([]string, error) {
|
||||
js, err := jetstream.New(j.conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
lister := js.KeyValueStoreNames(ctx)
|
||||
var out []string
|
||||
for name := range lister.Name() {
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, lister.Error()
|
||||
}
|
||||
|
||||
@@ -45,6 +45,11 @@ type Membership struct {
|
||||
// module that must tell the mesh from the world, the route proxy serving an internal name, reads
|
||||
// it here rather than keeping a definition of its own.
|
||||
Mesh []string `json:"mesh,omitempty"`
|
||||
// State is every bucket this module's code may reach, by the name it uses for each, and whether
|
||||
// it may write it (novox/hq ADR 0201): the runtime answers a bundle's state verbs from this list
|
||||
// and refuses, with the reason, what is not on it — the bus enforces only the union over every
|
||||
// module on the machine.
|
||||
State []StateIssued `json:"state,omitempty"`
|
||||
}
|
||||
|
||||
// Served is one address a tool is answered on.
|
||||
@@ -103,6 +108,7 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
|
||||
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
|
||||
}
|
||||
}
|
||||
m.State = stateIssuedFor(d)
|
||||
if len(d.Invokes) > 0 {
|
||||
m.Reaches = map[string][]string{}
|
||||
for _, t := range d.Invokes {
|
||||
|
||||
+82
-2
@@ -103,6 +103,12 @@ type Principal struct {
|
||||
// permission and nothing beside it.
|
||||
Invokes []string
|
||||
|
||||
// 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
|
||||
|
||||
// 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
|
||||
@@ -236,6 +242,8 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// which this package mirrors rather than reads, and a verb the seat does not declare is a
|
||||
// subject nothing publishes.
|
||||
sub = append(sub, "mesh.seat."+ControllerSeat+".tool.>")
|
||||
// And says so (novox/hq ADR 0197): it answers discovery for the seat it serves.
|
||||
sub = append(sub, announcing(ControllerSeat)...)
|
||||
|
||||
// The two events it reacts to, and its ack subject on the stream they arrive from
|
||||
// (streams.go). **Each named, not a pattern**: `mesh.mod.*.event.>` would make the
|
||||
@@ -271,6 +279,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
return Permissions{}, err
|
||||
}
|
||||
pub = append(pub, invoked...)
|
||||
// And may ask what answers (novox/hq ADR 0197): a question every service answers about
|
||||
// itself, its replies to the asker's own inbox.
|
||||
pub = append(pub, discovering()...)
|
||||
|
||||
case KindEnrolment:
|
||||
// A leaked token is useless for anything but enrolling: it cannot read a declaration, hear
|
||||
@@ -327,6 +338,15 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// away — no other principal may subscribe this namespace, and a caller's authority is
|
||||
// still granted per tool, by name, on the publish side.
|
||||
sub = append(sub, own+".tool.>")
|
||||
// It says what it serves (novox/hq ADR 0197): discovery for its own name and every seat it
|
||||
// holds a verb of, answered by the runtime that serves them.
|
||||
announced := []string{p.Module}
|
||||
for _, s := range p.Holds {
|
||||
if len(s.Serves) > 0 {
|
||||
announced = append(announced, s.Name)
|
||||
}
|
||||
}
|
||||
sub = append(sub, announcing(announced...)...)
|
||||
// Its own membership (ADR 0160): the one subject a runtime derives for itself, read
|
||||
// directly from the stream and followed live. Nothing else's.
|
||||
sub = append(sub, MembershipSubject(p.Node, p.Module))
|
||||
@@ -407,17 +427,26 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Its state, and the state of others it reads (novox/hq ADR 0201): every one read and
|
||||
// watched, its own written too.
|
||||
pub = append(pub, stateGrants(p.Module, p.State, p.Reads)...)
|
||||
|
||||
case KindNodeTools:
|
||||
// **One process serves what every module on the machine would have served for itself**
|
||||
// (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that
|
||||
// module's own principal has, for the same reason: the tools a module serves are what its
|
||||
// code answers, and a list here would be a second copy of it. Each held seat's verbs on
|
||||
// this node, as the holder's own principal would be granted them.
|
||||
var serves []string
|
||||
for _, d := range p.Carries {
|
||||
if !safeSubject.MatchString(d.Module) {
|
||||
return Permissions{}, fmt.Errorf(
|
||||
"%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", d.Module)
|
||||
}
|
||||
serves = append(serves, d.Module)
|
||||
for _, s := range d.Holds {
|
||||
serves = append(serves, s.Name)
|
||||
}
|
||||
own := "mesh.mod." + d.Module
|
||||
sub = append(sub, own+".tool.>")
|
||||
// A tool that emits an event is the module's code and emits under the module's name
|
||||
@@ -444,8 +473,37 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
return Permissions{}, err
|
||||
}
|
||||
pub = append(pub, invoked...)
|
||||
// Nothing about consumers: it consumes nothing. A module's reactions to events are its
|
||||
// own long-lived process, which ADR 0175 leaves where it is; what moves here is tools.
|
||||
// It says what it serves and may ask what answers (novox/hq ADR 0197): the runtime answers
|
||||
// discovery for each module and seat it carries, and the console it is asks the bus.
|
||||
// One service per runtime process, named for the runtime: the bus lets a principal answer each
|
||||
// request once, so the runtime announces everything it carries under its own name.
|
||||
sub = append(sub, announcing(append([]string{RuntimeModule}, serves...)...)...)
|
||||
pub = append(pub, discovering()...)
|
||||
// **And it consumes for the modules it carries** (novox/hq ADR 0198, which changes ADR 0175's
|
||||
// "it consumes nothing"): a module's long-running code is a bundle this runtime launches, and
|
||||
// the runtime is its bus — it reads the module's own durable consumer and acknowledges what
|
||||
// the module's code took. Exactly the grants the module's own principal has for that consumer,
|
||||
// on its name and no other's: asking about it, pulling from it, acknowledging it. The
|
||||
// consumer is still the controller's to make, from the module's own principal.
|
||||
for _, d := range p.Carries {
|
||||
own := Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
|
||||
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches}
|
||||
if _, consumes := ConsumerFor(own); !consumes {
|
||||
continue
|
||||
}
|
||||
stream, durable := consumerStream(own), consumerDurable(own)
|
||||
pub = append(pub,
|
||||
"$JS.API.CONSUMER.INFO."+stream+"."+durable,
|
||||
"$JS.API.CONSUMER.MSG.NEXT."+stream+"."+durable,
|
||||
"$JS.ACK."+stream+"."+durable+".>")
|
||||
}
|
||||
// **And it keeps and reads state for the modules it carries** (novox/hq ADR 0201): the union
|
||||
// of what each may do with a bucket — an owner's write, a reader's read. That one module's code
|
||||
// does not write another's bucket through it is the runtime's to keep, from the membership
|
||||
// each assignment is issued, as it keeps each module's events under that module's own name.
|
||||
for _, d := range p.Carries {
|
||||
pub = append(pub, stateGrants(d.Module, stateNames(d.State), d.Reads)...)
|
||||
}
|
||||
sub = unique(sub)
|
||||
pub = unique(pub)
|
||||
}
|
||||
@@ -765,3 +823,25 @@ func invokedSubjects(invokes []string) ([]string, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// announcing is what a principal that serves tools subscribes to answer the NATS services
|
||||
// protocol's discovery (novox/hq ADR 0197): the questions asked of every service, and those asked of
|
||||
// each name it serves — its own and no other's, so it cannot answer for a service it is not.
|
||||
func announcing(names ...string) []string {
|
||||
out := []string{"$SRV.PING", "$SRV.INFO", "$SRV.STATS"}
|
||||
for _, n := range names {
|
||||
if !safeSubject.MatchString(n) {
|
||||
continue
|
||||
}
|
||||
for _, verb := range []string{"PING", "INFO", "STATS"} {
|
||||
out = append(out, "$SRV."+verb+"."+n, "$SRV."+verb+"."+n+".>")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// discovering is what a principal publishes to ask what answers (novox/hq ADR 0197): the services
|
||||
// protocol's discovery requests, whose replies come to its own inbox.
|
||||
func discovering() []string {
|
||||
return []string{"$SRV.PING", "$SRV.PING.>", "$SRV.INFO", "$SRV.INFO.>"}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,9 @@ func TestAPersonReachesNothingButTools(t *testing.T) {
|
||||
perms, _ := PermissionsFor(Principal{Kind: KindPerson, Module: "jo",
|
||||
Invokes: []string{"*"}, PasswordHash: "x"})
|
||||
for _, p := range perms.Publish {
|
||||
if !strings.Contains(p, ".tool.") {
|
||||
// A tool call, or asking what answers (novox/hq ADR 0197) — a question every service
|
||||
// answers about itself, which claims nothing and controls nothing.
|
||||
if !strings.Contains(p, ".tool.") && !strings.HasPrefix(p, "$SRV.") {
|
||||
t.Errorf("a person may publish %q, which is not a tool call", p)
|
||||
}
|
||||
}
|
||||
@@ -376,7 +378,7 @@ func TestAModulePullsItsOwnConsumerAndNoOthers(t *testing.T) {
|
||||
// their tools (novox/hq ADR 0175): every carried module's tool namespace, every held seat's verbs
|
||||
// on this node, every module's membership on this node, and a call to anything. Nothing it
|
||||
// consumes, because it reacts to nothing.
|
||||
func TestTheRuntimeServesTheUnionAndConsumesNothing(t *testing.T) {
|
||||
func TestTheRuntimeServesTheUnionAndConsumesForItsModules(t *testing.T) {
|
||||
filter := Seat{Name: "node-packet-filter", Scope: "node", Serves: []string{"rules", "reload"}}
|
||||
p := Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, Carries: []Declared{
|
||||
{Module: "nftables", Holds: []Seat{filter}, Serves: []string{"firewall_rules"}},
|
||||
@@ -406,31 +408,46 @@ func TestTheRuntimeServesTheUnionAndConsumesNothing(t *testing.T) {
|
||||
t.Errorf("the runtime may not publish %s: %v", want, perms.Publish)
|
||||
}
|
||||
}
|
||||
// Nothing of what a carried module consumes, and no consumer of its own to ack.
|
||||
for _, s := range perms.Subscribe {
|
||||
if strings.Contains(s, ".event.") || strings.HasPrefix(s, "_DELIVER.") {
|
||||
t.Errorf("the runtime was granted a delivery it has no consumer for: %s", s)
|
||||
// It reads the consumer of every carried module that consumes — that module's, by its name, as
|
||||
// the module's own principal could (novox/hq ADR 0198) — and of no module that consumes nothing.
|
||||
for _, want := range []string{
|
||||
"$JS.API.CONSUMER.INFO.EVENTS.anchor_zsh",
|
||||
"$JS.API.CONSUMER.MSG.NEXT.EVENTS.anchor_zsh",
|
||||
"$JS.ACK.EVENTS.anchor_zsh.>",
|
||||
} {
|
||||
if !contains(perms.Publish, want) {
|
||||
t.Errorf("the runtime may not read zsh's consumer: %s missing from %v", want, perms.Publish)
|
||||
}
|
||||
}
|
||||
for _, s := range perms.Publish {
|
||||
if strings.HasPrefix(s, "$JS.ACK.") || strings.Contains(s, "CONSUMER") {
|
||||
t.Errorf("the runtime was granted a consumer's subject and has no consumer: %s", s)
|
||||
if (strings.HasPrefix(s, "$JS.ACK.") || strings.Contains(s, "CONSUMER")) && !strings.Contains(s, "anchor_zsh") {
|
||||
t.Errorf("the runtime was granted a consumer no carried module of it consumes on: %s", s)
|
||||
}
|
||||
}
|
||||
// It pulls; nothing is pushed to it, and it subscribes no event subject directly.
|
||||
for _, s := range perms.Subscribe {
|
||||
if strings.Contains(s, ".event.") || strings.HasPrefix(s, "_DELIVER.") {
|
||||
t.Errorf("the runtime was granted a delivery: %s", s)
|
||||
}
|
||||
}
|
||||
if !perms.AllowResponses {
|
||||
t.Error("the runtime answers what it is asked, and may not reply")
|
||||
}
|
||||
if _, needed := ConsumerFor(p); needed {
|
||||
t.Error("a consumer would be made for the runtime, which consumes nothing")
|
||||
t.Error("a consumer would be made for the runtime itself; it reads its modules' consumers, never one of its own")
|
||||
}
|
||||
// Each subject once: the file is read as the mesh's authority model.
|
||||
// Each subject once in each list: the file is read as the mesh's authority model. One subject may
|
||||
// stand in both — the runtime answers discovery on `$SRV.INFO` and, as the console, asks it
|
||||
// (novox/hq ADR 0197) — because subscribing and publishing are two different grants.
|
||||
for _, list := range [][]string{perms.Subscribe, perms.Publish} {
|
||||
seen := map[string]bool{}
|
||||
for _, s := range append(append([]string{}, perms.Subscribe...), perms.Publish...) {
|
||||
for _, s := range list {
|
||||
if seen[s] {
|
||||
t.Errorf("%s is granted twice", s)
|
||||
}
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(list []string, want string) bool {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A module's state on the bus (novox/hq ADR 0201, design 32 §4, design 25 §3).
|
||||
//
|
||||
// A module names the state it keeps (`state`) and the state of others it reads (`reads`), and each
|
||||
// is a key-value bucket: the server's own last-per-subject stream with direct reads, delete markers
|
||||
// and watches, which is the state relationship the mesh already uses for declarations, opened to
|
||||
// modules. The controller creates every bucket from the catalogue — from registration, like a
|
||||
// seat's stream, so a reader may watch before the owner runs anywhere — and no module can.
|
||||
//
|
||||
// Pure, like everything else in this package that decides what the bus holds; jetstream.go is the
|
||||
// part that asks a server.
|
||||
|
||||
// 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
|
||||
StateMaxBytes = 64 * 1024 * 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// bucketOfRead is the bucket a read names, `<module>.<name>`, or false when it names none.
|
||||
func bucketOfRead(read string) (string, bool) {
|
||||
at := strings.LastIndex(read, ".")
|
||||
if at <= 0 || at == len(read)-1 {
|
||||
return "", false
|
||||
}
|
||||
module, name := read[:at], read[at+1:]
|
||||
if !safeSubject.MatchString(module) || !safeSubject.MatchString(name) {
|
||||
return "", false
|
||||
}
|
||||
return BucketName(module, name), true
|
||||
}
|
||||
|
||||
// stateGrants is what a principal publishes to reach the state its modules keep and read: for every
|
||||
// bucket, binding to it, reading a key directly, and an ordered consumer for listing and watching,
|
||||
// created and deleted on the bucket's own stream, with its flow control answered; for a bucket an
|
||||
// owner keeps, writing under the bucket's own subjects too.
|
||||
//
|
||||
// **Measured against a running server, 2026-10-04** (novox/hq research 024), and each one is there
|
||||
// because leaving it out failed: without STREAM.INFO nothing binds; without DIRECT.GET nothing is
|
||||
// read; without CONSUMER.CREATE no key is listed and nothing is watched; without CONSUMER.DELETE a
|
||||
// watch cannot be stopped and lingers on the server. A write outside these is refused by the server
|
||||
// — and reaches the writer as a timeout, not a refusal, which is why the runtime refuses first.
|
||||
func stateGrants(module string, keeps []string, reads []string) []string {
|
||||
var out []string
|
||||
read := func(bucket string) {
|
||||
stream := "KV_" + bucket
|
||||
out = append(out,
|
||||
"$JS.API.STREAM.INFO."+stream,
|
||||
"$JS.API.DIRECT.GET."+stream+".>",
|
||||
"$JS.API.CONSUMER.CREATE."+stream+".>",
|
||||
"$JS.API.CONSUMER.DELETE."+stream+".>",
|
||||
"$JS.FC."+stream+".>")
|
||||
}
|
||||
for _, name := range keeps {
|
||||
if !safeSubject.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
bucket := BucketName(module, name)
|
||||
read(bucket)
|
||||
out = append(out, "$KV."+bucket+".>")
|
||||
}
|
||||
for _, r := range reads {
|
||||
if bucket, ok := bucketOfRead(r); ok {
|
||||
read(bucket)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StateIssued is one bucket an assignment may reach, by the name its module uses for it: its own
|
||||
// state by the local name, another's as `<module>.<name>` (novox/hq ADR 0201).
|
||||
type StateIssued struct {
|
||||
Name string `json:"name"`
|
||||
Bucket string `json:"bucket"`
|
||||
Writes bool `json:"writes,omitempty"`
|
||||
}
|
||||
|
||||
// stateIssuedFor is every bucket a module's code may reach, as its membership lists them.
|
||||
func stateIssuedFor(d Declared) []StateIssued {
|
||||
var out []StateIssued
|
||||
for _, b := range d.State {
|
||||
out = append(out, StateIssued{Name: b.Name, Bucket: BucketName(d.Module, b.Name), Writes: true})
|
||||
}
|
||||
for _, r := range d.Reads {
|
||||
if bucket, ok := bucketOfRead(r); ok {
|
||||
out = append(out, StateIssued{Name: r, Bucket: bucket})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stateNames is the local names of a module's own buckets.
|
||||
func stateNames(buckets []Bucket) []string {
|
||||
out := make([]string, 0, len(buckets))
|
||||
for _, b := range buckets {
|
||||
out = append(out, b.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A BucketAsserter is the part of a JetStream connection bucket assertion needs.
|
||||
type BucketAsserter interface {
|
||||
// EnsureBucket creates the bucket if absent and brings its options to match if present, never
|
||||
// discarding what it holds.
|
||||
EnsureBucket(b Bucket) error
|
||||
// BucketNames is every key-value bucket on the server.
|
||||
BucketNames() ([]string, error)
|
||||
}
|
||||
|
||||
// RaiseBuckets asserts every declared bucket and answers the buckets on the server that nothing
|
||||
// declares any more.
|
||||
//
|
||||
// **Those are reported, never removed** (novox/hq ADR 0201, ADR 0030): what a module stored is
|
||||
// data, and a manifest edited, a module renamed or a catalogue entry dropped is an ordinary day's
|
||||
// work that must not take data with it. Removing one is a person's act.
|
||||
func RaiseBuckets(a BucketAsserter, buckets []Bucket) (undeclared []string, err error) {
|
||||
sorted := append([]Bucket(nil), buckets...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Bucket() < sorted[j].Bucket() })
|
||||
declared := map[string]bool{}
|
||||
for _, b := range sorted {
|
||||
if err := a.EnsureBucket(b); err != nil {
|
||||
return nil, fmt.Errorf("asserting %s's state %q: %w", b.Module, b.Name, err)
|
||||
}
|
||||
declared[b.Bucket()] = true
|
||||
}
|
||||
names, err := a.BucketNames()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing the bus's state: %w", err)
|
||||
}
|
||||
for _, n := range names {
|
||||
if !declared[n] {
|
||||
undeclared = append(undeclared, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(undeclared)
|
||||
return undeclared, nil
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// The grants measured against a running server (novox/hq research 024): an owner reads and writes
|
||||
// its bucket, a reader only reads, and neither reaches any other bucket.
|
||||
func TestAnOwnerWritesItsStateAndAReaderOnlyReads(t *testing.T) {
|
||||
owner, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "claude-code",
|
||||
State: []string{"servers"}, PasswordHash: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range []string{
|
||||
"$KV.claude-code_servers.>",
|
||||
"$JS.API.STREAM.INFO.KV_claude-code_servers",
|
||||
"$JS.API.DIRECT.GET.KV_claude-code_servers.>",
|
||||
"$JS.API.CONSUMER.CREATE.KV_claude-code_servers.>",
|
||||
"$JS.API.CONSUMER.DELETE.KV_claude-code_servers.>",
|
||||
"$JS.FC.KV_claude-code_servers.>",
|
||||
} {
|
||||
has(t, owner.Publish, s)
|
||||
}
|
||||
hasNot(t, owner.Publish, "$KV.>")
|
||||
hasNot(t, owner.Publish, "$JS.API.>")
|
||||
|
||||
reader, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "console",
|
||||
Reads: []string{"claude-code.servers"}, PasswordHash: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
has(t, reader.Publish, "$JS.API.DIRECT.GET.KV_claude-code_servers.>")
|
||||
has(t, reader.Publish, "$JS.API.CONSUMER.CREATE.KV_claude-code_servers.>")
|
||||
hasNot(t, reader.Publish, "$KV.claude-code_servers.>")
|
||||
for _, s := range reader.Subscribe {
|
||||
if s == "$KV.claude-code_servers.>" {
|
||||
t.Fatalf("a reader subscribes the bucket's subjects directly: %v", reader.Subscribe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One runtime carries every module on its machine, so its grant is the union: the owner's write
|
||||
// where an owner is carried, a read where only a reader is.
|
||||
func TestTheRuntimeKeepsAndReadsStateForItsModules(t *testing.T) {
|
||||
perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "one", Module: RuntimeModule,
|
||||
Carries: []Declared{
|
||||
{Module: "claude-code", State: []Bucket{{Module: "claude-code", Name: "servers"}},
|
||||
Reads: []string{"licence-manager.bindings"}},
|
||||
{Module: "audit"},
|
||||
}, PasswordHash: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
has(t, perms.Publish, "$KV.claude-code_servers.>")
|
||||
has(t, perms.Publish, "$JS.API.DIRECT.GET.KV_licence-manager_bindings.>")
|
||||
hasNot(t, perms.Publish, "$KV.licence-manager_bindings.>")
|
||||
}
|
||||
|
||||
// A module with no state is granted nothing of any bucket — the composition of every module that
|
||||
// existed before this is unchanged.
|
||||
func TestAModuleWithNoStateReachesNoBucket(t *testing.T) {
|
||||
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "billing",
|
||||
Emits: []string{"order.placed"}, PasswordHash: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range perms.Publish {
|
||||
if strings.HasPrefix(s, "$KV.") || strings.HasPrefix(s, "$JS.FC.") || strings.Contains(s, ".KV_") {
|
||||
t.Fatalf("granted %q without declaring state", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A read that names no bucket grants nothing rather than something that happens to parse.
|
||||
func TestAReadThatNamesNoBucketGrantsNothing(t *testing.T) {
|
||||
if got := stateGrants("a", nil, []string{"nodot", "x.", ".y", "a.b>"}); len(got) != 0 {
|
||||
t.Fatalf("granted %v for reads that name no bucket", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The membership lists every bucket the module's code may reach, by the name the module uses for
|
||||
// it, and whether it may write it — the list the runtime refuses from.
|
||||
func TestAMembershipListsTheStateItsModuleMayReach(t *testing.T) {
|
||||
m := MembershipFor("one", Declared{Module: "claude-code",
|
||||
State: []Bucket{{Module: "claude-code", Name: "servers"}},
|
||||
Reads: []string{"licence-manager.bindings"}}, Placements{})
|
||||
want := []StateIssued{
|
||||
{Name: "servers", Bucket: "claude-code_servers", Writes: true},
|
||||
{Name: "licence-manager.bindings", Bucket: "licence-manager_bindings"},
|
||||
}
|
||||
if !slices.Equal(m.State, want) {
|
||||
t.Fatalf("issued %+v, want %+v", m.State, want)
|
||||
}
|
||||
if none := MembershipFor("one", Declared{Module: "audit"}, Placements{}); none.State != nil {
|
||||
t.Fatalf("a module with no state was issued %+v", none.State)
|
||||
}
|
||||
}
|
||||
|
||||
type buckets struct {
|
||||
ensured []string
|
||||
on []string
|
||||
}
|
||||
|
||||
func (b *buckets) EnsureBucket(x Bucket) error {
|
||||
b.ensured = append(b.ensured, x.Bucket())
|
||||
return nil
|
||||
}
|
||||
func (b *buckets) BucketNames() ([]string, error) { return b.on, nil }
|
||||
|
||||
// Every declared bucket is asserted; one on the server that nothing declares is said, not removed.
|
||||
func TestRaisingStateReportsWhatNothingDeclares(t *testing.T) {
|
||||
b := &buckets{on: []string{"claude-code_servers", "gone_old", "ours_by_hand"}}
|
||||
undeclared, err := RaiseBuckets(b, []Bucket{{Module: "claude-code", Name: "servers"}, {Module: "a", Name: "b"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(b.ensured, []string{"a_b", "claude-code_servers"}) {
|
||||
t.Fatalf("asserted %v", b.ensured)
|
||||
}
|
||||
if !slices.Equal(undeclared, []string{"gone_old", "ours_by_hand"}) {
|
||||
t.Fatalf("reported %v", undeclared)
|
||||
}
|
||||
}
|
||||
|
||||
// Against a real server: a bucket is created with the owner's options and the mesh's caps,
|
||||
// asserting it again changes nothing and keeps what it holds, and a changed option is brought to
|
||||
// match in place.
|
||||
func TestABucketIsAssertedInPlace(t *testing.T) {
|
||||
js := aLiveBus(t)
|
||||
b := Bucket{Module: "statetest", Name: "servers"}
|
||||
if _, err := RaiseBuckets(js, []Bucket{b}); err != nil {
|
||||
t.Fatalf("a real server refused a module's bucket: %v", err)
|
||||
}
|
||||
kv, err := js.Context().KeyValue(b.Bucket())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := kv.Put("all.one", []byte(`{"kept":true}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.History = 3
|
||||
if _, err := RaiseBuckets(js, []Bucket{b}); err != nil {
|
||||
t.Fatalf("asserting the bucket again failed, so a restart would: %v", err)
|
||||
}
|
||||
got, err := kv.Get("all.one")
|
||||
if err != nil || string(got.Value()) != `{"kept":true}` {
|
||||
t.Fatalf("asserting again lost what the bucket held: %v %v", got, err)
|
||||
}
|
||||
status, err := kv.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.History() != 3 {
|
||||
t.Fatalf("history is %d, the owner declared 3", status.History())
|
||||
}
|
||||
if s, ok := status.(*nats.KeyValueBucketStatus); ok {
|
||||
if c := s.StreamInfo().Config; c.MaxMsgSize != StateMaxValueBytes || c.MaxBytes != StateMaxBytes {
|
||||
t.Fatalf("the mesh's caps are not on the bucket: value %d, bucket %d", c.MaxMsgSize, c.MaxBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -25,7 +25,7 @@ accounts {
|
||||
users = [
|
||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.refused", "mesh.seat.node-build-agent.accept.>"] }
|
||||
subscribe: { allow: ["$JS.API.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] }
|
||||
subscribe: { allow: ["$JS.API.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
|
||||
@@ -38,17 +38,17 @@ accounts {
|
||||
} }
|
||||
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
|
||||
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
||||
subscribe: { allow: ["_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.telegram", "$SRV.INFO.telegram.>", "$SRV.PING", "$SRV.PING.telegram", "$SRV.PING.telegram.>", "$SRV.STATS", "$SRV.STATS.telegram", "$SRV.STATS.telegram.>", "_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
|
||||
publish: { allow: ["$JS.ACK.EVENTS.two_audit.>", "$JS.API.CONSUMER.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.audit"] }
|
||||
subscribe: { allow: ["_INBOX.two.audit.>", "mesh.assignment.two.audit", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
|
||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.audit", "$SRV.INFO.audit.>", "$SRV.PING", "$SRV.PING.audit", "$SRV.PING.audit.>", "$SRV.STATS", "$SRV.STATS.audit", "$SRV.STATS.audit.>", "_INBOX.two.audit.>", "mesh.assignment.two.audit", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: {
|
||||
publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "$JS.API.CONSUMER.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.shop", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] }
|
||||
subscribe: { allow: ["_INBOX.two.shop.>", "mesh.assignment.two.shop", "mesh.mod.shop.tool.>"] }
|
||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.shop", "$SRV.INFO.shop.>", "$SRV.PING", "$SRV.PING.shop", "$SRV.PING.shop.>", "$SRV.STATS", "$SRV.STATS.shop", "$SRV.STATS.shop.>", "_INBOX.two.shop.>", "mesh.assignment.two.shop", "mesh.mod.shop.tool.>"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
]
|
||||
|
||||
@@ -33,6 +33,10 @@ type Declared struct {
|
||||
Watches []Seat
|
||||
// Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152).
|
||||
Invokes []string
|
||||
// State is the state it keeps, each a bucket its instances write (novox/hq ADR 0201).
|
||||
State []Bucket
|
||||
// Reads are other modules' state it reads, each `<module>.<name>` (novox/hq ADR 0201).
|
||||
Reads []string
|
||||
}
|
||||
|
||||
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
||||
@@ -81,6 +85,7 @@ func Users(r Records) ([]Principal, error) {
|
||||
Kind: KindModule, Node: node, Module: d.Module,
|
||||
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
|
||||
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
|
||||
State: stateNames(d.State), Reads: d.Reads,
|
||||
})
|
||||
}
|
||||
if runtimeHere {
|
||||
|
||||
@@ -108,9 +108,10 @@ func TestAWorkerOfTheWrongTypeIsRemadeOnAWorkQueueAndAPullThenBinds(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
// On a stream that keeps its history, a worker of the wrong type is said and left: re-making it would
|
||||
// replay what it acknowledged (novox/hq issue 156), and that is a person's call.
|
||||
func TestAWorkerOfTheWrongTypeOnAHistoryStreamIsLeftAndSaid(t *testing.T) {
|
||||
// On a stream that keeps its history, a worker of the wrong type is re-made to deliver from now on:
|
||||
// re-making it from the start would replay what it acknowledged (novox/hq issue 156), and leaving it
|
||||
// for a hand re-made it exactly that way on 2026-10-03 (issue 207).
|
||||
func TestAWorkerOfTheWrongTypeOnAHistoryStreamIsRemadeFromNowOn(t *testing.T) {
|
||||
url := os.Getenv("MESH_TEST_NATS")
|
||||
if url == "" {
|
||||
t.Skip("MESH_TEST_NATS unset")
|
||||
@@ -132,6 +133,12 @@ func TestAWorkerOfTheWrongTypeOnAHistoryStreamIsLeftAndSaid(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// History the old consumer would have acknowledged long ago, and must not come back.
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := js.js.Publish("mesh.t.event.old", []byte("old")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := js.EnsureConsumer(Consumer{Name: worker, Stream: stream, Filters: []string{filter}, AckWaitSeconds: 60}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -139,7 +146,22 @@ func TestAWorkerOfTheWrongTypeOnAHistoryStreamIsLeftAndSaid(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if have.Config.DeliverSubject == "" {
|
||||
t.Fatal("a history stream's consumer was re-made, which replays what it acknowledged")
|
||||
if have.Config.DeliverSubject != "" {
|
||||
t.Fatal("a history stream's consumer of the wrong type was left as it was")
|
||||
}
|
||||
if have.Config.DeliverPolicy != nats.DeliverNewPolicy || have.NumPending != 0 {
|
||||
t.Fatalf("re-made consumer delivers %v with %d pending; it must deliver from now on with nothing of the past", have.Config.DeliverPolicy, have.NumPending)
|
||||
}
|
||||
// And what arrives from now on is delivered.
|
||||
if _, err := js.js.Publish("mesh.t.event.new", []byte("new")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sub, err := js.js.PullSubscribe(filter, worker, nats.Bind(stream, worker))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := sub.Fetch(1, nats.MaxWait(3*time.Second))
|
||||
if err != nil || len(got) != 1 || string(got[0].Data) != "new" {
|
||||
t.Fatalf("the re-made consumer delivered %v, %v; want the one new message", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
+158
-4
@@ -215,7 +215,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
||||
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
|
||||
for _, a := range artifacts {
|
||||
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
|
||||
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, seatBases, say)
|
||||
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, npmrc, seatBases, say)
|
||||
if err != nil {
|
||||
say("artifact", "%s FAILED: %v", a.Name, err)
|
||||
return Result{}, err
|
||||
@@ -443,7 +443,7 @@ func wantsPackages(manifest catalogue.Manifest, within string) bool {
|
||||
|
||||
func one(ctx context.Context, run Runner, publish Publisher,
|
||||
module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string,
|
||||
held map[string]string, npmrc string, seats map[string]string,
|
||||
held map[string]string, npmrc string, registry Npmrc, seats map[string]string,
|
||||
say func(step, format string, args ...any)) (catalogue.Built, error) {
|
||||
|
||||
switch a.Kind {
|
||||
@@ -582,11 +582,28 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
||||
"holds no copy of it. Build %s first",
|
||||
module, a.Name, chain.Language, chain.Base, chain.Artifact, chain.Base)
|
||||
}
|
||||
// The module's own packages first, where the compiler and the bundler resolve them from
|
||||
// (dependencies.go); nothing at all for a module whose package.json names only the SDK.
|
||||
if err := installOwn(ctx, run, tree, chain, base, registry, say); err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: %s: %w", module, a.Name, err)
|
||||
}
|
||||
say("bundle", "compiling %s in %s's toolchain", a.Language, chain.Base)
|
||||
compiled, err := compile(ctx, run, tree, chain, base, a)
|
||||
if err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: compiling %s failed: %w", module, a.Name, err)
|
||||
}
|
||||
// **Every entrypoint the runtime may serve is executable** (novox/hq ADR 0193). The runtime
|
||||
// knows no language; for one that runs through an interpreter the build writes the launcher.
|
||||
launchers, err := writeLaunchers(compiled, chain, a)
|
||||
if err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: writing %s's launchers failed: %w", module, a.Name, err)
|
||||
}
|
||||
if chain.Bundler != "" {
|
||||
say("bundle", "bundling each entrypoint into one file")
|
||||
if compiled, err = bundled(ctx, run, tree, chain, base, a, launchers); err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: bundling %s failed: %w", module, a.Name, err)
|
||||
}
|
||||
}
|
||||
say("bundle", "compiled, packing")
|
||||
body, err := pack(compiled)
|
||||
if err != nil {
|
||||
@@ -598,7 +615,7 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
||||
if err != nil {
|
||||
return catalogue.Built{}, err
|
||||
}
|
||||
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest}, nil
|
||||
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest, Launchers: launchers}, nil
|
||||
|
||||
case catalogue.ArtifactPackage:
|
||||
// Built and published on a public base, to the mesh's package registry, by version
|
||||
@@ -968,7 +985,7 @@ func compile(ctx context.Context, run Runner, tree string, chain Toolchain,
|
||||
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if chain.Dependencies != "" {
|
||||
if chain.Dependencies != "" && chain.Bundler == "" {
|
||||
// **What the bundle runs with, from the image it was compiled in** (Toolchain.Dependencies).
|
||||
// A second run in the same image rather than a shell wrapped around the compiler: the
|
||||
// compile line stays a plain command a reader can run by hand, and the copy is one more
|
||||
@@ -1165,6 +1182,9 @@ func readBy(manifest catalogue.Manifest) []catalogue.ArtifactContext {
|
||||
// binaryName is what a compiled bundle's executable is called: what the artifact says, or the name of
|
||||
// the package it is built from, which is what a compiler would have chosen anyway.
|
||||
func binaryName(a catalogue.Artifact) string {
|
||||
if name := catalogue.BinaryOf(a); name != "" {
|
||||
return name
|
||||
}
|
||||
if name := strings.TrimSpace(a.Binary); name != "" {
|
||||
return name
|
||||
}
|
||||
@@ -1173,3 +1193,137 @@ func binaryName(a catalogue.Artifact) string {
|
||||
}
|
||||
return a.Name
|
||||
}
|
||||
|
||||
// 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 = ".serve.mjs"
|
||||
|
||||
// writeLaunchers writes, beside every entrypoint of a TypeScript bundle, an executable that
|
||||
// imports the entrypoint and serves what it registered over MCP on stdio — through the bundle's
|
||||
// own copy of the SDK, so registering and serving meet in one registry (novox/hq ADR 0193). Its
|
||||
// answer is each entrypoint's launcher, by entrypoint, relative to the bundle's root; nothing for
|
||||
// a language whose build is already executable.
|
||||
func writeLaunchers(root string, chain Toolchain, a catalogue.Artifact) (map[string]string, error) {
|
||||
if chain.Language != "typescript" {
|
||||
return nil, nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, entry := range a.Entrypoints {
|
||||
if !strings.HasSuffix(entry, ".js") {
|
||||
continue
|
||||
}
|
||||
launcher := strings.TrimSuffix(entry, ".js") + launcherSuffix
|
||||
body := "#!/usr/bin/env node\n" +
|
||||
"// Written by the mesh's builder (novox/hq ADR 0193): serve what " + entry + " registers,\n" +
|
||||
"// over MCP on stdio, as the module the node's runtime names in MESH_SERVED_MODULE.\n" +
|
||||
"import { serveRegisteredOverStdio } from \"@novox/mesh-sdk/stdio\";\n" +
|
||||
"await import(\"./" + filepath.Base(entry) + "\");\n" +
|
||||
"await serveRegisteredOverStdio();\n"
|
||||
path := filepath.Join(root, filepath.FromSlash(launcher))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// WriteFile honours the umask; the mode a machine unpacks is the one packed, so it is set.
|
||||
if err := os.Chmod(path, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[entry] = launcher
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// bundledSuffix is where a bundle's one-file output is written, beside what the compiler wrote.
|
||||
const bundledSuffix = ".bundled"
|
||||
|
||||
// bundled makes every entrypoint and every launcher of a compiled bundle ONE file, in the toolchain
|
||||
// image's bundler, and answers the directory to pack (novox/hq ADR 0193).
|
||||
//
|
||||
// **What a launched bundle runs is what it imports, and nothing else.** Every served bundle is its
|
||||
// own process, so it carries its own copy of the SDK and its own dependencies inlined — the
|
||||
// toolchain's whole node_modules no longer travels in every bundle. An entrypoint a process runs by
|
||||
// name (`node daemon/index.js`) is bundled in place under its own name; a launcher keeps its name
|
||||
// and its first line, and stays executable. A package the bundler cannot inline is named by the
|
||||
// artifact (`external`), kept as an import, and only then is the toolchain's runtime directory
|
||||
// copied beside the files. CommonJS inlined into an ES module still finds `require`.
|
||||
func bundled(ctx context.Context, run Runner, tree string, chain Toolchain, base string,
|
||||
a catalogue.Artifact, launchers map[string]string) (string, error) {
|
||||
const within = "/app/modules/module"
|
||||
out, final := Out(a.Name), Out(a.Name)+bundledSuffix
|
||||
if err := os.RemoveAll(filepath.Join(tree, final)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(tree, final), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
common := []string{"--bundle", "--platform=node", "--format=esm", "--target=node22",
|
||||
"--outbase=" + out, "--outdir=" + final, "--log-level=warning",
|
||||
"--banner:js=import { createRequire as __meshRequire } from 'node:module'; const require = __meshRequire(import.meta.url);"}
|
||||
for _, x := range a.External {
|
||||
common = append(common, "--external:"+x)
|
||||
}
|
||||
var plain []string
|
||||
for _, e := range a.Entrypoints {
|
||||
if strings.HasSuffix(e, ".js") {
|
||||
plain = append(plain, out+"/"+e)
|
||||
}
|
||||
}
|
||||
var launch []string
|
||||
for _, l := range sortedValues(launchers) {
|
||||
launch = append(launch, out+"/"+l)
|
||||
}
|
||||
// Refused by name in an image that predates the bundler, as the dependencies copy is: a bundle
|
||||
// packed without it would carry nothing it imports. Run as itself: npm installs esbuild's native
|
||||
// binary in place of its script, which `node` cannot run.
|
||||
guard := `test -x "$0" || { echo "the toolchain image carries no bundler at $0: it predates one-file bundles, rebuild mesh-tools first" >&2; exit 1; }; exec "$0" "$@"`
|
||||
step := func(entries []string, extra ...string) error {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
invocation := []string{"run", "--rm", "--volume", tree + ":" + within, "--workdir", within, base,
|
||||
"sh", "-c", guard, chain.Bundler}
|
||||
invocation = append(invocation, entries...)
|
||||
invocation = append(invocation, common...)
|
||||
invocation = append(invocation, extra...)
|
||||
_, err := run(ctx, tree, "docker", invocation...)
|
||||
return err
|
||||
}
|
||||
if err := step(plain); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := step(launch, "--out-extension:.js=.mjs"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Plain `.js` output is an ES module; said once, as the runtime directory used to say it.
|
||||
if err := os.WriteFile(filepath.Join(tree, final, "package.json"), []byte(`{"type":"module","private":true}`+"\n"), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, l := range launchers {
|
||||
path := filepath.Join(tree, final, filepath.FromSlash(l))
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
if err := os.Chmod(path, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(a.External) > 0 && chain.Dependencies != "" {
|
||||
copying := []string{"run", "--rm", "--volume", tree + ":" + within, "--workdir", within, base,
|
||||
"sh", "-c", `cp -a "$0/node_modules" "$1/"`, chain.Dependencies, final}
|
||||
if _, err := run(ctx, tree, "docker", copying...); err != nil {
|
||||
return "", fmt.Errorf("copying the packages %s keeps external: %w", a.Name, err)
|
||||
}
|
||||
}
|
||||
return filepath.Join(tree, final), nil
|
||||
}
|
||||
|
||||
func sortedValues(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for _, v := range m {
|
||||
out = append(out, v)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -83,25 +83,49 @@ func TestABundleIsCompiledAndPackedWithNoDockerfile(t *testing.T) {
|
||||
t.Fatalf("the bundle was not pinned: %v", got.Manifest.Resources[0])
|
||||
}
|
||||
|
||||
// **And what it runs with, from the image it was compiled in** (novox/hq to-be 38 WP3). A
|
||||
// second run in the same toolchain image copies the toolchain's runtime directory — the
|
||||
// `"type": "module"` package.json and the pruned node_modules — into the output's root, and
|
||||
// refuses by name when the image carries none rather than packing a bundle that starts nowhere.
|
||||
var copied string
|
||||
// **One file per entrypoint and launcher, in the toolchain's bundler** (novox/hq ADR 0193). A
|
||||
// second run in the same toolchain image bundles each into the artifact's bundled output, the SDK
|
||||
// inlined, refusing by name in an image that predates the bundler; and the toolchain's
|
||||
// node_modules is no longer copied into a bundle that keeps nothing external.
|
||||
var bundling []string
|
||||
for _, line := range r.ran {
|
||||
if strings.HasPrefix(line, "docker run") && strings.Contains(line, "/app/runtime") {
|
||||
copied = line
|
||||
if strings.HasPrefix(line, "docker run") && strings.Contains(line, "esbuild") {
|
||||
bundling = append(bundling, line)
|
||||
}
|
||||
}
|
||||
if copied == "" {
|
||||
t.Fatalf("the bundle's dependencies were not copied in after the compile:\n%s", strings.Join(r.ran, "\n"))
|
||||
if len(bundling) != 2 {
|
||||
t.Fatalf("want one bundling run for the entrypoints and one for the launchers:\n%s", strings.Join(r.ran, "\n"))
|
||||
}
|
||||
if !strings.Contains(copied, "mesh-tools/build@sha256:") || !strings.Contains(copied, "predates") ||
|
||||
!strings.Contains(copied, Out("code")) {
|
||||
t.Fatalf("the copy does not run in the same toolchain, refuse an older image by name, or land in the artifact's output: %s", copied)
|
||||
for _, want := range []string{"mesh-tools/build@sha256:", "predates one-file bundles", "--bundle", "--format=esm",
|
||||
"--platform=node", "--outdir=" + Out("code") + ".bundled", Out("code") + "/index.js"} {
|
||||
if !strings.Contains(bundling[0], want) {
|
||||
t.Errorf("the entrypoints' bundling lacks %q: %s", want, bundling[0])
|
||||
}
|
||||
if strings.Index(strings.Join(r.ran, "\n"), "--outDir") > strings.Index(strings.Join(r.ran, "\n"), "/app/runtime") {
|
||||
t.Fatal("the dependencies were copied before the compile wrote its output")
|
||||
}
|
||||
if !strings.Contains(bundling[1], Out("code")+"/index.serve.mjs") || !strings.Contains(bundling[1], "--out-extension:.js=.mjs") {
|
||||
t.Errorf("the launcher is not bundled under its own name: %s", bundling[1])
|
||||
}
|
||||
if strings.Contains(strings.Join(r.ran, "\n"), "/app/runtime") {
|
||||
t.Errorf("the toolchain's node_modules was copied into a bundle that keeps nothing external:\n%s", strings.Join(r.ran, "\n"))
|
||||
}
|
||||
if strings.Index(strings.Join(r.ran, "\n"), "--outDir") > strings.Index(strings.Join(r.ran, "\n"), "esbuild") {
|
||||
t.Fatal("the bundler ran before the compile wrote its output")
|
||||
}
|
||||
}
|
||||
|
||||
// A bundle naming packages it keeps external is bundled with them as imports, and carries the
|
||||
// toolchain's node_modules for them — the one case it still does.
|
||||
func TestABundleKeepingAPackageExternalCarriesTheToolchainsModules(t *testing.T) {
|
||||
manifest := strings.Replace(aBundle, `"entrypoints":["index.js"]`, `"entrypoints":["index.js"],"external":["sharp"]`, 1)
|
||||
r, workspace := aRepository(t, manifest, map[string]string{"index.ts": "console.log(1)"})
|
||||
held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)}
|
||||
if _, err := Build(context.Background(), compiling{r}.run, r,
|
||||
"https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, GitCredential{}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all := strings.Join(r.ran, "\n")
|
||||
if !strings.Contains(all, "--external:sharp") || !strings.Contains(all, "/app/runtime") {
|
||||
t.Errorf("an external package was not kept as an import with the toolchain's modules beside it:\n%s", all)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A module's own packages, installed before its bundle is compiled, so the bundler inlines them.
|
||||
//
|
||||
// **A bundle could only import what the toolchain happened to carry.** The compiler and the bundler
|
||||
// resolve an import by walking up from the module's source: the module's own directory first, then
|
||||
// the toolchain image's node_modules. Nothing ever put anything in the first, so a module needing a
|
||||
// database driver (`pg`, `mongodb`, `mssql`) could not be a bundle at all, and kept a container whose
|
||||
// recipe installed it by hand (novox/hq ADR 0198 §4: "the backend's own driver inside the bundle").
|
||||
// Now the module's `package.json` says what it depends on, as any Node package does, and the build
|
||||
// installs exactly that into the module's own directory before compiling.
|
||||
//
|
||||
// **The SDK the toolchain carries is the one a bundle is built with, whatever the module says**
|
||||
// (novox/hq issue 212: the toolchain is rebuilt on every SDK release and every bundle after it). A
|
||||
// module's `package.json` names `@novox/mesh-sdk` with a range — it has to, to type-check on a
|
||||
// workstation — and installing that range would shadow the toolchain's copy for this module alone:
|
||||
// one module compiled against an older SDK than its neighbours, chosen by a caret nobody re-reads.
|
||||
// So the SDK is taken out of what is installed (and never fetched), and any copy something else
|
||||
// pulls in is removed afterwards; every import of it resolves past the module's node_modules to the
|
||||
// toolchain's. A module therefore cannot pin a different SDK, by design: the toolchain is the pin.
|
||||
//
|
||||
// **Correctness before speed.** Every build installs afresh into a fresh clone, from the lockfile
|
||||
// when the module has one (`npm ci`, exact) and from its ranges otherwise; nothing installed is kept
|
||||
// between builds. What is shared is npm's own download cache, a named volume, which is
|
||||
// content-addressed and verified by integrity on every read — it saves the network, never the
|
||||
// install. Install scripts do not run: the build node runs nobody's postinstall, and what a script
|
||||
// would build natively could not be inlined into one file anyway.
|
||||
|
||||
// sdkPackage is the package a TypeScript bundle's launcher serves through, and the one package a
|
||||
// module's own dependencies never supply (above).
|
||||
const sdkPackage = "@novox/mesh-sdk"
|
||||
|
||||
// npmCache is the named volume npm's download cache lives in across builds on one build node.
|
||||
const npmCache = "mesh-builder-npm-cache"
|
||||
|
||||
// ownDependencies is what a module's package.json depends on beyond the SDK, sorted; nothing when
|
||||
// the module has no package.json or depends on nothing else — which builds exactly as before.
|
||||
func ownDependencies(tree string) ([]string, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(tree, "package.json"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p struct {
|
||||
Dependencies map[string]string `json:"dependencies"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return nil, fmt.Errorf("the module's package.json is not JSON: %w", err)
|
||||
}
|
||||
var names []string
|
||||
for name := range p.Dependencies {
|
||||
if name != sdkPackage {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// installSteps is the script run inside the toolchain image, from the module's own directory ($0).
|
||||
// It works in a scratch copy so the module's package.json and lockfile are never rewritten, takes
|
||||
// the SDK out of what is installed, installs production dependencies only, removes any copy of the
|
||||
// SDK something pulled in, and puts the result at the module's node_modules.
|
||||
const installSteps = `set -e
|
||||
work="$(mktemp -d)"
|
||||
cp "$0/package.json" "$work/"
|
||||
if [ -f "$0/package-lock.json" ]; then cp "$0/package-lock.json" "$work/"; fi
|
||||
cd "$work"
|
||||
node -e '
|
||||
const fs = require("fs"), sdk = process.argv[1];
|
||||
const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
|
||||
for (const k of ["dependencies", "peerDependencies", "optionalDependencies"]) if (p[k]) delete p[k][sdk];
|
||||
delete p.devDependencies; delete p.scripts;
|
||||
fs.writeFileSync("package.json", JSON.stringify(p));
|
||||
' "$1"
|
||||
shift
|
||||
if [ -f package-lock.json ]; then
|
||||
npm ci --omit=dev --omit=peer --ignore-scripts --no-audit --no-fund "$@"
|
||||
else
|
||||
npm install --omit=dev --omit=peer --ignore-scripts --no-audit --no-fund --no-package-lock "$@"
|
||||
fi
|
||||
find node_modules -depth -type d -path "*/node_modules/@novox/mesh-sdk" -exec rm -rf {} +
|
||||
rm -rf "$0/node_modules"
|
||||
cp -a node_modules "$0/node_modules"
|
||||
`
|
||||
|
||||
// installOwn installs a TypeScript module's own production dependencies into its directory, in the
|
||||
// toolchain image, before the compile — or does nothing at all for a module that has none.
|
||||
func installOwn(ctx context.Context, run Runner, tree string, chain Toolchain, base string,
|
||||
registry Npmrc, say func(step, format string, args ...any)) error {
|
||||
if chain.Language != "typescript" {
|
||||
return nil
|
||||
}
|
||||
deps, err := ownDependencies(tree)
|
||||
if err != nil || len(deps) == 0 {
|
||||
return err
|
||||
}
|
||||
scoped := strings.TrimSpace(registry.Scope)
|
||||
if !registry.Enabled() {
|
||||
// **No registry, no scoped package.** Without the mesh's registry a scoped name resolves on
|
||||
// the public one, where anybody may have published it: a dependency that installs is not
|
||||
// the dependency the module meant.
|
||||
for _, d := range deps {
|
||||
if strings.HasPrefix(d, "@novox/") {
|
||||
return fmt.Errorf("the module depends on %s, and this build knows no package registry "+
|
||||
"for its scope; it would resolve from the public registry, which is not where the "+
|
||||
"mesh publishes it", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
const within = "/app/modules/module"
|
||||
invocation := []string{"run", "--rm",
|
||||
"--volume", tree + ":" + within,
|
||||
"--volume", npmCache + ":/root/.npm",
|
||||
"--workdir", within}
|
||||
var flags []string
|
||||
if registry.Enabled() {
|
||||
// The registry is reached where the binding says it is, which may be this machine's own
|
||||
// loopback — the reason an image build that resolves packages runs on the host network too.
|
||||
invocation = append(invocation, "--network", "host")
|
||||
reg := strings.TrimSpace(registry.Registry)
|
||||
if !strings.HasSuffix(reg, "/") {
|
||||
reg += "/"
|
||||
}
|
||||
flags = append(flags, "--"+scoped+":registry="+reg)
|
||||
}
|
||||
invocation = append(invocation, base, "sh", "-c", installSteps, within, sdkPackage)
|
||||
invocation = append(invocation, flags...)
|
||||
say("bundle", "installing the module's own packages: %s", strings.Join(deps, ", "))
|
||||
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
||||
return fmt.Errorf("installing the module's own packages (%s): %w", strings.Join(deps, ", "), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module's own packages (dependencies.go): installed into its own directory, in the toolchain,
|
||||
// before the compile, so the bundler inlines them — the SDK always the toolchain's.
|
||||
|
||||
func buildWithPackageJSON(t *testing.T, pkg string, extra map[string]string, registry Npmrc) (*recorded, error) {
|
||||
t.Helper()
|
||||
files := map[string]string{"index.ts": "console.log(1)"}
|
||||
if pkg != "" {
|
||||
files["package.json"] = pkg
|
||||
}
|
||||
for k, v := range extra {
|
||||
files[k] = v
|
||||
}
|
||||
r, workspace := aRepository(t, aBundle, files)
|
||||
held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)}
|
||||
_, err := Build(context.Background(), compiling{r}.run, r,
|
||||
"https://forge.invalid/greeter.git", "", "", workspace, held, registry, GitCredential{}, nil)
|
||||
return r, err
|
||||
}
|
||||
|
||||
func installs(r *recorded) []string {
|
||||
var out []string
|
||||
for _, line := range r.ran {
|
||||
if strings.HasPrefix(line, "docker run") && strings.Contains(line, "npm ci") {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func compileIndex(r *recorded) int {
|
||||
for i, line := range r.ran {
|
||||
if strings.Contains(line, "--outDir") {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func TestAModulesOwnPackagesAreInstalledInTheToolchainBeforeTheCompile(t *testing.T) {
|
||||
r, err := buildWithPackageJSON(t, `{"type":"module","dependencies":{"@novox/mesh-sdk":"^0.1.0","pg":"^8"},"devDependencies":{"typescript":"^5"}}`,
|
||||
nil, Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := installs(r)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("want one install of the module's own packages:\n%s", strings.Join(r.ran, "\n"))
|
||||
}
|
||||
line := got[0]
|
||||
for _, want := range []string{
|
||||
"mesh-tools/build@sha256:", // in the toolchain image
|
||||
":/app/modules/module", // into the module's own directory
|
||||
"--workdir /app/modules/module", //
|
||||
npmCache + ":/root/.npm", // npm's verified download cache, and only that
|
||||
"--omit=dev", "--ignore-scripts", // production packages, no build-node scripts
|
||||
"npm ci", "npm install", "--no-package-lock", // the lockfile when there is one, else the ranges
|
||||
"--@novox:registry=https://forge.invalid/api/packages/novox/npm/", // the scope from the mesh's registry
|
||||
"--network host",
|
||||
"@novox/mesh-sdk", // named, to be taken out of what is installed
|
||||
} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Errorf("the install lacks %q:\n%s", want, line)
|
||||
}
|
||||
}
|
||||
// The SDK is the toolchain's: never installed from the module's range, and any copy removed.
|
||||
if !strings.Contains(line, `delete p[k][sdk]`) || !strings.Contains(line, `-path "*/node_modules/@novox/mesh-sdk" -exec rm -rf`) {
|
||||
t.Errorf("the module's own SDK range could shadow the toolchain's SDK:\n%s", line)
|
||||
}
|
||||
if i, c := strings.Index(strings.Join(r.ran, "\n"), "npm ci"), compileIndex(r); c < 0 ||
|
||||
i > strings.Index(strings.Join(r.ran, "\n"), "--outDir") {
|
||||
t.Fatalf("the install did not run before the compile:\n%s", strings.Join(r.ran, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// **A module with nothing beyond the SDK builds exactly as before**: the same commands, no install.
|
||||
func TestAModuleDependingOnlyOnTheSDKBuildsExactlyAsBefore(t *testing.T) {
|
||||
without, err := buildWithPackageJSON(t, "", nil, Npmrc{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, pkg := range []string{
|
||||
`{"type":"module","dependencies":{"@novox/mesh-sdk":"^0.1.0"},"devDependencies":{"typescript":"^5"}}`,
|
||||
`{"type":"module"}`,
|
||||
} {
|
||||
with, err := buildWithPackageJSON(t, pkg, map[string]string{"package-lock.json": "{}"}, Npmrc{Scope: "@novox", Registry: "https://forge.invalid/npm/"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(strings.Join(with.ran, "\n"), "npm ") {
|
||||
t.Fatalf("a module depending on nothing but the SDK ran npm:\n%s", strings.Join(with.ran, "\n"))
|
||||
}
|
||||
if len(with.ran) != len(without.ran) {
|
||||
t.Fatalf("a module depending only on the SDK built differently from one with no package.json:\n%s\n---\n%s",
|
||||
strings.Join(with.ran, "\n"), strings.Join(without.ran, "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Without the mesh's registry a scoped package would resolve on the public one: refused by name.
|
||||
func TestAScopedPackageWithNoRegistryIsRefused(t *testing.T) {
|
||||
r, err := buildWithPackageJSON(t, `{"dependencies":{"@novox/mesh-sdk":"^0.1.0","@novox/other":"^1"}}`, nil, Npmrc{})
|
||||
if err == nil || !strings.Contains(err.Error(), "@novox/other") {
|
||||
t.Fatalf("a scoped package was installed with no registry for its scope: %v", err)
|
||||
}
|
||||
if strings.Contains(strings.Join(r.ran, "\n"), "--outDir") {
|
||||
t.Fatal("the compile ran after the refusal")
|
||||
}
|
||||
// A public package installs without one, from the public registry and nothing else.
|
||||
r, err = buildWithPackageJSON(t, `{"dependencies":{"mssql":"^11"}}`, nil, Npmrc{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := installs(r); len(got) != 1 || strings.Contains(got[0], ":registry=") || strings.Contains(got[0], "--network host") {
|
||||
t.Fatalf("a public package's install: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnreadablePackageJSONIsRefusedByName(t *testing.T) {
|
||||
_, err := buildWithPackageJSON(t, `{"dependencies":`, nil, Npmrc{})
|
||||
if err == nil || !strings.Contains(err.Error(), "package.json") {
|
||||
t.Fatalf("a broken package.json was not refused by name: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0193: every entrypoint the runtime may serve is executable, and the runtime knows no
|
||||
// language — so a TypeScript bundle carries a launcher beside each entrypoint.
|
||||
func TestATypeScriptBundleCarriesAnExecutableLauncherBesideEachEntrypoint(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
chain, err := ToolchainFor("typescript")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := writeLaunchers(root, chain, catalogue.Artifact{Name: "tools", Kind: catalogue.ArtifactBundle,
|
||||
Language: "typescript", Entrypoints: []string{"tools/index.js", "index.js"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["tools/index.js"] != "tools/index.serve.mjs" || got["index.js"] != "index.serve.mjs" {
|
||||
t.Fatalf("launchers: %v", got)
|
||||
}
|
||||
path := filepath.Join(root, "tools", "index.serve.mjs")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o755 {
|
||||
t.Errorf("the launcher is %v, not executable 0755", info.Mode().Perm())
|
||||
}
|
||||
body, _ := os.ReadFile(path)
|
||||
for _, want := range []string{"#!/usr/bin/env node\n", `from "@novox/mesh-sdk/stdio"`, `await import("./index.js")`, "serveRegisteredOverStdio()"} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Errorf("the launcher lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
|
||||
// A compiled language's build is executable already: no launcher.
|
||||
goChain, _ := ToolchainFor("go")
|
||||
none, err := writeLaunchers(t.TempDir(), goChain, catalogue.Artifact{Name: "tools", Kind: catalogue.ArtifactBundle, Language: "go"})
|
||||
if err != nil || len(none) != 0 {
|
||||
t.Errorf("a Go bundle was given launchers: %v %v", none, err)
|
||||
}
|
||||
}
|
||||
@@ -200,3 +200,23 @@ func TestWhatABuildReadIsTheRepositoriesItsRecipesName(t *testing.T) {
|
||||
t.Fatal("a module whose recipes name no other repository read one")
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/212: a toolchain stands on the SDK's published package, and is built with the
|
||||
// exact version the mesh published — an argument that changes when the SDK does, so a rebuild after
|
||||
// a release never reuses an install of the version before it.
|
||||
func TestAPackageTheMeshPublishedIsPassedByItsExactVersion(t *testing.T) {
|
||||
manifest := catalogue.Manifest{
|
||||
Module: "mesh-tools",
|
||||
Build: &catalogue.Build{
|
||||
On: []catalogue.BuildsOn{{Arg: "MESH_SDK", Module: "mesh-sdk", Artifact: "lib"}},
|
||||
},
|
||||
}
|
||||
held := map[string]string{"mesh-sdk/lib": "@novox/mesh-sdk@0.1.6"}
|
||||
args, resolved, err := standingOn(context.Background(), manifest, held, noMirror)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(args) != "[--build-arg MESH_SDK=@novox/mesh-sdk@0.1.6]" || fmt.Sprint(resolved) != "[@novox/mesh-sdk@0.1.6]" {
|
||||
t.Errorf("the package was passed as %v, recorded as %v", args, resolved)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,13 +67,23 @@ type Toolchain struct {
|
||||
// `package.json` saying `"type": "module"` — Node reads a bare `.js` as CommonJS otherwise, so a
|
||||
// bundle with its dependencies and without that line still fails to start — and the pruned,
|
||||
// production-only node_modules the runtime itself ships with: the SDK's and the runtime's
|
||||
// dependencies, and nothing module-specific yet (novox/hq ADR 0188 §5: a skeleton; a module's
|
||||
// own npm dependencies are a later step). Empty for a language whose bundle carries its own —
|
||||
// dependencies, and nothing module-specific (a module's own npm dependencies are installed into
|
||||
// its own directory before the compile and inlined by the bundler: dependencies.go). Empty for a
|
||||
// language whose bundle carries its own —
|
||||
// a Go binary is static, a Python bundle is installed with its dependencies.
|
||||
//
|
||||
// A toolchain image without the directory fails the build by name rather than packing a bundle
|
||||
// that starts nowhere: the image predates this and must be rebuilt first.
|
||||
//
|
||||
// *Since the bundler (below):* copied only for a bundle that names packages it keeps external,
|
||||
// which cannot be inlined; a bundle with none carries no node_modules at all.
|
||||
Dependencies string
|
||||
// Bundler is the bundler inside the toolchain image that makes each compiled entrypoint and each
|
||||
// launcher ONE self-contained file (novox/hq ADR 0193): every served bundle is its own process
|
||||
// now, so each carries its own copy of what it imports — the SDK included — and nothing else.
|
||||
// A bundle shrinks from the toolchain's whole node_modules to the code it runs. Empty for a
|
||||
// language whose build is already one file.
|
||||
Bundler string
|
||||
// SystemStamp is the variable this language's linker fills with the artifact's declared system,
|
||||
// for a language whose binaries are pinned to one at link time (novox/hq ADR 0005).
|
||||
//
|
||||
@@ -139,6 +149,7 @@ var toolchains = []Toolchain{
|
||||
Unit: UnitSources,
|
||||
SourceExt: ".ts",
|
||||
Dependencies: "/app/runtime",
|
||||
Bundler: "/app/node_modules/esbuild/bin/esbuild",
|
||||
},
|
||||
{
|
||||
Language: "go",
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"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 := ParseManifest([]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 composeTheService(t *testing.T, with Rendering) []map[string]any {
|
||||
t.Helper()
|
||||
with.Needed = map[string]map[string]string{"svc": {"store": "sealed-store"}}
|
||||
with.ArtifactStore = "anchor.internal:5100"
|
||||
out, err := Resolution{Node: "anchor", Modules: []Manifest{aServiceModule(t)}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatalf("the service does not compose: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// The account is created before anything is given to it. Its secrets are mesh-computed and so
|
||||
// placed before the module's own resources; given to a user the machine did not have yet, they were
|
||||
// refused on the first apply and the process started without them.
|
||||
func TestAModulesAccountComesBeforeWhatBelongsToIt(t *testing.T) {
|
||||
out := composeTheService(t, Rendering{})
|
||||
account, secret := indexOf(out, "svc.account"), indexOf(out, "svc."+NeedID("store"))
|
||||
if account < 0 || secret < 0 {
|
||||
t.Fatalf("the account or the secret is missing: %v", out)
|
||||
}
|
||||
if account > secret {
|
||||
t.Fatalf("the secret owned by svc is written before svc exists: account at %d, secret at %d",
|
||||
account, secret)
|
||||
}
|
||||
if owner := out[secret]["owner"]; owner != "svc" {
|
||||
t.Errorf("the secret belongs to %v, not the account its process runs as", owner)
|
||||
}
|
||||
}
|
||||
|
||||
// The process is the module's program; its preparation is the same program asked to prepare, as a
|
||||
// step before it — with the same account and environment, and handing nothing over.
|
||||
func TestAProcessIsPreparedByItsOwnProgram(t *testing.T) {
|
||||
out := composeTheService(t, Rendering{})
|
||||
step, process := indexOf(out, "svc.service-prepare"), indexOf(out, "svc.service")
|
||||
if step < 0 || process < 0 || step > process {
|
||||
t.Fatalf("the preparation is not a step before the process (%d, %d): %v", step, process, out)
|
||||
}
|
||||
s := out[step]
|
||||
if s["type"] != "process" || s["run-once"] != true || s["name"] != "svc-prepare" {
|
||||
t.Errorf("the preparation is not a run-once process: %v", s)
|
||||
}
|
||||
if run, _ := json.Marshal(s["run"]); string(run) != `["./svc","prepare"]` {
|
||||
t.Errorf("the preparation runs %s", run)
|
||||
}
|
||||
if s["user"] != "svc" || s["source"] != out[process]["source"] {
|
||||
t.Errorf("the preparation does not run the same bundle as the same account: %v", s)
|
||||
}
|
||||
if env, _ := s["env"].(map[string]any); env["SVC_STORE_FILE"] == nil {
|
||||
t.Errorf("the preparation is not given the process's environment: %v", s["env"])
|
||||
}
|
||||
if _, has := s["replaces"]; has {
|
||||
t.Errorf("the preparation would hand over what the process replaces: %v", s)
|
||||
}
|
||||
if _, has := s["args"]; has {
|
||||
t.Errorf("the preparation carries a container's args: %v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// What the process replaces is named as the host recorded it, `<module>.<id>`; unprefixed, the host
|
||||
// matches nothing and removes the container first, as before.
|
||||
func TestWhatAProcessReplacesIsNamedAsTheHostRecordedIt(t *testing.T) {
|
||||
out := composeTheService(t, Rendering{})
|
||||
p := out[indexOf(out, "svc.service")]
|
||||
if got, _ := json.Marshal(p["replaces"]); string(got) != `["svc.server"]` {
|
||||
t.Fatalf("the process replaces %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatReplacesMayNameIsRefusedNearItsAuthor(t *testing.T) {
|
||||
for what, resource := range map[string]string{
|
||||
"a container": `{"id":"c","type":"container","name":"c","image":"x@` + aServiceDigest + `","replaces":["old"]}`,
|
||||
"a step": `{"id":"p","type":"process","name":"p","run":["./p"],"run-once":true,"replaces":["old"]}`,
|
||||
"something declared": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["p"]}`,
|
||||
"another module's": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["other.old"]}`,
|
||||
"not a list": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":"old"}`,
|
||||
} {
|
||||
raw := `{"module":"m","version":"1","resources":[` + resource + `]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "replace") {
|
||||
t.Errorf("replaces on %s was accepted: %v", what, err)
|
||||
}
|
||||
}
|
||||
ok := `{"module":"m","version":"1","resources":[{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["old"]}]}`
|
||||
if _, err := ParseManifest([]byte(ok)); err != nil {
|
||||
t.Errorf("a process replacing what its module no longer declares was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func boundUsed(content string) [][2]string {
|
||||
// Three facts the mesh states about any provision, plus whatever the provider said it serves. A
|
||||
// module may not reach a binding it does not have — the same boundary as a secret, for the same
|
||||
// reason.
|
||||
func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]string {
|
||||
func knownFor(m Manifest, needs []Needed, node string) (map[string]map[string]string, error) {
|
||||
out := map[string]map[string]string{}
|
||||
for _, want := range m.Wants() {
|
||||
for i := range needs {
|
||||
@@ -54,12 +54,20 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str
|
||||
if n.Name != want || n.For != m.Module {
|
||||
continue
|
||||
}
|
||||
as := ConsumerIdentity(node, IdentitySource(m.Slug, m.Module))
|
||||
values := map[string]string{
|
||||
"at": n.At,
|
||||
"from": n.From,
|
||||
"as": ConsumerIdentity(node, IdentitySource(m.Slug, m.Module)),
|
||||
"as": as,
|
||||
}
|
||||
for key, value := range n.Serves {
|
||||
// What the provider derives for this consumer rather than for all of them
|
||||
// (novox/hq ADR 0201). Filled here, the one place a provision and the module
|
||||
// requiring it are both in hand.
|
||||
served, err := ServedTo(n.Serves, as)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s requires %s: %w", m.Module, want, err)
|
||||
}
|
||||
for key, value := range served {
|
||||
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
|
||||
// which is what a float would write and what a connection string would refuse.
|
||||
values[key] = plainly(value)
|
||||
@@ -67,7 +75,7 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str
|
||||
out[want] = values
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// withOwnNames adds a module's own composed names to what it may name from one binding:
|
||||
|
||||
+137
-2
@@ -2,6 +2,7 @@ package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
@@ -28,6 +29,9 @@ type Built struct {
|
||||
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
|
||||
}
|
||||
|
||||
// Resolve fills a manifest's resources in from what was built.
|
||||
@@ -73,6 +77,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
// build compare equal.
|
||||
out.Bundles = nil
|
||||
if m.Build != nil {
|
||||
run := runByAResource(m)
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a.Kind != ArtifactBundle {
|
||||
continue
|
||||
@@ -80,9 +85,20 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
made := by[a.Name]
|
||||
// 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 {
|
||||
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}
|
||||
}
|
||||
}
|
||||
// **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 —
|
||||
@@ -92,7 +108,8 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
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,
|
||||
Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers),
|
||||
Binary: BinaryOf(a),
|
||||
})
|
||||
}
|
||||
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
|
||||
@@ -203,6 +220,17 @@ func (b *Build) problems(module string) []string {
|
||||
// A bundle's source is the module's own directory by definition, and what it needs to say
|
||||
// is which compiler — because the mesh chooses that, and cannot choose for a module that
|
||||
// has not said.
|
||||
if len(a.External) > 0 && (a.Kind != ArtifactBundle || a.Language != "typescript") {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q names packages it keeps external, and only a TypeScript bundle is bundled into "+
|
||||
"one file with some kept out (novox/hq ADR 0193)", module, a.Name))
|
||||
}
|
||||
if len(a.Env) > 0 && a.Kind != ArtifactBundle {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q is a %q and says what it is given (env). Only a bundle the node's runtime "+
|
||||
"serves is given words (novox/hq ADR 0192); a container says its own environment",
|
||||
module, a.Name, a.Kind))
|
||||
}
|
||||
if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
|
||||
// **Except for a language that compiles to a binary, where it names which one**
|
||||
// (novox/hq 04-ISSUES/142). A bundle in an interpreted language is the module's own
|
||||
@@ -222,6 +250,7 @@ func (b *Build) problems(module string) []string {
|
||||
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
|
||||
"for it", module, a.Name))
|
||||
}
|
||||
problems = append(problems, bundleEnvProblems(module, a)...)
|
||||
// What the runtime loads is among what was compiled (ADR 0175): a name here that is
|
||||
// not an entrypoint is a file the bundle does not contain, and the runtime would
|
||||
// fail to import it on every machine rather than here.
|
||||
@@ -230,6 +259,11 @@ func (b *Build) problems(module string) []string {
|
||||
for _, e := range a.Entrypoints {
|
||||
found = found || e == load
|
||||
}
|
||||
// A bundle compiled to a binary is one executable: the runtime loads that or nothing
|
||||
// (novox/hq ADR 0193).
|
||||
if bin := BinaryOf(a); bin != "" {
|
||||
found = load == bin
|
||||
}
|
||||
if !found {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q says the runtime loads %q, which is not among its entrypoints — "+
|
||||
@@ -360,3 +394,104 @@ func versionOf(digest string) string {
|
||||
}
|
||||
return hex
|
||||
}
|
||||
|
||||
// bundleEnvWords are the words the runtime sets for itself; a bundle that named one would be
|
||||
// telling the runtime what it is, which is the mesh's to say (novox/hq ADR 0192).
|
||||
var bundleEnvWords = map[string]bool{
|
||||
RuntimeToolModules: true, RuntimeBrokerFile: true, RuntimeOperatorAccount: true,
|
||||
RuntimeOperatorHome: true, RuntimeToolEnv: true,
|
||||
}
|
||||
|
||||
// bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192):
|
||||
// a value is a path or a constant written with the references a container's environment may use
|
||||
// for a place or a port, and never a secret's content or another module's binding — a secret
|
||||
// reaches a tool as a file whose path is named.
|
||||
func bundleEnvProblems(module string, a Artifact) []string {
|
||||
if len(a.Env) == 0 {
|
||||
return nil
|
||||
}
|
||||
var problems []string
|
||||
for _, word := range sortedKeys(a.Env) {
|
||||
value := a.Env[word]
|
||||
if bundleEnvWords[word] {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q gives itself %s, which the node's runtime sets for itself; a bundle is "+
|
||||
"given its own words beside the runtime's, never in place of them (novox/hq ADR 0192)",
|
||||
module, a.Name, word))
|
||||
}
|
||||
rest := ofPort.ReplaceAllString(dirRef.ReplaceAllString(value, ""), "")
|
||||
if strings.Contains(rest, "${") {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q gives %s the value %q. A bundle's word is a path or a constant, written with "+
|
||||
"${dir:…} and ${port:…} only; a secret reaches a tool as a file the mesh places, "+
|
||||
"named by its path, never as its content (novox/hq ADR 0192)",
|
||||
module, a.Name, word, value))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// undeliveredBundles says which of a module's bundles nothing would ever put on a machine (novox/hq
|
||||
// 04-ISSUES/216). A bundle reaches a machine three ways: the node's runtime serves it (it says
|
||||
// `loads`, or its module declares `tools`), a resource names it (a process, a step, an archive), or
|
||||
// it is the runtime itself. One reached by none of them was built, recorded and pushed as success,
|
||||
// and was simply absent — seven modules' tools went missing that way on 2026-10-03. Refused here,
|
||||
// naming the field that would deliver it.
|
||||
func undeliveredBundles(m Manifest) []string {
|
||||
if m.Build == nil || m.Module == RuntimeModule {
|
||||
return nil
|
||||
}
|
||||
named := runByAResource(m)
|
||||
var problems []string
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a.Kind != ArtifactBundle || named[a.Name] || len(a.Loads) > 0 || len(m.Tools) > 0 {
|
||||
continue
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: the bundle %q would be built and never reach a machine: nothing loads it, runs it or "+
|
||||
"unpacks it. A tools bundle says `loads` (the entrypoints the node's runtime serves) or its "+
|
||||
"module lists its `tools`; a daemon or a step is a resource naming it (novox/hq 04-ISSUES/216)",
|
||||
m.Module, a.Name))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What a provider derives for one consumer, said once in the provider's definition and delivered
|
||||
// to both ends (novox/hq ADR 0201, issue 124).
|
||||
//
|
||||
// A `serves` block is otherwise literal: the same values for every consumer. Where the provider
|
||||
// *names the resource* — a bucket, a database, a vhost — the name is derived from who is asking,
|
||||
// and before this the mesh had no channel for it. The provider recomputed it in its own code and
|
||||
// every consumer transcribed it into its own definition by hand, which is a copy of somebody
|
||||
// else's rule kept in agreement by nobody. One of three transcriptions was wrong for months.
|
||||
//
|
||||
// **The mesh learns no protocol here; it spells its own name in an alphabet it already knows.**
|
||||
// The only fact a served value may name is the identity the mesh itself minted for the consumer,
|
||||
// in one of two alphabets: as it was minted, and as a DNS label. Everything a provider wants
|
||||
// around it — a prefix, a suffix, a separator — it writes around the placeholder, because a
|
||||
// served value is a string.
|
||||
|
||||
// consumerFact is `${consumer:<fact>}` or `${consumer:<fact>:<alphabet>}`.
|
||||
var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`)
|
||||
|
||||
// consumerFacts are what a served value may name about the consumer it is being derived for.
|
||||
// One entry, deliberately: the identity is the one thing about a consumer the mesh itself chose,
|
||||
// so it is the one thing the mesh can hand to a provider without either end guessing.
|
||||
var consumerFacts = []string{"as"}
|
||||
|
||||
// consumerAlphabets are the ways the mesh will write that identity. `dns` is the mesh's own
|
||||
// identifier with its separator written `-` instead of `_` — the whole of the difference between
|
||||
// the alphabet the mesh mints in and the one buckets, vhosts and hostnames accept.
|
||||
var consumerAlphabets = []string{"dns"}
|
||||
|
||||
// ServedTo fills a provider's served values for one consumer.
|
||||
//
|
||||
// `as` is the identity the mesh minted for that consumer — the same string it is told to present
|
||||
// as a login. Values with no placeholder are returned exactly as they were, and a block with no
|
||||
// placeholder at all is returned unchanged, so this costs nothing for the providers that derive
|
||||
// nothing.
|
||||
//
|
||||
// Only strings carry placeholders. A number, a boolean or a nested object is a value the provider
|
||||
// stated outright, and is left alone.
|
||||
func ServedTo(serves map[string]any, as string) (map[string]any, error) {
|
||||
if len(serves) == 0 {
|
||||
return serves, nil
|
||||
}
|
||||
var out map[string]any
|
||||
for _, key := range sortedAnyKeys(serves) {
|
||||
text, ok := serves[key].(string)
|
||||
if !ok || !strings.Contains(text, "${consumer:") {
|
||||
continue
|
||||
}
|
||||
filled, err := consumerInto(text, as)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the value served as %q: %w", key, err)
|
||||
}
|
||||
if out == nil {
|
||||
// Copied only once something actually changes: the caller's map is the manifest's,
|
||||
// and a provider that derives nothing must not have it rewritten underneath it.
|
||||
out = make(map[string]any, len(serves))
|
||||
for k, v := range serves {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
out[key] = filled
|
||||
}
|
||||
if out == nil {
|
||||
return serves, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// consumerInto replaces every `${consumer:…}` in one value.
|
||||
//
|
||||
// **A fact or an alphabet the mesh does not have is refused, not left standing.** Written through,
|
||||
// the literal `${consumer:as}` would reach a configuration file and be read as a bucket name,
|
||||
// failing somewhere that names neither the module nor the mesh — the same reasoning `${bound:…}`
|
||||
// is refused by (boundInto).
|
||||
func consumerInto(value, as string) (string, error) {
|
||||
var failed error
|
||||
out := consumerFact.ReplaceAllStringFunc(value, func(match string) string {
|
||||
parts := consumerFact.FindStringSubmatch(match)
|
||||
fact, alphabet := parts[1], parts[2]
|
||||
if fact != "as" {
|
||||
if failed == nil {
|
||||
failed = fmt.Errorf(
|
||||
"says %s, and the mesh states %s about a consumer", match, orNothing(consumerFacts))
|
||||
}
|
||||
return match
|
||||
}
|
||||
switch alphabet {
|
||||
case "":
|
||||
return as
|
||||
case "dns":
|
||||
return asDNSLabel(as)
|
||||
default:
|
||||
if failed == nil {
|
||||
failed = fmt.Errorf(
|
||||
"says %s, and the mesh writes an identity as %s", match, orNothing(consumerAlphabets))
|
||||
}
|
||||
return match
|
||||
}
|
||||
})
|
||||
if failed != nil {
|
||||
return "", failed
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// asDNSLabel writes a minted identity as a DNS label.
|
||||
//
|
||||
// The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and
|
||||
// already short enough for the tightest backend they reach (CheckIdentity, twenty characters). So
|
||||
// this is the separator and nothing else — no lower-casing of what is already lower case, no
|
||||
// truncation to a limit the identity is already inside, no padding of a name that is already long
|
||||
// enough. Each of those would be the mesh guessing at a rule it has not been given.
|
||||
func asDNSLabel(as string) string {
|
||||
return strings.ReplaceAll(as, "_", "-")
|
||||
}
|
||||
|
||||
// CheckServes refuses a `serves` block that names a consumer fact or an alphabet the mesh does not
|
||||
// have, when the definition is parsed rather than when a consumer is resolved.
|
||||
//
|
||||
// A provision nobody consumes yet still has its rule read: a definition that would be refused the
|
||||
// first time somebody required it is a definition that is wrong now.
|
||||
func CheckServes(m Manifest) []string {
|
||||
var problems []string
|
||||
for _, provision := range sortedServes(m.Serves) {
|
||||
for _, key := range sortedAnyKeys(m.Serves[provision]) {
|
||||
text, ok := m.Serves[provision][key].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// A probe identity, because what is checked is the shape of the statement and not
|
||||
// what any consumer is called.
|
||||
if _, err := consumerInto(text, "mesh_node_module"); err != nil {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func sortedServes(serves map[string]map[string]any) []string {
|
||||
out := make([]string, 0, len(serves))
|
||||
for k := range serves {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// derivedFor is what the provider on this machine derives for one consumer of one provision
|
||||
// (novox/hq ADR 0201).
|
||||
//
|
||||
// Settled first, then derived: an operator may set a prefix on what the provider serves and the
|
||||
// mesh still fills the consumer's half of it ([ADR 0174]). Only the keys that actually name the
|
||||
// consumer are returned — the rest of a `serves` block is the same for every consumer and is
|
||||
// already in the provider's own definition, so repeating it here would be a second copy to go
|
||||
// stale.
|
||||
//
|
||||
// The first module in the resolved order that says it serves the provision answers, which is the
|
||||
// choice servedOnThisMachine makes for the consumer's half. Nothing serving it on this machine is
|
||||
// not an error: a contribution can reach a machine whose provider is a record or an adapter, and
|
||||
// then there is nothing derived to tell.
|
||||
func (r Resolution) derivedFor(provision, as, consumer, local string, settings SettingsBy) (map[string]any, error) {
|
||||
for _, m := range r.Modules {
|
||||
serves, said := m.Serves[provision]
|
||||
if !said {
|
||||
continue
|
||||
}
|
||||
var names map[string]any
|
||||
for key, value := range serves {
|
||||
if text, ok := value.(string); ok && strings.Contains(text, "${consumer:") {
|
||||
if names == nil {
|
||||
names = map[string]any{}
|
||||
}
|
||||
names[key] = value
|
||||
}
|
||||
}
|
||||
if names == nil {
|
||||
return nil, nil
|
||||
}
|
||||
// **A consumer that keeps several holders of this provision is refused** — this is issue
|
||||
// 124's own failure one case to the side, and it would be just as quiet.
|
||||
//
|
||||
// Each holder gets its own login, `…_<local>` (ADR 0094), and a provider derives from the
|
||||
// login, so it would make one resource per holder. The consumer's side has no such
|
||||
// dimension: one binding file per provision, one `${bound:<provision>:<key>}`, both
|
||||
// derived from the un-suffixed identity. So the provider would create the holder's
|
||||
// resource and the consumer would be configured against a name nothing made — it would
|
||||
// authenticate successfully and be refused on every object, which reads like a credential
|
||||
// fault and is not one.
|
||||
//
|
||||
// Lifting this means giving the consumer's side a local dimension. That is a decision,
|
||||
// not an omission, and until it is taken the mesh says so rather than guessing.
|
||||
if local != "" {
|
||||
return nil, fmt.Errorf(
|
||||
"%s keeps several holders of %s (this one is %q), and %s derives %s for each "+
|
||||
"consumer from the login the mesh minted. Each holder has its own login, and a "+
|
||||
"consumer is told one value per requirement — so the two ends would name "+
|
||||
"different things and nothing would compare them (novox/hq ADR 0201)",
|
||||
consumer, local, provision, m.Module, orNothing(sortedAnyKeys(names)))
|
||||
}
|
||||
settled, err := Settle(names, settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s serving %s: %w", m.Module, provision, err)
|
||||
}
|
||||
derived, err := ServedTo(settled, as)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s serving %s to %s: %w", m.Module, provision, as, err)
|
||||
}
|
||||
return derived, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// notTranscribed refuses a consumer's file that writes out the value its provider derives for it,
|
||||
// instead of asking for it (novox/hq ADR 0201, issue 124).
|
||||
//
|
||||
// **What would have caught the one wrong instance.** The object store's three consumers each wrote
|
||||
// their bucket into their own configuration by hand. One of them named a predecessor's bucket, and
|
||||
// nothing compared it to what the provider would actually create: the module would have
|
||||
// authenticated successfully and been refused on every object, which reads like a credential fault
|
||||
// and is not one. It looked authoritative for months.
|
||||
//
|
||||
// The test is exact and costs one string search: a definition whose file already contains the
|
||||
// value the mesh is about to derive for it has written down somebody else's rule. It cannot be a
|
||||
// coincidence — a derived value carries the identity the mesh minted for this very consumer on
|
||||
// this very machine, which nothing else would spell out — and it cannot be checked afterwards,
|
||||
// because after substitution every consumer's file contains it legitimately.
|
||||
//
|
||||
// Only values that actually name the consumer are judged. A provider that serves a constant under
|
||||
// the same key serves the same constant to everyone, and a consumer repeating it is redundant
|
||||
// rather than wrong.
|
||||
func notTranscribed(resource map[string]any, known map[string]map[string]string, module string) error {
|
||||
if fmt.Sprint(resource["type"]) != "file" {
|
||||
return nil
|
||||
}
|
||||
content, ok := resource["content"].(string)
|
||||
if !ok || content == "" {
|
||||
return nil
|
||||
}
|
||||
for _, provision := range sortedKnown(known) {
|
||||
values := known[provision]
|
||||
identity := values["as"]
|
||||
if identity == "" {
|
||||
continue
|
||||
}
|
||||
for _, key := range sortedStringKeys(values) {
|
||||
if key == "as" {
|
||||
// The login is not derived from itself, and a consumer that must present it in a
|
||||
// connection string legitimately has it from `${bound:…}` — which is what it will
|
||||
// be after substitution, so this would judge the substitution, not the module.
|
||||
continue
|
||||
}
|
||||
value := values[key]
|
||||
if value == "" || !namesTheConsumer(value, identity) {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(content, value) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"%s writes %q into %v, and that is exactly what %s derives for it — a definition "+
|
||||
"keeping its own copy of somebody else's naming rule is one that can disagree "+
|
||||
"with it, silently. Say ${bound:%s:%s} and be told",
|
||||
module, value, resource["id"], provision, provision, key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// namesTheConsumer is whether a derived value was built from this consumer's identity — in the
|
||||
// alphabet it was minted in, or as a DNS label. A value that does not contain it was not derived
|
||||
// from it, whatever else it may be.
|
||||
func namesTheConsumer(value, identity string) bool {
|
||||
return strings.Contains(value, identity) || strings.Contains(value, asDNSLabel(identity))
|
||||
}
|
||||
|
||||
func sortedKnown(known map[string]map[string]string) []string {
|
||||
out := make([]string, 0, len(known))
|
||||
for k := range known {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedStringKeys(values map[string]string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for k := range values {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq issue 213: the controller is a Go program and was the one piece of the mesh's own Go
|
||||
// code still shipped as an image (ADR 0188 §1). Its own manifest, composed for the machine that runs
|
||||
// it, is a Go bundle run by the host as a process — and no container.
|
||||
func TestTheControllerIsAProcessAndNoContainer(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("the controller's own manifest does not parse:\n%v", err)
|
||||
}
|
||||
if m.Build == nil || len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("the controller builds %+v; it is one bundle", m.Build)
|
||||
}
|
||||
a := m.Build.Artifacts[0]
|
||||
if a.Kind != ArtifactBundle || a.Language != "go" || a.System == "" || BinaryOf(a) != "mesh-controller" {
|
||||
t.Fatalf("the controller's artifact is %+v, not a Go bundle naming its system and binary", a)
|
||||
}
|
||||
for _, c := range m.Capabilities {
|
||||
if c == "container-runtime" {
|
||||
t.Error("the controller still requires a container runtime on its machine")
|
||||
}
|
||||
}
|
||||
|
||||
digest := "sha256:" + strings.Repeat("c", 64)
|
||||
control, err := m.Resolve([]Built{{Name: a.Name, Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "mesh-controller/" + a.Name + "@" + digest, Digest: digest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The node's runtime does not launch it: it serves its seat's verbs itself.
|
||||
if loads := control.Bundles[0].Loads; len(loads) != 0 {
|
||||
t.Errorf("the node's runtime would launch the controller as a tools bundle: %v", loads)
|
||||
}
|
||||
|
||||
needed := map[string]map[string]string{"mesh-controller": {}}
|
||||
for name := range m.OwnSecrets {
|
||||
needed["mesh-controller"][name] = "sealed-" + name
|
||||
}
|
||||
out, err := Resolution{Node: "anchor", Modules: []Manifest{control}}.Declaration(Rendering{
|
||||
Needed: needed, ArtifactStore: "anchor.internal:5100",
|
||||
Seats: map[string]map[int]int{"mesh-store": {5432: 6852}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("the controller does not compose: %v", err)
|
||||
}
|
||||
|
||||
var process, step map[string]any
|
||||
account, firstSecret := -1, -1
|
||||
for i, r := range out {
|
||||
switch {
|
||||
case r["type"] == "container":
|
||||
t.Errorf("the controller's declaration still runs a container: %v", r)
|
||||
case r["id"] == "mesh-controller.controller":
|
||||
process = r
|
||||
case r["id"] == "mesh-controller.controller-prepare":
|
||||
step = r
|
||||
if process != nil {
|
||||
t.Error("the controller's preparation is placed after the process it prepares for")
|
||||
}
|
||||
case r["type"] == "user" && r["name"] == "mesh-controller":
|
||||
account = i
|
||||
case strings.HasPrefix(fmt.Sprint(r["id"]), "mesh-controller.needs-") && firstSecret < 0:
|
||||
firstSecret = i
|
||||
}
|
||||
}
|
||||
if process == nil {
|
||||
t.Fatalf("the controller's process is not in its declaration: %v", out)
|
||||
}
|
||||
if run, _ := json.Marshal(process["run"]); string(run) != `["./mesh-controller","serve"]` {
|
||||
t.Errorf("the controller is run as %s, not its own bundle's binary", run)
|
||||
}
|
||||
if process["source"] != "anchor.internal:5100/mesh-controller/"+a.Name+"@"+digest || process["digest"] != digest {
|
||||
t.Errorf("the controller's bundle is fetched from %v (%v)", process["source"], process["digest"])
|
||||
}
|
||||
// The user: an account the host declares, which owns what the process reads.
|
||||
if process["user"] != "mesh-controller" || account < 0 {
|
||||
t.Errorf("the controller runs as %v, and the account declared is at %d", process["user"], account)
|
||||
}
|
||||
if firstSecret >= 0 && account > firstSecret {
|
||||
t.Error("the controller's secrets are written before the account they belong to exists")
|
||||
}
|
||||
for _, r := range out {
|
||||
if strings.HasPrefix(fmt.Sprint(r["id"]), "mesh-controller.needs-") && r["owner"] != "mesh-controller" {
|
||||
t.Errorf("%v belongs to %v, which the controller's process cannot read", r["id"], r["owner"])
|
||||
}
|
||||
}
|
||||
if dir := fileNamed(out, "mesh-controller.mesh-state"); dir == nil || dir["owner"] != "mesh-controller" {
|
||||
t.Errorf("the controller's state directory is not its account's to enter: %v", dir)
|
||||
}
|
||||
// Each mount became a path the process reads: nothing it is told is a path inside a container.
|
||||
state := fmt.Sprint(fileNamed(out, "mesh-controller.mesh-state")["path"])
|
||||
env, _ := process["env"].(map[string]any)
|
||||
for key, value := range env {
|
||||
v := fmt.Sprint(value)
|
||||
if strings.HasPrefix(v, "/run/secrets") || strings.HasPrefix(v, "/broker-tls") {
|
||||
t.Errorf("%s=%s is a path inside the container the controller no longer runs in", key, v)
|
||||
}
|
||||
if strings.HasSuffix(key, "_FILE") && !strings.HasPrefix(v, state+"/") {
|
||||
t.Errorf("%s=%s is not one of the files the mesh places for it", key, v)
|
||||
}
|
||||
}
|
||||
if env["MESH_BROKER_CERTIFICATE"] != "/var/lib/mesh-broker-tls/tls.crt" {
|
||||
t.Errorf("the controller reads the broker's certificate from %v", env["MESH_BROKER_CERTIFICATE"])
|
||||
}
|
||||
if env["MESH_STORE_INVENTORY_PORT"] != "6852" {
|
||||
t.Errorf("the controller is told the store is on %v; the node put it on 6852", env["MESH_STORE_INVENTORY_PORT"])
|
||||
}
|
||||
// The handover: the container it ran as goes only once this is running.
|
||||
if got, _ := json.Marshal(process["replaces"]); string(got) != `["mesh-controller.server"]` {
|
||||
t.Errorf("the controller's process replaces %s, not the container it ran as", got)
|
||||
}
|
||||
// And its state is prepared first, by the same program as the same account.
|
||||
if step == nil || step["run-once"] != true || step["user"] != "mesh-controller" {
|
||||
t.Fatalf("the controller's preparation is %v", step)
|
||||
}
|
||||
if run, _ := json.Marshal(step["run"]); string(run) != `["./mesh-controller","prepare"]` {
|
||||
t.Errorf("the controller's preparation runs %s", run)
|
||||
}
|
||||
}
|
||||
@@ -645,7 +645,16 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)), own)
|
||||
as := ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module))
|
||||
// What the provider derives for THIS consumer, filled here where the consumer is
|
||||
// known (novox/hq ADR 0201). The same fill knownFor does below, so the binding file
|
||||
// and the module's `${bound:…}` substitutions cannot say different things.
|
||||
told := *found
|
||||
told.Serves, err = ServedTo(told.Serves, as)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s is told about %s: %w", m.Module, to, err)
|
||||
}
|
||||
file, err := boundFile(told, m.Binds[to], as, own)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -693,7 +702,15 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
|
||||
// Now, and not before: a module whose resources are computed replaces them wholesale, and
|
||||
// merging earlier would throw away the files it still needs.
|
||||
resources = append(append([]map[string]any{}, first...), resources...)
|
||||
//
|
||||
// **Except the module's own accounts, which go before even those** (novox/hq issue 213). What
|
||||
// the mesh computes may belong to one: a module whose code runs as an account it declares has
|
||||
// its secrets written owned by that account, and a file given to a user the machine does not
|
||||
// have yet fails — so on the first apply the secrets were refused, the process started without
|
||||
// them, and the second apply healed it, which is the fault the paragraph above describes.
|
||||
// An account depends on nothing the mesh computes.
|
||||
accounts, rest := accountsFirst(resources)
|
||||
resources = append(append(accounts, first...), rest...)
|
||||
|
||||
// No container is given the mesh's names (novox/hq ADR 0148). It used to be: every
|
||||
// container got the whole roster as `--add-host` entries at creation, and a name that
|
||||
@@ -711,7 +728,10 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
return nil, err
|
||||
}
|
||||
// And what its bindings say, for the half of a connection that is not secret.
|
||||
known := knownFor(m, r.Needs, r.Node)
|
||||
known, err := knownFor(m, r.Needs, r.Node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A requirement answered on this same machine is not in r.Needs — its binding file is
|
||||
// written from `here` (above) — and so `${bound:…}` could not name it, though the file
|
||||
// beside it said the same facts. Filled from the same answer, so the two cannot disagree.
|
||||
@@ -728,7 +748,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
local := *answered
|
||||
local.For = m.Module
|
||||
for provision, values := range knownFor(m, []Needed{local}, r.Node) {
|
||||
here, err := knownFor(m, []Needed{local}, r.Node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for provision, values := range here {
|
||||
known[provision] = values
|
||||
}
|
||||
}
|
||||
@@ -753,6 +777,17 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
// And the machine underneath, which no binding of its own can tell it.
|
||||
thisMachine := machineFacts(r, with.Names, with.MeshRange)
|
||||
|
||||
// **A definition that already holds the answer transcribed it** (novox/hq ADR 0201).
|
||||
// Judged over what the module itself declares, and before anything is substituted: the
|
||||
// mesh's own generated files — the binding, the contributions — legitimately carry the
|
||||
// derived value, and after substitution so does every consumer's file, so this is the one
|
||||
// moment the two can be told apart.
|
||||
for _, own := range m.Resources {
|
||||
if err := notTranscribed(own, known, m.Module); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Which of this module's files carry a secret, for the rule that a container may not read
|
||||
// one of them as its environment without saying so (ADR 0086, issue 041).
|
||||
secretFiles := secretFilesOf(resources)
|
||||
@@ -872,6 +907,21 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil {
|
||||
copied["reload-on"] = renamed
|
||||
}
|
||||
// And which of its module's containers a scheduled step holds still (novox/hq ADR 0189).
|
||||
// **The loudest of the three when it is missed.** An unprefixed `restart-on` matches
|
||||
// nothing and a service quietly never restarts; an unprefixed `while-stopped` names a
|
||||
// container the declaration does not contain, and the host refuses the whole
|
||||
// declaration — so the machine takes nothing at all, for every push, until this is
|
||||
// right. That is what it did on the control node (2026-10-04).
|
||||
if renamed := reflectsRenamed(m.Module, resource[WhileStopped]); renamed != nil {
|
||||
copied[WhileStopped] = renamed
|
||||
}
|
||||
// And what a process replaces (novox/hq issue 213): a resource of this module's that it
|
||||
// no longer declares, named as the host recorded it, or the host hands nothing over and
|
||||
// removes it first.
|
||||
if renamed := reflectsRenamed(m.Module, resource["replaces"]); renamed != nil {
|
||||
copied["replaces"] = renamed
|
||||
}
|
||||
// **What reads one of this module's own secrets is restarted when it changes** (novox/hq
|
||||
// issue 203, issue 206). A credential is re-issued by the mesh, and a container that
|
||||
// mounted the old file keeps the old one open: the build machine ran for an hour on a
|
||||
@@ -921,6 +971,30 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
owner[fmt.Sprint(process["id"])] = RuntimeModule
|
||||
out = append(out, process)
|
||||
// What each module's bundles are given is read as the account the runtime runs as.
|
||||
words := map[string]map[string]string{}
|
||||
for _, m := range r.Modules {
|
||||
w, err := bundleWords(m, with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
words[m.Module] = w
|
||||
}
|
||||
// And a file a module's words name is one the runtime is restarted for when it changes.
|
||||
if named := givenTo(out, owner, words, r.Account); len(named) > 0 {
|
||||
restarts, _ := process["restart-on"].([]any)
|
||||
seen := map[string]bool{}
|
||||
for _, id := range restarts {
|
||||
seen[fmt.Sprint(id)] = true
|
||||
}
|
||||
for _, id := range named {
|
||||
if !seen[id] {
|
||||
restarts = append(restarts, id)
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
process["restart-on"] = restarts
|
||||
}
|
||||
}
|
||||
if with.Adopted {
|
||||
// First, before anything a module declares: what the mesh needs reachable, then its guard.
|
||||
@@ -1137,6 +1211,19 @@ type Contribution struct {
|
||||
// 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 0201).
|
||||
//
|
||||
// **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"`
|
||||
}
|
||||
|
||||
// grantPath is where one consumer's sealed credential lands on the providing machine.
|
||||
@@ -1228,12 +1315,17 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
||||
// told about it and withdraws the login on its next pass.
|
||||
continue
|
||||
}
|
||||
as := holderAs(ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)), g.Local)
|
||||
derived, err := r.derivedFor(g.Provision, as, g.From, g.Local, settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[g.Provision] = append(out[g.Provision], Contribution{
|
||||
From: g.From, Node: g.Consumer, At: g.At, Values: g.Values,
|
||||
From: g.From, Node: g.Consumer, At: g.At, Values: g.Values, Derived: derived,
|
||||
// One holder per local name: the identity the consumer is known by, and the local name
|
||||
// after it where the module keeps several (ADR 0094). Not a login any backend checks —
|
||||
// a secret is not a login — so the identity limit does not apply to the suffix.
|
||||
As: holderAs(ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)), g.Local),
|
||||
As: as,
|
||||
Secret: grantPath(directories[g.Provision], g.Consumer, holderAs(g.From, g.Local)),
|
||||
})
|
||||
if granted[g.Provision] == nil {
|
||||
@@ -1986,12 +2078,18 @@ func preparationTarget(m Manifest) string {
|
||||
return ""
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "container" || !ownArtifact(r, m.Module) {
|
||||
// A container, or a process the host runs from a bundle the module built (novox/hq issue
|
||||
// 213): the same program in the same context, hosted as a unit rather than a container.
|
||||
kind := fmt.Sprint(r["type"])
|
||||
if (kind != "container" && kind != "process") || !ownArtifact(r, m.Module) {
|
||||
continue
|
||||
}
|
||||
if once, _ := r["run-once"].(bool); once {
|
||||
continue
|
||||
}
|
||||
if r["schedule"] != nil {
|
||||
continue
|
||||
}
|
||||
return fmt.Sprint(r["id"])
|
||||
}
|
||||
return ""
|
||||
@@ -2005,7 +2103,10 @@ func ownArtifact(resource map[string]any, module string) bool {
|
||||
return true
|
||||
}
|
||||
image, _ := resource["image"].(string)
|
||||
return strings.HasPrefix(image, ArtifactStoreScheme+module+"/")
|
||||
// A process or an archive carries what was built as its source (novox/hq issue 213).
|
||||
source, _ := resource["source"].(string)
|
||||
return strings.HasPrefix(image, ArtifactStoreScheme+module+"/") ||
|
||||
strings.HasPrefix(source, ArtifactStoreScheme+module+"/")
|
||||
}
|
||||
|
||||
// prepared is the module's own resource as the step that prepares its state: the same image, the same
|
||||
@@ -2027,7 +2128,19 @@ func prepared(from map[string]any) map[string]any {
|
||||
step["id"] = fmt.Sprint(from["id"]) + "-prepare"
|
||||
step["name"] = fmt.Sprint(from["name"]) + "-prepare"
|
||||
step["run-once"] = true
|
||||
if fmt.Sprint(from["type"]) == "process" {
|
||||
// A process says its whole command: the program, then its arguments. The step is the same
|
||||
// program asked to prepare (novox/hq issue 213). It replaces nothing — what the process
|
||||
// replaces is handed over to the process, never to the step that runs before it — and a
|
||||
// step is not restarted, it runs again when what it reads changed, which `restart-on` says.
|
||||
run := stringsIn(from["run"])
|
||||
if len(run) > 0 {
|
||||
step["run"] = []any{run[0], PreparationArgument}
|
||||
}
|
||||
delete(step, "replaces")
|
||||
} else {
|
||||
step["args"] = []any{PreparationArgument}
|
||||
}
|
||||
delete(step, "ports")
|
||||
delete(step, "ip")
|
||||
delete(step, "schedule")
|
||||
@@ -2172,3 +2285,16 @@ func withRestartOn(have any, add []string) []any {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// accountsFirst splits a module's resources into its accounts and everything else, each in the order
|
||||
// written.
|
||||
func accountsFirst(resources []map[string]any) (accounts, rest []map[string]any) {
|
||||
for _, r := range resources {
|
||||
if fmt.Sprint(r["type"]) == "user" {
|
||||
accounts = append(accounts, r)
|
||||
continue
|
||||
}
|
||||
rest = append(rest, r)
|
||||
}
|
||||
return accounts, rest
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// What a provider derives for each consumer, said once and delivered to both ends
|
||||
// (novox/hq ADR 0201, issue 124).
|
||||
//
|
||||
// The failure these are written against: the object store's provisioner derived each consumer's
|
||||
// bucket from the login the mesh minted, in its own code, and the mesh had no channel to tell the
|
||||
// consumer which bucket that was — so all three consumers wrote the answer into their own
|
||||
// definitions by hand. Two were right. One named a predecessor's bucket and would have
|
||||
// authenticated successfully and been refused on every object. Each of them also named the
|
||||
// machine the module happens to run on, which a definition may not do.
|
||||
|
||||
// store is an object store in the shape minio has: it serves a region and a port to everyone, and
|
||||
// a bucket named for whoever is asking.
|
||||
func store() Manifest {
|
||||
return Manifest{
|
||||
Module: "store", Version: "1",
|
||||
Provides: FromAnywhere("s3-bucket"),
|
||||
Listens: []Listening{{Port: 9000, Protocol: "tcp", From: FromMesh}},
|
||||
Serves: map[string]map[string]any{"s3-bucket": {
|
||||
"region": "eu-west",
|
||||
"bucket": "${consumer:as:dns}",
|
||||
}},
|
||||
Receives: map[string]string{"s3-bucket": "/var/lib/store/grants/mesh.json"},
|
||||
Grants: map[string]string{"s3-bucket": "/var/lib/store/grants"},
|
||||
Resources: []map[string]any{{
|
||||
"id": "server", "type": "container", "name": "store", "ports": []any{"9000"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// files is a consumer that writes the bucket into its own configuration — which is the thing it
|
||||
// could not do before, and had to transcribe.
|
||||
func files() Manifest {
|
||||
return Manifest{
|
||||
Module: "files", Version: "1", Slug: "files",
|
||||
Requires: []string{"s3-bucket"},
|
||||
Binds: map[string]string{"s3-bucket": "/var/lib/files/store.json"},
|
||||
Secrets: map[string]string{"s3-bucket": "/var/lib/files/store.secret"},
|
||||
Resources: []map[string]any{{
|
||||
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
||||
"content": "BUCKET=${bound:s3-bucket:bucket}\nREGION=${bound:s3-bucket:region}\n",
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// pics is a second consumer of the same provider on the same machine: two derivations, neither
|
||||
// the other's.
|
||||
func pics() Manifest {
|
||||
return Manifest{
|
||||
Module: "pics", Version: "1", Slug: "pics",
|
||||
Requires: []string{"s3-bucket"},
|
||||
Binds: map[string]string{"s3-bucket": "/var/lib/pics/store.json"},
|
||||
Secrets: map[string]string{"s3-bucket": "/var/lib/pics/store.secret"},
|
||||
Resources: []map[string]any{{
|
||||
"id": "env", "type": "file", "path": "/var/lib/pics/env", "mode": "0600",
|
||||
"content": "BUCKET=${bound:s3-bucket:bucket}\n",
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// The three places the derived value lands must agree, because agreeing is the whole point: the
|
||||
// consumer's own file, the binding it reads as JSON, and the provider's contributions entry.
|
||||
func TestADerivedValueReachesBothEndsAndAgrees(t *testing.T) {
|
||||
r, err := Resolve(shelf(store(), files()), []string{"store", "files"}, reachable(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Declaration(Rendering{Grants: []Grant{{
|
||||
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The mesh minted this identity for the consumer; the bucket is that identity as a DNS label.
|
||||
// Derived here with the mesh's own function, so the test cannot agree with a wrong rule.
|
||||
as := ConsumerIdentity("workstation", IdentitySource("files", "files"))
|
||||
want := strings.ReplaceAll(as, "_", "-")
|
||||
if want == as || !strings.Contains(as, "_") {
|
||||
t.Fatalf("the mesh's identity %q has no separator to rewrite; this test proves nothing", as)
|
||||
}
|
||||
|
||||
env := fileNamed(out, "files.env")
|
||||
if env == nil {
|
||||
t.Fatalf("the consumer was given no file: %v", out)
|
||||
}
|
||||
if got := env["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") {
|
||||
t.Errorf("the consumer's own file was not told the bucket:\n%s\nwant BUCKET=%s", got, want)
|
||||
}
|
||||
|
||||
binding := fileNamed(out, "files.bound-s3-bucket")
|
||||
if binding == nil {
|
||||
t.Fatalf("the consumer was given no binding: %v", out)
|
||||
}
|
||||
var said struct {
|
||||
Serves map[string]any `json:"serves"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if said.Serves["bucket"] != want {
|
||||
t.Errorf("the binding says the bucket is %q, want %q", said.Serves["bucket"], want)
|
||||
}
|
||||
// And what is the same for everybody is still the same for everybody.
|
||||
if said.Serves["region"] != "eu-west" {
|
||||
t.Errorf("the binding lost what the provider serves to all: %v", said.Serves)
|
||||
}
|
||||
|
||||
given := storeGrants(t, out)
|
||||
if len(given) != 1 {
|
||||
t.Fatalf("the provider was told about %d consumer(s): %v", len(given), given)
|
||||
}
|
||||
if given[0].Derived["bucket"] != want {
|
||||
t.Errorf("the provider was told the bucket is %v, and the consumer was told %q — "+
|
||||
"the two ends disagree, which is the whole failure", given[0].Derived["bucket"], want)
|
||||
}
|
||||
// Only the per-consumer half. The region is the same for everyone and is already in the
|
||||
// provider's own definition; repeating it here would be a copy to go stale.
|
||||
if _, carried := given[0].Derived["region"]; carried {
|
||||
t.Errorf("the provider was handed back what it already says for everyone: %v", given[0].Derived)
|
||||
}
|
||||
}
|
||||
|
||||
// Two consumers of one provider on one machine get two buckets, and neither gets the other's.
|
||||
func TestTwoConsumersOfOneProviderGetTheirOwnDerivation(t *testing.T) {
|
||||
r, err := Resolve(shelf(store(), files(), pics()),
|
||||
[]string{"store", "files", "pics"}, reachable(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Declaration(Rendering{Grants: []Grant{
|
||||
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||
Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
||||
{Provision: "s3-bucket", Consumer: "workstation", From: "pics", Slug: "pics",
|
||||
Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forFiles := strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("files", "files")), "_", "-")
|
||||
forPics := strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("pics", "pics")), "_", "-")
|
||||
if forFiles == forPics {
|
||||
t.Fatal("the two consumers were given the same identity; this test proves nothing")
|
||||
}
|
||||
if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+forFiles+"\n") {
|
||||
t.Errorf("files was not given its own bucket:\n%s", got)
|
||||
}
|
||||
if got := fileNamed(out, "pics.env")["content"].(string); !strings.Contains(got, "BUCKET="+forPics+"\n") {
|
||||
t.Errorf("pics was not given its own bucket:\n%s", got)
|
||||
}
|
||||
var buckets []any
|
||||
for _, g := range storeGrants(t, out) {
|
||||
buckets = append(buckets, g.Derived["bucket"])
|
||||
}
|
||||
if len(buckets) != 2 || buckets[0] == buckets[1] {
|
||||
t.Errorf("the provider was told %v; it must be told one bucket per consumer", buckets)
|
||||
}
|
||||
}
|
||||
|
||||
// An operator may still set what the provider serves, and the mesh still derives the rest: the
|
||||
// setting is laid on first, then the consumer's half is filled.
|
||||
func TestASettingComposesWithADerivedValue(t *testing.T) {
|
||||
r, err := Resolve(shelf(store(), files()), []string{"store", "files"}, reachable(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Declaration(Rendering{
|
||||
Settings: SettingsBy{"store": {{From: "the operator",
|
||||
Values: map[string]any{"bucket": "team-${consumer:as:dns}"}}}},
|
||||
Grants: []Grant{{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||
Values: map[string]any{}, Sealed: "c2VhbGVk"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "team-" + strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("files", "files")), "_", "-")
|
||||
if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") {
|
||||
t.Errorf("the operator's prefix did not survive the derivation:\n%s\nwant BUCKET=%s", got, want)
|
||||
}
|
||||
if given := storeGrants(t, out); given[0].Derived["bucket"] != want {
|
||||
t.Errorf("the provider was told %v, the consumer %q", given[0].Derived["bucket"], want)
|
||||
}
|
||||
}
|
||||
|
||||
// A fact or an alphabet the mesh does not have is refused where the definition is, not where a
|
||||
// consumer happens to be resolved — and the refusal says what may be said instead.
|
||||
func TestAServedValueNamingSomethingTheMeshDoesNotHaveIsRefused(t *testing.T) {
|
||||
for _, c := range []struct{ value, says string }{
|
||||
{"${consumer:node}", "as"},
|
||||
{"${consumer:as:punycode}", "dns"},
|
||||
} {
|
||||
m := store()
|
||||
m.Serves["s3-bucket"]["bucket"] = c.value
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = ParseManifest(raw)
|
||||
if err == nil {
|
||||
t.Fatalf("%s was accepted", c.value)
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.value) {
|
||||
t.Errorf("the refusal of %s does not quote it: %v", c.value, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("the refusal of %s does not say what may be said (%q): %v", c.value, c.says, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `dns` is checked against an identity the mesh actually mints, not an invented string.
|
||||
func TestTheDNSAlphabetIsTheMintedIdentityWithItsSeparatorRewritten(t *testing.T) {
|
||||
as := ConsumerIdentity("anchor", IdentitySource("ncloud", "nextcloud"))
|
||||
if err := CheckIdentity("anchor", IdentitySource("ncloud", "nextcloud")); err != nil {
|
||||
t.Fatalf("the mesh would not mint this identity at all: %v", err)
|
||||
}
|
||||
label := asDNSLabel(as)
|
||||
if strings.Contains(label, "_") {
|
||||
t.Errorf("%q is not a DNS label", label)
|
||||
}
|
||||
if strings.ReplaceAll(label, "-", "_") != as {
|
||||
t.Errorf("%q is not %q with its separator rewritten", label, as)
|
||||
}
|
||||
}
|
||||
|
||||
// The check that would have caught the one wrong instance: a consumer that writes the derived
|
||||
// value into its own definition instead of asking for it is refused, whether it transcribed the
|
||||
// right answer or a predecessor's.
|
||||
func TestAConsumerThatTranscribesWhatItsProviderDerivesIsRefused(t *testing.T) {
|
||||
as := ConsumerIdentity("workstation", IdentitySource("files", "files"))
|
||||
transcribed := strings.ReplaceAll(as, "_", "-")
|
||||
|
||||
m := files()
|
||||
m.Resources = []map[string]any{{
|
||||
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
||||
// Exactly what the provider will create — correct today, and a copy of a rule that is
|
||||
// not this module's.
|
||||
"content": "BUCKET=" + transcribed + "\n",
|
||||
}}
|
||||
r, err := Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = r.Declaration(Rendering{Grants: []Grant{{
|
||||
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
||||
}}})
|
||||
if err == nil {
|
||||
t.Fatal("a definition holding its own copy of the provider's naming rule was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "${bound:s3-bucket:bucket}") {
|
||||
t.Errorf("the refusal does not say what to write instead: %v", err)
|
||||
}
|
||||
|
||||
// And a constant the provider serves to everyone is not a transcription: repeating it is
|
||||
// redundant, not wrong, and refusing it would be the mesh policing style.
|
||||
m.Resources = []map[string]any{{
|
||||
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
||||
"content": "REGION=eu-west\n",
|
||||
}}
|
||||
r, err = Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Declaration(Rendering{Grants: []Grant{{
|
||||
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
||||
}}}); err != nil {
|
||||
t.Errorf("a value the provider serves to everyone was judged a transcription: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func storeGrants(t *testing.T, out []map[string]any) []Contribution {
|
||||
t.Helper()
|
||||
for _, r := range out {
|
||||
if r["path"] != "/var/lib/store/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 contributions file: %v", out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// A consumer that keeps SEVERAL holders of one provision is refused, rather than told one thing
|
||||
// while its provider is told another.
|
||||
//
|
||||
// **This is issue 124's own failure, one case to the side.** The mesh gives each holder its own
|
||||
// login — `mesh_node_mod_<local>` (ADR 0094) — and the provider derives from the login, so it
|
||||
// would make one resource per holder. The consumer's side has no such dimension: there is one
|
||||
// binding file per provision and one `${bound:<provision>:<key>}`, both derived from the
|
||||
// un-suffixed identity. So the provider would create `…-mod-cold` and the consumer would be
|
||||
// configured against `…-mod`: it would authenticate successfully and be refused on every object,
|
||||
// which is exactly the fault this whole record exists to end.
|
||||
//
|
||||
// Refused, loudly, at the one place that can see both halves. Lifting it means giving the
|
||||
// consumer's side a local dimension, which is a decision and not an omission.
|
||||
func TestAConsumerWithSeveralHoldersOfADerivingProviderIsRefused(t *testing.T) {
|
||||
m := files()
|
||||
// Two holders of the one provision, the shape ADR 0094 gives a module that keeps several.
|
||||
m.Secrets = nil
|
||||
m.SecretsMany = map[string]map[string]string{"s3-bucket": {
|
||||
"hot": "/var/lib/files/hot.secret",
|
||||
"cold": "/var/lib/files/cold.secret",
|
||||
}}
|
||||
m.Resources = []map[string]any{{
|
||||
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
||||
"content": "BUCKET=${bound:s3-bucket:bucket}\n",
|
||||
}}
|
||||
r, err := Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = r.Declaration(Rendering{Grants: []Grant{
|
||||
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||
Local: "hot", Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
||||
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||
Local: "cold", Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
||||
}})
|
||||
if err == nil {
|
||||
t.Fatal("a consumer with several holders of a deriving provider was accepted; " +
|
||||
"its two ends would have disagreed in silence")
|
||||
}
|
||||
for _, want := range []string{"files", "s3-bucket", "bucket"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not name %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
@@ -170,7 +171,7 @@ func TestTheForgeHoldsTheNpmAndGitSeats(t *testing.T) {
|
||||
// **And the forge's own address follows it**, composed from the manifest in the catalogue beside
|
||||
// this checkout (novox/hq 04-ISSUES/088).
|
||||
//
|
||||
// The forge is reached a third way that neither test above covers: by its own sidecar, over the
|
||||
// The forge is reached a third way that neither test above covers: by its own code, over the
|
||||
// machine's loopback, told where to go in its environment. The `2999:3000` mapping that lets the
|
||||
// forge go on binding 3000 does nothing for a caller dialling the machine — so a literal there is
|
||||
// wrong on every node whose assignment differs, and wrong for a second reason on a node given the
|
||||
@@ -178,13 +179,13 @@ func TestTheForgeHoldsTheNpmAndGitSeats(t *testing.T) {
|
||||
// in an `env` at all is a declaration, not a manifest.
|
||||
func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) {
|
||||
forge, err := catalogueManifest(t, "gitea").Resolve([]Built{{
|
||||
Name: "runtime", Kind: ArtifactImage,
|
||||
Reference: "registry.example/gitea-runtime@sha256:" + strings.Repeat("a", 64),
|
||||
Name: "code", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "gitea/code/blobs/" + bundleDigest, Digest: bundleDigest,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("the forge's manifest does not resolve against its own build: %v", err)
|
||||
}
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{forge}, Needs: []Needed{
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{forge, theRuntime(t)}, Needs: []Needed{
|
||||
{Name: "postgres-database", For: "gitea", From: "anchor", At: "127.0.0.1",
|
||||
Serves: map[string]any{"port": float64(5432)}, Sealed: "sealed-db"},
|
||||
{Name: "route", For: "gitea", From: "anchor"},
|
||||
@@ -194,8 +195,8 @@ func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) {
|
||||
|
||||
// The number this node was given for the forge — the one the machine it is about to run on
|
||||
// already publishes.
|
||||
out, err := r.Declaration(Rendering{
|
||||
Needed: map[string]map[string]string{"gitea": {"broker": "sealed-broker"}},
|
||||
out, err := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-broker"}},
|
||||
Given: map[string]map[int]int{"gitea": {3000: 2999}},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -210,14 +211,19 @@ func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) {
|
||||
if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "2999:3000") {
|
||||
t.Fatalf("the forge is not published on the port this node gave it: %v", server["ports"])
|
||||
}
|
||||
runtime := fileNamed(out, "gitea.runtime")
|
||||
// The forge's own code runs in the node's runtime (novox/hq ADR 0198), given its words there.
|
||||
runtime := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||
if runtime == nil {
|
||||
t.Fatalf("the forge's sidecar is not in the declaration: %v", out)
|
||||
t.Fatalf("the node's runtime is not in the declaration: %v", ids(out))
|
||||
}
|
||||
env, _ := runtime["env"].(map[string]any)
|
||||
if env["MESH_GITEA_URL"] != "http://127.0.0.1:2999" {
|
||||
t.Fatalf("the forge's sidecar dials %v while the machine publishes the forge on 2999 — "+
|
||||
"whatever reads it dials a dead port", env["MESH_GITEA_URL"])
|
||||
env, _ := runtime["env"].(map[string]string)
|
||||
var given map[string]map[string]string
|
||||
if err := json.Unmarshal([]byte(env[RuntimeToolEnv]), &given); err != nil {
|
||||
t.Fatalf("the runtime's %s is not JSON: %q", RuntimeToolEnv, env[RuntimeToolEnv])
|
||||
}
|
||||
if given["gitea"]["MESH_GITEA_URL"] != "http://127.0.0.1:2999" {
|
||||
t.Fatalf("the forge's code dials %v while the machine publishes the forge on 2999 — "+
|
||||
"whatever reads it dials a dead port", given["gitea"]["MESH_GITEA_URL"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,13 +235,13 @@ func declaredGiteaSsh(t *testing.T, given map[int]int) map[string]any {
|
||||
t.Helper()
|
||||
forge := catalogueManifest(t, "gitea")
|
||||
resolved, err := forge.Resolve([]Built{{
|
||||
Name: "runtime", Kind: ArtifactImage,
|
||||
Reference: "registry.example/gitea-runtime@sha256:" + strings.Repeat("a", 64),
|
||||
Name: "code", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "gitea/code/blobs/" + bundleDigest, Digest: bundleDigest,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("the forge's manifest does not resolve against its own build: %v", err)
|
||||
}
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{resolved}, Needs: []Needed{
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{resolved, theRuntime(t)}, Needs: []Needed{
|
||||
{Name: "postgres-database", For: "gitea", From: "anchor", At: "127.0.0.1",
|
||||
Serves: map[string]any{"port": float64(5432)}, Sealed: "sealed-db"},
|
||||
{Name: "route", For: "gitea", From: "anchor"},
|
||||
@@ -246,8 +252,8 @@ func declaredGiteaSsh(t *testing.T, given map[int]int) map[string]any {
|
||||
for k, v := range given {
|
||||
givenPorts[k] = v
|
||||
}
|
||||
out, err := r.Declaration(Rendering{
|
||||
Needed: map[string]map[string]string{"gitea": {"broker": "sealed-broker"}},
|
||||
out, err := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-broker"}},
|
||||
Ports: map[string]map[int]int{"gitea": givenPorts},
|
||||
Given: map[string]map[int]int{"gitea": given},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq issue 223: genesis raises the controller as a container built from this repository's own
|
||||
// Dockerfile, with no build arguments — the manifest no longer builds an image, so nothing passes a
|
||||
// base in. The Dockerfile's own default must therefore be a Go that builds this module, pinned by
|
||||
// digest, and the replacement the manifest's process names must be the container genesis raises.
|
||||
func TestGenesisCanBuildTheControllersImageAsItStands(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../Dockerfile")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !regexp.MustCompile(`(?m)^ARG GO_BASE=golang@sha256:[0-9a-f]{64}$`).Match(raw) {
|
||||
t.Fatal("the Dockerfile's default Go base is not pinned by digest; genesis builds it with no arguments")
|
||||
}
|
||||
makefile, err := os.ReadFile("../../Makefile")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pin := regexp.MustCompile(`golang@sha256:[0-9a-f]{64}`)
|
||||
if string(pin.Find(raw)) != string(pin.Find(makefile)) {
|
||||
t.Errorf("the Dockerfile and the Makefile build on different Go: %s, %s", pin.Find(raw), pin.Find(makefile))
|
||||
}
|
||||
}
|
||||
@@ -325,6 +325,15 @@ type Manifest struct {
|
||||
// person's account (design 25 §7) already had the same shape.
|
||||
Invokes []string `json:"invokes,omitempty"`
|
||||
|
||||
// State is the current state this module keeps on the bus, by local name: each a key-value
|
||||
// bucket the controller creates, which every instance of the module writes and reads
|
||||
// (novox/hq ADR 0201). Not history — that is an event — and never a secret, sealed or not.
|
||||
State []StateDeclaration `json:"state,omitempty"`
|
||||
|
||||
// Reads are other modules' state this module reads and watches, each `<module>.<name>`
|
||||
// (novox/hq ADR 0201). Read-only: only the owner's instances write.
|
||||
Reads []string `json:"reads,omitempty"`
|
||||
|
||||
// Capabilities the machine must have. A different field from Requires because the remedy
|
||||
// differs: a missing module can be assigned, and a missing capability means the wrong
|
||||
// machine.
|
||||
@@ -602,6 +611,14 @@ type Bundle struct {
|
||||
// every entrypoint for a module declaring tools that said nothing. Empty for a bundle that is
|
||||
// run rather than loaded.
|
||||
Loads []string `json:"loads,omitempty"`
|
||||
// Env is what the artifact said it is given (ADR 0192), as written; composed per machine.
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
// Launchers are the executables the build wrote beside its entrypoints, by entrypoint (novox/hq
|
||||
// ADR 0193). A bundle built before them has none, and is served as it was built.
|
||||
Launchers map[string]string `json:"launchers,omitempty"`
|
||||
// Binary is the executable a bundle compiled to a binary is, at its root (novox/hq ADR 0193):
|
||||
// what runs it, where an interpreted bundle names an interpreter and an entrypoint.
|
||||
Binary string `json:"binary,omitempty"`
|
||||
}
|
||||
|
||||
// Build says how to produce this module's artifacts from its source.
|
||||
@@ -748,6 +765,16 @@ type Artifact struct {
|
||||
// module's tools and nothing else is the ordinary case and should not have to say the same
|
||||
// list twice. A module declaring no tools has nothing the runtime loads, whatever it compiles.
|
||||
Loads []string `json:"loads,omitempty"`
|
||||
|
||||
// External are packages a TypeScript bundle keeps as imports rather than inlining — a native
|
||||
// addon, a package that reads its own files — and so carries the toolchain's node_modules for
|
||||
// (novox/hq ADR 0193). Absent for nearly every bundle, which is then one file per entrypoint.
|
||||
External []string `json:"external,omitempty"`
|
||||
|
||||
// Env is what a tools bundle is given on a machine (novox/hq ADR 0192): words and their values,
|
||||
// paths and constants composed with ${dir:…} and ${port:…} exactly as a container's environment
|
||||
// is, never a secret's content. The node's runtime hands it to this bundle and to no other.
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
}
|
||||
|
||||
// Kinds an artifact may be.
|
||||
@@ -1298,6 +1325,8 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
// module whose event names are wrong installs, starts, connects and reacts to nothing, with
|
||||
// every log line saying it is fine (novox/hq 04-ISSUES/127).
|
||||
problems = append(problems, EventProblems(m)...)
|
||||
// And what it may call its state, and whose it may read (state.go, novox/hq ADR 0201).
|
||||
problems = append(problems, StateProblems(m)...)
|
||||
wellFormed := true
|
||||
for _, c := range m.Claims {
|
||||
if !name.MatchString(c.Name) {
|
||||
@@ -1360,6 +1389,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
}
|
||||
}
|
||||
problems = append(problems, m.Build.problems(m.Module)...)
|
||||
problems = append(problems, undeliveredBundles(m)...)
|
||||
// **What provides the artifact store cannot be delivered through it** (novox/hq 04-ISSUES/029).
|
||||
//
|
||||
// Building publishes to the store, and the builder will not start without one. So a module
|
||||
@@ -1409,6 +1439,10 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
"%s serves %q to whoever requires it, and does not provide it", m.Module, to))
|
||||
}
|
||||
}
|
||||
// A served value may be derived for the consumer it is served to (novox/hq ADR 0201). Read
|
||||
// here, where the definition is, rather than when somebody first requires it: a rule that
|
||||
// would be refused at the first consumer is wrong from the moment it is written.
|
||||
problems = append(problems, CheckServes(m)...)
|
||||
for to, where := range m.Binds {
|
||||
if !placedOrAbsolute(where) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
@@ -1496,6 +1530,53 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
"program that reads what the mesh delivered and reconciles",
|
||||
m.Module, r["id"]))
|
||||
}
|
||||
// **What a process replaces is something the module no longer declares** (novox/hq issue 213).
|
||||
// The host keeps it running until the process is, then removes it: so it is named by the id the
|
||||
// module used to give it, it is never a resource the module still declares — that would be
|
||||
// applied and removed by one declaration — and only a process that stays up has anything to
|
||||
// hand over to. Said here, near the author, as the host would refuse it far away.
|
||||
ids := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
ids[fmt.Sprint(r["id"])] = true
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
raw, present := r["replaces"]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
if fmt.Sprint(r["type"]) != "process" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %v says what it replaces, and only a process does", m.Module, r["id"]))
|
||||
continue
|
||||
}
|
||||
if once, _ := r["run-once"].(bool); once || r["schedule"] != nil {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %v replaces something and runs once or on a schedule — only a process that stays "+
|
||||
"up is there a moment later to hand over to", m.Module, r["id"]))
|
||||
}
|
||||
list, ok := raw.([]any)
|
||||
if !ok {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %v replaces %v; replaces is a list of the ids this module no longer declares",
|
||||
m.Module, r["id"], raw))
|
||||
continue
|
||||
}
|
||||
for _, item := range list {
|
||||
id, ok := item.(string)
|
||||
switch {
|
||||
case !ok || strings.TrimSpace(id) == "":
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %v replaces %v, which is not an id", m.Module, r["id"], item))
|
||||
case strings.Contains(id, "."):
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %v replaces %q; a process replaces only a resource of its own module, named "+
|
||||
"by its own id", m.Module, r["id"], id))
|
||||
case ids[id]:
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %v replaces %q, which this module still declares", m.Module, r["id"], id))
|
||||
}
|
||||
}
|
||||
}
|
||||
// **A module that prepares its state must have code the mesh can run** (novox/hq ADR 0135). The
|
||||
// preparation is the module's own program in its preparation mode, so it is derived from the
|
||||
// resource that runs that program — and a module declaring none has asked for something the mesh
|
||||
@@ -1503,7 +1584,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
// quietly prepares nothing.
|
||||
if m.Prepares && preparationTarget(m) == "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s says it prepares its state, and declares no container running an artifact it built — "+
|
||||
"%s says it prepares its state, and declares no container or process running an artifact it built — "+
|
||||
"the preparation is this module's own program, so there has to be one for the mesh to "+
|
||||
"run it in", m.Module))
|
||||
}
|
||||
@@ -1556,6 +1637,13 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// **A scheduled step may hold this module's own containers still while it runs**
|
||||
// (novox/hq ADR 0189). What the host judges is the declaration it receives — whether each
|
||||
// id is a container placed on that machine; what belongs here is what only the definition
|
||||
// shows: that the ids are this module's, that they are containers, and that the step is
|
||||
// scheduled. A module naming a neighbour's container would be a module that can stop the
|
||||
// mesh, and the manifest is where that is visible.
|
||||
problems = append(problems, whileStoppedProblems(m, r, hasSchedule(r))...)
|
||||
}
|
||||
for name, own := range m.OwnSecrets {
|
||||
if !placedOrAbsolute(own.Path) {
|
||||
@@ -2087,3 +2175,71 @@ func (o OwnSecrets) Paths() map[string]string {
|
||||
// InstancesInterchangeable is the one value of a definition's `instances`: the module is the same
|
||||
// on every machine, so any instance may answer for the module.
|
||||
const InstancesInterchangeable = "interchangeable"
|
||||
|
||||
// WhileStopped is the resource key naming the containers a scheduled step holds still while it
|
||||
// runs (novox/hq ADR 0189). Carried to the host unchanged, like `schedule`.
|
||||
const WhileStopped = "while-stopped"
|
||||
|
||||
// hasSchedule is whether a resource declares a cadence, as a string.
|
||||
func hasSchedule(r map[string]any) bool {
|
||||
s, _ := r["schedule"].(string)
|
||||
return s != ""
|
||||
}
|
||||
|
||||
// whileStoppedProblems judges one container's maintenance window against its own definition
|
||||
// (novox/hq ADR 0189).
|
||||
//
|
||||
// Three things the manifest is the only place to see: that the step is scheduled (a one-time
|
||||
// offline job says *before* rather than *instead of* — at apply the host already has a window,
|
||||
// because the declaration is applied in order and a run-once step gates what follows); that every
|
||||
// id it names is **this module's own** container; and that it does not name itself.
|
||||
//
|
||||
// The host checks the fourth — that the container is actually placed on that machine — because
|
||||
// that is a fact about the declaration and not about the definition.
|
||||
func whileStoppedProblems(m Manifest, r map[string]any, scheduled bool) []string {
|
||||
raw, present := r[WhileStopped]
|
||||
if !present {
|
||||
return nil
|
||||
}
|
||||
ids, ok := raw.([]any)
|
||||
if !ok {
|
||||
return []string{fmt.Sprintf(
|
||||
"%s declares %s on %v as a %T; it is a list of this module's container ids",
|
||||
m.Module, WhileStopped, r["id"], raw)}
|
||||
}
|
||||
var problems []string
|
||||
if len(ids) > 0 && !scheduled {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares %s on %v, which has no schedule. A maintenance window is for a recurring "+
|
||||
"step: at apply the mesh already has one, because a run-once step gates what is "+
|
||||
"declared after it (novox/hq ADR 0189)", m.Module, WhileStopped, r["id"]))
|
||||
}
|
||||
containers := map[string]bool{}
|
||||
for _, own := range m.Resources {
|
||||
if fmt.Sprint(own["type"]) == "container" {
|
||||
containers[fmt.Sprint(own["id"])] = true
|
||||
}
|
||||
}
|
||||
for _, each := range ids {
|
||||
id, ok := each.(string)
|
||||
if !ok {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares %s on %v naming a %T; each entry is a container's id",
|
||||
m.Module, WhileStopped, r["id"], each))
|
||||
continue
|
||||
}
|
||||
if id == fmt.Sprint(r["id"]) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares %s on %v naming itself", m.Module, WhileStopped, r["id"]))
|
||||
continue
|
||||
}
|
||||
if !containers[id] {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares %s on %v naming %q, which is not a container this module declares. "+
|
||||
"A step may hold still its own module's containers and nobody else's — one "+
|
||||
"that could quiesce a neighbour could stop the mesh",
|
||||
m.Module, WhileStopped, r["id"], id))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Which machine serves each routed name (novox/hq ADR 0066, issue 178).
|
||||
//
|
||||
// A routed name is a label the mesh composed for a consumer's endpoint, and it is *served* by the
|
||||
// provider that answers requests for it — the proxy the consumer's route reaches. The same name is
|
||||
// composed into every labelled contribution the consumer makes, because a provider that must know
|
||||
// the consumer's public name (an identity provider composing a redirect) is told it the same way
|
||||
// (04-ISSUES/122). Attributing the name to whichever of those providers a map happened to yield
|
||||
// last sent a public name to the identity provider's machine on one plan and to the proxy's on the
|
||||
// next (forge issue 227), and the whole names region flipped with it.
|
||||
//
|
||||
// **The terminus serves the name.** Among the providers a name reaches, the one that serves it is
|
||||
// the one that is not itself routed: a provider that contributes a labelled name of its own to some
|
||||
// requirement is published through another provider, and is a consumer of names, not their end.
|
||||
// Name-agnostic — nothing here knows what "route" means — and structural: it reads the graph the
|
||||
// modules declared. Deterministic: names, requirements and nodes are walked in order, so two
|
||||
// plans of one mesh yield one region.
|
||||
|
||||
// NamesServed is every routed name across the mesh and the node that serves it, from every node's
|
||||
// resolution and settings. A name several termini claim goes to the first node in name order, so
|
||||
// the answer is stable; a name nothing terminal claims is left out.
|
||||
func NamesServed(plans map[string]Resolution, settings map[string]SettingsBy) (map[string]string, error) {
|
||||
nodes := make([]string, 0, len(plans))
|
||||
for n := range plans {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
|
||||
out := map[string]string{}
|
||||
for _, node := range nodes {
|
||||
plan := plans[node]
|
||||
all, err := plan.contributions(settings[node], nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
requirements := make([]string, 0, len(all))
|
||||
for to := range all {
|
||||
requirements = append(requirements, to)
|
||||
}
|
||||
sort.Strings(requirements)
|
||||
for _, to := range requirements {
|
||||
for _, given := range all[to] {
|
||||
if given.Node != "" {
|
||||
// Said from another machine; that machine's own resolution carries it.
|
||||
continue
|
||||
}
|
||||
// A routed name, and only that: a contribution the mesh composed a name for from a
|
||||
// label it was given. A grant that happens to carry a `name` of its own — a database
|
||||
// name — carries no label and is left alone.
|
||||
if _, labelled := given.Values["label"]; !labelled {
|
||||
continue
|
||||
}
|
||||
name, _ := given.Values["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
serving := servingNodeOf(plan, to, given.From, node)
|
||||
if !servesNames(plans[serving], to) {
|
||||
continue
|
||||
}
|
||||
name = strings.ToLower(name)
|
||||
if held, taken := out[name]; !taken || serving < held {
|
||||
out[name] = serving
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// servingNodeOf is the node answering one consumer's requirement: whoever the plan needs it from,
|
||||
// or this same node when the provider is beside the consumer.
|
||||
func servingNodeOf(plan Resolution, requirement, consumer, self string) string {
|
||||
for _, need := range plan.Needs {
|
||||
if need.Name == requirement && need.For == consumer && need.From != "" {
|
||||
return need.From
|
||||
}
|
||||
}
|
||||
return self
|
||||
}
|
||||
|
||||
// servesNames says whether the module providing a requirement on a node is a terminus: it is not
|
||||
// itself published under a labelled name through some other provider. A node whose plan is not
|
||||
// known (it did not resolve) serves nothing.
|
||||
func servesNames(plan Resolution, requirement string) bool {
|
||||
for _, m := range plan.Modules {
|
||||
if !offers(m, requirement) {
|
||||
continue
|
||||
}
|
||||
return !contributesALabel(m)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func offers(m Manifest, requirement string) bool {
|
||||
for _, o := range m.Offers() {
|
||||
if o == requirement {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func contributesALabel(m Manifest) bool {
|
||||
for _, values := range m.Contributes {
|
||||
if _, labelled := values["label"]; labelled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, locals := range m.ContributesMany {
|
||||
for _, values := range locals {
|
||||
if _, labelled := values["label"]; labelled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The mesh of forge issue 227 (novox/hq issue 178): a dashboard on the home server contributes its
|
||||
// label to the route its proxy serves AND to the identity provider on the control node, which must
|
||||
// know the dashboard's public name to compose a redirect. Both contributions carry the composed
|
||||
// name; only the proxy serves it.
|
||||
func twoNodesOneName(t *testing.T) (map[string]Resolution, map[string]SettingsBy) {
|
||||
t.Helper()
|
||||
catalogue := shelf(
|
||||
Manifest{Module: "route-adapter", Version: "1", Provides: Offers("route"),
|
||||
Serves: map[string]map[string]any{"route": {}}, Receives: map[string]string{"route": "/etc/adapter/mesh.json"}},
|
||||
Manifest{Module: "route-proxy", Version: "1", Provides: Offers("route"),
|
||||
Serves: map[string]map[string]any{"route": {}}, Receives: map[string]string{"route": "/etc/proxy/mesh.json"}},
|
||||
Manifest{Module: "keycloak", Version: "1", Provides: FromAnywhere("oidc-client"),
|
||||
Serves: map[string]map[string]any{"oidc-client": {"token-path": "/token"}},
|
||||
Receives: map[string]string{"oidc-client": "/etc/keycloak/clients.json"},
|
||||
Listens: []Listening{{Port: 8080, From: FromMesh, Why: "the login page"}},
|
||||
// Published through the proxy itself: the identity provider is routed, not a router.
|
||||
Contributes: map[string]map[string]any{"route": {"label": "login", "endpoint": "web", "port": 8080}}},
|
||||
Manifest{Module: "grafana", Version: "1",
|
||||
Listens: []Listening{{Port: 3000, From: FromMesh, Why: "dashboards"}},
|
||||
Contributes: map[string]map[string]any{
|
||||
"route": {"label": "grafana", "endpoint": "web", "port": 3000},
|
||||
"oidc-client": {"label": "grafana", "endpoint": "web", "port": 3000, "callback": "/login"},
|
||||
}},
|
||||
)
|
||||
home := withDomain("home.example")
|
||||
home.Name, home.At = "home-server", "home-server.internal"
|
||||
control := withDomain("control.example")
|
||||
control.Name, control.At = "anchor", "anchor.internal"
|
||||
|
||||
onHome, err := Resolve(catalogue, []string{"grafana", "route-adapter"}, home, World{
|
||||
Offered: map[string][]Provider{"oidc-client": {{Node: "anchor", At: "anchor.internal", Module: "keycloak"}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
onControl, err := Resolve(catalogue, []string{"keycloak", "route-proxy"}, control, World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return map[string]Resolution{"home-server": onHome, "anchor": onControl},
|
||||
map[string]SettingsBy{"home-server": {}, "anchor": {}}
|
||||
}
|
||||
|
||||
func TestANameResolvesToTheNodeWhoseProxyServesIt(t *testing.T) {
|
||||
plans, settings := twoNodesOneName(t)
|
||||
// Many times, because the fault was map order: one plan said one node, the next the other.
|
||||
for i := 0; i < 25; i++ {
|
||||
served, err := NamesServed(plans, settings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if served["grafana.home.example"] != "home-server" {
|
||||
t.Fatalf("run %d: the dashboard's name is served by %q, and its proxy is on the home server: %v",
|
||||
i, served["grafana.home.example"], served)
|
||||
}
|
||||
if served["login.control.example"] != "anchor" {
|
||||
t.Fatalf("run %d: the identity provider's own name is served by its proxy on the control node: %v", i, served)
|
||||
}
|
||||
if _, leaked := served["grafana.control.example"]; leaked {
|
||||
t.Fatalf("a name composed for the identity provider's benefit is not one it serves: %v", served)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A module that is routed several times names each route (ADR 0094's sibling for contributes);
|
||||
// every one of them is a name the mesh must resolve, and none reached the names region before.
|
||||
func TestEveryRouteOfAModuleWithSeveralIsANameServed(t *testing.T) {
|
||||
catalogue := shelf(
|
||||
Manifest{Module: "route-proxy", Version: "1", Provides: Offers("route"),
|
||||
Serves: map[string]map[string]any{"route": {}}, Receives: map[string]string{"route": "/etc/proxy/mesh.json"}},
|
||||
Manifest{Module: "photos", Version: "1",
|
||||
Listens: []Listening{{Port: 8102, From: FromMesh, Why: "web"}, {Port: 9102, From: FromMesh, Why: "api"}},
|
||||
ContributesMany: map[string]map[string]map[string]any{"route": {
|
||||
"site": {"label": "photos", "endpoint": "web", "port": 8102},
|
||||
"api": {"label": "photos-api", "endpoint": "api", "port": 9102},
|
||||
}}},
|
||||
)
|
||||
node := withDomain("control.example")
|
||||
node.Name, node.At = "anchor", "anchor.internal"
|
||||
plan, err := Resolve(catalogue, []string{"photos", "route-proxy"}, node, World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
served, err := NamesServed(map[string]Resolution{"anchor": plan}, map[string]SettingsBy{"anchor": {}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"photos.control.example", "photos-api.control.example"} {
|
||||
if served[name] != "anchor" {
|
||||
t.Fatalf("%s is not served by its proxy: %v", name, served)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import (
|
||||
//
|
||||
// So a module asks. `${port:8080}` is "the machine-side port you gave me for the 8080 I said I
|
||||
// listen on", and the module writes that where it would otherwise have written a literal — in a
|
||||
// file's content, or in a value of a container's `env`.
|
||||
// file's content, or in a value of a container's or a process's `env`.
|
||||
//
|
||||
// **The environment is filled by the control plane, exactly as a bound value is.** A port is not
|
||||
// secret — the mesh holds it in the clear — so there is nothing for the host to be the only
|
||||
@@ -64,7 +64,12 @@ func portsUsed(content string) []int {
|
||||
}
|
||||
|
||||
// portInto replaces a resource's ${port:…} placeholders with what this machine assigned — in a
|
||||
// file's content, and in a value of a container's environment.
|
||||
// file's content, and in a value of a container's or a process's environment.
|
||||
//
|
||||
// **A process's environment is a container's** (novox/hq to-be 38 WP4c). A module's code moving out
|
||||
// of its container becomes a process on the machine and still has to be told what the container
|
||||
// was told; filled for one kind and not the other, the literal reached the process and was read as
|
||||
// a port, and the modules that moved first wrote their run-once steps a 0600 env file instead.
|
||||
//
|
||||
// A port the module did not say it listens on is refused, for the same reason a binding's unknown
|
||||
// key is: the module is asking about something it never declared, and the answer would be a guess.
|
||||
@@ -84,7 +89,7 @@ func portInto(resource map[string]any, module string, listens []Listening, with
|
||||
}
|
||||
resource["content"] = filled
|
||||
|
||||
case "container":
|
||||
case "container", "process":
|
||||
env, ok := resource["env"].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -106,8 +111,8 @@ func portInto(resource map[string]any, module string, listens []Listening, with
|
||||
continue
|
||||
}
|
||||
value, err := portsFilledInto(written,
|
||||
fmt.Sprintf("%s's container %s sets %s to something that",
|
||||
module, resource["name"], key), module, listens, with)
|
||||
fmt.Sprintf("%s's %s %s sets %s to something that",
|
||||
module, resource["type"], resource["name"], key), module, listens, with)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **A process's environment is composed as a container's is** (novox/hq to-be 38 WP4c).
|
||||
//
|
||||
// A module's code moving out of its container becomes a process on the machine, and what its
|
||||
// container's environment asked for — the port this machine gave the module, the place it put the
|
||||
// module's directory — it still has to be told. Filled for a container and not for a process, the
|
||||
// literal `${port:8080}` reached the process as its environment and was read as a port; the modules
|
||||
// that moved first wrote their run-once steps an env file instead.
|
||||
func processModule(env map[string]any) Manifest {
|
||||
return Manifest{
|
||||
Module: "showcase",
|
||||
Listens: []Listening{{Port: 8080, From: FromMesh}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "data", "type": "directory", "mode": "0700"},
|
||||
{"id": "setup", "type": "process", "name": "showcase-setup", "run-once": true,
|
||||
"run": []any{"/usr/bin/showcase", "setup"}, "env": env},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProcessIsToldItsPortAndItsPlaceInItsEnvironment(t *testing.T) {
|
||||
env := map[string]any{
|
||||
"SHOWCASE_URL": "http://127.0.0.1:${port:8080}",
|
||||
"SHOWCASE_DATA": "${dir:data}/objects",
|
||||
"SHOWCASE_DB": "127.0.0.1:${seat:mesh-store:5432}",
|
||||
"GREETING": "hello",
|
||||
}
|
||||
out, err := Resolution{Node: "anchor", Modules: []Manifest{processModule(env)}}.Declaration(Rendering{
|
||||
Ports: map[string]map[int]int{"showcase": {8080: 21000}},
|
||||
Seats: map[string]map[int]int{"mesh-store": {5432: 6852}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("a process asking for its port and its place does not compose: %v", err)
|
||||
}
|
||||
setup := fileNamed(out, "showcase.setup")
|
||||
if setup == nil {
|
||||
t.Fatalf("the process is not in the declaration: %v", out)
|
||||
}
|
||||
got, _ := setup["env"].(map[string]any)
|
||||
for key, want := range map[string]string{
|
||||
"SHOWCASE_URL": "http://127.0.0.1:21000",
|
||||
"SHOWCASE_DATA": "/var/lib/showcase/data/objects",
|
||||
"SHOWCASE_DB": "127.0.0.1:6852",
|
||||
"GREETING": "hello",
|
||||
} {
|
||||
if got[key] != want {
|
||||
t.Errorf("the process is told %s=%v, want %q", key, got[key], want)
|
||||
}
|
||||
}
|
||||
if env["SHOWCASE_URL"] != "http://127.0.0.1:${port:8080}" {
|
||||
t.Fatalf("composing for one machine edited the module's own manifest: %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown reference in a process's environment is refused as a container's is, naming the
|
||||
// process and the variable — left alone, it would reach the machine as a literal.
|
||||
func TestAProcessAskingAboutAnUndeclaredPortIsRefused(t *testing.T) {
|
||||
env := map[string]any{"SHOWCASE_URL": "http://127.0.0.1:${port:9999}"}
|
||||
_, err := Resolution{Node: "anchor", Modules: []Manifest{processModule(env)}}.Declaration(Rendering{})
|
||||
if err == nil {
|
||||
t.Fatal("a process was told a port its module never said it listens on")
|
||||
}
|
||||
for _, said := range []string{"showcase-setup", "SHOWCASE_URL", "${port:9999}", "8080"} {
|
||||
if !strings.Contains(err.Error(), said) {
|
||||
t.Errorf("the refusal does not say %q: %v", said, err)
|
||||
}
|
||||
}
|
||||
|
||||
env = map[string]any{"SHOWCASE_DATA": "${dir:date}/objects"}
|
||||
if _, err := (Resolution{Node: "anchor", Modules: []Manifest{processModule(env)}}).Declaration(Rendering{}); err == nil ||
|
||||
!strings.Contains(err.Error(), "${dir:date}") {
|
||||
t.Fatalf("a process naming no directory of its module was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,10 @@ import (
|
||||
|
||||
// A RosterFile is a file the mesh renders from the roster of machines, in the format the module
|
||||
// gives as a Go text/template. The template sees a rosterView: `.Node` (this machine's bare name),
|
||||
// `.Suffix` (what its mesh name ends in), and two sets of `{Name, FQDN, Address}` — `.Names`, every
|
||||
// name the mesh serves, and `.Machines`, only the nodes of the mesh. Which set a template ranges is
|
||||
// how the hq issue 111 distinction is drawn: a container's hosts wants every name; a resolver told
|
||||
// the suffix is its own wants only the machines.
|
||||
// `.Suffix` (what its mesh name ends in), and two sets of `{Name, FQDN, Address}` — `.Names` and
|
||||
// `.Machines`. Both are the nodes of the mesh: routed names were once in `.Names` too, and are not
|
||||
// since every route became a name under its node's internal domain (novox/hq ADR 0191) — the hq
|
||||
// issue 111 distinction is kept as two fields so the templates that range either keep rendering.
|
||||
type RosterFile struct {
|
||||
// Path is where on the machine the rendered file goes. Absolute, or it is refused here rather
|
||||
// than discovered as a daemon that reads nothing.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -82,6 +83,11 @@ const (
|
||||
RuntimeBrokerFile = "MESH_BROKER_FILE"
|
||||
RuntimeOperatorAccount = "MESH_OPERATOR_ACCOUNT"
|
||||
RuntimeOperatorHome = "MESH_OPERATOR_HOME"
|
||||
// RuntimeToolEnv is every served module's composed environment, as JSON (novox/hq ADR 0192):
|
||||
// {"<module>": {"<word>": "<value>"}}. The runtime takes it at start, removes it from its own
|
||||
// environment and hands each module's words to that module's bundles alone. In the unit, so a
|
||||
// change to any module's words changes the process and restarts it.
|
||||
RuntimeToolEnv = "MESH_TOOL_ENV"
|
||||
)
|
||||
|
||||
// interpreterFor is how a bundle in a language is run: the program the host's unit starts, with the
|
||||
@@ -123,6 +129,12 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
||||
RuntimeModule, r.Node, len(runtime.Bundles))
|
||||
}
|
||||
bundle := runtime.Bundles[0]
|
||||
// What runs it (novox/hq ADR 0193): a runtime compiled to a binary runs itself, from its own
|
||||
// unpacked bundle; an interpreted one is its language's interpreter and its one entrypoint.
|
||||
var run []any
|
||||
if bundle.Binary != "" {
|
||||
run = []any{"./" + bundle.Binary}
|
||||
} else {
|
||||
if len(bundle.Entrypoints) != 1 {
|
||||
return nil, fmt.Errorf(
|
||||
"%s's bundle %q names %d entrypoint(s); the runtime is run from one, so the module "+
|
||||
@@ -132,6 +144,8 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
run = []any{interpreter, bundle.Entrypoints[0]}
|
||||
}
|
||||
credential, declared := runtime.OwnSecrets["broker"]
|
||||
if !declared {
|
||||
return nil, fmt.Errorf(
|
||||
@@ -146,15 +160,28 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
||||
// would be told to load files that were never delivered.
|
||||
var served []string
|
||||
var restartOn []string
|
||||
given := map[string]map[string]string{}
|
||||
for _, m := range r.Modules {
|
||||
if with.Adopted && m.Filtering != nil {
|
||||
continue
|
||||
}
|
||||
words, err := bundleWords(m, with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(words) > 0 {
|
||||
given[m.Module] = words
|
||||
}
|
||||
for _, b := range m.Bundles {
|
||||
if len(b.Loads) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, load := range b.Loads {
|
||||
// What the runtime starts: the launcher the build wrote beside the entrypoint, where
|
||||
// it wrote one (ADR 0193); the entrypoint itself for a build from before them.
|
||||
if launcher, has := b.Launchers[load]; has {
|
||||
load = launcher
|
||||
}
|
||||
served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load)
|
||||
}
|
||||
restartOn = append(restartOn, m.Module+"."+BundleID(b.Name))
|
||||
@@ -168,10 +195,18 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
||||
RuntimeToolModules: strings.Join(served, ","),
|
||||
RuntimeBrokerFile: credential.Path,
|
||||
}
|
||||
if len(given) > 0 {
|
||||
// Marshalled from maps, whose keys encoding/json sorts: the same words, the same unit.
|
||||
body, err := json.Marshal(given)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env[RuntimeToolEnv] = string(body)
|
||||
}
|
||||
process := map[string]any{
|
||||
"id": RuntimeModule + "." + RuntimeProcessID(), "type": "process", "name": RuntimeModule,
|
||||
"source": bundle.Source, "digest": bundle.Digest,
|
||||
"run": []any{interpreter, bundle.Entrypoints[0]},
|
||||
"run": run,
|
||||
"env": env,
|
||||
"restart-on": toAny(restartOn),
|
||||
}
|
||||
@@ -249,3 +284,82 @@ func ToolContainerOnTheRuntime(m Manifest, against []string) string {
|
||||
"now (novox/hq ADR 0175, to-be 38); declare the tools as a bundle and drop the container",
|
||||
m.Module, RuntimeImageModule, RuntimeImageArtifact, RuntimeModule)
|
||||
}
|
||||
|
||||
// bundleWords is what one module's tools bundles are given on this machine (novox/hq ADR 0192):
|
||||
// each loaded bundle's env, its ${dir:…} resolved to where this machine places the module's
|
||||
// directories and its ${port:…} to the port this machine gave it — the same resolution a
|
||||
// container's environment gets. Two bundles of one module naming one word differently is refused:
|
||||
// the runtime hands a module's words to all its bundles.
|
||||
func bundleWords(m Manifest, with Rendering) (map[string]string, error) {
|
||||
var out map[string]string
|
||||
dirs := dirsFor(m, with)
|
||||
for _, b := range m.Bundles {
|
||||
if len(b.Loads) == 0 || len(b.Env) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, word := range sortedKeys(b.Env) {
|
||||
value, err := dirFill(b.Env[word], dirs, m.Module)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value, err = portsFilledInto(value, m.Module+"'s bundle "+b.Name+" ("+word+")", m.Module, m.Listens, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = map[string]string{}
|
||||
}
|
||||
if was, had := out[word]; had && was != value {
|
||||
return nil, fmt.Errorf("%s's bundles give %s two values (%q, %q); a module's words are "+
|
||||
"handed to all its bundles, so they agree (novox/hq ADR 0192)", m.Module, word, was, value)
|
||||
}
|
||||
out[word] = value
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// givenTo makes what a bundle's words name readable by the account the runtime runs as (novox/hq
|
||||
// ADR 0192): every file and directory of the module whose path a word names, or that holds one,
|
||||
// is owned by the account — a tool reads its configuration and its secret as the account, and a
|
||||
// root-owned 0600 file or a 0700 directory is one it cannot. Only where it says no owner already:
|
||||
// a module that named one knew why. Nothing on a machine with no account, where the runtime is root.
|
||||
//
|
||||
// It answers the files a word names exactly: what a tool reads, whose change the runtime must be
|
||||
// restarted for, as the container the tools came from was restarted when its configuration changed.
|
||||
func givenTo(out []map[string]any, owner map[string]string, words map[string]map[string]string, account string) []string {
|
||||
var named []string
|
||||
if len(words) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, resource := range out {
|
||||
module := owner[fmt.Sprint(resource["id"])]
|
||||
mine := words[module]
|
||||
if len(mine) == 0 {
|
||||
continue
|
||||
}
|
||||
kind := fmt.Sprint(resource["type"])
|
||||
if kind != "file" && kind != "directory" {
|
||||
continue
|
||||
}
|
||||
path, _ := resource["path"].(string)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
for _, value := range mine {
|
||||
if kind == "file" && value == path {
|
||||
named = append(named, fmt.Sprint(resource["id"]))
|
||||
}
|
||||
}
|
||||
if _, said := resource["owner"]; said || account == "" {
|
||||
continue
|
||||
}
|
||||
for _, value := range mine {
|
||||
if value == path || strings.HasPrefix(value, strings.TrimRight(path, "/")+"/") {
|
||||
resource["owner"] = account
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(named)
|
||||
return named
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -210,3 +211,249 @@ func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// novox/hq ADR 0192: a tools bundle says what it is given; the composer resolves it per machine as
|
||||
// a container's environment, hands it to the runtime as the module's words, and makes what the
|
||||
// words name readable by the account the runtime runs as.
|
||||
func TestABundleIsGivenItsWordsResolvedForThisMachine(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{
|
||||
RuntimeModule: {"broker": "sealed-credential"},
|
||||
"dash": {"token": "sealed-token"},
|
||||
}}
|
||||
dash := Manifest{Module: "dash", Version: "1", Tools: []string{"status"},
|
||||
Listens: []Listening{{Name: "web", Port: 3000, Protocol: "tcp"}},
|
||||
OwnSecrets: OwnSecrets{"token": {Path: "${dir:mesh-state}/token"}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh"},
|
||||
{"id": "config", "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", "content": "{}\n"},
|
||||
{"id": "unrelated", "type": "file", "path": "/etc/dash.conf", "content": "x\n"},
|
||||
},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "typescript",
|
||||
Entrypoints: []string{"tools/index.js"},
|
||||
Env: map[string]string{
|
||||
"DASH_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||
"DASH_TOKEN_FILE": "${dir:mesh-state}/token",
|
||||
"DASH_URL": "http://127.0.0.1:${port:3000}",
|
||||
"DASH_ADMIN": "mesh-admin",
|
||||
}}}}}
|
||||
if problems := dash.Build.problems(dash.Module); len(problems) > 0 {
|
||||
t.Fatalf("a bundle's words written with ${dir:…} and ${port:…} were refused: %v", problems)
|
||||
}
|
||||
dash, err := dash.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "dash/tools/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := aToolsModule(t, "nftables", "tools/index.js")
|
||||
|
||||
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{dash, other, theRuntime(t)}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := fileNamed(out, "dash.mesh-state")
|
||||
if dir == nil {
|
||||
t.Fatalf("no directory: %v", ids(out))
|
||||
}
|
||||
at := fmt.Sprint(dir["path"])
|
||||
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||
env := process["env"].(map[string]string)
|
||||
var given map[string]map[string]string
|
||||
if err := json.Unmarshal([]byte(env[RuntimeToolEnv]), &given); err != nil {
|
||||
t.Fatalf("the runtime's %s is not JSON: %q", RuntimeToolEnv, env[RuntimeToolEnv])
|
||||
}
|
||||
want := map[string]string{
|
||||
"DASH_CONFIG_FILE": at + "/config.json",
|
||||
"DASH_TOKEN_FILE": at + "/token",
|
||||
"DASH_URL": "http://127.0.0.1:3000",
|
||||
"DASH_ADMIN": "mesh-admin",
|
||||
}
|
||||
if fmt.Sprint(given["dash"]) != fmt.Sprint(want) {
|
||||
t.Errorf("dash is given %v, want %v", given["dash"], want)
|
||||
}
|
||||
if _, has := given["nftables"]; has {
|
||||
t.Errorf("a module that declares no words was given some: %v", given)
|
||||
}
|
||||
// What the words name is the account's to read; nothing else of the module's is touched.
|
||||
for _, id := range []string{"dash.mesh-state", "dash.config", "dash." + NeedID("token")} {
|
||||
if r := fileNamed(out, id); r == nil || r["owner"] != "ops" {
|
||||
t.Errorf("%s is not the account's to read: %v", id, r)
|
||||
}
|
||||
}
|
||||
if r := fileNamed(out, "dash.unrelated"); r == nil || r["owner"] != nil {
|
||||
t.Errorf("a file no word names was given an owner: %v", r)
|
||||
}
|
||||
// A file a word names restarts the runtime when it changes, as it restarted the tool container.
|
||||
restarts := fmt.Sprint(process["restart-on"])
|
||||
for _, want := range []string{"dash.config", "dash." + NeedID("token")} {
|
||||
if !strings.Contains(restarts, want) {
|
||||
t.Errorf("the runtime is not restarted when %s changes: %s", want, restarts)
|
||||
}
|
||||
}
|
||||
if strings.Contains(restarts, "dash.unrelated") || strings.Contains(restarts, "dash.mesh-state") {
|
||||
t.Errorf("the runtime restarts for something no word names as a file: %s", restarts)
|
||||
}
|
||||
|
||||
t.Run("on a machine with no account the runtime is root and nothing is re-owned", func(t *testing.T) {
|
||||
out, err := Resolution{Node: "anchor", Modules: []Manifest{dash, theRuntime(t)}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := fileNamed(out, "dash.config"); r["owner"] != nil {
|
||||
t.Errorf("re-owned with no account: %v", r)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a change to a module's words changes the runtime's process", func(t *testing.T) {
|
||||
changed := dash
|
||||
changed.Bundles = append([]Bundle(nil), dash.Bundles...)
|
||||
changed.Bundles[0].Env = map[string]string{"DASH_ADMIN": "somebody-else"}
|
||||
out2, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{changed, theRuntime(t)}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(fileNamed(out2, RuntimeModule+"."+RuntimeProcessID())["env"]) == fmt.Sprint(env) {
|
||||
t.Error("the runtime's process is the same after a module's words changed, so it would not restart")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestABundlesWordsAreRefusedWhenTheyAreNotPathsOrConstants(t *testing.T) {
|
||||
m := Manifest{Module: "dash", Version: "1", Build: &Build{Artifacts: []Artifact{
|
||||
{Name: "tools", Kind: ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"},
|
||||
Env: map[string]string{"DASH_TOKEN": "${secret:token}", RuntimeBrokerFile: "/x", "DASH_PEER": "${bound:db:url}"}},
|
||||
{Name: "runtime", Kind: ArtifactImage, From: "Dockerfile", Env: map[string]string{"X": "y"}},
|
||||
}}}
|
||||
said := strings.Join(m.Build.problems(m.Module), "\n")
|
||||
for _, want := range []string{
|
||||
`"tools" gives DASH_TOKEN the value "${secret:token}"`,
|
||||
`"tools" gives DASH_PEER the value "${bound:db:url}"`,
|
||||
`"tools" gives itself ` + RuntimeBrokerFile,
|
||||
`"runtime" is a "image" and says what it is given`,
|
||||
} {
|
||||
if !strings.Contains(said, want) {
|
||||
t.Errorf("not refused: %s\nsaid:\n%s", want, said)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0193: the runtime is told the launcher a build wrote, and the entrypoint itself for a
|
||||
// build from before launchers — so the move needs no flag day.
|
||||
func TestTheRuntimeStartsTheLauncherWhereTheBuildWroteOne(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
launched := Manifest{Module: "dash", Version: "1", Tools: []string{"status"},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "typescript",
|
||||
Entrypoints: []string{"tools/index.js"}}}}}
|
||||
launched, err := launched.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "dash/tools/blobs/" + bundleDigest, Digest: bundleDigest,
|
||||
Launchers: map[string]string{"tools/index.js": "tools/index.serve.mjs"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
older := aToolsModule(t, "nftables", "tools/index.js")
|
||||
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{launched, older, theRuntime(t)}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())["env"].(map[string]string)
|
||||
want := "dash=" + BundleRoot + "/dash/tools/tools/index.serve.mjs,nftables=" + BundleRoot + "/nftables/tools/tools/index.js"
|
||||
if env[RuntimeToolModules] != want {
|
||||
t.Errorf("the runtime is told %q, want %q", env[RuntimeToolModules], want)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0193: a runtime compiled to a binary runs itself from its own unpacked bundle.
|
||||
func TestARuntimeCompiledToABinaryRunsItself(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
goRuntime := Manifest{Module: RuntimeModule, Version: "1",
|
||||
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/node-tools"}}}}
|
||||
goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{aToolsModule(t, "nftables", "tools/index.js"), goRuntime}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||
if fmt.Sprint(process["run"]) != "[./node-tools]" {
|
||||
t.Errorf("a Go runtime is run as %v, want its own binary", process["run"])
|
||||
}
|
||||
env := process["env"].(map[string]string)
|
||||
if env[RuntimeToolModules] == "" || process["user"] != "ops" {
|
||||
t.Errorf("the Go runtime is not told what to serve or whose it is: %v %v", env, process["user"])
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/216: a bundle nothing loads, runs or unpacks is refused at registration; saying
|
||||
// `loads`, listing `tools`, or a resource naming it admits it.
|
||||
func TestABundleNothingDeliversIsRefused(t *testing.T) {
|
||||
base := func() Manifest {
|
||||
return Manifest{Module: "baserow", Version: "1", Build: &Build{Artifacts: []Artifact{
|
||||
{Name: "tools", Kind: ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"}}}}}
|
||||
}
|
||||
if p := undeliveredBundles(base()); len(p) != 1 || !strings.Contains(p[0], "never reach a machine") {
|
||||
t.Fatalf("a bundle nothing delivers was admitted: %v", p)
|
||||
}
|
||||
loads := base()
|
||||
loads.Build.Artifacts[0].Loads = []string{"tools/index.js"}
|
||||
tools := base()
|
||||
tools.Tools = []string{"baserow_list_rows"}
|
||||
run := base()
|
||||
run.Resources = []map[string]any{{"id": "daemon", "type": "process", "artifact": "tools", "run": []any{"node", "tools/index.js"}}}
|
||||
runtime := base()
|
||||
runtime.Module = RuntimeModule
|
||||
for name, m := range map[string]Manifest{"loads": loads, "tools": tools, "a process": run, "the runtime": runtime} {
|
||||
if p := undeliveredBundles(m); len(p) != 0 {
|
||||
t.Errorf("a bundle delivered by %s was refused: %v", name, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0193: a Go tools bundle is served — its binary is what the runtime starts, delivered
|
||||
// like any tools bundle, named to the runtime where a TypeScript bundle names its launcher.
|
||||
func TestAGoToolsBundleIsServedByItsBinary(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
lamp := Manifest{Module: "lamp", Version: "1", Tools: []string{"on"},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/lamp-tools"}}}}
|
||||
if p := lamp.Build.problems("lamp"); len(p) != 0 {
|
||||
t.Fatalf("a Go tools bundle was refused: %v", p)
|
||||
}
|
||||
lamp, err := lamp.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "lamp/tools/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(lamp.Bundles[0].Loads) != "[lamp-tools]" {
|
||||
t.Fatalf("the runtime loads %v from a Go bundle, want its binary", lamp.Bundles[0].Loads)
|
||||
}
|
||||
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{lamp, theRuntime(t)}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fileNamed(out, "lamp."+BundleID("tools")) == nil {
|
||||
t.Errorf("the Go bundle is not delivered: %v", ids(out))
|
||||
}
|
||||
env := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())["env"].(map[string]string)
|
||||
if env[RuntimeToolModules] != "lamp="+BundlePath("lamp", "tools")+"/lamp-tools" {
|
||||
t.Errorf("the runtime is told %q, want the binary", env[RuntimeToolModules])
|
||||
}
|
||||
|
||||
// An artifact may say it explicitly; naming anything but the binary is refused.
|
||||
said := Manifest{Module: "lamp", Version: "1", Build: &Build{Artifacts: []Artifact{{Name: "tools",
|
||||
Kind: ArtifactBundle, Language: "go", System: "arch", Binary: "lamp", Loads: []string{"lamp"}}}}}
|
||||
if p := said.Build.problems("lamp"); len(p) != 0 {
|
||||
t.Errorf("loads naming the binary was refused: %v", p)
|
||||
}
|
||||
said.Build.Artifacts[0].Loads = []string{"tools/index.js"}
|
||||
if p := said.Build.problems("lamp"); len(p) == 0 {
|
||||
t.Error("a Go bundle loading a file it does not contain was admitted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,8 +43,8 @@ import (
|
||||
var ofSeat = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):([0-9]+)\}`)
|
||||
|
||||
// seatInto replaces a resource's ${seat:…} placeholders with where this machine put each seat's
|
||||
// holder — in a file's content, and in a value of a container's environment. The same two places
|
||||
// portInto fills, for the same reason: they are where a process reads a number from.
|
||||
// holder — in a file's content, and in a value of a container's or a process's environment. The
|
||||
// same places portInto fills, for the same reason: they are where a program reads a number from.
|
||||
func seatInto(resource map[string]any, module string, with Rendering) error {
|
||||
switch fmt.Sprint(resource["type"]) {
|
||||
case "file":
|
||||
@@ -58,7 +58,7 @@ func seatInto(resource map[string]any, module string, with Rendering) error {
|
||||
}
|
||||
resource["content"] = filled
|
||||
|
||||
case "container":
|
||||
case "container", "process":
|
||||
env, ok := resource["env"].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -78,8 +78,8 @@ func seatInto(resource map[string]any, module string, with Rendering) error {
|
||||
continue
|
||||
}
|
||||
value, err := seatsFilledInto(written,
|
||||
fmt.Sprintf("%s's container %s sets %s to something that",
|
||||
module, resource["name"], key), with)
|
||||
fmt.Sprintf("%s's %s %s sets %s to something that",
|
||||
module, resource["type"], resource["name"], key), with)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
|
||||
}
|
||||
// The manifest itself names them now; withSeatPorts is a no-op on it, and this holds it so.
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] != "container" {
|
||||
if r["type"] != "process" {
|
||||
continue
|
||||
}
|
||||
env, _ := r["env"].(map[string]any)
|
||||
@@ -113,8 +113,9 @@ func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
|
||||
}
|
||||
m = withSeatPorts(m)
|
||||
control, err := m.Resolve([]Built{{
|
||||
Name: "server", Kind: ArtifactImage,
|
||||
Reference: ArtifactStoreScheme + "mesh-controller/server@sha256:" + strings.Repeat("c", 64),
|
||||
Name: "controller", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "mesh-controller/controller@sha256:" + strings.Repeat("c", 64),
|
||||
Digest: "sha256:" + strings.Repeat("c", 64),
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -135,9 +136,9 @@ func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("the control plane does not compose: %v", err)
|
||||
}
|
||||
server := fileNamed(out, "mesh-controller.server")
|
||||
server := fileNamed(out, "mesh-controller.controller")
|
||||
if server == nil {
|
||||
t.Fatalf("the control plane's container is not in the declaration: %v", out)
|
||||
t.Fatalf("the control plane's process is not in the declaration: %v", out)
|
||||
}
|
||||
env, _ := server["env"].(map[string]any)
|
||||
for key, want := range map[string]string{
|
||||
@@ -151,8 +152,8 @@ func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
|
||||
t.Errorf("the control plane is told %s=%v; the node put it on %s", key, env[key], want)
|
||||
}
|
||||
}
|
||||
if got := server["image"]; got != "anchor.internal:5100/mesh-controller/server@sha256:"+strings.Repeat("c", 64) {
|
||||
t.Errorf("the control plane's own image is %v, not routed through the store", got)
|
||||
if got := server["source"]; got != "anchor.internal:5100/mesh-controller/controller@sha256:"+strings.Repeat("c", 64) {
|
||||
t.Errorf("the control plane's own bundle is fetched from %v, not routed through the store", got)
|
||||
}
|
||||
|
||||
// And on a mesh where the foundation is where genesis raised it, nothing is added.
|
||||
@@ -160,7 +161,7 @@ func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env, _ = fileNamed(out, "mesh-controller.server")["env"].(map[string]any)
|
||||
env, _ = fileNamed(out, "mesh-controller.controller")["env"].(map[string]any)
|
||||
if env["MESH_STORE_INVENTORY_PORT"] != "" {
|
||||
t.Errorf("with no settings, the control plane is told %v", env)
|
||||
}
|
||||
@@ -186,7 +187,7 @@ func withSeatPorts(m Manifest) Manifest {
|
||||
out := m
|
||||
out.Resources = nil
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] != "container" {
|
||||
if r["type"] != "container" && r["type"] != "process" {
|
||||
out.Resources = append(out.Resources, r)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -215,6 +215,13 @@ func CatalogueProblems(shelf Shelf) []string {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A read of a module's state that module does not keep (novox/hq ADR 0201) — said only where the
|
||||
// owner is on the shelf, as a consumer may be installed before its emitter.
|
||||
var manifests []Manifest
|
||||
for _, module := range shelfOrder(shelf) {
|
||||
manifests = append(manifests, shelf[module])
|
||||
}
|
||||
problems = append(problems, StateReadsNothingDeclares(manifests)...)
|
||||
sort.Strings(problems)
|
||||
return problems
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What a module may call its state, and whose state it may ask to read (novox/hq ADR 0201).
|
||||
//
|
||||
// A module names its state **locally** — `servers`, never a bucket or a subject — and another
|
||||
// module's as `<module>.<name>`, the way a consumed event names its emitter (design 32 §1). The
|
||||
// mesh derives the bucket from the two names, so the module and the local name must each be one
|
||||
// token: the bucket joins them with an underscore, which neither may contain, so two modules can
|
||||
// never derive one bucket.
|
||||
|
||||
// stateName is one local name of a module's state: lower-case, no dot, no underscore.
|
||||
var stateName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
// The mesh's caps on what a module may ask of a bucket's history.
|
||||
const (
|
||||
// StateMostHistory is the most past values a key may keep. The server's own limit.
|
||||
StateMostHistory = 64
|
||||
)
|
||||
|
||||
// StateDeclaration is one bucket a module owns: its local name, and the options that are the
|
||||
// owner's to choose, as a seat chooses how long its backlog survives (design 32 §3).
|
||||
type StateDeclaration struct {
|
||||
Name string `json:"name"`
|
||||
// History is how many values a key keeps, the current one included; zero is one.
|
||||
History int `json:"history,omitempty"`
|
||||
// TTLSeconds is how long a value lives once written; zero is until it is replaced or deleted.
|
||||
TTLSeconds int `json:"ttl-seconds,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds}.
|
||||
func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) > 0 && trimmed[0] == '"' {
|
||||
return json.Unmarshal(trimmed, &s.Name)
|
||||
}
|
||||
type plain StateDeclaration
|
||||
var full plain
|
||||
dec := json.NewDecoder(bytes.NewReader(trimmed))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&full); err != nil {
|
||||
return fmt.Errorf("a state is either a name or {name, history, ttl-seconds}: %w", err)
|
||||
}
|
||||
*s = StateDeclaration(full)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON writes back the short form when there is nothing else to say.
|
||||
func (s StateDeclaration) MarshalJSON() ([]byte, error) {
|
||||
if s.History == 0 && s.TTLSeconds == 0 {
|
||||
return json.Marshal(s.Name)
|
||||
}
|
||||
type plain StateDeclaration
|
||||
return json.Marshal(plain(s))
|
||||
}
|
||||
|
||||
// ReadState splits a read into the owning module and the local name, or says why it is not one.
|
||||
func ReadState(read string) (module, local string, err error) {
|
||||
at := strings.LastIndex(read, ".")
|
||||
if at <= 0 || at == len(read)-1 {
|
||||
return "", "", fmt.Errorf("%q does not name a module and its state: a read is <module>.<name>", read)
|
||||
}
|
||||
module, local = read[:at], read[at+1:]
|
||||
if !stateName.MatchString(module) {
|
||||
return "", "", fmt.Errorf("%q cannot own state: a module whose state is read is one plain name", module)
|
||||
}
|
||||
if !stateName.MatchString(local) {
|
||||
return "", "", fmt.Errorf("%q is not a state name: lower-case letters, digits and hyphens", local)
|
||||
}
|
||||
return module, local, nil
|
||||
}
|
||||
|
||||
// StateProblems is what is wrong with a manifest's state and reads.
|
||||
//
|
||||
// Refused at registration, because a bucket name the bus cannot hold is a module that installs,
|
||||
// starts, and is refused on its first write with a reason about a bucket nobody named.
|
||||
func StateProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
if len(m.State) > 0 && !stateName.MatchString(m.Module) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s keeps state, and a module's name is part of its buckets' names, which take one plain "+
|
||||
"name — no dot (novox/hq ADR 0201)", m.Module))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, s := range m.State {
|
||||
switch {
|
||||
case !stateName.MatchString(s.Name):
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s keeps state %q: a state is named locally — lower-case letters, digits and hyphens, "+
|
||||
"no dot and no underscore; the mesh derives the bucket (novox/hq ADR 0201)", m.Module, s.Name))
|
||||
case seen[s.Name]:
|
||||
problems = append(problems, fmt.Sprintf("%s keeps state %q twice", m.Module, s.Name))
|
||||
}
|
||||
seen[s.Name] = true
|
||||
if s.History < 0 || s.History > StateMostHistory {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s keeps %d values of %q; a key keeps between 1 and %d", m.Module, s.History, s.Name, StateMostHistory))
|
||||
}
|
||||
if s.TTLSeconds < 0 {
|
||||
problems = append(problems, fmt.Sprintf("%s gives %q a negative lifetime", m.Module, s.Name))
|
||||
}
|
||||
}
|
||||
for _, r := range m.Reads {
|
||||
module, _, err := ReadState(r)
|
||||
if err != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s reads %v", m.Module, err))
|
||||
continue
|
||||
}
|
||||
if module == m.Module {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s reads %q, which is its own state: a module reads and writes what it keeps already", m.Module, r))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// StateReadsNothingDeclares is every read across a catalogue whose owner is present and declares no
|
||||
// such state. An absent owner says nothing — a module may be installed long before the one whose
|
||||
// state it reads, as a consumer may before its emitter (design 32 §1).
|
||||
func StateReadsNothingDeclares(manifests []Manifest) []string {
|
||||
declared := map[string]map[string]bool{}
|
||||
for _, m := range manifests {
|
||||
own := map[string]bool{}
|
||||
for _, s := range m.State {
|
||||
own[s.Name] = true
|
||||
}
|
||||
declared[m.Module] = own
|
||||
}
|
||||
var problems []string
|
||||
for _, m := range manifests {
|
||||
for _, r := range m.Reads {
|
||||
module, local, err := ReadState(r)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if own, present := declared[module]; present && !own[local] {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s reads %q, and %s keeps no state called %q", m.Module, r, module, local))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module declares the state it keeps and the state it reads (novox/hq ADR 0201), a bucket by its
|
||||
// bare name or with the owner's options.
|
||||
func TestAManifestMaySayWhatStateItKeepsAndReads(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"claude-code","version":"1",` +
|
||||
`"state":["servers",{"name":"seen","history":5,"ttl-seconds":3600}],` +
|
||||
`"reads":["licence-manager.bindings"]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.State) != 2 || m.State[0].Name != "servers" || m.State[1].History != 5 || m.State[1].TTLSeconds != 3600 {
|
||||
t.Fatalf("state not read: %+v", m.State)
|
||||
}
|
||||
if len(m.Reads) != 1 || m.Reads[0] != "licence-manager.bindings" {
|
||||
t.Fatalf("reads not read: %v", m.Reads)
|
||||
}
|
||||
// Written back as it came in: the short form where nothing else is said.
|
||||
out, _ := json.Marshal(m.State)
|
||||
if string(out) != `["servers",{"name":"seen","history":5,"ttl-seconds":3600}]` {
|
||||
t.Fatalf("written back as %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A name the bus could not hold, or that would let two modules derive one bucket, is refused at
|
||||
// registration in the manifest's words.
|
||||
func TestAStateNameIsLocalAndOneToken(t *testing.T) {
|
||||
for _, c := range []struct{ manifest, says string }{
|
||||
{`{"module":"a","version":"1","state":["mesh.servers"]}`, `keeps state "mesh.servers": a state is named locally`},
|
||||
{`{"module":"a","version":"1","state":["my_servers"]}`, `keeps state "my_servers"`},
|
||||
{`{"module":"a","version":"1","state":["s","s"]}`, `keeps state "s" twice`},
|
||||
{`{"module":"a","version":"1","state":[{"name":"s","history":65}]}`, `a key keeps between 1 and 64`},
|
||||
{`{"module":"a.b","version":"1","state":["s"]}`, `no dot`},
|
||||
{`{"module":"a","version":"1","reads":["bindings"]}`, `a read is <module>.<name>`},
|
||||
{`{"module":"a","version":"1","reads":["a.s"]}`, `which is its own state`},
|
||||
{`{"module":"a","version":"1","state":[{"name":"s","shared":true}]}`, `{name, history, ttl-seconds}`},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(c.manifest))
|
||||
if err == nil {
|
||||
t.Errorf("%s was accepted", c.manifest)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s refused for the wrong reason: %v", c.manifest, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A read whose owner is present must name a state that owner keeps; an absent owner says nothing,
|
||||
// because a module may be installed before the one whose state it reads.
|
||||
func TestAReadNamesStateItsOwnerKeeps(t *testing.T) {
|
||||
owner := Manifest{Module: "licence-manager", State: []StateDeclaration{{Name: "bindings"}}}
|
||||
good := Manifest{Module: "claude-code", Reads: []string{"licence-manager.bindings", "absent.anything"}}
|
||||
bad := Manifest{Module: "other", Reads: []string{"licence-manager.tokens"}}
|
||||
if p := StateReadsNothingDeclares([]Manifest{owner, good}); len(p) != 0 {
|
||||
t.Fatalf("a read of declared state was refused: %v", p)
|
||||
}
|
||||
p := StateReadsNothingDeclares([]Manifest{owner, bad})
|
||||
if len(p) != 1 || !strings.Contains(p[0], `licence-manager keeps no state called "tokens"`) {
|
||||
t.Fatalf("a read of state nobody keeps was not named: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// **Across the whole catalogue**: every state name is local, and every read whose owner is present
|
||||
// names state that owner keeps.
|
||||
func TestEveryManifestsStateIsLocalAndEveryReadIsKept(t *testing.T) {
|
||||
manifests := theCatalogue(t)
|
||||
var problems []string
|
||||
for _, m := range manifests {
|
||||
problems = append(problems, StateProblems(m)...)
|
||||
}
|
||||
problems = append(problems, StateReadsNothingDeclares(manifests)...)
|
||||
if len(problems) > 0 {
|
||||
t.Fatalf("the catalogue's state is not what ADR 0201 says:\n %s", strings.Join(problems, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// `module check` says it too: the cross-catalogue pass names a read nothing on the shelf keeps.
|
||||
func TestTheCataloguePassNamesAReadItsOwnerDoesNotKeep(t *testing.T) {
|
||||
shelf := Shelf{
|
||||
"licence-manager": {Module: "licence-manager", State: []StateDeclaration{{Name: "bindings"}}},
|
||||
"claude-code": {Module: "claude-code", Reads: []string{"licence-manager.tokens"}},
|
||||
}
|
||||
problems := CatalogueProblems(shelf)
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], `keeps no state called "tokens"`) {
|
||||
t.Fatalf("the catalogue pass said %v", problems)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A scheduled step may hold its module's own containers still while it runs (novox/hq ADR 0189).
|
||||
//
|
||||
// The host judges what it receives — whether each id is a container on that machine. What the
|
||||
// definition is the only place to see is judged here, near whoever wrote it.
|
||||
|
||||
func aStoreManifest(step map[string]any) []byte {
|
||||
m := map[string]any{
|
||||
"module": "distribution", "version": "1",
|
||||
"resources": []any{
|
||||
map[string]any{"id": "store", "type": "container", "name": "mesh-registry",
|
||||
"image": "registry@sha256:" + strings.Repeat("a", 64)},
|
||||
step,
|
||||
},
|
||||
}
|
||||
raw, _ := json.Marshal(m)
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestAMaintenanceWindowOnItsOwnModulesContainerIsAccepted(t *testing.T) {
|
||||
raw := aStoreManifest(map[string]any{
|
||||
"id": "collect", "type": "container", "name": "mesh-registry-collect",
|
||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||
"schedule": "30 3 * * *", "while-stopped": []any{"store"},
|
||||
})
|
||||
if _, err := ParseManifest(raw); err != nil {
|
||||
t.Fatalf("a step holding its own module's container still was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMaintenanceWindowIsRefusedWhereTheDefinitionShowsItCannotMean(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
step map[string]any
|
||||
says string
|
||||
}{
|
||||
{
|
||||
"on a step with no schedule",
|
||||
map[string]any{"id": "collect", "type": "container", "name": "c",
|
||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||
"while-stopped": []any{"store"}},
|
||||
"gates what is declared after it",
|
||||
},
|
||||
{
|
||||
"on a run-once step, which already has order",
|
||||
map[string]any{"id": "collect", "type": "container", "name": "c",
|
||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||
"run-once": true, "while-stopped": []any{"store"}},
|
||||
"A maintenance window is for a recurring step",
|
||||
},
|
||||
{
|
||||
"naming a container this module does not declare",
|
||||
map[string]any{"id": "collect", "type": "container", "name": "c",
|
||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||
"schedule": "30 3 * * *", "while-stopped": []any{"the-broker"}},
|
||||
"could quiesce a neighbour could stop the mesh",
|
||||
},
|
||||
{
|
||||
"naming itself",
|
||||
map[string]any{"id": "collect", "type": "container", "name": "c",
|
||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||
"schedule": "30 3 * * *", "while-stopped": []any{"collect"}},
|
||||
"naming itself",
|
||||
},
|
||||
{
|
||||
"written as something that is not a list",
|
||||
map[string]any{"id": "collect", "type": "container", "name": "c",
|
||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||
"schedule": "30 3 * * *", "while-stopped": "store"},
|
||||
"a list of this module's container ids",
|
||||
},
|
||||
} {
|
||||
_, err := ParseManifest(aStoreManifest(c.step))
|
||||
if err == nil {
|
||||
t.Errorf("%s was accepted", c.name)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: the refusal does not say %q:\n%v", c.name, c.says, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A composed declaration names the step's held containers the way the machine knows them.
|
||||
//
|
||||
// **The gap that let a bug through to the control node.** The manifest says `while-stopped:
|
||||
// ["store"]`, because a module names its own resources locally; the declaration a machine
|
||||
// receives calls that container `distribution.store`, because every resource is composed under
|
||||
// its module. `restart-on` and `reload-on` are rewritten for exactly this reason, and
|
||||
// `while-stopped` was not — so the host found no container by that id and refused the whole
|
||||
// declaration, every push, until it was fixed.
|
||||
//
|
||||
// It passed every test on both sides: the controller's tests read manifests, the host's read
|
||||
// hand-written declarations with bare ids. Only composing one and judging the result catches it.
|
||||
func TestAComposedWindowNamesTheContainerAsTheMachineKnowsIt(t *testing.T) {
|
||||
store := Manifest{
|
||||
Module: "distribution", Version: "1",
|
||||
Provides: FromAnywhere("artifact-store"),
|
||||
Listens: []Listening{{Port: 5000, Protocol: "tcp", From: FromMesh}},
|
||||
Serves: map[string]map[string]any{"artifact-store": {"port": 5000}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "store", "type": "container", "name": "mesh-registry",
|
||||
"image": "registry@sha256:" + strings.Repeat("a", 64), "ports": []any{"5000"}},
|
||||
{"id": "collect", "type": "container", "name": "mesh-registry-collect",
|
||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||
"schedule": "30 3 * * *", WhileStopped: []any{"store"}},
|
||||
},
|
||||
}
|
||||
r, err := Resolve(shelf(store), []string{"distribution"}, reachable(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collect := fileNamed(out, "distribution.collect")
|
||||
if collect == nil {
|
||||
for _, res := range out {
|
||||
if res["id"] == "distribution.collect" {
|
||||
collect = res
|
||||
}
|
||||
}
|
||||
}
|
||||
if collect == nil {
|
||||
t.Fatalf("the step was not composed at all: %v", out)
|
||||
}
|
||||
held, _ := collect[WhileStopped].([]any)
|
||||
if len(held) != 1 {
|
||||
t.Fatalf("the composed step holds %v still; want one container", collect[WhileStopped])
|
||||
}
|
||||
if got := fmt.Sprint(held[0]); got != "distribution.store" {
|
||||
t.Fatalf("the composed step says it holds %q still, and the machine's container is "+
|
||||
"called %q — the host refuses a declaration naming a container it does not have, "+
|
||||
"whole, so the machine would take nothing at all", got, "distribution.store")
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,29 @@ type Build struct {
|
||||
// Failed is the builder's own words, empty when it worked.
|
||||
Failed string
|
||||
Made []Artifact
|
||||
// Asked is when the build was requested, zero when that is not known (an id of another shape,
|
||||
// or a build recorded before the mesh kept it). **What orders one build of a module against
|
||||
// another** (novox/hq 04-ISSUES/219): builds in flight together finish in any order, and the
|
||||
// one asked last stood on the newest bases.
|
||||
Asked time.Time
|
||||
// At is when the outcome was recorded — when it finished, not when it was asked.
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// AskedOrAt is when the build was asked, or when it was recorded when that is not known — the
|
||||
// order the mesh had before it kept the request time.
|
||||
func (b Build) AskedOrAt() time.Time {
|
||||
if !b.Asked.IsZero() {
|
||||
return b.Asked
|
||||
}
|
||||
return b.At
|
||||
}
|
||||
|
||||
// newestRequestFirst is the ordering every "what a module currently is" question uses: the newest
|
||||
// request wins, whenever it finished (novox/hq 04-ISSUES/219). A build whose request time is not
|
||||
// known is placed at the moment it was recorded, which is the rule that held before.
|
||||
const newestRequestFirst = `coalesce(asked, at) desc, at desc`
|
||||
|
||||
// ReadRepository is a repository a build read source from besides the module's own.
|
||||
type ReadRepository struct {
|
||||
Repository string `json:"repository"`
|
||||
@@ -83,13 +103,17 @@ func (i *Inventory) RecordBuild(ctx context.Context, b Build) error {
|
||||
if b.Module != "" {
|
||||
module = &b.Module
|
||||
}
|
||||
var asked *time.Time
|
||||
if !b.Asked.IsZero() {
|
||||
asked = &b.Asked
|
||||
}
|
||||
_, err = i.store.Pool().Exec(ctx,
|
||||
`insert into build (id, repository, ref, module, commit_hash, built_on, failed, made,
|
||||
source_path, manifest, built_against, built_contexts)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
source_path, manifest, built_against, built_contexts, asked)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
on conflict (id) do nothing`,
|
||||
b.ID, b.Repository, b.Ref, module, b.Commit, b.On, b.Failed, made,
|
||||
b.Path, manifestOrNil(b.Manifest), against, read)
|
||||
b.Path, manifestOrNil(b.Manifest), against, read, asked)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -102,11 +126,11 @@ func (i *Inventory) Builds(ctx context.Context, module string, limit int) ([]Bui
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
query := `select id, repository, ref, coalesce(module,''), commit_hash, built_on, failed, made, at
|
||||
query := `select id, repository, ref, coalesce(module,''), commit_hash, built_on, failed, made, asked, at
|
||||
from build order by at desc limit $1`
|
||||
args := []any{limit}
|
||||
if module != "" {
|
||||
query = `select id, repository, ref, coalesce(module,''), commit_hash, built_on, failed, made, at
|
||||
query = `select id, repository, ref, coalesce(module,''), commit_hash, built_on, failed, made, asked, at
|
||||
from build where module = $2 order by at desc limit $1`
|
||||
args = append(args, module)
|
||||
}
|
||||
@@ -121,10 +145,14 @@ func (i *Inventory) Builds(ctx context.Context, module string, limit int) ([]Bui
|
||||
for rows.Next() {
|
||||
var b Build
|
||||
var made []byte
|
||||
var asked *time.Time
|
||||
if err := rows.Scan(&b.ID, &b.Repository, &b.Ref, &b.Module, &b.Commit,
|
||||
&b.On, &b.Failed, &made, &b.At); err != nil {
|
||||
&b.On, &b.Failed, &made, &asked, &b.At); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if asked != nil {
|
||||
b.Asked = *asked
|
||||
}
|
||||
if err := json.Unmarshal(made, &b.Made); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -135,8 +163,9 @@ func (i *Inventory) Builds(ctx context.Context, module string, limit int) ([]Bui
|
||||
|
||||
// Held is every artifact this mesh has built, keyed "<module>/<artifact>".
|
||||
//
|
||||
// **The newest successful build of each module wins**, which is the same rule the rest of the mesh
|
||||
// uses for what a module currently is. A module rebuilt to something broken and then rebuilt again
|
||||
// **The successful build of each module asked last wins**, which is the same rule the rest of the
|
||||
// mesh uses for what a module currently is — asked last, not finished last (novox/hq
|
||||
// 04-ISSUES/219): an older request that finishes later stood on older bases. A module rebuilt to something broken and then rebuilt again
|
||||
// is at the second one; a module whose last build failed is at the last one that worked, because a
|
||||
// failure published nothing and the thing it published before is still what exists.
|
||||
//
|
||||
@@ -147,7 +176,7 @@ func (i *Inventory) Held(ctx context.Context) (map[string]string, error) {
|
||||
`select distinct on (module) module, made
|
||||
from build
|
||||
where module is not null and module <> '' and failed = ''
|
||||
order by module, at desc`)
|
||||
order by module, `+newestRequestFirst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -185,7 +214,7 @@ func (i *Inventory) BuiltAgainst(ctx context.Context) (map[string][]string, erro
|
||||
`select distinct on (module) module, built_against
|
||||
from build
|
||||
where module is not null and module <> '' and failed = ''
|
||||
order by module, at desc`)
|
||||
order by module, `+newestRequestFirst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -223,7 +252,7 @@ func (i *Inventory) ReadRepositories(ctx context.Context) (map[string][]ReadRepo
|
||||
`select distinct on (module) module, built_contexts
|
||||
from build
|
||||
where module is not null and module <> '' and failed = ''
|
||||
order by module, at desc`)
|
||||
order by module, `+newestRequestFirst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -274,7 +303,7 @@ func manifestOrNil(raw []byte) any {
|
||||
// follows when it decides whether to announce at all.
|
||||
//
|
||||
// One row per module and commit: a module built twice at the same commit is one fact, and the
|
||||
// latest row is the one whose artifacts are current.
|
||||
// row asked last is the one whose artifacts are current (novox/hq 04-ISSUES/219).
|
||||
func (i *Inventory) Announceable(ctx context.Context) ([]Build, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select distinct on (module, commit_hash)
|
||||
@@ -282,7 +311,7 @@ func (i *Inventory) Announceable(ctx context.Context) ([]Build, error) {
|
||||
source_path, manifest, built_against, at
|
||||
from build
|
||||
where failed = '' and module is not null and module <> '' and commit_hash <> ''
|
||||
order by module, commit_hash, at desc`)
|
||||
order by module, commit_hash, `+newestRequestFirst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -49,6 +49,12 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Who holds each seat held once for the mesh, where the mesh recorded it (novox/hq issue 218).
|
||||
holdings, err := i.Holdings(ctx)
|
||||
if err != nil {
|
||||
return broker.Records{}, fmt.Errorf("cannot read who holds the mesh's seats: %w", err)
|
||||
}
|
||||
|
||||
out := broker.Records{Assigned: map[string][]broker.Declared{}, People: map[string][]string{},
|
||||
Interchangeable: map[string]bool{}}
|
||||
for _, n := range nodes {
|
||||
@@ -72,7 +78,9 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
|
||||
"%s is assigned to %s and is not in the catalogue, so what it may say cannot "+
|
||||
"be derived", module, n.Name)
|
||||
}
|
||||
out.Assigned[n.Name] = append(out.Assigned[n.Name], declaredFor(m, seats))
|
||||
d := declaredFor(m, seats)
|
||||
d.Holds = heldHere(d.Holds, holdings, n.Name, module)
|
||||
out.Assigned[n.Name] = append(out.Assigned[n.Name], d)
|
||||
if m.Instances == catalogue.InstancesInterchangeable {
|
||||
out.Interchangeable[m.Module] = true
|
||||
}
|
||||
@@ -124,6 +132,9 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
||||
Serves: m.Tools,
|
||||
// And what it calls (novox/hq ADR 0152) — the console's `*`, nothing else's.
|
||||
Invokes: m.Invokes,
|
||||
// And the state it keeps and reads (novox/hq ADR 0201).
|
||||
State: bucketsOf(m),
|
||||
Reads: m.Reads,
|
||||
}
|
||||
for _, c := range m.Claims {
|
||||
// Every seat with a protocol, the mesh's own included. One that says only who does a job is
|
||||
@@ -140,6 +151,30 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
||||
return d
|
||||
}
|
||||
|
||||
// bucketsOf is the state a module keeps, as the bus holds it.
|
||||
func bucketsOf(m catalogue.Manifest) []broker.Bucket {
|
||||
var out []broker.Bucket
|
||||
for _, s := range m.State {
|
||||
out = append(out, broker.Bucket{Module: m.Module, Name: s.Name, History: s.History, TTLSeconds: s.TTLSeconds})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DeclaredBuckets is every bucket the catalogue declares, registered modules assigned or not: a
|
||||
// bucket exists from registration, like a seat's stream, so a module reading it may watch before its
|
||||
// owner runs anywhere (novox/hq ADR 0201).
|
||||
func (i *Inventory) DeclaredBuckets(ctx context.Context) ([]broker.Bucket, error) {
|
||||
declared, err := i.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read the catalogue: %w", err)
|
||||
}
|
||||
var out []broker.Bucket
|
||||
for _, m := range declared {
|
||||
out = append(out, bucketsOf(m)...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func asSeat(s catalogue.SeatDeclaration) broker.Seat {
|
||||
return broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits,
|
||||
Serves: catalogue.VerbNames(s.Serves)}
|
||||
@@ -183,3 +218,33 @@ func (i *Inventory) NodesWithALiveToken(ctx context.Context) ([]string, error) {
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// heldHere keeps of what a module claims only the seats it holds on this machine (novox/hq issue 218).
|
||||
// A seat held once per machine is held by every assignment that claims it. A seat held once for the
|
||||
// mesh is held by one assignment: where the mesh recorded who holds it, a claim on any other machine
|
||||
// grants nothing and issues nothing — or the module would serve the role's verbs from a machine that
|
||||
// is not the role's, and a question to the mesh's store would be answered from the wrong database. A
|
||||
// mesh seat with no holder on record is left as it was derived.
|
||||
func heldHere(claimed []broker.Seat, holdings []catalogue.Held, node, module string) []broker.Seat {
|
||||
recorded := map[string][]catalogue.Held{}
|
||||
for _, h := range holdings {
|
||||
if h.Scope == catalogue.ScopeMesh {
|
||||
recorded[h.Claim] = append(recorded[h.Claim], h)
|
||||
}
|
||||
}
|
||||
var out []broker.Seat
|
||||
for _, s := range claimed {
|
||||
holders, onRecord := recorded[s.Name]
|
||||
if s.Scope != catalogue.ScopeMesh || !onRecord {
|
||||
out = append(out, s)
|
||||
continue
|
||||
}
|
||||
for _, h := range holders {
|
||||
if h.Node == node && h.Module == module {
|
||||
out = append(out, s)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ import (
|
||||
// ErrNoSuchModule is what the mesh says about a module it has never been told about.
|
||||
var ErrNoSuchModule = errors.New("no module of that name")
|
||||
|
||||
// ErrSuperseded is a registration from a build asked before the one the module is already at
|
||||
// (novox/hq 04-ISSUES/219). The build is recorded; what the module is does not change.
|
||||
var ErrSuperseded = errors.New("a build asked later is already what the module is")
|
||||
|
||||
// ErrStillAssigned is why a module cannot be forgotten.
|
||||
//
|
||||
// Its own error because it is not a fault: it means a machine is running that module now, and
|
||||
@@ -47,6 +51,10 @@ type Source struct {
|
||||
// itself no longer carries its build (novox/hq to-be 38 WP2.4). Empty for a manifest handed over
|
||||
// by hand, which carries its `build.on` itself.
|
||||
Against []string
|
||||
// Asked is when the build this manifest came from was requested (novox/hq 04-ISSUES/219). Zero
|
||||
// is a manifest handed over by hand, or a build whose request time is not known: either is
|
||||
// taken as asked at the moment it is registered.
|
||||
Asked time.Time
|
||||
}
|
||||
|
||||
// Current reports whether what the mesh holds is what the source last had.
|
||||
@@ -70,11 +78,12 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
||||
// (novox/hq ADR 0175, to-be 38 WP2.4): a module serving its tools from a container built on the
|
||||
// runtime's image. Refused at registration, by name, for a module that is new to the catalogue
|
||||
// or that was registered in another shape — the mechanism that keeps the old pattern from
|
||||
// returning by habit. **Not refused for a module already registered in that shape**: the
|
||||
// catalogue holds some thirty of them the day the runtime arrives, each moves to a bundle in
|
||||
// its own change (to-be 38 WP4 onward), and a gate that refused every rebuild of every unmoved
|
||||
// module in the meantime would stop the whole pipeline to make a point the record already makes.
|
||||
// Before the runtime exists the pattern is accepted as it always was.
|
||||
// returning by habit. Before the runtime exists the pattern is accepted as it always was.
|
||||
//
|
||||
// *Since 2026-10-04 (to-be 38 WP4b's last step):* refused for **every** module. While some
|
||||
// thirty modules still stood in that shape, one already registered so was rebuilt without
|
||||
// complaint, so the pipeline kept running while each moved; every module has moved since, and
|
||||
// the exception would only let one move back.
|
||||
if m.Module != catalogue.RuntimeModule {
|
||||
if why := catalogue.ToolContainerOnTheRuntime(m, from.Against); why != "" {
|
||||
runtime, err := i.hasModule(ctx, catalogue.RuntimeModule)
|
||||
@@ -82,28 +91,32 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
||||
return err
|
||||
}
|
||||
if runtime {
|
||||
already, err := i.registeredInThatShape(ctx, m.Module)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !already {
|
||||
return fmt.Errorf("%s is not registered: %s", m.Module, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
asked := from.Asked
|
||||
if asked.IsZero() {
|
||||
asked = time.Now()
|
||||
}
|
||||
|
||||
// A module registered without provenance keeps whatever it had. Handing over a manifest by
|
||||
// hand is a legitimate way to fix something in a hurry, and it should not silently erase the
|
||||
// record of where the module normally comes from — which is the only thing that would say,
|
||||
// afterwards, that the machine is running something nobody can rebuild.
|
||||
_, err = i.store.Pool().Exec(ctx,
|
||||
`insert into module (name, manifest, version, source, source_path, source_seat, ref, built_from, source_head)
|
||||
values ($1, $2, nullif($3,''), nullif($4,''), $7, $8, nullif($5,''), nullif($6,''), nullif($6,''))
|
||||
//
|
||||
// **An older request never replaces a newer one** (novox/hq 04-ISSUES/219). Builds of one
|
||||
// module in flight together finish in any order, and each stood on the bases the mesh held when
|
||||
// it was asked; the one asked later is what the module is, whichever is heard last. An outcome
|
||||
// of an earlier request is kept in the build records and changes nothing here.
|
||||
tag, err := i.store.Pool().Exec(ctx,
|
||||
`insert into module (name, manifest, version, source, source_path, source_seat, ref, built_from, source_head, built_asked)
|
||||
values ($1, $2, nullif($3,''), nullif($4,''), $7, $8, nullif($5,''), nullif($6,''), nullif($6,''), $9)
|
||||
on conflict (name) do update set
|
||||
manifest = excluded.manifest,
|
||||
version = excluded.version,
|
||||
@@ -115,29 +128,23 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
||||
else excluded.source_seat end,
|
||||
ref = coalesce(excluded.ref, module.ref),
|
||||
built_from = coalesce(excluded.built_from, module.built_from),
|
||||
source_head = coalesce(excluded.built_from, module.source_head)`,
|
||||
m.Module, raw, m.Version, from.Repository, from.Ref, from.BuiltFrom, from.Path, from.Seat)
|
||||
source_head = coalesce(excluded.built_from, module.source_head),
|
||||
built_asked = excluded.built_asked
|
||||
where module.built_asked is null or module.built_asked <= excluded.built_asked`,
|
||||
m.Module, raw, m.Version, from.Repository, from.Ref, from.BuiltFrom, from.Path, from.Seat, asked)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// registeredInThatShape is whether the catalogue already holds this module as a tools container on
|
||||
// the runtime's image — judged from the manifest it holds and what that module's newest build stood
|
||||
// on, the same two things the gate judges a new registration by. False for a module the catalogue
|
||||
// does not hold.
|
||||
func (i *Inventory) registeredInThatShape(ctx context.Context, name string) (bool, error) {
|
||||
held, err := i.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
stored, has := held[name]
|
||||
if !has {
|
||||
return false, nil
|
||||
if tag.RowsAffected() == 0 {
|
||||
var current time.Time
|
||||
if err := i.store.Pool().QueryRow(ctx,
|
||||
`select built_asked from module where name = $1`, m.Module).Scan(¤t); err != nil {
|
||||
return err
|
||||
}
|
||||
against, err := i.BuiltAgainst(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return fmt.Errorf("%w: %s is at a build asked %s, and this one was asked %s",
|
||||
ErrSuperseded, m.Module, current.UTC().Format(time.RFC3339), asked.UTC().Format(time.RFC3339))
|
||||
}
|
||||
return catalogue.ToolContainerOnTheRuntime(stored, against[name]) != "", nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasModule is whether the catalogue holds a module of that name.
|
||||
|
||||
@@ -688,9 +688,9 @@ func TestRegisteringWithoutProvenanceKeepsTheSeat(t *testing.T) {
|
||||
|
||||
// Once the node's tool runtime is in the catalogue, a module serving its tools from a container
|
||||
// built on the runtime's image is refused at registration, naming the record (novox/hq ADR 0175,
|
||||
// to-be 38 WP2.4) — for a module new to the catalogue or one that had moved away from it; a module
|
||||
// already standing in that shape is rebuilt as before, so the catalogue's pipeline keeps running
|
||||
// while each moves (WP3's amendment). Before the runtime, it is accepted as it always was — so a
|
||||
// to-be 38 WP2.4) — for every module, since every module has moved (WP4b's last step; WP3's
|
||||
// amendment let one already standing in that shape be rebuilt while each moved). Before the
|
||||
// runtime, it is accepted as it always was — so a
|
||||
// mesh converts in the order the design says and nothing is refused before there is anything to
|
||||
// move to.
|
||||
func TestAToolContainerIsRefusedOnceTheRuntimeIsRegistered(t *testing.T) {
|
||||
@@ -715,11 +715,11 @@ func TestAToolContainerIsRefusedOnceTheRuntimeIsRegistered(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// **A module already registered in that shape is rebuilt without complaint** (to-be 38 WP2.4 as
|
||||
// amended by WP3): some thirty of them stand the day the runtime arrives, and each moves in its
|
||||
// own change. The gate is against the pattern spreading, not against the pipeline running.
|
||||
if err := inv.RegisterModule(ctx, filter, Source{Repository: "/r", Against: stoodOn}); err != nil {
|
||||
t.Fatalf("a rebuild of a module that already had the pattern was refused: %v", err)
|
||||
// **A module already registered in that shape is refused too** (WP4b's last step): every module
|
||||
// has moved, and a rebuild in the old shape is one moving back.
|
||||
if err := inv.RegisterModule(ctx, filter, Source{Repository: "/r", Against: stoodOn}); err == nil ||
|
||||
!strings.Contains(err.Error(), "ADR 0175") {
|
||||
t.Fatalf("a rebuild of a module in the old pattern was registered beside the runtime: %v", err)
|
||||
}
|
||||
// A module new to the catalogue in that shape is refused, naming the record.
|
||||
newcomer := filter
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What the artifact store keeps, and what it may let go (novox/hq ADR 0189, issue 108).
|
||||
//
|
||||
// The store has never collected anything: every build pushes another layer set and nothing has
|
||||
// ever removed one. The registry's own answer — collect what no tag names — is wrong here, because
|
||||
// the mesh pushes each artifact under one moving tag and pins machines by digest, so every build
|
||||
// but the newest is untagged and some machine may still be running it.
|
||||
//
|
||||
// **So the mesh decides, from its own records, and it never has to look in the store to do it.**
|
||||
// It has never put anything there it did not record, which means every digest it could remove is
|
||||
// already in a build row. A digest the mesh did not record making is therefore never named here —
|
||||
// not as a safety margin but as the rule restated, and it is what keeps the sweep away from the
|
||||
// images genesis pushed before any record existed (04-ISSUES/102, F4).
|
||||
|
||||
// KeptBuilds is how many successful builds of each module keep their artifacts, counting the
|
||||
// newest. The newest is what the mesh hands a machine now; the four behind it are how far back a
|
||||
// release that turns out wrong can be taken.
|
||||
const KeptBuilds = 5
|
||||
|
||||
// ToCollect is every artifact the mesh made, no longer keeps, and has not already collected.
|
||||
//
|
||||
// Three reasons an artifact stays, and nothing else is a reason:
|
||||
//
|
||||
// - **a definition names it** — the reference appears in a module's recorded manifest, which is
|
||||
// what the mesh would hand a machine now. No age limit: this is the floor;
|
||||
// - **the mesh can still go back to it** — it is an artifact of one of the KeptBuilds most
|
||||
// recent successful builds of its module;
|
||||
// - it was already collected, in which case there is nothing left to do.
|
||||
//
|
||||
// Returned in a stated order so two runs over the same records ask for the same things in the
|
||||
// same sequence, which is what makes a failed sweep safe to simply run again.
|
||||
func (i *Inventory) ToCollect(ctx context.Context) ([]string, error) {
|
||||
keep, err := i.keptReferences(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
// Every artifact of every successful build, oldest first, minus what has already been
|
||||
// collected. A failed build published nothing, so it names nothing to remove.
|
||||
`select b.made
|
||||
from build b
|
||||
where b.failed = '' and b.module is not null and b.module <> ''
|
||||
order by b.at asc, b.id asc`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
collected, err := i.alreadyCollected(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var made []Artifact
|
||||
if err := json.Unmarshal(raw, &made); err != nil {
|
||||
// One unreadable record must not stop the rest being collected — and an artifact this
|
||||
// row named is simply not offered, which errs toward keeping.
|
||||
continue
|
||||
}
|
||||
for _, a := range made {
|
||||
if a.Reference == "" || keep[a.Reference] || collected[a.Reference] || seen[a.Reference] {
|
||||
continue
|
||||
}
|
||||
seen[a.Reference] = true
|
||||
out = append(out, a.Reference)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// keptReferences is every artifact reference the mesh still keeps, for either of the two reasons.
|
||||
func (i *Inventory) keptReferences(ctx context.Context) (map[string]bool, error) {
|
||||
keep := map[string]bool{}
|
||||
|
||||
// **Whatever a definition the mesh holds names.** Read as text rather than by walking the
|
||||
// resource shapes: a reference may be a container's image, a bundle's source, or a field some
|
||||
// later kind of resource grows, and what matters is only whether the mesh could hand this
|
||||
// string to a machine. A manifest that mentions it is a manifest that might.
|
||||
manifests, err := i.store.Pool().Query(ctx, `select manifest::text from module where manifest is not null`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer manifests.Close()
|
||||
var named []string
|
||||
for manifests.Next() {
|
||||
var text string
|
||||
if err := manifests.Scan(&text); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
named = append(named, text)
|
||||
}
|
||||
if err := manifests.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The KeptBuilds most recent successful builds of each module, whole.
|
||||
recent, err := i.store.Pool().Query(ctx,
|
||||
`select made from (
|
||||
select made, row_number() over (partition by module order by at desc, id desc) as back
|
||||
from build
|
||||
where failed = '' and module is not null and module <> ''
|
||||
) ranked where back <= $1`, KeptBuilds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer recent.Close()
|
||||
for recent.Next() {
|
||||
var raw []byte
|
||||
if err := recent.Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var made []Artifact
|
||||
if err := json.Unmarshal(raw, &made); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, a := range made {
|
||||
if a.Reference != "" {
|
||||
keep[a.Reference] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := recent.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// And anything a manifest mentions. Done after the recent set so the scan runs over the
|
||||
// candidates rather than over every reference ever recorded: a manifest holds a reference
|
||||
// composed with the store's address or kept bare, so the search is for the digest within it.
|
||||
if len(named) > 0 {
|
||||
all, err := i.everyReferenceMade(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, reference := range all {
|
||||
if keep[reference] {
|
||||
continue
|
||||
}
|
||||
digest := digestIn(reference)
|
||||
if digest == "" {
|
||||
// Not something the store holds by digest; nothing here can speak for it, so it
|
||||
// is kept rather than guessed about.
|
||||
keep[reference] = true
|
||||
continue
|
||||
}
|
||||
for _, text := range named {
|
||||
if strings.Contains(text, digest) {
|
||||
keep[reference] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return keep, nil
|
||||
}
|
||||
|
||||
// everyReferenceMade is every artifact reference any successful build recorded.
|
||||
func (i *Inventory) everyReferenceMade(ctx context.Context) ([]string, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select made from build where failed = '' and module is not null and module <> ''`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var made []Artifact
|
||||
if err := json.Unmarshal(raw, &made); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, a := range made {
|
||||
if a.Reference == "" || seen[a.Reference] {
|
||||
continue
|
||||
}
|
||||
seen[a.Reference] = true
|
||||
out = append(out, a.Reference)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// digestIn is the `sha256:<hex>` a reference names, empty when it names none.
|
||||
func digestIn(reference string) string {
|
||||
for _, marker := range []string{"@sha256:", "/sha256:"} {
|
||||
if _, after, ok := strings.Cut(reference, marker); ok {
|
||||
return "sha256:" + after
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// alreadyCollected is what the store has already been asked to let go.
|
||||
func (i *Inventory) alreadyCollected(ctx context.Context) (map[string]bool, error) {
|
||||
rows, err := i.store.Pool().Query(ctx, `select reference from artifact_collected`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var reference string
|
||||
if err := rows.Scan(&reference); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[reference] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MarkCollected records that the store no longer holds these.
|
||||
//
|
||||
// **A store that answered "not found" is recorded too.** The outcome wanted is that the artifact
|
||||
// is gone, and it is; retrying it every sweep for ever is the failure this table exists to
|
||||
// prevent. Only a store that could not be reached, or refused, leaves a reference unmarked — and
|
||||
// then the next sweep asks again, which is what should happen.
|
||||
func (i *Inventory) MarkCollected(ctx context.Context, references []string) error {
|
||||
for _, reference := range references {
|
||||
if _, err := i.store.Pool().Exec(ctx,
|
||||
`insert into artifact_collected (reference) values ($1) on conflict (reference) do nothing`,
|
||||
reference); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// What the store keeps, and what it may let go (novox/hq ADR 0189, issue 108).
|
||||
//
|
||||
// The store has collected nothing since it was raised, and the registry's own answer — collect
|
||||
// what no tag names — would delete images machines are running, because the mesh pushes under one
|
||||
// moving tag and pins by digest. So the rule is the mesh's, read from its own records, and these
|
||||
// are the three reasons an artifact stays and the one reason it goes.
|
||||
|
||||
// ref is an artifact reference as the mesh records one.
|
||||
func ref(module, artifact string, n int) string {
|
||||
return fmt.Sprintf("%s%s/%s@sha256:%064x", catalogue.ArtifactStoreScheme, module, artifact, n)
|
||||
}
|
||||
|
||||
// built records one successful build of a module publishing one image.
|
||||
func built(t *testing.T, inv *Inventory, id, module string, n int) string {
|
||||
t.Helper()
|
||||
reference := ref(module, "app", n)
|
||||
b := aBuild(id, module, "")
|
||||
b.Made = []Artifact{{Name: "app", Kind: "image", Reference: reference}}
|
||||
if err := inv.RecordBuild(context.Background(), b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reference
|
||||
}
|
||||
|
||||
func TestTheStoreKeepsTheRecentBuildsAndLetsGoOfTheRest(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Eight builds of one module, oldest first. Five are kept — the newest, and the four a
|
||||
// release that turns out wrong can be taken back to.
|
||||
var made []string
|
||||
for i := 1; i <= 8; i++ {
|
||||
made = append(made, built(t, inv, fmt.Sprintf("b%02d", i), "web", i))
|
||||
}
|
||||
|
||||
go_, err := inv.ToCollect(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := made[:3] // the three oldest
|
||||
if len(go_) != len(want) {
|
||||
t.Fatalf("offered %v to collect; want the %d oldest of %d", go_, len(want), len(made))
|
||||
}
|
||||
for i := range want {
|
||||
if go_[i] != want[i] {
|
||||
t.Fatalf("offered %v; want %v — and in that order, so a failed sweep is safe to run again",
|
||||
go_, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestADefinitionNamingAnArtifactKeepsItHoweverOldItIs(t *testing.T) {
|
||||
// The floor: no age limit. A module recorded at an older commit still names what the mesh
|
||||
// would hand a machine now, and that is what must not be collected out from under it.
|
||||
inv := fresh(t)
|
||||
ctx := context.Background()
|
||||
|
||||
var made []string
|
||||
for i := 1; i <= 8; i++ {
|
||||
made = append(made, built(t, inv, fmt.Sprintf("b%02d", i), "web", i))
|
||||
}
|
||||
oldest := made[0]
|
||||
|
||||
// A definition the mesh holds, whose container runs that oldest image.
|
||||
m := catalogue.Manifest{Module: "web", Version: "1", Resources: []map[string]any{{
|
||||
"id": "app", "type": "container", "name": "web", "image": oldest,
|
||||
}}}
|
||||
if err := inv.RegisterModule(ctx, m, Source{Repository: "https://forge.invalid/web.git"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
go_, err := inv.ToCollect(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, reference := range go_ {
|
||||
if reference == oldest {
|
||||
t.Fatalf("the mesh offered to collect %s, which a definition it holds names", oldest)
|
||||
}
|
||||
}
|
||||
if len(go_) != 2 {
|
||||
t.Fatalf("offered %v; want the two oldest that nothing names", go_)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatHasBeenCollectedIsNotOfferedAgain(t *testing.T) {
|
||||
// Without this the sweep reissues a delete for every artifact it has ever collected, every
|
||||
// time it runs, for ever — a number of requests that grows with the mesh's whole history.
|
||||
inv := fresh(t)
|
||||
ctx := context.Background()
|
||||
for i := 1; i <= 7; i++ {
|
||||
built(t, inv, fmt.Sprintf("b%02d", i), "web", i)
|
||||
}
|
||||
first, err := inv.ToCollect(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(first) != 2 {
|
||||
t.Fatalf("offered %v, want two", first)
|
||||
}
|
||||
if err := inv.MarkCollected(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := inv.ToCollect(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(again) != 0 {
|
||||
t.Fatalf("offered %v again after collecting it", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedBuildNamesNothingToCollectAndEachModuleIsCountedOnItsOwn(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// A failed build published nothing, so it is neither kept nor collected — and it must not
|
||||
// count against the module's five.
|
||||
for i := 1; i <= 6; i++ {
|
||||
built(t, inv, fmt.Sprintf("w%02d", i), "web", i)
|
||||
}
|
||||
if err := inv.RecordBuild(ctx, aBuild("w99", "web", "the recipe would not build")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// And a second module with three builds keeps all three: five each, not five between them.
|
||||
for i := 1; i <= 3; i++ {
|
||||
built(t, inv, fmt.Sprintf("d%02d", i), "db", 100+i)
|
||||
}
|
||||
|
||||
go_, err := inv.ToCollect(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(go_) != 1 || go_[0] != ref("web", "app", 1) {
|
||||
t.Fatalf("offered %v; want only web's oldest — db's three are all within its five", go_)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/builder"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
@@ -96,6 +97,21 @@ func dependenciesOf(entries []Entry, against map[string][]string, read map[strin
|
||||
add(name, on.Module, EdgeDeclared)
|
||||
}
|
||||
}
|
||||
// **A bundle stands on the toolchain it is compiled in** (novox/hq 04-ISSUES/211). A
|
||||
// manifest names its toolchain by language, not in `build.on`, so the edge was implicit
|
||||
// and a merge that moved the toolchain and a bundle together built both in one tier —
|
||||
// the bundle against the toolchain as it was, recorded as built from the new commit. Read
|
||||
// from the manifest, so it holds before any build has recorded what it stood on; and a
|
||||
// toolchain that moves rebuilds every bundle compiled in it, which is what a toolchain
|
||||
// carrying a bundle's dependencies requires.
|
||||
for _, a := range e.Manifest.Build.Artifacts {
|
||||
if a.Kind != catalogue.ArtifactBundle {
|
||||
continue
|
||||
}
|
||||
if chain, err := builder.ToolchainFor(a.Language); err == nil {
|
||||
add(name, chain.Base, EdgeStandsOn)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, ref := range against[name] {
|
||||
if rest, ok := strings.CutPrefix(ref, catalogue.ArtifactStoreScheme); ok {
|
||||
|
||||
@@ -62,3 +62,61 @@ func TestDependenciesAreOneRelationWithTheirKinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/211: a bundle stands on the toolchain it is compiled in, so a merge moving both
|
||||
// builds the toolchain first — read from the manifest, before any build recorded it.
|
||||
func TestABundleStandsOnTheToolchainItIsCompiledIn(t *testing.T) {
|
||||
entries := []Entry{
|
||||
{Manifest: catalogue.Manifest{Module: "mesh-tools"}, Source: Source{Repository: "novox/mesh-tools"}},
|
||||
{Manifest: catalogue.Manifest{Module: "mesh-tools-go"}, Source: Source{Repository: "novox/mesh-tools-go"}},
|
||||
{Manifest: catalogue.Manifest{Module: "node-tools", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
|
||||
{Name: "runtime", Kind: catalogue.ArtifactBundle, Language: "go", System: "arch", From: "cmd/node-tools"}}}},
|
||||
Source: Source{Repository: "novox/mesh-tools"}},
|
||||
{Manifest: catalogue.Manifest{Module: "nftables", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
|
||||
{Name: "tools", Kind: catalogue.ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"}}}}},
|
||||
Source: Source{Repository: "novox/mesh-catalog"}},
|
||||
{Manifest: catalogue.Manifest{Module: "photos", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
|
||||
{Name: "server", Kind: catalogue.ArtifactImage, From: "Dockerfile"}}}},
|
||||
Source: Source{Repository: "novox/photos"}},
|
||||
}
|
||||
edges := dependenciesOf(entries, nil, nil)
|
||||
has := func(from, to string) bool {
|
||||
for _, e := range edges {
|
||||
if e.From == from && e.To == to && e.Kind == EdgeStandsOn {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !has("nftables", "mesh-tools") {
|
||||
t.Errorf("a TypeScript bundle does not stand on the TypeScript toolchain: %v", edges)
|
||||
}
|
||||
if !has("node-tools", "mesh-tools-go") {
|
||||
t.Errorf("a Go bundle does not stand on the Go toolchain: %v", edges)
|
||||
}
|
||||
for _, e := range edges {
|
||||
if e.From == "photos" && e.Kind == EdgeStandsOn {
|
||||
t.Errorf("an image stands on a toolchain it is not compiled in: %v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/212: a toolchain standing on the SDK's package is planned after the SDK, so a
|
||||
// release of the SDK rebuilds the toolchain, and every bundle compiled in it after that.
|
||||
func TestAToolchainStandingOnTheSDKFollowsIt(t *testing.T) {
|
||||
entries := []Entry{
|
||||
{Manifest: catalogue.Manifest{Module: "mesh-sdk"}},
|
||||
{Manifest: catalogue.Manifest{Module: "mesh-tools", Build: &catalogue.Build{
|
||||
On: []catalogue.BuildsOn{{Arg: "MESH_SDK", Module: "mesh-sdk", Artifact: "lib"}}}}},
|
||||
}
|
||||
edges := dependenciesOf(entries, nil, nil)
|
||||
found := false
|
||||
for _, e := range edges {
|
||||
if e.From == "mesh-tools" && e.To == "mesh-sdk" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no edge from the toolchain to the SDK: %v", edges)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// novox/hq issue 218: a seat held once for the mesh is granted and issued only to the holder on record;
|
||||
// a node seat to every machine's claimant; a mesh seat with no holder on record as derived.
|
||||
func TestOnlyTheRecordedHolderHoldsAMeshSeat(t *testing.T) {
|
||||
claimed := []broker.Seat{
|
||||
{Name: "mesh-store", Scope: catalogue.ScopeMesh},
|
||||
{Name: "node-packet-filter", Scope: catalogue.ScopeNode},
|
||||
{Name: "unrecorded", Scope: catalogue.ScopeMesh},
|
||||
}
|
||||
holdings := []catalogue.Held{{Claim: "mesh-store", Scope: catalogue.ScopeMesh, Node: "control", Module: "postgres"}}
|
||||
names := func(ss []broker.Seat) (out []string) {
|
||||
for _, s := range ss {
|
||||
out = append(out, s.Name)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got := names(heldHere(claimed, holdings, "control", "postgres")); len(got) != 3 {
|
||||
t.Errorf("the holder lost a seat: %v", got)
|
||||
}
|
||||
got := names(heldHere(claimed, holdings, "other", "postgres"))
|
||||
if len(got) != 2 || got[0] != "node-packet-filter" || got[1] != "unrecorded" {
|
||||
t.Errorf("a claimant on another machine holds %v; want the node seat and the unrecorded one, not the store", got)
|
||||
}
|
||||
}
|
||||
@@ -87,3 +87,62 @@ func (i *Inventory) tryHold(ctx context.Context, sorted []string) (func(), strin
|
||||
}
|
||||
return release, "", nil
|
||||
}
|
||||
|
||||
// ErrPlansBusy is the plans held by another act — on a machine replacing its controller, the other
|
||||
// controller — for longer than a caller waits, or at all for one that does not wait.
|
||||
var ErrPlansBusy = errors.New("another controller is working the plans")
|
||||
|
||||
// HoldPlans makes working the plans one act at a time, across every controller on the store
|
||||
// (novox/hq issue 213). A plan is read, changed and written whole; two controllers doing that at
|
||||
// once — the old and the new for the moment a machine hands its controller over, or a controller
|
||||
// and a person's `plans stop` — each act on what the other has not saved yet: a tier asked twice,
|
||||
// an outcome written over. A session-level advisory lock on one connection, released by the
|
||||
// returned function and by the session ending, so a controller that dies holding it holds nothing.
|
||||
//
|
||||
// wait false gives ErrPlansBusy at once when another holds them — the timer's way: the holder is
|
||||
// moving the plans already. wait true looks again every HoldPoll for up to HoldWaitFor — an
|
||||
// outcome's or a merge's way, which must be written.
|
||||
func (i *Inventory) HoldPlans(ctx context.Context, wait bool) (func(), error) {
|
||||
deadline := time.Now().Add(HoldWaitFor)
|
||||
for {
|
||||
release, took, err := i.tryLock(ctx, "mesh-plans")
|
||||
if err != nil || took {
|
||||
return release, err
|
||||
}
|
||||
if !wait || time.Now().After(deadline) {
|
||||
return nil, ErrPlansBusy
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(HoldPoll):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tryLock takes one named advisory lock on a connection of its own, or gives the connection back.
|
||||
func (i *Inventory) tryLock(ctx context.Context, key string) (func(), bool, error) {
|
||||
conn, err := i.store.Pool().Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var once sync.Once
|
||||
release := func() {
|
||||
once.Do(func() {
|
||||
if _, err := conn.Exec(context.WithoutCancel(ctx), `select pg_advisory_unlock_all()`); err != nil {
|
||||
_ = conn.Conn().Close(context.WithoutCancel(ctx))
|
||||
}
|
||||
conn.Release()
|
||||
})
|
||||
}
|
||||
var took bool
|
||||
if err := conn.QueryRow(ctx, `select pg_try_advisory_lock(hashtext($1)::bigint)`, key).Scan(&took); err != nil {
|
||||
release()
|
||||
return nil, false, err
|
||||
}
|
||||
if !took {
|
||||
release()
|
||||
return nil, false, nil
|
||||
}
|
||||
return release, true, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// novox/hq issue 213: while a machine hands its controller over from the container to the process,
|
||||
// two controllers run on one store for a moment. Working the plans is one act at a time across them.
|
||||
func TestThePlansAreWorkedByOneControllerAtATime(t *testing.T) {
|
||||
first := ForTest(t)
|
||||
// A second controller: its own connections to the same store.
|
||||
second, err := Open(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(second.Close)
|
||||
|
||||
release, err := first.HoldPlans(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatalf("the plans could not be held when nobody held them: %v", err)
|
||||
}
|
||||
t.Cleanup(release) // a pool closing waits for a connection still held; release is idempotent
|
||||
if _, err := second.HoldPlans(t.Context(), false); !errors.Is(err, ErrPlansBusy) {
|
||||
t.Fatalf("a second controller held the plans while the first did: %v", err)
|
||||
}
|
||||
// A waiter gets them once they are let go.
|
||||
was := HoldPoll
|
||||
HoldPoll = 10 * time.Millisecond
|
||||
defer func() { HoldPoll = was }()
|
||||
go func() { time.Sleep(50 * time.Millisecond); release() }()
|
||||
again, err := second.HoldPlans(t.Context(), true)
|
||||
if err != nil {
|
||||
t.Fatalf("a waiting controller never got the plans once they were let go: %v", err)
|
||||
}
|
||||
again()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
-- A build is ordered by when it was asked, not when it finished (novox/hq 04-ISSUES/219).
|
||||
--
|
||||
-- Two builds of one module can be in flight together — two merge plans a few minutes apart, each
|
||||
-- asking for everything standing on what it changed — and they finish in any order. Each build
|
||||
-- stands on the bases the mesh held when it was *asked*, so the one asked later is the newer one.
|
||||
-- The mesh ordered builds by `at`, which is when the outcome was recorded, and registered whatever
|
||||
-- it heard last: an older request that took longer replaced a newer one as what the module is, and
|
||||
-- the next push sent machines an image built on a base the mesh had already replaced.
|
||||
--
|
||||
-- `build.asked` is when the build was requested, read from the correlation id the controller wrote
|
||||
-- (`build-<unix nanoseconds>`). Nullable: an id of any other shape says no request time, and such a
|
||||
-- build is placed where it was recorded, which is the order the mesh had before this.
|
||||
alter table build add column asked timestamptz;
|
||||
|
||||
update build
|
||||
set asked = to_timestamp((substring(id from '^build-([0-9]{19})$'))::numeric / 1000000000)
|
||||
where id ~ '^build-[0-9]{19}$';
|
||||
|
||||
-- `module.built_asked` is when the build the module's registered manifest came from was asked, so
|
||||
-- a later-heard outcome of an earlier request is recorded and not registered. A manifest handed over
|
||||
-- by hand is a request made when it is handed over. Null for a module registered before this was
|
||||
-- kept: its next registration, whichever it is, sets it.
|
||||
alter table module add column built_asked timestamptz;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- What the artifact store no longer keeps (novox/hq ADR 0189, issue 108).
|
||||
--
|
||||
-- The mesh removes from its store only what it put there and can account for: every digest it
|
||||
-- could remove is already in a build record, so the sweep reads its own records rather than
|
||||
-- enumerating the store. What it does not get from those records is whether it has already
|
||||
-- removed something -- `build.made` says what that build published, for ever, which is history
|
||||
-- and not an index of what is on disk.
|
||||
--
|
||||
-- Without this the sweep would reissue a delete for every artifact it has ever collected, every
|
||||
-- time it runs, and each one would answer 404 -- a number of requests that grows with the mesh's
|
||||
-- whole history and never shrinks.
|
||||
--
|
||||
-- Keyed by the reference as the mesh records it (`artifact-store://<module>/<artifact>@sha256:…`),
|
||||
-- because that is the identity the record uses everywhere else. Not a foreign key to build: two
|
||||
-- builds can publish the same digest (the same source built twice produces the same bytes), and
|
||||
-- what is collected is the artifact, not the attempt that made it.
|
||||
create table artifact_collected (
|
||||
reference text primary key,
|
||||
|
||||
-- When the store answered. Kept so a reader of an old build record can tell "this artifact is
|
||||
-- gone" from "this artifact was never there", which are different kinds of surprise.
|
||||
at timestamptz not null default now()
|
||||
);
|
||||
@@ -0,0 +1,124 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// novox/hq 04-ISSUES/219: two builds of one module in flight together, the one asked first heard
|
||||
// last. The newer request stood on the newer base; the older one's late outcome is recorded and is
|
||||
// not what the module is.
|
||||
|
||||
func postgresBuild(id string, asked time.Time, image string) Build {
|
||||
b := aBuild(id, "postgres", "")
|
||||
b.Asked = asked
|
||||
b.Against = []string{"mesh-tools/runtime@sha256:" + id}
|
||||
b.Made = []Artifact{{Name: "server", Kind: "image", Reference: "postgres@sha256:" + image}}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestAnOlderRequestFinishingLaterIsNotWhatTheModuleHolds(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := context.Background()
|
||||
older := time.Date(2026, 10, 3, 21, 33, 45, 0, time.UTC)
|
||||
newer := time.Date(2026, 10, 3, 21, 51, 57, 0, time.UTC)
|
||||
|
||||
// The newer request finishes first, the older one last — recorded in that order.
|
||||
if err := inv.RecordBuild(ctx, postgresBuild("newer", newer, "4bcd5f73")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordBuild(ctx, postgresBuild("older", older, "0ab07fa9")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
held, err := inv.Held(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := held["postgres/server"]; got != "postgres@sha256:4bcd5f73" {
|
||||
t.Errorf("postgres holds %q; want the newer request's image 4bcd5f73", got)
|
||||
}
|
||||
against, err := inv.BuiltAgainst(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := against["postgres"]; len(got) != 1 || got[0] != "mesh-tools/runtime@sha256:newer" {
|
||||
t.Errorf("postgres stands on %v; want what the newer request stood on", got)
|
||||
}
|
||||
|
||||
// Both are still recorded, the late one first as what happened lately.
|
||||
builds, err := inv.Builds(ctx, "postgres", 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(builds) != 2 || builds[0].ID != "older" || !builds[0].Asked.Equal(older) {
|
||||
t.Fatalf("both builds, newest heard first, with when they were asked: %+v", builds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABuildWithNoKnownRequestTimeIsOrderedByWhenItWasRecorded(t *testing.T) {
|
||||
// What the mesh did before it kept the request time, so a row from before still answers.
|
||||
inv := fresh(t)
|
||||
ctx := context.Background()
|
||||
for _, id := range []string{"first", "second"} {
|
||||
b := aBuild(id, "shell", "")
|
||||
b.Made = []Artifact{{Name: "config", Kind: "archive", Reference: "…/" + id}}
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
held, err := inv.Held(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := held["shell/config"]; got != "…/second" {
|
||||
t.Errorf("shell holds %q; want the one recorded last", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARegistrationFromAnOlderRequestDoesNotReplaceANewerOne(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := context.Background()
|
||||
older := time.Date(2026, 10, 3, 21, 33, 45, 0, time.UTC)
|
||||
newer := time.Date(2026, 10, 3, 21, 51, 57, 0, time.UTC)
|
||||
from := func(asked time.Time) Source {
|
||||
return Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/postgres",
|
||||
BuiltFrom: "efff5415", Asked: asked}
|
||||
}
|
||||
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "fixed"}, from(newer)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "stale"}, from(older))
|
||||
if !errors.Is(err, ErrSuperseded) {
|
||||
t.Fatalf("an older request's registration was not refused as superseded: %v", err)
|
||||
}
|
||||
shelf, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := shelf["postgres"].Version; got != "fixed" {
|
||||
t.Fatalf("postgres is %q; want the newer request's manifest", got)
|
||||
}
|
||||
|
||||
// A later request, and a manifest handed over by hand — asked when it is handed over — both
|
||||
// replace it as before.
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "later"},
|
||||
from(newer.Add(time.Minute))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "by-hand"}, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if shelf, _ := inv.Catalogue(ctx); shelf["postgres"].Version != "by-hand" {
|
||||
t.Fatalf("postgres is %q; want the manifest handed over by hand", shelf["postgres"].Version)
|
||||
}
|
||||
src, err := inv.SourceOf(ctx, "postgres")
|
||||
if err != nil || src.Repository != "novox/mesh-catalog" {
|
||||
t.Fatalf("a hand registration erased the provenance: %+v %v", src, err)
|
||||
}
|
||||
}
|
||||
@@ -44,8 +44,15 @@ func TestAPersonMayCallToolsAndNothingElse(t *testing.T) {
|
||||
}
|
||||
// The one tool, both ways it is addressed (novox/hq ADR 0159): to whichever instance
|
||||
// answers, and to the instance on one machine. Nothing else.
|
||||
if len(perms.Publish) != 2 || perms.Publish[0] != "mesh.mod.mesh-catalog.tool.catalog_tools" ||
|
||||
perms.Publish[1] != "mesh.mod.mesh-catalog.tool.catalog_tools.*" {
|
||||
// And asking what answers (novox/hq ADR 0197), which claims nothing and calls nothing.
|
||||
var tools []string
|
||||
for _, s := range perms.Publish {
|
||||
if !strings.HasPrefix(s, "$SRV.") {
|
||||
tools = append(tools, s)
|
||||
}
|
||||
}
|
||||
if len(tools) != 2 || tools[0] != "mesh.mod.mesh-catalog.tool.catalog_tools" ||
|
||||
tools[1] != "mesh.mod.mesh-catalog.tool.catalog_tools.*" {
|
||||
t.Errorf("ada may publish %v, which should be the one tool, both ways addressed, and nothing else", perms.Publish)
|
||||
}
|
||||
for _, s := range perms.Publish {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/micro"
|
||||
)
|
||||
|
||||
// What answers announces itself (novox/hq ADR 0197). A holder that serves a seat's verbs answers the
|
||||
// NATS services protocol's discovery — `$SRV.PING` and `$SRV.INFO`, and each by its service's name
|
||||
// and instance — with exactly what it serves, in NATS's own format, so the console and the standard
|
||||
// `nats micro` commands learn what exists from what answers rather than from a roster.
|
||||
|
||||
// DiscoverySubjects are where one service instance is asked to say what it is.
|
||||
func DiscoverySubjects(name, id string) []string {
|
||||
var out []string
|
||||
for _, verb := range []string{"PING", "INFO", "STATS"} {
|
||||
out = append(out, "$SRV."+verb, "$SRV."+verb+"."+name, "$SRV."+verb+"."+name+"."+id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Announce answers discovery for one service until stopped. The answer is fixed at the call: a holder
|
||||
// whose verbs change announces again. Every instance answers, so there is no queue group.
|
||||
func (b OverNATS) Announce(info micro.Info, logger *log.Logger) (func(), error) {
|
||||
info.Type = micro.InfoResponseType
|
||||
infoBody, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pingBody, err := json.Marshal(micro.Ping{ServiceIdentity: info.ServiceIdentity, Type: micro.PingResponseType})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Statistics the protocol asks for; the controller keeps none per verb, so it answers its
|
||||
// identity and its endpoints with nothing counted — an honest zero, not a refusal.
|
||||
stats := micro.Stats{ServiceIdentity: info.ServiceIdentity, Type: micro.StatsResponseType}
|
||||
for _, e := range info.Endpoints {
|
||||
stats.Endpoints = append(stats.Endpoints, µ.EndpointStats{Name: e.Name, Subject: e.Subject, QueueGroup: e.QueueGroup})
|
||||
}
|
||||
statsBody, err := json.Marshal(stats)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var subs []*nats.Subscription
|
||||
done := make(chan struct{})
|
||||
stop := func() {
|
||||
close(done)
|
||||
for _, s := range subs {
|
||||
_ = s.Unsubscribe()
|
||||
}
|
||||
}
|
||||
for _, subject := range DiscoverySubjects(info.Name, info.ID) {
|
||||
subject := subject
|
||||
body := infoBody
|
||||
switch {
|
||||
case len(subject) >= 9 && subject[:9] == "$SRV.PING":
|
||||
body = pingBody
|
||||
case len(subject) >= 10 && subject[:10] == "$SRV.STATS":
|
||||
body = statsBody
|
||||
}
|
||||
bind := func() (*nats.Subscription, error) {
|
||||
return b.Conn.Subscribe(subject, func(msg *nats.Msg) {
|
||||
if err := msg.Respond(body); err != nil && logger != nil {
|
||||
logger.Printf("%s: could not answer: %v", subject, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
sub, err := bind()
|
||||
if err != nil {
|
||||
stop()
|
||||
return nil, fmt.Errorf("announcing %s on %s: %w", info.Name, subject, err)
|
||||
}
|
||||
subs = append(subs, sub)
|
||||
go keepBound(sub, bind, subject, done, logger)
|
||||
}
|
||||
return stop, nil
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package link
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Asking a machine to build a module, and hearing what came out.
|
||||
@@ -17,6 +20,32 @@ import (
|
||||
// holds no opinion about what they contain, and a host that also built things would be a host
|
||||
// with a container runtime requirement and a git dependency (novox/hq ADR 0005).
|
||||
|
||||
// NewBuildID is the correlation for a build asked at that moment: `build-<unix nanoseconds>`.
|
||||
//
|
||||
// **The id carries when the build was asked, and that is read back** (novox/hq 04-ISSUES/219). Builds
|
||||
// of one module can be in flight together and finish in any order; what a module currently is must
|
||||
// be the newest *request's* outcome, not the last one heard, and the id is the one thing every
|
||||
// outcome echoes whichever builder answered it. One place writes the shape and one reads it.
|
||||
func NewBuildID(asked time.Time) string {
|
||||
return "build-" + strconv.FormatInt(asked.UnixNano(), 10)
|
||||
}
|
||||
|
||||
// BuildAskedAt is when the build with this id was asked, as NewBuildID wrote it. False for an id
|
||||
// of any other shape — one written before this was read, or by hand — whose request time the mesh
|
||||
// does not know.
|
||||
func BuildAskedAt(id string) (time.Time, bool) {
|
||||
digits, ok := strings.CutPrefix(id, "build-")
|
||||
if !ok || digits == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
nanos, err := strconv.ParseInt(digits, 10, 64)
|
||||
// A number too small to be a moment this mesh could have asked at is a name, not a time.
|
||||
if err != nil || nanos < time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).UnixNano() {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(0, nanos).UTC(), true
|
||||
}
|
||||
|
||||
// BuildRequest is one module to build.
|
||||
type BuildRequest struct {
|
||||
// ID correlates the answer with the asking. Not the module name: two builds of one module can
|
||||
|
||||
@@ -193,9 +193,15 @@ func (m *natsMachine) Take(ctx context.Context, do func(context.Context, Build))
|
||||
// machine with nothing to build, and is asked again.
|
||||
fetched, err := sub.Fetch(1, nats.Context(ctx))
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
|
||||
case ctx.Err() != nil:
|
||||
// Ours ended: the machine is being stopped.
|
||||
return nil
|
||||
case errors.Is(err, nats.ErrTimeout):
|
||||
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded), errors.Is(err, nats.ErrTimeout):
|
||||
// **An empty queue, not the end.** A fetch on a context without a deadline waits the
|
||||
// client's own while and then says the deadline passed — the client's, not ours. Read
|
||||
// as "stop", every idle build machine exited clean every half minute and was started
|
||||
// again by its supervisor, which looked like a crash loop with nothing in the log to
|
||||
// say why (2026-10-03, the first build agents). Asked again.
|
||||
continue
|
||||
case err != nil:
|
||||
if sub.IsValid() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -78,10 +79,15 @@ func (n *natsInbound) Receive(ctx context.Context, act func(context.Context, Con
|
||||
// than creating one here: the consumer is an object with a configuration — ack policy, ack
|
||||
// wait, redelivery — and a client that creates its own would be a second opinion about it.
|
||||
control := make(chan *nats.Msg, Prefetch)
|
||||
said, err := js.ChanSubscribe("", control, nats.Bind("CONTROL", broker.ControllerName))
|
||||
said, err := standingBy(ctx, log.Default(), "CONTROL", func() (*nats.Subscription, error) {
|
||||
return js.ChanSubscribe("", control, nats.Bind("CONTROL", broker.ControllerName))
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("subscribing to what nodes say: %w", err)
|
||||
}
|
||||
if said == nil {
|
||||
return nil // stopped while standing by
|
||||
}
|
||||
defer func() { _ = said.Unsubscribe() }()
|
||||
|
||||
// Heartbeats, on core NATS and off any stream (design 25 §3). Their own subscription because
|
||||
@@ -97,10 +103,15 @@ func (n *natsInbound) Receive(ctx context.Context, act func(context.Context, Con
|
||||
var events chan *nats.Msg
|
||||
if len(n.follows) > 0 {
|
||||
events = make(chan *nats.Msg, Prefetch)
|
||||
followed, err := js.ChanSubscribe("", events, nats.Bind("EVENTS", broker.ControllerName))
|
||||
followed, err := standingBy(ctx, log.Default(), "EVENTS", func() (*nats.Subscription, error) {
|
||||
return js.ChanSubscribe("", events, nats.Bind("EVENTS", broker.ControllerName))
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("subscribing to what the catalogue says: %w", err)
|
||||
}
|
||||
if followed == nil {
|
||||
return nil
|
||||
}
|
||||
defer func() { _ = followed.Unsubscribe() }()
|
||||
}
|
||||
|
||||
@@ -324,3 +335,44 @@ func (m *natsControl) forget() {
|
||||
type replyAddressed struct {
|
||||
ReplyTo string `json:"reply_to,omitempty"`
|
||||
}
|
||||
|
||||
// StandbyPoll is how often a controller standing by looks again for its consumers. A variable so a
|
||||
// test need not wait.
|
||||
var StandbyPoll = 2 * time.Second
|
||||
|
||||
// standingBy binds one of the controller's consumers, waiting while another controller holds it.
|
||||
//
|
||||
// **Two controllers, one consumer** (novox/hq issue 213). The controller's consumers are push
|
||||
// consumers with no delivery group, so the server lets one subscription bind each — on purpose:
|
||||
// two would each act on every message (issue 146). When a machine hands its controller over from
|
||||
// the container to the process, the host starts the process first and removes the container only
|
||||
// once the process is up; the process then finds the consumers bound. Exiting on that would never
|
||||
// be up, so the container would never go. It stands by instead — the seat's verbs are already
|
||||
// served from a queue group, and the plans wait on their lock — and binds as soon as the other lets
|
||||
// go. Nil and no error is ctx ending while it waited.
|
||||
func standingBy(ctx context.Context, logger interface{ Printf(string, ...any) }, stream string,
|
||||
bind func() (*nats.Subscription, error)) (*nats.Subscription, error) {
|
||||
said := false
|
||||
for {
|
||||
sub, err := bind()
|
||||
if err == nil {
|
||||
if said {
|
||||
logger.Printf("took the controller's consumer on %s: the controller that held it let go", stream)
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already bound") {
|
||||
return nil, err
|
||||
}
|
||||
if !said {
|
||||
logger.Printf("another controller holds the controller's consumer on %s; standing by "+
|
||||
"until it lets go", stream)
|
||||
said = true
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, nil
|
||||
case <-time.After(StandbyPoll):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
)
|
||||
|
||||
// novox/hq issue 213: while a machine hands its controller over, the new controller (the process)
|
||||
// is started while the old one (the container) still holds the controller's consumers. It must not
|
||||
// exit — the host would read that as a replacement that did not come up and never remove the
|
||||
// container — and must not act on what the old one is handed. It stands by, and takes the consumers
|
||||
// when the old one lets go.
|
||||
func TestNatsASecondControllerStandsByAndTakesOverWhenTheFirstLetsGo(t *testing.T) {
|
||||
js := aBus(t)
|
||||
was := StandbyPoll
|
||||
StandbyPoll = 50 * time.Millisecond
|
||||
defer func() { StandbyPoll = was }()
|
||||
|
||||
old := &counted{}
|
||||
_, stopOld := servingOn(t, js, old)
|
||||
eventually(t, "the first controller binding its consumer", func() bool {
|
||||
info, err := js.Context().ConsumerInfo("CONTROL", broker.ControllerName)
|
||||
return err == nil && info.PushBound
|
||||
})
|
||||
|
||||
// The new one, on a connection of its own as the process would have.
|
||||
second, err := broker.Dial(os.Getenv("MESH_TEST_NATS"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(second.Close)
|
||||
fresh := &counted{}
|
||||
s := &Server{inbound: Nats(second), bus: OverNATS{Conn: second.Conn(), JS: second.Context()},
|
||||
listener: fresh, log: quiet()}
|
||||
ctx, stopNew := context.WithCancel(context.Background())
|
||||
defer stopNew()
|
||||
ended := make(chan error, 1)
|
||||
go func() { ended <- s.Serve(ctx) }()
|
||||
|
||||
select {
|
||||
case err := <-ended:
|
||||
t.Fatalf("the second controller stopped instead of standing by: %v", err)
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
|
||||
report := func(declared string) {
|
||||
body, _ := json.Marshal(Report{Node: "anchor", Declared: declared, Applied: []string{"store"}})
|
||||
if _, err := js.Context().Publish(ReportSubject("anchor"), body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
report("d1")
|
||||
eventually(t, "the holding controller hearing the report", func() bool { return old.count() == 1 })
|
||||
if fresh.count() != 0 {
|
||||
t.Fatal("the controller standing by acted on a report the holder was handed")
|
||||
}
|
||||
|
||||
stopOld()
|
||||
report("d2")
|
||||
eventually(t, "the second controller taking over once the first let go", func() bool { return fresh.count() == 1 })
|
||||
if old.count() != 1 {
|
||||
t.Errorf("the first controller heard %d reports", old.count())
|
||||
}
|
||||
}
|
||||
@@ -169,10 +169,9 @@ func (g *Generator) Graph() Graph { return g.graph }
|
||||
//
|
||||
// - No floor: no header, no localhost, no `127.0.1.1` — those are the machine's, above the region.
|
||||
// - A machine's own line is marked, and its mesh name resolves to its mesh address, not loopback.
|
||||
// - `.Names` is every name the mesh serves (issue 111), so anything on the machine reaching a
|
||||
// routed name through its resolver finds the machine serving it; machines with no address yet
|
||||
// are already left out of the set. A routed name is one alias, itself — a machine has a bare
|
||||
// name beside its full one, a routed name has nothing beside it (issue 157).
|
||||
// - `.Names` is the machines (novox/hq ADR 0191): a route's internal name is under its node's
|
||||
// internal domain and the resolver answers it by wildcard, and a public name is public DNS's.
|
||||
// Machines with no address yet are already left out of the set.
|
||||
const hostsTemplate = "# The mesh's names. This region is replaced whenever a machine joins or leaves.\n" +
|
||||
"{{range .Names}}{{.Address}}\t{{.FQDN}}{{if ne .Name .FQDN}}\t{{.Name}}{{end}}{{if eq .Name $.Node}}\t# this machine{{end}}\n{{end}}"
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestTheManifestsOwnPlaceholderUnfilledLeavesTheStoreWhereTheFileSays(t *tes
|
||||
}
|
||||
var written string
|
||||
for _, r := range m.Resources {
|
||||
if r.Type == "container" {
|
||||
if r.Type == "process" {
|
||||
written = r.Env["MESH_STORE_INVENTORY_PORT"]
|
||||
}
|
||||
}
|
||||
|
||||
+31
-38
@@ -2,9 +2,6 @@
|
||||
"module": "mesh-controller",
|
||||
"version": "1",
|
||||
"slug": "control",
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "mesh-controller",
|
||||
@@ -26,7 +23,7 @@
|
||||
"broker-address": "${dir:mesh-state}/broker-address",
|
||||
"bus": "${dir:mesh-state}/bus"
|
||||
},
|
||||
"secrets-owner": "65534:65534",
|
||||
"secrets-owner": "mesh-controller",
|
||||
"prepares": true,
|
||||
"tools": [
|
||||
"tools",
|
||||
@@ -43,62 +40,58 @@
|
||||
"build"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-controller",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-controller"
|
||||
},
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "mesh"
|
||||
"place": "mesh",
|
||||
"owner": "mesh-controller"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"id": "controller",
|
||||
"type": "process",
|
||||
"name": "mesh-controller",
|
||||
"network": "host",
|
||||
"args": [
|
||||
"artifact": "controller",
|
||||
"run": [
|
||||
"./mesh-controller",
|
||||
"serve"
|
||||
],
|
||||
"user": "mesh-controller",
|
||||
"env": {
|
||||
"MESH_BROKER_CERTIFICATE": "/broker-tls/tls.crt",
|
||||
"MESH_STORE_INVENTORY_FILE": "/run/secrets/inventory",
|
||||
"MESH_STORE_IDENTITY_FILE": "/run/secrets/identity",
|
||||
"MESH_STORE_LICENCES_FILE": "/run/secrets/licences",
|
||||
"MESH_BROKER_MANAGEMENT_FILE": "/run/secrets/broker-management",
|
||||
"MESH_BROKER_ADDRESS_FILE": "/run/secrets/broker-address",
|
||||
"MESH_BROKER_CERTIFICATE": "/var/lib/mesh-broker-tls/tls.crt",
|
||||
"MESH_STORE_INVENTORY_FILE": "${dir:mesh-state}/inventory",
|
||||
"MESH_STORE_IDENTITY_FILE": "${dir:mesh-state}/identity",
|
||||
"MESH_STORE_LICENCES_FILE": "${dir:mesh-state}/licences",
|
||||
"MESH_BROKER_MANAGEMENT_FILE": "${dir:mesh-state}/broker-management",
|
||||
"MESH_BROKER_ADDRESS_FILE": "${dir:mesh-state}/broker-address",
|
||||
"MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}",
|
||||
"MESH_STORE_IDENTITY_PORT": "${seat:mesh-store:5432}",
|
||||
"MESH_STORE_LICENCES_PORT": "${seat:mesh-store:5432}",
|
||||
"MESH_BROKER_MANAGEMENT_PORT": "${seat:mesh-broker:15672}",
|
||||
"MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}",
|
||||
"MESH_BUS_NATS_FILE": "/run/secrets/bus"
|
||||
"MESH_BUS_NATS_FILE": "${dir:mesh-state}/bus"
|
||||
},
|
||||
"volumes": [
|
||||
"/var/lib/mesh-broker-tls:/broker-tls:ro",
|
||||
"${dir:mesh-state}/inventory:/run/secrets/inventory:ro",
|
||||
"${dir:mesh-state}/identity:/run/secrets/identity:ro",
|
||||
"${dir:mesh-state}/licences:/run/secrets/licences:ro",
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:mesh-state}/bus:/run/secrets/bus:ro",
|
||||
"${dir:mesh-state}/broker-management:/run/secrets/broker-management:ro",
|
||||
"${dir:mesh-state}/broker-address:/run/secrets/broker-address:ro"
|
||||
],
|
||||
"artifact": "server",
|
||||
"restart-on": [
|
||||
"control-env"
|
||||
"replaces": [
|
||||
"server"
|
||||
]
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "server",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
}
|
||||
],
|
||||
"on": [
|
||||
{
|
||||
"arg": "GO_BASE",
|
||||
"image": "golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c"
|
||||
"name": "controller",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-controller",
|
||||
"binary": "mesh-controller"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user