Compare commits
250
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
934c736fcf | ||
|
|
20516e3fcb | ||
|
|
0a40c046cf | ||
|
|
8f51c66eb1 | ||
|
|
477d3ac9a7 | ||
|
|
6ff449e363 | ||
|
|
3ca1f5d26a | ||
|
|
74a1382164 | ||
|
|
40f169229d | ||
|
|
9a99c422a1 | ||
|
|
73a34cc0a7 | ||
|
|
75932d6f3e | ||
|
|
c430ca76f0 | ||
|
|
ba6a3ea829 | ||
|
|
dcf6278523 | ||
|
|
d94f9e7f9f | ||
|
|
882687afd4 | ||
|
|
884c088949 | ||
|
|
76f27562a1 | ||
|
|
89d43e0dad | ||
|
|
09b636e628 | ||
|
|
d2ed6d50c9 | ||
|
|
fdf338dbd1 | ||
|
|
a69a832248 | ||
|
|
bdcbda801e | ||
|
|
5a7ed56f61 | ||
|
|
d54ecb3bf2 | ||
|
|
cf84117638 | ||
|
|
8d4e940866 | ||
|
|
11b20b10ff | ||
|
|
7e701c0db2 | ||
|
|
853c63b181 | ||
|
|
84ac840ff4 | ||
|
|
e8e502343f | ||
|
|
1edc44b25f | ||
|
|
5a1b37e477 | ||
|
|
4a6a4eadeb | ||
|
|
69f559ab4c | ||
|
|
05b90f966a | ||
|
|
184913b620 | ||
|
|
de7aed5016 | ||
|
|
18958154f0 | ||
|
|
a2b1f9e936 | ||
|
|
c9a0b1f9f4 | ||
|
|
f450303e8e | ||
|
|
603ad61142 | ||
|
|
e7cff3d38e | ||
|
|
55c5c061ab | ||
|
|
ccae1ec303 | ||
|
|
9fd5971212 | ||
|
|
05ccab2e4b | ||
|
|
05ae5e5040 | ||
|
|
988250f37a | ||
|
|
6ca4ba68c8 | ||
|
|
1469f5ff82 | ||
|
|
0e977399d4 | ||
|
|
c462db105e | ||
|
|
7273ca2f0f | ||
|
|
ad797d8742 | ||
|
|
5b39e95361 | ||
|
|
7e4a0ecf9a | ||
|
|
7661f57833 | ||
|
|
076e0ae259 | ||
|
|
a96e2f0d78 | ||
|
|
17f7cb0d9c | ||
|
|
f6685ed22d | ||
|
|
52c18f7a45 | ||
|
|
fa7415fcd0 | ||
|
|
f8947a806d | ||
|
|
b98e503a61 | ||
|
|
5b832918df | ||
|
|
17bbcc1596 | ||
|
|
29be985c23 | ||
|
|
05d977666a | ||
|
|
e871991495 | ||
|
|
f9e19814eb | ||
|
|
af31315a5f | ||
|
|
474f68b34c | ||
|
|
1a724f20fe | ||
|
|
0da0bb2157 | ||
|
|
118e333ff8 | ||
|
|
c1334f3f85 | ||
|
|
70d379816c | ||
|
|
d8e7c13f6d | ||
|
|
1851a15e57 | ||
|
|
d9dbc9a59a | ||
|
|
d5e1332dda | ||
|
|
5ea0e87059 | ||
|
|
8e81266cdc | ||
|
|
70705ffe45 | ||
|
|
91c4da8a82 | ||
|
|
e9df5dccab | ||
|
|
990ef27cd2 | ||
|
|
0a17a9a2eb | ||
|
|
23907984a3 | ||
|
|
f0634e11f4 | ||
|
|
542ce76c0a | ||
|
|
2882b5fcb1 | ||
|
|
481b1a7b05 | ||
|
|
b58578f88d | ||
|
|
6cb285dd5c | ||
|
|
46f324b10b | ||
|
|
d188eec318 | ||
|
|
c978aa7d64 | ||
|
|
0c7f42a18a | ||
|
|
9a5584b1b6 | ||
|
|
1bc099a2db | ||
|
|
3fc1feff38 | ||
|
|
ffe176f6d1 | ||
|
|
8057cc4888 | ||
|
|
9d6dad37c5 | ||
|
|
7f84ddecc5 | ||
|
|
94ab9f665e | ||
|
|
3600f2cf16 | ||
|
|
005bc16c24 | ||
|
|
985e2008ba | ||
|
|
bd7ee12938 | ||
|
|
0983b00284 | ||
|
|
8ee2e4d441 | ||
|
|
1d9c102889 | ||
|
|
2542aa67b0 | ||
|
|
1da96e8803 | ||
|
|
cf2f62cf14 | ||
|
|
7683ba8b5b | ||
|
|
a0d7d72a26 | ||
|
|
bfd983e3f8 | ||
|
|
e7da39de57 | ||
|
|
e6ddc59cde | ||
|
|
6c5dfd0c25 | ||
|
|
775df79893 | ||
|
|
3fbf658c16 | ||
|
|
bc31745607 | ||
|
|
e5c2eb20f2 | ||
|
|
05fb7fb5eb | ||
|
|
2c1733de8d | ||
|
|
e51c94dcb5 | ||
|
|
07c07902ff | ||
|
|
864cdea4c6 | ||
|
|
6e810907b2 | ||
|
|
2b20a12c4a | ||
|
|
9c83dacfce | ||
|
|
64ba053f3b | ||
|
|
96416bd8a7 | ||
|
|
4d2003d77b | ||
|
|
aaad02fd38 | ||
|
|
c68d3a7432 | ||
|
|
a5209bd849 | ||
|
|
bdf965dab6 | ||
|
|
b4da20ecc0 | ||
|
|
4b33b72160 | ||
|
|
d5505fe3d4 | ||
|
|
264c9e41e9 | ||
|
|
76ac3c99bd | ||
|
|
fe5988c536 | ||
|
|
ed5d467d90 | ||
|
|
228d0226dd | ||
|
|
6215ff0760 | ||
|
|
54812306be | ||
|
|
ce9e20fbbc | ||
|
|
878690697e | ||
|
|
ad97297576 | ||
|
|
683b1ed693 | ||
|
|
04f9f378b0 | ||
|
|
1c3f44a526 | ||
|
|
89e152dfe2 | ||
|
|
1ebad3786c | ||
|
|
f2f526a60a | ||
|
|
4b4c7e0e0d | ||
|
|
cec792ce9d | ||
|
|
338d033632 | ||
|
|
1be926cec4 | ||
|
|
2134768dfe | ||
|
|
ef825688ee | ||
|
|
77a14360df | ||
|
|
9be2fb4750 | ||
|
|
5a963aec10 | ||
|
|
45d1c28a28 | ||
|
|
a3e7683c63 | ||
|
|
da394b45e6 | ||
|
|
2f3bfda8c0 | ||
|
|
208388978a | ||
|
|
aa771616bb | ||
|
|
1513bbaac9 | ||
|
|
0014984116 | ||
|
|
d6e49dbd68 | ||
|
|
3756bb3460 | ||
|
|
60be9c5360 | ||
|
|
da31bcb11e | ||
|
|
f03e7b33c9 | ||
|
|
0baf727f36 | ||
|
|
94dd49a968 | ||
|
|
83a298e7e0 | ||
|
|
220b79f5cd | ||
|
|
e62201e227 | ||
|
|
0ab9b86f0a | ||
|
|
c7aabd3037 | ||
|
|
c74d990cee | ||
|
|
35252af665 | ||
|
|
aab6ded41b | ||
|
|
aecac5bda2 | ||
|
|
30362118a1 | ||
|
|
81e76fa485 | ||
|
|
12ed35e87d | ||
|
|
525f10b858 | ||
|
|
0547316cf2 | ||
|
|
9fe9b5349c | ||
|
|
d1e488efaf | ||
|
|
c5dc7e732a | ||
|
|
7efcccd013 | ||
|
|
964285f08c | ||
|
|
5698dda11f | ||
|
|
6005a8471f | ||
|
|
e2ee0dfe98 | ||
|
|
70341cfbc7 | ||
|
|
77643aa3f4 | ||
|
|
1fd6194ff8 | ||
|
|
c37018fdd2 | ||
|
|
3907ea0db0 | ||
|
|
40f5e9a41c | ||
|
|
2c2eb51878 | ||
|
|
aa2d0b51ea | ||
|
|
64d154d9d7 | ||
|
|
ffa390f916 | ||
|
|
4d62e6caf1 | ||
|
|
386ae676ca | ||
|
|
f8a9c3d6bc | ||
|
|
83671fae5f | ||
|
|
9b715524a2 | ||
|
|
e06fc1ed16 | ||
|
|
13d7c5c5dd | ||
|
|
7b02feaebb | ||
|
|
bd10e2c695 | ||
|
|
4b209d944d | ||
|
|
84024cbdb6 | ||
|
|
ad2eed2f71 | ||
|
|
e8aa7ed9e7 | ||
|
|
c585158836 | ||
|
|
337aaea123 | ||
|
|
4c41628b20 | ||
|
|
ae7fb520d7 | ||
|
|
f325073982 | ||
|
|
33c4e4be34 | ||
|
|
585a6abbdd | ||
|
|
8d52a2cfb0 | ||
|
|
1cfe6be9c4 | ||
|
|
4e4481b6f2 | ||
|
|
63ca073938 | ||
|
|
b244a768a3 | ||
|
|
81d52719f4 | ||
|
|
f6a93fe74c |
@@ -27,8 +27,18 @@ build:
|
|||||||
IMAGE ?= mesh-controller:$(VERSION)
|
IMAGE ?= mesh-controller:$(VERSION)
|
||||||
DEV_TAG ?= mesh-controller:development
|
DEV_TAG ?= mesh-controller:development
|
||||||
|
|
||||||
|
# The base the module declares, read from the manifest rather than written here twice.
|
||||||
|
#
|
||||||
|
# **`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)
|
||||||
|
|
||||||
image:
|
image:
|
||||||
docker build --build-arg VERSION=$(VERSION) -t $(IMAGE) -t $(DEV_TAG) .
|
@test -n "$(GO_BASE)" || { echo "module.json declares no GO_BASE; pass GO_BASE=<image> or fix the manifest"; exit 1; }
|
||||||
|
docker build --build-arg GO_BASE=$(GO_BASE) --build-arg VERSION=$(VERSION) -t $(IMAGE) -t $(DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
|
|
||||||
@@ -38,7 +48,8 @@ BUILDER_IMAGE ?= mesh-builder:$(VERSION)
|
|||||||
BUILDER_DEV_TAG ?= mesh-builder:development
|
BUILDER_DEV_TAG ?= mesh-builder:development
|
||||||
|
|
||||||
builder-image:
|
builder-image:
|
||||||
docker build -f cmd/mesh-builder/Dockerfile -t $(BUILDER_IMAGE) -t $(BUILDER_DEV_TAG) .
|
@test -n "$(GO_BASE)" || { echo "module.json declares no GO_BASE; pass GO_BASE=<image> or fix the manifest"; exit 1; }
|
||||||
|
docker build --build-arg GO_BASE=$(GO_BASE) -f cmd/mesh-builder/Dockerfile -t $(BUILDER_IMAGE) -t $(BUILDER_DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(BUILDER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(BUILDER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
|
|
||||||
|
|||||||
+51
-89
@@ -17,12 +17,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -30,8 +25,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
"github.com/novox/mesh-controller/internal/builder"
|
"github.com/novox/mesh-controller/internal/builder"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
@@ -52,7 +45,6 @@ const usage = `mesh-builder — builds modules for the mesh
|
|||||||
It consumes build requests and answers with what it made. Nothing is listened on and nothing
|
It consumes build requests and answers with what it made. Nothing is listened on and nothing
|
||||||
is dialled except the broker.
|
is dialled except the broker.
|
||||||
|
|
||||||
MESH_BROKER_AMQP where the broker is, with this builder's own credential
|
|
||||||
MESH_BROKER_FILE a file the mesh sealed to this machine holding the same
|
MESH_BROKER_FILE a file the mesh sealed to this machine holding the same
|
||||||
MESH_REGISTRY host:port to publish artifacts to, when the mesh has not said
|
MESH_REGISTRY host:port to publish artifacts to, when the mesh has not said
|
||||||
MESH_BINDING a file the mesh wrote saying where the artifact store is
|
MESH_BINDING a file the mesh wrote saying where the artifact store is
|
||||||
@@ -135,32 +127,17 @@ func run() error {
|
|||||||
// machine told about both would take work from one and answer on the other, and every log line would
|
// machine told about both would take work from one and answer on the other, and every log line would
|
||||||
// say it was fine.
|
// say it was fine.
|
||||||
func takeWorkFrom(credential Credential, on string) (link.BuildMachine, error) {
|
func takeWorkFrom(credential Credential, on string) (link.BuildMachine, error) {
|
||||||
address, onNATS, err := broker.OnNATS()
|
// **The credential names the bus, and there is one** (novox/hq ADR 0131, design 28 task 5.5).
|
||||||
|
// A credential for the mesh's bus carries user, password and fingerprint beside the address,
|
||||||
|
// and that is enough to dial it, pinned.
|
||||||
|
if !credential.onTheNewBus() {
|
||||||
|
return nil, fmt.Errorf("the credential at hand names %q, which is not the mesh's bus", credential.URL)
|
||||||
|
}
|
||||||
|
js, err := broker.DialPinned(credential.natsURL(), credential.Fingerprint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := broker.MustBeOneBus(credential.URL, address); err != nil {
|
return link.MachineOverNATS(js, on), nil
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if onNATS {
|
|
||||||
js, err := broker.Dial(address)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cannot reach the bus at %s: %w", address, err)
|
|
||||||
}
|
|
||||||
return link.MachineOverNATS(js, on), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, err := dial(credential)
|
|
||||||
if err != nil {
|
|
||||||
// Not quoted back: the URL carries this builder's broker password.
|
|
||||||
return nil, fmt.Errorf("cannot reach the broker: %w", err)
|
|
||||||
}
|
|
||||||
channel, err := conn.Channel()
|
|
||||||
if err != nil {
|
|
||||||
conn.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return link.MachineOverCurrent(conn, channel, on), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// answer does one build and says what happened, whichever way it went.
|
// answer does one build and says what happened, whichever way it went.
|
||||||
@@ -171,20 +148,34 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
|||||||
// it either finishes or fails is indistinguishable from one that never arrived — which cost a long
|
// it either finishes or fails is indistinguishable from one that never arrived — which cost a long
|
||||||
// diagnosis against a running mesh, chasing "the handler never fired" when the truth was only that
|
// diagnosis against a running mesh, chasing "the handler never fired" when the truth was only that
|
||||||
// the handler said nothing until the end.
|
// the handler said nothing until the end.
|
||||||
fmt.Fprintf(os.Stderr, "a build request arrived for %s\n", request.Repository)
|
fmt.Fprintf(os.Stderr, "a build request arrived for %s (%s)\n", request.Repository, request.ID)
|
||||||
|
|
||||||
|
// **Everything a build says goes two ways**: to stderr, as always, and onto the bus as the
|
||||||
|
// role's own events under the build's id (novox/hq ADR 0157) — so whoever asked, and anybody
|
||||||
|
// watching, reads the same lines this container's log holds, live, and after the fact from the
|
||||||
|
// stream. Said first, before anything runs, so a build that hangs is one that visibly started.
|
||||||
|
say := func(step, message string) {
|
||||||
|
fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message)
|
||||||
|
work.Say(step, message)
|
||||||
|
}
|
||||||
|
builder.Said = say
|
||||||
|
defer func() { builder.Said = nil }()
|
||||||
|
if err := work.Began(ctx); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "cannot say a build started: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
result := link.BuildResult{
|
result := link.BuildResult{
|
||||||
ID: request.ID, Repository: request.Repository, Path: request.Path,
|
ID: request.ID, Repository: request.Repository, Path: request.Path,
|
||||||
Ref: request.Ref, On: on,
|
Ref: request.Ref, On: on, Source: request.Source,
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "building %s", request.Repository)
|
what := "building " + request.Repository
|
||||||
if request.Path != "" {
|
if request.Path != "" {
|
||||||
fmt.Fprintf(os.Stderr, " at %s", request.Path)
|
what += " at " + request.Path
|
||||||
}
|
}
|
||||||
if request.Ref != "" {
|
if request.Ref != "" {
|
||||||
fmt.Fprintf(os.Stderr, " at %s", request.Ref)
|
what += " on " + request.Ref
|
||||||
}
|
}
|
||||||
fmt.Fprintln(os.Stderr)
|
say("build", what)
|
||||||
|
|
||||||
npmrc, err := packagesFrom()
|
npmrc, err := packagesFrom()
|
||||||
var built builder.Result
|
var built builder.Result
|
||||||
@@ -194,16 +185,13 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
|||||||
// after a clone that then fails at npm ci.
|
// after a clone that then fails at npm ci.
|
||||||
built, err = builder.Build(ctx, builder.Command, publisher,
|
built, err = builder.Build(ctx, builder.Command, publisher,
|
||||||
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
|
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
|
||||||
forgeFrom(),
|
forgeFrom(), say, request.Seats)
|
||||||
func(step, message string) {
|
|
||||||
fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A failure is a result. A build that fails and says nothing is indistinguishable from a
|
// A failure is a result. A build that fails and says nothing is indistinguishable from a
|
||||||
// builder that is not running, and those want completely different responses.
|
// builder that is not running, and those want completely different responses.
|
||||||
result.Failed = err.Error()
|
result.Failed = err.Error()
|
||||||
fmt.Fprintf(os.Stderr, " failed: %v\n", err)
|
say("failed", err.Error())
|
||||||
} else {
|
} else {
|
||||||
manifest, marshalErr := json.Marshal(built.Manifest)
|
manifest, marshalErr := json.Marshal(built.Manifest)
|
||||||
if marshalErr != nil {
|
if marshalErr != nil {
|
||||||
@@ -217,7 +205,10 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
result.Against = built.Against
|
result.Against = built.Against
|
||||||
fmt.Fprintf(os.Stderr, " built %s from %s\n", built.Manifest.Module, short(built.Commit))
|
for _, r := range built.Read {
|
||||||
|
result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
||||||
|
}
|
||||||
|
say("built", built.Manifest.Module+" from "+short(built.Commit))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,13 +433,8 @@ func brokerFrom() (Credential, error) {
|
|||||||
// broker is then verified against whatever this machine already trusts.
|
// broker is then verified against whatever this machine already trusts.
|
||||||
return Credential{URL: said}, nil
|
return Credential{URL: said}, nil
|
||||||
}
|
}
|
||||||
url := strings.TrimSpace(os.Getenv("MESH_BROKER_AMQP"))
|
return Credential{}, fmt.Errorf(
|
||||||
if url == "" {
|
"no MESH_BROKER_FILE: a build machine with no credential for the bus has nothing to build")
|
||||||
return Credential{}, fmt.Errorf(
|
|
||||||
"neither MESH_BROKER_FILE nor MESH_BROKER_AMQP: a builder with no broker has " +
|
|
||||||
"nothing to build")
|
|
||||||
}
|
|
||||||
return Credential{URL: url}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Credential is what a build machine is given so it can reach the broker.
|
// Credential is what a build machine is given so it can reach the broker.
|
||||||
@@ -460,48 +446,24 @@ func brokerFrom() (Credential, error) {
|
|||||||
// **The same shape a node gets, for the same reason** (novox/hq ADR 0004): the fingerprint travels
|
// **The same shape a node gets, for the same reason** (novox/hq ADR 0004): the fingerprint travels
|
||||||
// out of band — here, sealed with the credential — and the endpoint is verified once at connect.
|
// out of band — here, sealed with the credential — and the endpoint is verified once at connect.
|
||||||
type Credential struct {
|
type Credential struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
// Fingerprint is SHA-256 over the broker certificate's DER bytes, or empty to verify the
|
|
||||||
// ordinary way.
|
|
||||||
Fingerprint string `json:"fingerprint,omitempty"`
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
// User and Password ride beside the address on the bus being built (design 25): a credential
|
||||||
|
// embedded in a URL leaks into every log line that prints a connection, so the mesh seals them
|
||||||
|
// as two fields and this machine joins them once, here, to dial.
|
||||||
|
User string `json:"user,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// dial opens the connection, pinning the broker's certificate when there is one to pin.
|
// onTheNewBus is whether a credential is for the bus being built: its address says so, and the
|
||||||
func dial(held Credential) (*amqp.Connection, error) {
|
// mesh only ever seals such a credential with the user and password beside it.
|
||||||
if held.Fingerprint == "" {
|
func (c Credential) onTheNewBus() bool { return strings.HasPrefix(strings.TrimSpace(c.URL), "nats://") }
|
||||||
return amqp.Dial(held.URL)
|
|
||||||
}
|
|
||||||
return amqp.DialTLS(held.URL, pinning(held.Fingerprint))
|
|
||||||
}
|
|
||||||
|
|
||||||
// pinning is a TLS configuration that trusts exactly one certificate.
|
// natsURL is the address with this machine's credential in it, for the one dial that needs it.
|
||||||
//
|
func (c Credential) natsURL() string {
|
||||||
// InsecureSkipVerify with a VerifyPeerCertificate is **pinning, not skipping**: the standard chain
|
rest := strings.TrimPrefix(strings.TrimSpace(c.URL), "nats://")
|
||||||
// check is replaced, not removed, and what replaces it is stricter — one certificate is accepted
|
if c.User == "" {
|
||||||
// rather than every certificate a public authority would sign.
|
return "nats://" + rest
|
||||||
//
|
|
||||||
// Its own function so a test can drive it against a real handshake. A pin check that is only ever
|
|
||||||
// exercised through a broker is a pin check nothing tests.
|
|
||||||
func pinning(fingerprint string) *tls.Config {
|
|
||||||
return &tls.Config{
|
|
||||||
InsecureSkipVerify: true,
|
|
||||||
VerifyPeerCertificate: func(raw [][]byte, _ [][]*x509.Certificate) error {
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return errors.New("the broker presented no certificate")
|
|
||||||
}
|
|
||||||
// The leaf, and in the same spelling the mesh writes it — `sha256:` and 64 hex
|
|
||||||
// characters. Comparing a bare digest against a written fingerprint never matches,
|
|
||||||
// and the failure is indistinguishable from being pointed at the wrong broker.
|
|
||||||
sum := sha256.Sum256(raw[0])
|
|
||||||
got := "sha256:" + hex.EncodeToString(sum[:])
|
|
||||||
if got != fingerprint {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"this is not the broker this builder was told about\n expected %s\n "+
|
|
||||||
"got %s\nEither this mesh's broker was replaced, or this builder is "+
|
|
||||||
"being pointed at something else. Retrying will not help",
|
|
||||||
fingerprint, got)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
return "nats://" + c.User + ":" + c.Password + "@" + rest
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ func buildOnce(ctx context.Context, args []string) error {
|
|||||||
Manifest: built.Manifest,
|
Manifest: built.Manifest,
|
||||||
Against: built.Against,
|
Against: built.Against,
|
||||||
}
|
}
|
||||||
|
for _, r := range built.Read {
|
||||||
|
out.Read = append(out.Read, readRepository{Repository: r.Repository, Ref: r.Ref})
|
||||||
|
}
|
||||||
for _, made := range built.Built {
|
for _, made := range built.Built {
|
||||||
out.Made = append(out.Made, madeArtifact{Name: made.Name, Kind: made.Kind, Reference: made.Reference})
|
out.Made = append(out.Made, madeArtifact{Name: made.Name, Kind: made.Kind, Reference: made.Reference})
|
||||||
}
|
}
|
||||||
@@ -123,14 +126,21 @@ func buildOnce(ctx context.Context, args []string) error {
|
|||||||
// The same fields the mesh records for a build, so a reader comparing a genesis build against an
|
// The same fields the mesh records for a build, so a reader comparing a genesis build against an
|
||||||
// ordinary one is comparing the same thing said the same way.
|
// ordinary one is comparing the same thing said the same way.
|
||||||
type onceResult struct {
|
type onceResult struct {
|
||||||
Module string `json:"module"`
|
Module string `json:"module"`
|
||||||
Commit string `json:"commit"`
|
Commit string `json:"commit"`
|
||||||
Repository string `json:"repository"`
|
Repository string `json:"repository"`
|
||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
Ref string `json:"ref,omitempty"`
|
Ref string `json:"ref,omitempty"`
|
||||||
Manifest any `json:"manifest"`
|
Manifest any `json:"manifest"`
|
||||||
Made []madeArtifact `json:"made"`
|
Made []madeArtifact `json:"made"`
|
||||||
Against []string `json:"against,omitempty"`
|
Against []string `json:"against,omitempty"`
|
||||||
|
Read []readRepository `json:"read,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRepository is a repository this build read source from besides the module's own.
|
||||||
|
type readRepository struct {
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Ref string `json:"ref,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type madeArtifact struct {
|
type madeArtifact struct {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ func TestBuilderDiagnosticsStayOffStdout(t *testing.T) {
|
|||||||
allowed := map[string]bool{
|
allowed := map[string]bool{
|
||||||
"string(body)": true, // once.go: the result JSON, which IS stdout
|
"string(body)": true, // once.go: the result JSON, which IS stdout
|
||||||
"version)": true, // --version
|
"version)": true, // --version
|
||||||
`"stopping")`: true, // the loop.s shutdown line
|
`"stopping")`: true, // the loop.s shutdown line
|
||||||
"usage)": true, // --help text, for a human
|
"usage)": true, // --help text, for a human
|
||||||
}
|
}
|
||||||
for _, file := range []string{"once.go", "main.go"} {
|
for _, file := range []string{"once.go", "main.go"} {
|
||||||
src, err := os.ReadFile(file)
|
src, err := os.ReadFile(file)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Where a builder publishes.
|
// Where a builder publishes.
|
||||||
@@ -193,9 +195,9 @@ func TestThePinIsComparedInTheSpellingTheMeshWritesIt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handshakeWith runs the builder's own pin check against an address.
|
// handshakeWith runs the pin check the builder dials with against an address.
|
||||||
func handshakeWith(address, pin string) error {
|
func handshakeWith(address, pin string) error {
|
||||||
conn, err := tls.Dial("tcp", address, pinning(pin))
|
conn, err := tls.Dial("tcp", address, broker.PinnedToFingerprint(pin))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer release()
|
defer release()
|
||||||
|
// **Before the new assignment can unsettle a seat somebody holds only by being alone**
|
||||||
|
// (novox/hq 04-ISSUES/170): what the mesh derived so far is written down, and then the
|
||||||
|
// assignment resolves against a record rather than against a coincidence.
|
||||||
|
settled, err := recordDerivedHolders(ctx, open)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
fresh, err := open.inventory.Assign(ctx, node, module)
|
fresh, err := open.inventory.Assign(ctx, node, module)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -57,6 +64,9 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
|||||||
node, module), nil
|
node, module), nil
|
||||||
}
|
}
|
||||||
said := fmt.Sprintf("%s is assigned %s", node, module)
|
said := fmt.Sprintf("%s is assigned %s", node, module)
|
||||||
|
for _, line := range settled {
|
||||||
|
said += "\n " + line
|
||||||
|
}
|
||||||
plan, _, err := planFor(ctx, open, node)
|
plan, _, err := planFor(ctx, open, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Kept, and still refused. Both halves are the answer, and the rest of the mesh is still
|
// Kept, and still refused. Both halves are the answer, and the rest of the mesh is still
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import (
|
|||||||
|
|
||||||
var aDigest = "sha256:" + strings.Repeat("e", 64)
|
var aDigest = "sha256:" + strings.Repeat("e", 64)
|
||||||
|
|
||||||
// **A build is recorded by digest and path**, whatever address the builder pushed to — and only
|
// **A build is recorded by digest and path**, whatever address the builder pushed to — what it
|
||||||
// what the build made is rewritten: an image the module runs from elsewhere is left where it says.
|
// made and what it stood on both; an image the module runs from elsewhere is left where it says.
|
||||||
func TestABuildIsRecordedWithoutTheStoresAddress(t *testing.T) {
|
func TestABuildIsRecordedWithoutTheStoresAddress(t *testing.T) {
|
||||||
manifest, _ := json.Marshal(map[string]any{
|
manifest, _ := json.Marshal(map[string]any{
|
||||||
"module": "gitea", "version": "1",
|
"module": "gitea", "version": "1",
|
||||||
@@ -65,8 +65,11 @@ func TestABuildIsRecordedWithoutTheStoresAddress(t *testing.T) {
|
|||||||
if strings.Contains(string(kept.Manifest), "anchor.internal:5100") {
|
if strings.Contains(string(kept.Manifest), "anchor.internal:5100") {
|
||||||
t.Errorf("the recorded manifest still carries the store's address:\n%s", kept.Manifest)
|
t.Errorf("the recorded manifest still carries the store's address:\n%s", kept.Manifest)
|
||||||
}
|
}
|
||||||
if kept.Against[0] != "anchor.internal:5100/mesh-tools/runtime@"+aDigest {
|
// What the build stood on is an edge to another module's artifact, and it is recorded the way
|
||||||
t.Errorf("what the build stood on was rewritten: %v", kept.Against)
|
// that artifact is: by path in the store, so the edge still names the same thing when the
|
||||||
|
// store answers at another address.
|
||||||
|
if kept.Against[0] != catalogue.ArtifactStoreScheme+"mesh-tools/runtime@"+aDigest {
|
||||||
|
t.Errorf("what the build stood on was recorded by address: %v", kept.Against)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,9 +88,13 @@ func reportsReaching(t *testing.T, open *stores, reachable []link.Reach, held ..
|
|||||||
if err := open.inventory.RecordSent(ctx, record.ID, digestOf(body)); err != nil {
|
if err := open.inventory.RecordSent(ctx, record.ID, digestOf(body)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := (link.Enrolment{Inventory: open.inventory}).Heard(ctx, link.Report{
|
if _, err := (link.Enrolment{Inventory: open.inventory}).Heard(ctx, link.Report{
|
||||||
Node: "anchor", Applied: []string{"hello-web.x"}, Declared: digestOf(body),
|
Node: "anchor", Applied: []string{"hello-web.x"}, Declared: digestOf(body),
|
||||||
Firewall: "ufw", Held: held, Reachable: reachable,
|
Firewall: "ufw", Held: held, Reachable: reachable,
|
||||||
|
// A machine says which of its links face outside on every apply (novox/hq ADR 0140), and a
|
||||||
|
// filter is not sent to one that has not. The anchor reports one, as a real host does; this
|
||||||
|
// fixture lacked it from 2026-09-28 and nothing ran the test (issue 177).
|
||||||
|
Outward: []string{"eth0"},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -105,10 +109,10 @@ var (
|
|||||||
|
|
||||||
func TestTakingAModuleNotOnTheNodeIsRefused(t *testing.T) {
|
func TestTakingAModuleNotOnTheNodeIsRefused(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
if _, err := take(t.Context(), open, "anchor", "nftables"); !errors.Is(err, inventory.ErrNotAssigned) {
|
if _, err := take(t.Context(), open, "anchor", "nftables", takeOptions{Yes: true}); !errors.Is(err, inventory.ErrNotAssigned) {
|
||||||
t.Fatalf("taking an unassigned module gave %v", err)
|
t.Fatalf("taking an unassigned module gave %v", err)
|
||||||
}
|
}
|
||||||
if _, err := take(t.Context(), open, "laptop", "network"); !errors.Is(err, inventory.ErrNotAdopted) {
|
if _, err := take(t.Context(), open, "laptop", "network", takeOptions{Yes: true}); !errors.Is(err, inventory.ErrNotAdopted) {
|
||||||
t.Fatalf("taking on a converged node gave %v", err)
|
t.Fatalf("taking on a converged node gave %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,7 +143,24 @@ func TestTheFlipIsRefusedWhileAFoundContainerIsHeld(t *testing.T) {
|
|||||||
func TestTakingNamesWhatItReplaces(t *testing.T) {
|
func TestTakingNamesWhatItReplaces(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
reportsHolding(t, open, heldContainer, heldFile)
|
reportsHolding(t, open, heldContainer, heldFile)
|
||||||
said, err := take(t.Context(), open, "anchor", "hello-web")
|
ctx := t.Context()
|
||||||
|
// The machine holds something for the module, so the take acts on the preview the operator
|
||||||
|
// saw and names its digest (novox/hq ADR 0163).
|
||||||
|
preview, err := take(ctx, open, "anchor", "hello-web", takeOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
saw := takeDigestIn(t, preview)
|
||||||
|
if !strings.Contains(preview, "nothing taken; `take anchor hello-web --yes "+saw+"`") {
|
||||||
|
t.Fatalf("the preview does not say how to act on it:\n%s", preview)
|
||||||
|
}
|
||||||
|
if taken, _ := open.inventory.Taken(ctx, "anchor"); len(taken) != 0 {
|
||||||
|
t.Fatal("the preview took something")
|
||||||
|
}
|
||||||
|
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err == nil || !strings.Contains(err.Error(), "name its digest") {
|
||||||
|
t.Fatalf("--yes without the digest was not refused: %v", err)
|
||||||
|
}
|
||||||
|
said, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -149,10 +170,71 @@ func TestTakingNamesWhatItReplaces(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// takeDigestIn is the digest a take's preview printed.
|
||||||
|
func takeDigestIn(t *testing.T, preview string) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, line := range strings.Split(preview, "\n") {
|
||||||
|
if fields := strings.Fields(line); len(fields) == 2 && fields[0] == "preview" {
|
||||||
|
return fields[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("the preview printed no digest:\n%s", preview)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// A take acts on the preview the operator saw, and on an account of the machine that is still the
|
||||||
|
// machine: a changed preview and a stale account refuse (novox/hq ADR 0163, rule 1).
|
||||||
|
func TestATakeIsRefusedOnAChangedPreviewOrAStaleAccount(t *testing.T) {
|
||||||
|
open, _ := anAdoptedAnchor(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
reportsHolding(t, open, heldContainer, heldFile)
|
||||||
|
preview, err := take(ctx, open, "anchor", "hello-web", takeOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
saw := takeDigestIn(t, preview)
|
||||||
|
|
||||||
|
// The machine reports again, and what it holds has changed: the found container now carries
|
||||||
|
// facts the preview never showed.
|
||||||
|
changed := heldContainer
|
||||||
|
changed.Facts = map[string]any{"image": "hello:2", "declared_image": "registry.example/hello"}
|
||||||
|
reportsHolding(t, open, changed, heldFile)
|
||||||
|
_, err = take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "has changed since preview "+saw) {
|
||||||
|
t.Fatalf("a changed preview was acted on: %v", err)
|
||||||
|
}
|
||||||
|
if taken, _ := open.inventory.Taken(ctx, "anchor"); len(taken) != 0 {
|
||||||
|
t.Fatal("a refused take took something")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And an account older than the flip allows.
|
||||||
|
preview, err = take(ctx, open, "anchor", "hello-web", takeOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
saw = takeDigestIn(t, preview)
|
||||||
|
saved := reportFreshFor
|
||||||
|
reportFreshFor = -time.Second
|
||||||
|
defer func() { reportFreshFor = saved }()
|
||||||
|
_, err = take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "a take acts only on an account newer than") {
|
||||||
|
t.Fatalf("a stale account was acted on: %v", err)
|
||||||
|
}
|
||||||
|
reportFreshFor = saved
|
||||||
|
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A module the machine holds nothing for has nothing to compare: --yes alone suffices.
|
||||||
|
if _, err := take(ctx, open, "anchor", "notes", takeOptions{Yes: true}); err == nil {
|
||||||
|
// notes holds a file, so this one needs the digest too.
|
||||||
|
t.Fatal("notes holds a found file and was taken without a digest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConvergingPreviewsThenChangesAndAdoptingKeepsWhatWasTaken(t *testing.T) {
|
func TestConvergingPreviewsThenChangesAndAdoptingKeepsWhatWasTaken(t *testing.T) {
|
||||||
open, sent := anAdoptedAnchor(t)
|
open, sent := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
|
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reportsHolding(t, open, heldFile)
|
reportsHolding(t, open, heldFile)
|
||||||
@@ -326,7 +408,7 @@ func digestIn(t *testing.T, preview string) string {
|
|||||||
func TestTheFlipActsOnlyOnThePreviewTheOperatorSaw(t *testing.T) {
|
func TestTheFlipActsOnlyOnThePreviewTheOperatorSaw(t *testing.T) {
|
||||||
open, sent := anAdoptedAnchor(t)
|
open, sent := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
|
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reportsHolding(t, open, heldFile)
|
reportsHolding(t, open, heldFile)
|
||||||
@@ -392,7 +474,7 @@ func TestTheFlipActsOnlyOnThePreviewTheOperatorSaw(t *testing.T) {
|
|||||||
func TestTheFlipHoldsTheNodeWhileItSends(t *testing.T) {
|
func TestTheFlipHoldsTheNodeWhileItSends(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
|
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reportsHolding(t, open, heldFile)
|
reportsHolding(t, open, heldFile)
|
||||||
@@ -437,7 +519,7 @@ func TestTheFlipHoldsTheNodeWhileItSends(t *testing.T) {
|
|||||||
func TestThePreviewNamesEveryHeldKind(t *testing.T) {
|
func TestThePreviewNamesEveryHeldKind(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
|
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
since := time.Now()
|
since := time.Now()
|
||||||
@@ -480,7 +562,7 @@ func TestThePreviewNamesEveryHeldKind(t *testing.T) {
|
|||||||
func TestTheFlipIsRefusedOnAnAccountNamingNothingReachable(t *testing.T) {
|
func TestTheFlipIsRefusedOnAnAccountNamingNothingReachable(t *testing.T) {
|
||||||
open, sent := anAdoptedAnchor(t)
|
open, sent := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
|
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Only a loopback listener: nothing off the machine, which is the same silence.
|
// Only a loopback listener: nothing off the machine, which is the same silence.
|
||||||
@@ -508,7 +590,7 @@ func TestTheFlipIsRefusedOnAnAccountNamingNothingReachable(t *testing.T) {
|
|||||||
func TestAssigningWaitsForWhateverIsConvergingTheNode(t *testing.T) {
|
func TestAssigningWaitsForWhateverIsConvergingTheNode(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
|
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reportsHolding(t, open, heldFile)
|
reportsHolding(t, open, heldFile)
|
||||||
|
|||||||
+418
-18
@@ -24,6 +24,11 @@ import (
|
|||||||
func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node) error {
|
func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node) error {
|
||||||
if !node.Adopted {
|
if !node.Adopted {
|
||||||
fmt.Printf(" mode converged\n")
|
fmt.Printf(" mode converged\n")
|
||||||
|
// A converged machine holds nothing, and can still run what nobody asked for
|
||||||
|
// (novox/hq ADR 0163): what it reports as strays is said whatever its mode.
|
||||||
|
if said, err := inv.AdoptionOf(ctx, node.Name); err == nil && len(said.Strays) > 0 {
|
||||||
|
showStrays(said.Strays)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fmt.Printf(" mode adopted since %s\n",
|
fmt.Printf(" mode adopted since %s\n",
|
||||||
@@ -62,11 +67,26 @@ func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node
|
|||||||
if h.Kept != "" {
|
if h.Kept != "" {
|
||||||
fmt.Printf(" %-17s original kept at %s\n", "", h.Kept)
|
fmt.Printf(" %-17s original kept at %s\n", "", h.Kept)
|
||||||
}
|
}
|
||||||
|
for _, f := range comparisonLines(h) {
|
||||||
|
fmt.Printf(" %-17s %s\n", "", f)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
showStrays(said.Strays)
|
||||||
fmt.Printf(" as of %s\n", said.At.Local().Format(time.DateTime))
|
fmt.Printf(" as of %s\n", said.At.Local().Format(time.DateTime))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// showStrays says what a machine runs that the mesh neither wrote nor holds (ADR 0163).
|
||||||
|
func showStrays(strays []inventory.Stray) {
|
||||||
|
if len(strays) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" strays %d container(s) the mesh neither wrote nor holds:\n", len(strays))
|
||||||
|
for _, s := range strays {
|
||||||
|
fmt.Printf(" %-17s %s (%s)\n", "", s.Name, s.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// showTunnel is the node show lines about the tunnel an adopted node found and carried (novox/hq
|
// showTunnel is the node show lines about the tunnel an adopted node found and carried (novox/hq
|
||||||
// ADR 0105): what it presented at enrolment, and what it last said about taking it over.
|
// ADR 0105): what it presented at enrolment, and what it last said about taking it over.
|
||||||
func showTunnel(ctx context.Context, inv *inventory.Inventory, name string) error {
|
func showTunnel(ctx context.Context, inv *inventory.Inventory, name string) error {
|
||||||
@@ -136,7 +156,28 @@ const DefaultFilter = "nftables"
|
|||||||
// take is a module's cutover on an adopted node: the operator's act, done when that module's data
|
// take is a module's cutover on an adopted node: the operator's act, done when that module's data
|
||||||
// has moved. From the next push its resources converge there like any other, replacing what the
|
// has moved. From the next push its resources converge there like any other, replacing what the
|
||||||
// node found and holds for it.
|
// node found and holds for it.
|
||||||
func take(ctx context.Context, open *stores, node, module string) (string, error) {
|
//
|
||||||
|
// **Previewed, and the preview is a comparison** (novox/hq ADR 0163): for every held thing the
|
||||||
|
// module would replace, what runs beside what the module declares, and the difference; the
|
||||||
|
// module's secrets on the machine and where each came from; its settings on the machine. Without
|
||||||
|
// --yes the comparison is printed and nothing changes. `--yes <digest>` cuts over exactly what was
|
||||||
|
// previewed, the way the flip is confirmed: the preview ends with a digest of what it said, and a
|
||||||
|
// take naming an older one, or acting on an account of the machine older than the flip allows, is
|
||||||
|
// refused. A module the machine holds nothing for has nothing to compare, and `--yes` suffices.
|
||||||
|
// takeOptions is what a take was told about the differences it may pass (novox/hq ADR 0163).
|
||||||
|
type takeOptions struct {
|
||||||
|
Yes bool
|
||||||
|
// Digest is the preview's, named with --yes; required whenever the machine holds something
|
||||||
|
// for the module.
|
||||||
|
Digest string
|
||||||
|
Downgrade bool
|
||||||
|
Replace map[string]bool
|
||||||
|
// Mint names the secrets the service shall take a new value for, although the mesh minted
|
||||||
|
// one and the service already has its own (rule 2).
|
||||||
|
Mint map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func take(ctx context.Context, open *stores, node, module string, opts takeOptions) (string, error) {
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
assigned, err := inv.Assigned(ctx, node)
|
assigned, err := inv.Assigned(ctx, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -150,27 +191,337 @@ func take(ctx context.Context, open *stores, node, module string) (string, error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The comparison first (novox/hq ADR 0163): every held thing the module would replace, beside
|
||||||
|
// what the module declares, and the differences that refuse unless named.
|
||||||
|
c, err := comparisonFor(ctx, open, node, module)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
preview, refusals, saw := comparisonOf(module, c, opts)
|
||||||
|
if len(refusals) > 0 {
|
||||||
|
return "", fmt.Errorf("taking %s on %s is refused:\n %s\n%s", module, node,
|
||||||
|
strings.Join(refusals, "\n "), preview)
|
||||||
|
}
|
||||||
|
holds := len(heldOf(c.reported, module)) > 0
|
||||||
|
if holds {
|
||||||
|
preview += "\n preview " + saw
|
||||||
|
}
|
||||||
|
if !opts.Yes {
|
||||||
|
if !holds {
|
||||||
|
return preview + fmt.Sprintf("\nnothing taken; `take %s %s --yes` declares it as the mesh's own", node, module), nil
|
||||||
|
}
|
||||||
|
return preview + fmt.Sprintf("\nnothing taken; `take %s %s --yes %s` cuts it over as previewed", node, module, saw), nil
|
||||||
|
}
|
||||||
|
if holds {
|
||||||
|
// The take acts on the preview the operator saw, and on an account of the machine that
|
||||||
|
// is still the machine: the same two refusals the flip makes.
|
||||||
|
if age := time.Since(c.reported.At); age > reportFreshFor {
|
||||||
|
return preview, fmt.Errorf("%s last said what it holds %s ago, and a take acts only on "+
|
||||||
|
"an account newer than %s: run `push %s --wait 2m`, then preview again",
|
||||||
|
node, age.Round(time.Second), reportFreshFor, node)
|
||||||
|
}
|
||||||
|
if opts.Digest == "" {
|
||||||
|
return preview, fmt.Errorf("taking %s on %s acts on the preview you saw: name its digest, "+
|
||||||
|
"`take %s %s --yes %s`, once you have read it", module, node, node, module, saw)
|
||||||
|
}
|
||||||
|
if opts.Digest != saw {
|
||||||
|
return preview, fmt.Errorf("what taking %s on %s would replace has changed since preview %s "+
|
||||||
|
"(it is now %s): read the preview above, and run `take %s %s --yes %s` if it is "+
|
||||||
|
"what you want", module, node, opts.Digest, saw, node, module, saw)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := inv.Take(ctx, node, module); err != nil {
|
if err := inv.Take(ctx, node, module); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
said := fmt.Sprintf("%s is taken on %s", module, node)
|
said := fmt.Sprintf("%s is taken on %s", module, node)
|
||||||
reported, err := inv.AdoptionOf(ctx, node)
|
if holds {
|
||||||
if err != nil {
|
said += "; the next push replaces what the node found and holds for it:\n" + preview
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
var replaces []string
|
|
||||||
for _, h := range reported.Held {
|
|
||||||
if h.Module == module {
|
|
||||||
replaces = append(replaces, " "+heldLine(h))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(replaces) > 0 {
|
|
||||||
said += "; the next push replaces what the node found and holds for it:\n" +
|
|
||||||
strings.Join(replaces, "\n")
|
|
||||||
}
|
}
|
||||||
return said + fmt.Sprintf("\n run `push %s` to cut it over", node), nil
|
return said + fmt.Sprintf("\n run `push %s` to cut it over", node), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// comparison is everything a take puts beside what the module declares: the machine's account of
|
||||||
|
// what it holds and what is reachable on it, the module's secrets on the machine, its settings
|
||||||
|
// there, and which found networks a setting keeps for each of its containers (by held id).
|
||||||
|
type comparison struct {
|
||||||
|
reported inventory.Adoption
|
||||||
|
secrets []inventory.SecretState
|
||||||
|
layers []catalogue.Layer
|
||||||
|
keeps map[string][]string
|
||||||
|
// settingsRefused is why the module's settings cannot compose with its definition, when
|
||||||
|
// they cannot — the module would be left out of the declaration (rule 6).
|
||||||
|
settingsRefused string
|
||||||
|
}
|
||||||
|
|
||||||
|
func comparisonFor(ctx context.Context, open *stores, node, module string) (comparison, error) {
|
||||||
|
inv := open.inventory
|
||||||
|
var c comparison
|
||||||
|
var err error
|
||||||
|
if c.reported, err = inv.AdoptionOf(ctx, node); err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
if c.secrets, err = inv.SecretsOf(ctx, node, module); err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
if c.layers, err = inv.SettingsFor(ctx, node, module); err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
shelf, err := inv.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
if m, known := shelf[module]; known && len(c.layers) > 0 {
|
||||||
|
if err := catalogue.JudgeSettings(m, c.layers, true); err != nil {
|
||||||
|
c.settingsRefused = err.Error()
|
||||||
|
}
|
||||||
|
if kept, err := catalogue.KeptNetworks(m, c.layers, true); err == nil && len(kept) > 0 {
|
||||||
|
c.keeps = map[string][]string{}
|
||||||
|
for id, networks := range kept {
|
||||||
|
c.keeps[module+"."+id] = networks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldOf is what a node holds for one module.
|
||||||
|
func heldOf(reported inventory.Adoption, module string) []inventory.Held {
|
||||||
|
var out []inventory.Held
|
||||||
|
for _, h := range reported.Held {
|
||||||
|
if h.Module == module {
|
||||||
|
out = append(out, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// comparisonOf is a take's preview: for every held thing of the module, what runs beside what the
|
||||||
|
// module declares; its secrets and its settings on the machine; and the refusals the differences
|
||||||
|
// earn unless the take named them (novox/hq ADR 0163): an image older than the one running, a
|
||||||
|
// declared file that differs from the found one, a secret the mesh minted for a service whose data
|
||||||
|
// was found. A narrowed port and a shared network are said and not refused. The digest is of what
|
||||||
|
// the preview says, so anything in it changing changes the digest.
|
||||||
|
func comparisonOf(module string, c comparison, opts takeOptions) (preview string, refusals []string, digest string) {
|
||||||
|
var b strings.Builder
|
||||||
|
held := heldOf(c.reported, module)
|
||||||
|
foundData := false
|
||||||
|
for _, h := range held {
|
||||||
|
if h.Kind == "container" || h.Kind == "directory" {
|
||||||
|
foundData = true
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " %s", heldLine(h))
|
||||||
|
if h.Kept != "" {
|
||||||
|
fmt.Fprintf(&b, ", original kept at %s", h.Kept)
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
for _, line := range comparisonLinesWith(h, c.keeps[h.ID], c.reported) {
|
||||||
|
fmt.Fprintf(&b, " %s\n", line)
|
||||||
|
}
|
||||||
|
f := factsOf(h)
|
||||||
|
if f.downgrade && !opts.Downgrade {
|
||||||
|
refusals = append(refusals, fmt.Sprintf("%s: the module's image (%s, made %s) is older than the one running (%s, made %s) — "+
|
||||||
|
"a service that migrated its data forward may not start on it; `--downgrade` to take it anyway",
|
||||||
|
h.Target, f.declaredImage, day(f.declaredCreated), f.image, day(f.imageCreated)))
|
||||||
|
}
|
||||||
|
if f.differs && !opts.Replace[h.Target] && !opts.Replace["*"] {
|
||||||
|
refusals = append(refusals, fmt.Sprintf("%s: the module's content differs from the file found; the lines above "+
|
||||||
|
"marked - are lost by taking it; `--replace %s` to replace it anyway, or declare the file partially",
|
||||||
|
h.Target, h.Target))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The module's secrets on the machine (rule 2 and 3): a service whose data was found already
|
||||||
|
// has a value for each, so one the mesh minted and nobody accepted refuses unless --mint says
|
||||||
|
// the service shall take a new one.
|
||||||
|
for _, sec := range c.secrets {
|
||||||
|
name := sec.Name
|
||||||
|
if sec.Local != "" {
|
||||||
|
name += " (" + sec.Local + ")"
|
||||||
|
}
|
||||||
|
what := "own secret"
|
||||||
|
accept := fmt.Sprintf("`secret accept <node> %s %s`", module, sec.Name)
|
||||||
|
if !sec.Own() {
|
||||||
|
what = "secret from " + sec.Provider
|
||||||
|
accept = fmt.Sprintf("`secret accept <node> %s %s --provider %s`", module, sec.Name, sec.Provider)
|
||||||
|
if sec.Local != "" {
|
||||||
|
accept = strings.TrimSuffix(accept, "`") + " --local " + sec.Local + "`"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case sec.Origin == inventory.OriginAccepted:
|
||||||
|
fmt.Fprintf(&b, " %s %s: accepted from a person, carried in as it is\n", what, name)
|
||||||
|
case opts.Mint[sec.Name]:
|
||||||
|
fmt.Fprintf(&b, " %s %s: minted by the mesh; the service takes the new value, as --mint said\n", what, name)
|
||||||
|
case foundData:
|
||||||
|
fmt.Fprintf(&b, " %s %s: MINTED by the mesh and not accepted — the running service already has one\n", what, name)
|
||||||
|
refusals = append(refusals, fmt.Sprintf("%s: the mesh minted a value and the service whose data was found "+
|
||||||
|
"already uses its own; %s carries the existing value in, or `--mint %s` says the service shall take "+
|
||||||
|
"the new one", name, accept, sec.Name))
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(&b, " %s %s: minted by the mesh\n", what, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And its settings on this machine, composed against its definition (rule 1, rule 6).
|
||||||
|
for _, layer := range c.layers {
|
||||||
|
keys := make([]string, 0, len(layer.Values))
|
||||||
|
for k := range layer.Values {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
fmt.Fprintf(&b, " settings from %s: %s\n", layer.From, strings.Join(keys, ", "))
|
||||||
|
}
|
||||||
|
if c.settingsRefused != "" {
|
||||||
|
fmt.Fprintf(&b, " SETTINGS DO NOT COMPOSE with the module's definition, so the push leaves it out: %s\n", c.settingsRefused)
|
||||||
|
}
|
||||||
|
preview = strings.TrimRight(b.String(), "\n")
|
||||||
|
sum := sha256.Sum256([]byte(preview))
|
||||||
|
return preview, refusals, hex.EncodeToString(sum[:])[:12]
|
||||||
|
}
|
||||||
|
|
||||||
|
// facts is a held thing's facts as the preview reads them.
|
||||||
|
type facts struct {
|
||||||
|
image, imageCreated, declaredImage, declaredCreated string
|
||||||
|
downgrade, differs bool
|
||||||
|
networks map[string][]string
|
||||||
|
mounts, ports, declaredPorts, declaredVolumes []string
|
||||||
|
difference []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func factsOf(h inventory.Held) facts {
|
||||||
|
var f facts
|
||||||
|
if h.Facts == nil {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
str := func(k string) string { s, _ := h.Facts[k].(string); return s }
|
||||||
|
list := func(k string) []string {
|
||||||
|
var out []string
|
||||||
|
if raw, ok := h.Facts[k].([]any); ok {
|
||||||
|
for _, x := range raw {
|
||||||
|
if s, ok := x.(string); ok {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
f.image, f.imageCreated = str("image"), str("image_created")
|
||||||
|
f.declaredImage, f.declaredCreated = str("declared_image"), str("declared_image_created")
|
||||||
|
f.downgrade, _ = h.Facts["downgrade"].(bool)
|
||||||
|
f.differs, _ = h.Facts["differs"].(bool)
|
||||||
|
f.mounts, f.ports = list("mounts"), list("ports")
|
||||||
|
f.declaredPorts, f.declaredVolumes, f.difference = list("declared_ports"), list("declared_volumes"), list("difference")
|
||||||
|
if raw, ok := h.Facts["networks"].(map[string]any); ok {
|
||||||
|
f.networks = map[string][]string{}
|
||||||
|
for name, members := range raw {
|
||||||
|
var out []string
|
||||||
|
if ms, ok := members.([]any); ok {
|
||||||
|
for _, m := range ms {
|
||||||
|
if s, ok := m.(string); ok {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.networks[name] = out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// comparisonLines says a held thing's facts the way a person weighs them.
|
||||||
|
func comparisonLines(h inventory.Held) []string {
|
||||||
|
return comparisonLinesWith(h, nil, inventory.Adoption{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// comparisonLinesWith is comparisonLines knowing which found networks this machine's setting keeps
|
||||||
|
// for the container (rule 4) and what the machine reports reachable, so a published port's reach
|
||||||
|
// is said beside the port (rule 1).
|
||||||
|
func comparisonLinesWith(h inventory.Held, keeps []string, reported inventory.Adoption) []string {
|
||||||
|
f := factsOf(h)
|
||||||
|
var out []string
|
||||||
|
if f.image != "" || f.declaredImage != "" {
|
||||||
|
line := fmt.Sprintf("runs %s", orNone(f.image))
|
||||||
|
if f.imageCreated != "" {
|
||||||
|
line += " (made " + day(f.imageCreated) + ")"
|
||||||
|
}
|
||||||
|
line += "; the module declares " + orNone(f.declaredImage)
|
||||||
|
switch {
|
||||||
|
case f.declaredCreated != "":
|
||||||
|
line += " (made " + day(f.declaredCreated) + ")"
|
||||||
|
case f.declaredImage != "":
|
||||||
|
line += " (not on the machine yet, so its age is unknown)"
|
||||||
|
}
|
||||||
|
if f.downgrade {
|
||||||
|
line += " — DOWNGRADE"
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(f.networks))
|
||||||
|
for n := range f.networks {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, n := range names {
|
||||||
|
members := f.networks[n]
|
||||||
|
if len(members) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Contains(keeps, n) {
|
||||||
|
out = append(out, fmt.Sprintf("on the network %s with %s — kept by this machine's setting, so they still reach it by name once taken",
|
||||||
|
n, strings.Join(members, ", ")))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, fmt.Sprintf("on the network %s with %s, which may reach it by name and will not once it moves to the module's own network"+
|
||||||
|
" (`settings set %s --node <node>` with {%q: {<container>: [%q]}} keeps it)",
|
||||||
|
n, strings.Join(members, ", "), h.Module, catalogue.NetworksSetting, n))
|
||||||
|
}
|
||||||
|
for _, n := range keeps {
|
||||||
|
if _, found := f.networks[n]; !found {
|
||||||
|
out = append(out, fmt.Sprintf("keeps the network %s by this machine's setting, which the found container is not on", n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(f.ports) > 0 || len(f.declaredPorts) > 0 {
|
||||||
|
out = append(out, fmt.Sprintf("publishes %s; the module declares %s",
|
||||||
|
orNone(strings.Join(f.ports, " ")), orNone(strings.Join(f.declaredPorts, " "))))
|
||||||
|
// How far each published port reaches now, as the machine reported it: the listener the
|
||||||
|
// runtime publishes for this container. The found firewall's and the guard's rules are
|
||||||
|
// not read; what they let through is said as what was reported reachable.
|
||||||
|
var reach []string
|
||||||
|
for _, r := range reported.Reachable {
|
||||||
|
if r.By == h.Target && r.Published {
|
||||||
|
reach = append(reach, fmt.Sprintf("%s:%d (%s, container port %d)", r.Address, r.Port, r.Protocol, r.ContainerPort))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(reach) > 0:
|
||||||
|
line := "reachable now at " + strings.Join(reach, ", ")
|
||||||
|
if reported.Firewall != "" && reported.Firewall != "none" {
|
||||||
|
line += ", behind the found firewall (" + reported.Firewall + "), whose rules are not read"
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
case len(f.ports) > 0 && len(reported.Reachable) > 0:
|
||||||
|
out = append(out, "not reported reachable on the machine")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(f.mounts) > 0 || len(f.declaredVolumes) > 0 {
|
||||||
|
out = append(out, fmt.Sprintf("mounts %s; the module declares %s",
|
||||||
|
orNone(strings.Join(f.mounts, " ")), orNone(strings.Join(f.declaredVolumes, " "))))
|
||||||
|
}
|
||||||
|
if f.differs {
|
||||||
|
out = append(out, "the declared content differs from the file found (- lost, + new):")
|
||||||
|
for _, d := range f.difference {
|
||||||
|
out = append(out, " "+d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// day is a timestamp as a person reads it in a preview: its date.
|
||||||
|
func day(stamp string) string {
|
||||||
|
if len(stamp) >= 10 {
|
||||||
|
return stamp[:10]
|
||||||
|
}
|
||||||
|
return stamp
|
||||||
|
}
|
||||||
|
|
||||||
// reportFreshFor is how old a node's account of itself may be for the flip to act on it. A
|
// reportFreshFor is how old a node's account of itself may be for the flip to act on it. A
|
||||||
// variable so a test can age a report without waiting.
|
// variable so a test can age a report without waiting.
|
||||||
var reportFreshFor = 15 * time.Minute
|
var reportFreshFor = 15 * time.Minute
|
||||||
@@ -309,7 +660,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh,
|
derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh,
|
||||||
outward: plan.PublicDomain != ""}
|
outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks}
|
||||||
preview, saw := previewOf(node, reported, derived, plan, taken, filter, runs[filter])
|
preview, saw := previewOf(node, reported, derived, plan, taken, filter, runs[filter])
|
||||||
preview += "\n\n preview " + saw
|
preview += "\n\n preview " + saw
|
||||||
if !yes {
|
if !yes {
|
||||||
@@ -414,6 +765,18 @@ func previewOf(node string, reported inventory.Adoption, derived derivedFilter,
|
|||||||
b.WriteString(" not previewed: traffic the machine routes that is not a published port " +
|
b.WriteString(" not previewed: traffic the machine routes that is not a published port " +
|
||||||
"(a tunnel, NAT in the found firewall) — the derived filter drops it unless a module " +
|
"(a tunnel, NAT in the found firewall) — the derived filter drops it unless a module " +
|
||||||
"declares it\n")
|
"declares it\n")
|
||||||
|
// Which links the filter constrains, said rather than left to the sentence above (novox/hq ADR
|
||||||
|
// 0140). Everything arriving anywhere else is this machine's own guest and keeps working — which
|
||||||
|
// is what a reader most wants to know, because the previous shape of this filter cut a machine's
|
||||||
|
// guests off at the flip without saying so, and that is how this was found.
|
||||||
|
if len(derived.outwardLinks) > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf(" it filters what arrives on: %s, and on the private network "+
|
||||||
|
"— everything its own guests send keeps working\n",
|
||||||
|
strings.Join(derived.outwardLinks, ", ")))
|
||||||
|
} else {
|
||||||
|
b.WriteString(" it has reported no link facing outside, so no filter can be composed " +
|
||||||
|
"for it — the flip is refused until it reports one\n")
|
||||||
|
}
|
||||||
|
|
||||||
isTaken := map[string]bool{}
|
isTaken := map[string]bool{}
|
||||||
for _, m := range taken {
|
for _, m := range taken {
|
||||||
@@ -473,6 +836,10 @@ type derivedFilter struct {
|
|||||||
// mesh is every address on the private network; outward says the machine faces outside.
|
// mesh is every address on the private network; outward says the machine faces outside.
|
||||||
mesh []string
|
mesh []string
|
||||||
outward bool
|
outward bool
|
||||||
|
// outwardLinks is the links this machine reported as facing outside it (novox/hq ADR 0140).
|
||||||
|
// The filter constrains what arrives on them; everything arriving elsewhere is this machine's
|
||||||
|
// own guest and is not filtered.
|
||||||
|
outwardLinks []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// closesOutside is what a narrowing from everywhere to the private network is called: it closes.
|
// closesOutside is what a narrowing from everywhere to the private network is called: it closes.
|
||||||
@@ -498,6 +865,12 @@ func (d derivedFilter) fate(r inventory.Reach) string {
|
|||||||
return "stays open — the mesh's own, from anywhere"
|
return "stays open — the mesh's own, from anywhere"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// This machine's own guests ask it for an address and for names, and those two arrive here
|
||||||
|
// (novox/hq ADR 0140). Admitted by the link they arrive on, so a listener bound anywhere but an
|
||||||
|
// outward link keeps answering them.
|
||||||
|
if (r.Protocol == "udp" && (r.Port == 53 || r.Port == 67)) || (r.Protocol == "tcp" && r.Port == 53) {
|
||||||
|
return "stays open — this machine's own guests asking it for an address and for names"
|
||||||
|
}
|
||||||
for _, rule := range d.rules {
|
for _, rule := range d.rules {
|
||||||
if rule.Port != r.Port || rule.Protocol != r.Protocol {
|
if rule.Port != r.Port || rule.Protocol != r.Protocol {
|
||||||
continue
|
continue
|
||||||
@@ -574,12 +947,39 @@ func adopt(ctx context.Context, open *stores, node string) (string, error) {
|
|||||||
|
|
||||||
// takeCommand, convergeCommand and adoptCommand are the command line's adapters to the acts above.
|
// takeCommand, convergeCommand and adoptCommand are the command line's adapters to the acts above.
|
||||||
func takeCommand(ctx context.Context, args []string) error {
|
func takeCommand(ctx context.Context, args []string) error {
|
||||||
if len(args) != 2 {
|
set := flag.NewFlagSet("take", flag.ContinueOnError)
|
||||||
return errors.New("take <node> <module>")
|
yes := set.Bool("yes", false, "cut over as previewed, naming the digest the preview printed after it; "+
|
||||||
|
"without it the comparison is printed and nothing is taken")
|
||||||
|
downgrade := set.Bool("downgrade", false, "take it although the module's image is older than the one running")
|
||||||
|
var replace, mint stringList
|
||||||
|
set.Var(&replace, "replace", "a found file's path whose content the module may replace although it differs (repeatable; * for every one)")
|
||||||
|
set.Var(&mint, "mint", "a secret the service shall take the mesh's minted value for, although it already has its own (repeatable)")
|
||||||
|
positionals, err := parseAround(set, args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return runAct(ctx, func(open *stores) (string, error) { return take(ctx, open, args[0], args[1]) })
|
if len(positionals) < 2 || len(positionals) > 3 || (len(positionals) == 3 && !*yes) {
|
||||||
|
return errors.New("take <node> <module> [--yes <digest>] [--downgrade] [--replace <path>]... [--mint <secret>]...")
|
||||||
|
}
|
||||||
|
opts := takeOptions{Yes: *yes, Downgrade: *downgrade, Replace: map[string]bool{}, Mint: map[string]bool{}}
|
||||||
|
if len(positionals) == 3 {
|
||||||
|
opts.Digest = positionals[2]
|
||||||
|
}
|
||||||
|
for _, r := range replace {
|
||||||
|
opts.Replace[r] = true
|
||||||
|
}
|
||||||
|
for _, m := range mint {
|
||||||
|
opts.Mint[m] = true
|
||||||
|
}
|
||||||
|
return runAct(ctx, func(open *stores) (string, error) { return take(ctx, open, positionals[0], positionals[1], opts) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stringList is a repeatable flag.
|
||||||
|
type stringList []string
|
||||||
|
|
||||||
|
func (l *stringList) String() string { return strings.Join(*l, ",") }
|
||||||
|
func (l *stringList) Set(v string) error { *l = append(*l, v); return nil }
|
||||||
|
|
||||||
func convergeCommand(ctx context.Context, args []string) error {
|
func convergeCommand(ctx context.Context, args []string) error {
|
||||||
set := flag.NewFlagSet("converge", flag.ContinueOnError)
|
set := flag.NewFlagSet("converge", flag.ContinueOnError)
|
||||||
yes := set.String("yes", "", "do it, naming the digest the preview printed; without it, only "+
|
yes := set.String("yes", "", "do it, naming the digest the preview printed; without it, only "+
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func commands(who Authenticator) http.Handler {
|
|||||||
}))
|
}))
|
||||||
// Adoption (novox/hq ADR 0100): the same acts as `take`, `converge` and `adopt`.
|
// Adoption (novox/hq ADR 0100): the same acts as `take`, `converge` and `adopt`.
|
||||||
mux.HandleFunc("POST /take", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
mux.HandleFunc("POST /take", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||||
return take(ctx, open, in.Node, in.Module)
|
return take(ctx, open, in.Node, in.Module, takeOptions{Yes: in.Yes, Digest: in.Digest})
|
||||||
}))
|
}))
|
||||||
mux.HandleFunc("POST /converge", acting(who, false, func(ctx context.Context, open *stores, in request) (string, error) {
|
mux.HandleFunc("POST /converge", acting(who, false, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||||
return converge(ctx, open, in.Node, in.Yes, in.Digest, in.Filter)
|
return converge(ctx, open, in.Node, in.Yes, in.Digest, in.Filter)
|
||||||
@@ -124,8 +124,8 @@ func commands(who Authenticator) http.Handler {
|
|||||||
type request struct {
|
type request struct {
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
Module string `json:"module"`
|
Module string `json:"module"`
|
||||||
// Yes, Digest and Filter are converge's: do it rather than preview it, the digest of the
|
// Yes, Digest and Filter are converge's and take's: do it rather than preview it, the digest
|
||||||
// preview it acts on, and which module loads the mesh's filter.
|
// of the preview it acts on, and (converge) which module loads the mesh's filter.
|
||||||
Yes bool `json:"yes,omitempty"`
|
Yes bool `json:"yes,omitempty"`
|
||||||
Digest string `json:"digest,omitempty"`
|
Digest string `json:"digest,omitempty"`
|
||||||
Filter string `json:"filter,omitempty"`
|
Filter string `json:"filter,omitempty"`
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ func askCommand(ctx context.Context, args []string) error {
|
|||||||
arguments = json.RawMessage(positionals[2])
|
arguments = json.RawMessage(positionals[2])
|
||||||
}
|
}
|
||||||
|
|
||||||
server, err := link.Connect(nil, nil)
|
server, err := connectLink(ctx, nil, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
answer, err := link.Ask(ctx, server.Channel(), module, tool, arguments, *wait)
|
answer, err := link.Ask(ctx, server.Bus(), module, tool, arguments, *wait)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+255
-120
@@ -2,8 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
@@ -12,6 +10,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
@@ -31,6 +31,51 @@ import (
|
|||||||
// control plane may send a machine is bounded by the declaration language. This is the shape the
|
// control plane may send a machine is bounded by the declaration language. This is the shape the
|
||||||
// builder module will take when it is given work over the broker; today a person runs it, and the
|
// builder module will take when it is given work over the broker; today a person runs it, and the
|
||||||
// mesh records the result the same way either way.
|
// mesh records the result the same way either way.
|
||||||
|
// buildOn rebuilds every module the mesh holds that stands on the named module's artifacts — the
|
||||||
|
// rebuild a changed base needs, which nothing else asks for: their sources did not move, and
|
||||||
|
// "behind" does not see a base that did (novox/hq 04-ISSUES/131). Bases first among them too.
|
||||||
|
func buildOn(ctx context.Context, base string, wait time.Duration) error {
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
held, err := open.inventory.Catalogued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
against, err := open.inventory.BuiltAgainst(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var on []inventory.Entry
|
||||||
|
for _, e := range held {
|
||||||
|
if standsOnModule(e, base, against) {
|
||||||
|
on = append(on, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(on) == 0 {
|
||||||
|
fmt.Printf("nothing the mesh holds stands on %s\n", base)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
on = orderByBases(on, against)
|
||||||
|
fmt.Printf("%d module(s) stand on %s:\n", len(on), base)
|
||||||
|
var failed []string
|
||||||
|
for _, e := range on {
|
||||||
|
fmt.Printf("--- %s\n", e.Manifest.Module)
|
||||||
|
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
||||||
|
if err := buildOne(ctx, source, e.Source.Path, e.Source.Ref, wait); err != nil {
|
||||||
|
fmt.Printf(" %v\n", err)
|
||||||
|
failed = append(failed, e.Manifest.Module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(failed) > 0 {
|
||||||
|
return fmt.Errorf("%d of %d could not be built: %s", len(failed), len(on), strings.Join(failed, ", "))
|
||||||
|
}
|
||||||
|
fmt.Printf("\n%d module(s) rebuilt on %s. `push --behind` sends them on\n", len(on), base)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func buildCommand(ctx context.Context, args []string) error {
|
func buildCommand(ctx context.Context, args []string) error {
|
||||||
set := flag.NewFlagSet("build", flag.ContinueOnError)
|
set := flag.NewFlagSet("build", flag.ContinueOnError)
|
||||||
ref := set.String("ref", "", "the branch, tag or commit to build")
|
ref := set.String("ref", "", "the branch, tag or commit to build")
|
||||||
@@ -46,6 +91,7 @@ func buildCommand(ctx context.Context, args []string) error {
|
|||||||
// retype each repository is asking them to be the loop. Naming a repository and asking which
|
// retype each repository is asking them to be the loop. Naming a repository and asking which
|
||||||
// ones need building are different requests, so they are not combined.
|
// ones need building are different requests, so they are not combined.
|
||||||
behind := set.Bool("behind", false, "every module the mesh holds older than its source has")
|
behind := set.Bool("behind", false, "every module the mesh holds older than its source has")
|
||||||
|
on := set.String("on", "", "rebuild every module that stands on this module's artifacts — the rebuild a changed base needs")
|
||||||
// A repository on the mesh's own forge, named by its path there (novox/hq ADR 0111). Without it
|
// A repository on the mesh's own forge, named by its path there (novox/hq ADR 0111). Without it
|
||||||
// the repository is external, cloned exactly as given — see source.go.
|
// the repository is external, cloned exactly as given — see source.go.
|
||||||
self := set.Bool("self", false, "the repository is a path on the forge holding the git seat")
|
self := set.Bool("self", false, "the repository is a path on the forge holding the git seat")
|
||||||
@@ -53,6 +99,13 @@ func buildCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if *on != "" {
|
||||||
|
if len(positionals) != 0 || *behind || *self {
|
||||||
|
return errors.New("build --on <module> names a base and nothing else")
|
||||||
|
}
|
||||||
|
return buildOn(ctx, *on, *wait)
|
||||||
|
}
|
||||||
|
|
||||||
if *behind {
|
if *behind {
|
||||||
if len(positionals) != 0 || *self {
|
if len(positionals) != 0 || *self {
|
||||||
return errors.New("build <repository> or build --behind, not both: one names a " +
|
return errors.New("build <repository> or build --behind, not both: one names a " +
|
||||||
@@ -61,7 +114,7 @@ func buildCommand(ctx context.Context, args []string) error {
|
|||||||
return buildBehind(ctx, *wait)
|
return buildBehind(ctx, *wait)
|
||||||
}
|
}
|
||||||
if len(positionals) != 1 {
|
if len(positionals) != 1 {
|
||||||
return errors.New("build <repository> [--self] [--path P] [--ref R] [--wait D] [--dry-run]")
|
return errors.New("build <repository> [--self] [--path P] [--ref R] [--wait D] [--dry-run] | build --behind | build --on <module>")
|
||||||
}
|
}
|
||||||
source := buildSource{Repository: positionals[0]}
|
source := buildSource{Repository: positionals[0]}
|
||||||
if *self {
|
if *self {
|
||||||
@@ -81,8 +134,9 @@ func buildCommand(ctx context.Context, args []string) error {
|
|||||||
//
|
//
|
||||||
// **By digest and path, never by where it was pushed** (novox/hq 04-ISSUES/102). The builder
|
// **By digest and path, never by where it was pushed** (novox/hq 04-ISSUES/102). The builder
|
||||||
// says `<registry>:<port>/<module>/<artifact>@sha256:…`; the mesh records the artifact-store
|
// says `<registry>:<port>/<module>/<artifact>@sha256:…`; the mesh records the artifact-store
|
||||||
// reference and composes the store's address back in where a reference is used. `against` is kept
|
// reference and composes the store's address back in where a reference is used. `against` — what
|
||||||
// as announced: it is what the build stood on as the builder saw it, and the catalogue's edge.
|
// the build stood on, the catalogue's edge — is recorded the same way, so an edge names a module's
|
||||||
|
// artifact and not the machine it was pulled from.
|
||||||
func buildFrom(result link.BuildResult) inventory.Build {
|
func buildFrom(result link.BuildResult) inventory.Build {
|
||||||
kept := inventory.Build{
|
kept := inventory.Build{
|
||||||
ID: result.ID, Repository: result.Repository, Ref: result.Ref,
|
ID: result.ID, Repository: result.Repository, Ref: result.Ref,
|
||||||
@@ -92,7 +146,13 @@ func buildFrom(result link.BuildResult) inventory.Build {
|
|||||||
// edges, and it is not always listening when a build happens — on a fresh mesh it cannot
|
// edges, and it is not always listening when a build happens — on a fresh mesh it cannot
|
||||||
// be, for exactly the modules it needs most. Keeping them is what makes a replay able to
|
// be, for exactly the modules it needs most. Keeping them is what makes a replay able to
|
||||||
// rebuild the graph rather than a list of names.
|
// rebuild the graph rather than a list of names.
|
||||||
Path: result.Path, Against: result.Against,
|
Path: result.Path,
|
||||||
|
}
|
||||||
|
for _, ref := range result.Against {
|
||||||
|
kept.Against = append(kept.Against, catalogue.Recorded(ref))
|
||||||
|
}
|
||||||
|
for _, r := range result.Read {
|
||||||
|
kept.Read = append(kept.Read, inventory.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
||||||
}
|
}
|
||||||
var announced []inventory.Artifact
|
var announced []inventory.Artifact
|
||||||
for _, made := range result.Made {
|
for _, made := range result.Made {
|
||||||
@@ -117,10 +177,14 @@ func buildFrom(result link.BuildResult) inventory.Build {
|
|||||||
func buildsCommand(ctx context.Context, args []string) error {
|
func buildsCommand(ctx context.Context, args []string) error {
|
||||||
set := flag.NewFlagSet("builds", flag.ContinueOnError)
|
set := flag.NewFlagSet("builds", flag.ContinueOnError)
|
||||||
limit := set.Int("n", 20, "how many to show")
|
limit := set.Int("n", 20, "how many to show")
|
||||||
|
logOf := set.String("log", "", "a build's id: print what the build machine said, line by line")
|
||||||
positionals, err := parseAround(set, args)
|
positionals, err := parseAround(set, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if *logOf != "" {
|
||||||
|
return buildLog(ctx, *logOf)
|
||||||
|
}
|
||||||
module := ""
|
module := ""
|
||||||
if len(positionals) == 1 {
|
if len(positionals) == 1 {
|
||||||
module = positionals[0]
|
module = positionals[0]
|
||||||
@@ -161,8 +225,8 @@ func buildsCommand(ctx context.Context, args []string) error {
|
|||||||
if !b.Worked() {
|
if !b.Worked() {
|
||||||
outcome = "failed"
|
outcome = "failed"
|
||||||
}
|
}
|
||||||
fmt.Printf("%-18s %-14s %-10s %s\n",
|
fmt.Printf("%-18s %-14s %-10s %s %s\n",
|
||||||
what, outcome, b.On, b.At.Local().Format("2006-01-02 15:04"))
|
what, outcome, b.On, b.At.Local().Format("2006-01-02 15:04"), b.ID)
|
||||||
fmt.Printf(" %s", b.Repository)
|
fmt.Printf(" %s", b.Repository)
|
||||||
if b.Ref != "" {
|
if b.Ref != "" {
|
||||||
fmt.Printf(" at %s", b.Ref)
|
fmt.Printf(" at %s", b.Ref)
|
||||||
@@ -206,85 +270,45 @@ func builderCommand(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
name := positionals[1]
|
name := positionals[1]
|
||||||
|
|
||||||
management, err := broker.ManagementFromEnvironment()
|
// **The build machine's credential is a module's credential** (novox/hq ADR 0131, design 28
|
||||||
|
// task 5.5): minted into the mesh's records and sealed to the machine as the builder module's
|
||||||
|
// broker secret, usable at the next push — the same act `module issue` performs, and the same
|
||||||
|
// account the composed user list carries. Nothing is created on a server; the bus reads the list.
|
||||||
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer open.Close()
|
||||||
// The same shape of secret a token carries: enough entropy that guessing is not a strategy,
|
inv := open.inventory
|
||||||
// and safe to put in a URL because that is where it goes.
|
shelf, err := inv.Catalogue(ctx)
|
||||||
raw := make([]byte, 32)
|
if err != nil {
|
||||||
if _, err := rand.Read(raw); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
password := base64.RawURLEncoding.EncodeToString(raw)
|
m, known := shelf[*module]
|
||||||
if err := management.CreateBuilderAccount(ctx, name, password); err != nil {
|
if !known {
|
||||||
|
return fmt.Errorf("%s is not in the catalogue; `module add` it first", *module)
|
||||||
|
}
|
||||||
|
fmt.Printf("build machine %s: ", name)
|
||||||
|
node := *forNode
|
||||||
|
if node == "" {
|
||||||
|
entries, err := inv.Catalogued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Manifest.Module == *module && len(e.On) > 0 {
|
||||||
|
node = e.On[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if node == "" {
|
||||||
|
return fmt.Errorf("%s is assigned nowhere; `assign <machine> %s` first, or say --node", *module, *module)
|
||||||
|
}
|
||||||
|
address, err := broker.BusAddress()
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return issueOnTheNewBus(ctx, inv, m, node, address)
|
||||||
fmt.Printf("broker account %s created, scoped to the %s queue and the %s exchange\n\n",
|
|
||||||
name, link.BuildQueue, link.Exchange)
|
|
||||||
|
|
||||||
if *forNode != "" {
|
|
||||||
known, err := broker.FromEnvironment()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot deliver a credential without knowing where the broker is: %w", err)
|
|
||||||
}
|
|
||||||
inv, err := openInventory(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer inv.Close()
|
|
||||||
|
|
||||||
brokerAddr, err := brokerReachableAt(ctx, inv, known, *forNode)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// The URL and what verifies the broker, together. A mesh's broker presents a certificate
|
|
||||||
// of the mesh's own, which is in no public trust store — so a URL on its own reaches only
|
|
||||||
// a broker somebody else vouches for, and the connection fails at TLS with an error about
|
|
||||||
// an unknown authority rather than about a missing pin.
|
|
||||||
//
|
|
||||||
// **The same two facts a node's token carries** (novox/hq ADR 0004), delivered the same
|
|
||||||
// way: out of band relative to the broker, so what is trusted does not come from the thing
|
|
||||||
// being trusted.
|
|
||||||
held, err := json.Marshal(struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Fingerprint string `json:"fingerprint,omitempty"`
|
|
||||||
}{
|
|
||||||
URL: fmt.Sprintf("amqps://%s:%s@%s/", name, password, brokerAddr),
|
|
||||||
Fingerprint: known.Fingerprint,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := inv.AcceptSecretForModule(ctx, *forNode, *module, "broker", string(held)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Not printed. It is sealed to that machine and the mesh cannot read it back, which is
|
|
||||||
// the whole point — printing it here would put the one copy that matters on a terminal.
|
|
||||||
fmt.Printf(" sealed to %s, for the %s module. It arrives with the next push.\n",
|
|
||||||
*forNode, *module)
|
|
||||||
fmt.Printf(" run `push %s` to send it\n", *forNode)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// The whole line only when the address is known. A URL with a placeholder where the host
|
|
||||||
// should be is a URL somebody pastes and then debugs, and the placeholder is the last thing
|
|
||||||
// they look at.
|
|
||||||
if known, err := broker.FromEnvironment(); err == nil {
|
|
||||||
fmt.Printf(" MESH_BROKER_AMQP=amqps://%s:%s@%s/\n\n", name, password, known.Address)
|
|
||||||
} else {
|
|
||||||
fmt.Printf(" the password is %s\n\n", password)
|
|
||||||
fmt.Printf(" This control plane has no %s, so it cannot say where the broker is.\n"+
|
|
||||||
" Put the password in MESH_BROKER_AMQP on the build machine.\n\n",
|
|
||||||
broker.AddressVar)
|
|
||||||
}
|
|
||||||
// Shown once, like a token, and for the same reason: what is stored is the broker's own hash
|
|
||||||
// of it, and a control plane that could show it back would be a control plane that holds it.
|
|
||||||
fmt.Println("This is the only time it is shown.")
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildBehind builds every module the mesh holds older than its source has.
|
// buildBehind builds every module the mesh holds older than its source has.
|
||||||
@@ -329,6 +353,14 @@ func buildBehind(ctx context.Context, wait time.Duration) error {
|
|||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
|
// Bases first: a module built before the module it stands on is built against the old one
|
||||||
|
// and reports success (novox/hq 04-ISSUES/131).
|
||||||
|
against, err := inv.BuiltAgainst(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stale = orderByBases(stale, against)
|
||||||
|
|
||||||
var failed []string
|
var failed []string
|
||||||
for _, e := range stale {
|
for _, e := range stale {
|
||||||
fmt.Printf("--- %s\n", e.Manifest.Module)
|
fmt.Printf("--- %s\n", e.Manifest.Module)
|
||||||
@@ -368,7 +400,7 @@ func buildOne(ctx context.Context, source buildSource, path, ref string, wait ti
|
|||||||
}
|
}
|
||||||
defer ident.Close()
|
defer ident.Close()
|
||||||
|
|
||||||
server, err := link.Connect(nil, nil)
|
server, err := connectLink(ctx, nil, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -382,11 +414,14 @@ func buildOne(ctx context.Context, source buildSource, path, ref string, wait ti
|
|||||||
Path: path,
|
Path: path,
|
||||||
Ref: ref,
|
Ref: ref,
|
||||||
Held: heldBy(ctx),
|
Held: heldBy(ctx),
|
||||||
|
Seats: seatBases(ctx),
|
||||||
}
|
}
|
||||||
fmt.Printf("asked for %s", source)
|
fmt.Printf("asked for %s", source)
|
||||||
if source.Seat != "" {
|
if source.Seat != "" {
|
||||||
fmt.Printf(" (%s)", repository)
|
fmt.Printf(" (%s)", repository)
|
||||||
}
|
}
|
||||||
|
// The id is how a person follows this build while it runs: `builds --log <id>`.
|
||||||
|
fmt.Printf(" as %s", request.ID)
|
||||||
if path != "" {
|
if path != "" {
|
||||||
fmt.Printf(" at %s", path)
|
fmt.Printf(" at %s", path)
|
||||||
}
|
}
|
||||||
@@ -401,64 +436,104 @@ func buildOne(ctx context.Context, source buildSource, path, ref string, wait ti
|
|||||||
}
|
}
|
||||||
defer ask.Close()
|
defer ask.Close()
|
||||||
|
|
||||||
|
if wait == 0 {
|
||||||
|
// Asked and not waited for (novox/hq issue 176): the outcome is the role's event, and the
|
||||||
|
// controller takes it in — records the build, registers the module — whether or not anybody
|
||||||
|
// is still here. A tool call cannot hold a connection for the minutes a build takes; it
|
||||||
|
// follows the build by its id instead.
|
||||||
|
if err := ask.Ask(ctx, request); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("asked, not waited for: `builds --log %s` follows it as it runs, and `builds` "+
|
||||||
|
"shows what came of it; the module is registered when the outcome comes\n", request.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
result, err := ask.Submit(ctx, request, wait)
|
result, err := ask.Submit(ctx, request, wait)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kept before it is judged. A failed build that leaves no trace is indistinguishable from one
|
|
||||||
// nobody asked for, and the difference is the whole of whether somebody should be looking at
|
|
||||||
// something.
|
|
||||||
open, err := openStores(ctx)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer open.Close()
|
defer open.Close()
|
||||||
inv := open.inventory
|
manifest, kept, err := takeIn(ctx, open.inventory, result)
|
||||||
kept := buildFrom(result)
|
if err != nil {
|
||||||
if err := inv.RecordBuild(ctx, kept); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Failed != "" {
|
|
||||||
// The builder's own words. Wrapping them in something about the control plane would put
|
|
||||||
// two explanations between a person and a build log.
|
|
||||||
return fmt.Errorf("%s could not build %s:\n%s", result.On, result.Repository, result.Failed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Said as recorded: what each artifact is, not where this builder happened to push it.
|
// Said as recorded: what each artifact is, not where this builder happened to push it.
|
||||||
for _, made := range kept.Made {
|
for _, made := range kept.Made {
|
||||||
fmt.Printf(" %-12s %s %s\n", made.Name, made.Kind, made.Reference)
|
fmt.Printf(" %-12s %s %s\n", made.Name, made.Kind, made.Reference)
|
||||||
}
|
}
|
||||||
|
fmt.Printf("\n%s %s, built on %s from %s\n",
|
||||||
|
manifest.Module, manifest.Version, result.On, short(result.Commit))
|
||||||
|
saysWhenThePolicyActs(ctx, open.inventory, manifest.Module)
|
||||||
|
fmt.Printf(" run `assign <node> %s` to put it somewhere\n", manifest.Module)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Parsed with the same parser a hand-written manifest goes through. A second path would be a
|
// saysWhenThePolicyActs tells whoever built a module that its upgrade policy will send the
|
||||||
// second thing to disagree about what a manifest is. The manifest as recorded, so the catalogue
|
// result on at once (novox/hq issue 126, ADR 0163): a person choreographing a data move must
|
||||||
// holds references by digest and path and every declaration composes the store's address in.
|
// know which module will not wait for them.
|
||||||
|
func saysWhenThePolicyActs(ctx context.Context, inv *inventory.Inventory, module string) {
|
||||||
|
if u, err := inv.UpgradeOf(ctx, module); err == nil && u.RollOut {
|
||||||
|
how := "one machine at a time"
|
||||||
|
if u.Together {
|
||||||
|
how = "every machine at once"
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s rolls out on build: the machines running it are sent this now, %s — "+
|
||||||
|
"`upgrade %s record` first if something must move before it does\n", module, how, module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// takeIn is what the mesh does with a build's outcome, whoever hears it: the waiting command and
|
||||||
|
// the daemon that follows the role's events both come here (novox/hq issue 176), so a build's
|
||||||
|
// result reaches the catalogue whether or not the asker was still listening.
|
||||||
|
//
|
||||||
|
// Kept before it is judged. A failed build that leaves no trace is indistinguishable from one
|
||||||
|
// nobody asked for, and the difference is the whole of whether somebody should be looking at
|
||||||
|
// something. Then parsed with the same parser a hand-written manifest goes through — a second path
|
||||||
|
// would be a second thing to disagree about what a manifest is — and registered with where it came
|
||||||
|
// from: **for a source on a seat, as the path and the seat, never the URL just cloned** (ADR 0111),
|
||||||
|
// which the request carried and the outcome echoes. A definition naming an installation is refused
|
||||||
|
// here, where it would enter the catalogue; the build stays recorded and the refusal says which.
|
||||||
|
//
|
||||||
|
// Idempotent: the same outcome taken in twice registers the same module twice, which is one row
|
||||||
|
// written with the same values.
|
||||||
|
func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResult) (
|
||||||
|
catalogue.Manifest, inventory.Build, error) {
|
||||||
|
kept := buildFrom(result)
|
||||||
|
if err := inv.RecordBuild(ctx, kept); err != nil {
|
||||||
|
return catalogue.Manifest{}, kept, err
|
||||||
|
}
|
||||||
|
if result.Failed != "" {
|
||||||
|
// The builder's own words. Wrapping them in something about the control plane would put
|
||||||
|
// two explanations between a person and a build log.
|
||||||
|
return catalogue.Manifest{}, kept, fmt.Errorf("%s could not build %s:\n%s",
|
||||||
|
result.On, result.Repository, result.Failed)
|
||||||
|
}
|
||||||
manifest, err := catalogue.ParseManifest(kept.Manifest)
|
manifest, err := catalogue.ParseManifest(kept.Manifest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%s built %s and what came back is not a manifest: %w",
|
return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s and what came back is not a manifest: %w",
|
||||||
result.On, result.Repository, err)
|
result.On, result.Repository, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recorded with where it came from, so "is this current?" is answerable without building it
|
|
||||||
// again (novox/hq ADR 0009). **For a source on a seat, as the path and the seat, never the URL
|
|
||||||
// just cloned** (ADR 0111): the URL is where the forge runs today, and recording it would put
|
|
||||||
// the forge's address back into every module built from it. The build log above keeps the URL,
|
|
||||||
// because that is what was cloned.
|
|
||||||
recorded := inventory.Source{
|
recorded := inventory.Source{
|
||||||
Repository: result.Repository, Path: result.Path, Ref: result.Ref,
|
Repository: result.Repository, Path: result.Path, Ref: result.Ref,
|
||||||
BuiltFrom: result.Commit, Head: result.Commit,
|
BuiltFrom: result.Commit, Head: result.Commit,
|
||||||
}
|
}
|
||||||
if source.Seat != "" {
|
if result.Source != nil && result.Source.Seat != "" {
|
||||||
recorded.Repository, recorded.Seat = source.Repository, source.Seat
|
recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat
|
||||||
|
}
|
||||||
|
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 err := inv.RegisterModule(ctx, manifest, recorded); err != nil {
|
||||||
return err
|
return manifest, kept, err
|
||||||
}
|
}
|
||||||
fmt.Printf("\n%s %s, built on %s from %s\n",
|
return manifest, kept, nil
|
||||||
manifest.Module, manifest.Version, result.On, short(result.Commit))
|
|
||||||
fmt.Printf(" run `assign <node> %s` to put it somewhere\n", manifest.Module)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildAndShow builds and prints the manifest without recording anything.
|
// buildAndShow builds and prints the manifest without recording anything.
|
||||||
@@ -472,7 +547,7 @@ func buildAndShow(ctx context.Context, source buildSource, path, ref string, wai
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer ident.Close()
|
defer ident.Close()
|
||||||
server, err := link.Connect(nil, nil)
|
server, err := connectLink(ctx, nil, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -487,7 +562,7 @@ func buildAndShow(ctx context.Context, source buildSource, path, ref string, wai
|
|||||||
result, err := ask.Submit(ctx, link.BuildRequest{
|
result, err := ask.Submit(ctx, link.BuildRequest{
|
||||||
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()),
|
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()),
|
||||||
Repository: repository, Path: path, Ref: ref,
|
Repository: repository, Path: path, Ref: ref,
|
||||||
Held: heldBy(ctx),
|
Held: heldBy(ctx), Seats: seatBases(ctx),
|
||||||
}, wait)
|
}, wait)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -521,6 +596,8 @@ type answers struct {
|
|||||||
// pair that answers "has it caught up", which waiting alone cannot (the sent digest is
|
// pair that answers "has it caught up", which waiting alone cannot (the sent digest is
|
||||||
// recorded at send, not at apply).
|
// recorded at send, not at apply).
|
||||||
reported []inventory.Reported
|
reported []inventory.Reported
|
||||||
|
// plans is what the last merges produced and where each stands (novox/hq ADR 0162).
|
||||||
|
plans []inventory.Plan
|
||||||
// refused is why a machine cannot be worked out at all, by name. A different thing from every
|
// refused is why a machine cannot be worked out at all, by name. A different thing from every
|
||||||
// other answer here: those are about a machine that was told something, and this is about one
|
// other answer here: those are about a machine that was told something, and this is about one
|
||||||
// that cannot be told anything — it never reaches waiting, because nothing was computed for it
|
// that cannot be told anything — it never reaches waiting, because nothing was computed for it
|
||||||
@@ -530,6 +607,16 @@ type answers struct {
|
|||||||
// a consequence of the refusals above: a node that does not resolve is not on the network, and
|
// a consequence of the refusals above: a node that does not resolve is not on the network, and
|
||||||
// a mesh whose hub is that node has no hub.
|
// a mesh whose hub is that node has no hub.
|
||||||
network string
|
network string
|
||||||
|
// untaken is, per machine, each assigned module whose resources the machine is holding as it
|
||||||
|
// found them, and how many — a module that was assigned, sent, and is running none of what it
|
||||||
|
// declares because nothing has taken it (novox/hq ADR 0100, 04-ISSUES/125).
|
||||||
|
//
|
||||||
|
// **Its absence cost an outage.** The module was assigned, the push reported success, this
|
||||||
|
// command said the machine was doing everything it was told, and the module's three containers
|
||||||
|
// did not exist. On the strength of those reports the predecessor's proxy was stopped and every
|
||||||
|
// public name on the machine went dark. The holds were correct; they were recorded only in the
|
||||||
|
// machine's own state file, and the one visible symptom was a count that did not add up.
|
||||||
|
untaken map[string]map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
// heldBy is every artifact this mesh has built, for a build that may need one as its base.
|
// heldBy is every artifact this mesh has built, for a build that may need one as its base.
|
||||||
@@ -575,16 +662,64 @@ func heldBy(ctx context.Context) map[string]string {
|
|||||||
// **One place chooses**, as everywhere else the bus change went (novox/hq ADR 0116 step 5). On the bus
|
// **One place chooses**, as everywhere else the bus change went (novox/hq ADR 0116 step 5). On the bus
|
||||||
// the mesh runs on today this needs the controller's own connection, so it is handed one; on the bus
|
// the mesh runs on today this needs the controller's own connection, so it is handed one; on the bus
|
||||||
// being built it dials, because a build request is a one-shot and holds nothing else.
|
// being built it dials, because a build request is a one-shot and holds nothing else.
|
||||||
func askOver(server *link.Server) (link.Builders, error) {
|
func askOver(_ *link.Server) (link.Builders, error) {
|
||||||
address, onNATS, err := broker.OnNATS()
|
address, err := broker.BusAddress()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := broker.MustBeOneBus(os.Getenv(broker.AMQPVarName), address); err != nil {
|
return link.BuildsOverNATS(address)
|
||||||
return nil, err
|
}
|
||||||
|
|
||||||
|
// buildLog prints everything a build machine said about one build, read back from the bus.
|
||||||
|
//
|
||||||
|
// **From the stream, not from a record** (novox/hq ADR 0157). A build's lines are the role's own
|
||||||
|
// events under the build's id, retained with every other event; the mesh keeps no second copy. Read
|
||||||
|
// with a consumer of its own that is gone when this returns, so nothing accumulates in the server
|
||||||
|
// for the reading, and filtered by subject, so one build's lines are all that travel.
|
||||||
|
func buildLog(ctx context.Context, id string) error {
|
||||||
|
address, err := broker.BusAddress()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if onNATS {
|
js, err := broker.Dial(address)
|
||||||
return link.BuildsOverNATS(address)
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot reach the bus to read a build's log: %w", err)
|
||||||
}
|
}
|
||||||
return link.BuildsOverCurrent(server.Channel()), nil
|
defer js.Close()
|
||||||
|
|
||||||
|
sub, err := js.Context().PullSubscribe(link.BuildLog(id), "",
|
||||||
|
nats.BindStream(broker.EventsStream), nats.DeliverAll(), nats.AckNone())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot read %s from the bus: %w", link.BuildLog(id), err)
|
||||||
|
}
|
||||||
|
defer func() { _ = sub.Unsubscribe() }()
|
||||||
|
|
||||||
|
printed := 0
|
||||||
|
for {
|
||||||
|
batch, err := sub.Fetch(200, nats.MaxWait(2*time.Second))
|
||||||
|
if err != nil && !errors.Is(err, nats.ErrTimeout) && !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return fmt.Errorf("reading a build's log: %w", err)
|
||||||
|
}
|
||||||
|
for _, msg := range batch {
|
||||||
|
var line link.BuildLine
|
||||||
|
if err := json.Unmarshal(msg.Data, &line); err != nil {
|
||||||
|
fmt.Printf(" ? %s\n", string(msg.Data))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
at := line.At
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, line.At); err == nil {
|
||||||
|
at = t.Local().Format("15:04:05")
|
||||||
|
}
|
||||||
|
fmt.Printf("%s %4d [%s] %s\n", at, line.Seq, line.Step, line.Message)
|
||||||
|
printed++
|
||||||
|
}
|
||||||
|
if len(batch) < 200 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if printed == 0 {
|
||||||
|
fmt.Printf("nothing on the bus for build %s: no build by that id in the last week, or a build "+
|
||||||
|
"machine older than this that said nothing while building\n", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A build's outcome is taken in the same way whoever hears it (novox/hq issue 176): recorded, and
|
||||||
|
// the module registered with its source as the seat and path when the request said so — never the
|
||||||
|
// URL. A definition naming an installation is recorded and not registered; a failure is recorded
|
||||||
|
// and said.
|
||||||
|
func TestABuildHeardIsRecordedAndRegistered(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
manifest, _ := json.Marshal(map[string]any{"module": "shop", "version": "3"})
|
||||||
|
m, _, err := takeIn(ctx, open.inventory, link.BuildResult{
|
||||||
|
ID: "b-1", Repository: "http://forge.internal:20000/novox/shop.git", Path: "modules/shop",
|
||||||
|
Ref: "main", On: "anchor", Commit: "abcdef0123", Manifest: manifest,
|
||||||
|
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/shop"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.Module != "shop" {
|
||||||
|
t.Fatalf("registered %q", m.Module)
|
||||||
|
}
|
||||||
|
shelf, err := open.inventory.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, held := shelf["shop"]; !held {
|
||||||
|
t.Fatal("the module a heard build produced is not in the catalogue")
|
||||||
|
}
|
||||||
|
src, err := open.inventory.SourceOf(ctx, "shop")
|
||||||
|
if err != nil || src.Seat != "git" || src.Repository != "novox/shop" || src.BuiltFrom != "abcdef0123" {
|
||||||
|
t.Fatalf("the source is the seat and the path, never the URL: %+v %v", src, err)
|
||||||
|
}
|
||||||
|
builds, err := open.inventory.Builds(ctx, "shop", 5)
|
||||||
|
if err != nil || len(builds) != 1 || builds[0].ID != "b-1" {
|
||||||
|
t.Fatalf("the build is not recorded once: %v %v", builds, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
named, _ := json.Marshal(map[string]any{"module": "idp", "version": "1", "resources": []any{
|
||||||
|
map[string]any{"id": "server", "type": "container", "image": "x@sha256:aa",
|
||||||
|
"env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}}}})
|
||||||
|
_, _, err = takeIn(ctx, open.inventory, link.BuildResult{
|
||||||
|
ID: "b-2", Repository: "/r", On: "anchor", Commit: "0123456789", Manifest: named})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "does not register it") {
|
||||||
|
t.Fatalf("a definition naming an installation was taken in: %v", err)
|
||||||
|
}
|
||||||
|
if shelf, _ := open.inventory.Catalogue(ctx); shelf["idp"].Module != "" {
|
||||||
|
t.Fatal("the refused module was registered anyway")
|
||||||
|
}
|
||||||
|
if builds, _ := open.inventory.Builds(ctx, "idp", 5); len(builds) != 1 {
|
||||||
|
t.Fatalf("the refused build was not recorded: %v", builds)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err = takeIn(ctx, open.inventory, link.BuildResult{ID: "b-3", Repository: "/r", On: "anchor", Failed: "no compiler"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "no compiler") {
|
||||||
|
t.Fatalf("a failure is said in the builder's words: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The bus's own certificate, made by the mesh rather than borrowed from an image.
|
||||||
|
//
|
||||||
|
// **The foundation asked a third-party image for a tool it never said must be there** (novox/hq
|
||||||
|
// 04-ISSUES/146). The bootstrap made this certificate by running `openssl` inside the broker's
|
||||||
|
// image, which worked while the broker was one that happened to carry it and stopped the day the
|
||||||
|
// bus changed: the new one has a shell and no openssl, so the step exited 127 and no mesh could be
|
||||||
|
// raised. Substituting another image the bundle names does not help — none of them carry it
|
||||||
|
// either.
|
||||||
|
//
|
||||||
|
// So the program that needs a certificate makes one. It is the mesh's own binary, already on the
|
||||||
|
// machine at this point in the bootstrap (the schema step ran it), and it needs nothing from the
|
||||||
|
// image it writes into but a mounted directory.
|
||||||
|
//
|
||||||
|
// **Self-signed, and that is the design** — a host pins this server's exact certificate and
|
||||||
|
// authenticates with a password (novox/hq ADR 0004). There is no authority above it to ask, and at
|
||||||
|
// this moment in a bootstrap there is no mesh to ask one of.
|
||||||
|
//
|
||||||
|
// Idempotent, because the step is applied again on every reconcile and a second certificate would
|
||||||
|
// be one the hosts that pinned the first no longer believe.
|
||||||
|
|
||||||
|
// busCertificateNames is what the bus is reached by: the container name on a mesh network, and the
|
||||||
|
// loopback address the machine's own foundation dials.
|
||||||
|
var busCertificateNames = []string{"mesh-broker"}
|
||||||
|
|
||||||
|
const busCertificateLife = 10 * 365 * 24 * time.Hour
|
||||||
|
|
||||||
|
// busCertificate makes the bus's certificate in a directory, or says whether one is there.
|
||||||
|
//
|
||||||
|
// broker certificate --into /tls make it if it is not there
|
||||||
|
// broker certificate --check --into /tls exit non-zero unless a usable pair is
|
||||||
|
func busCertificate(args []string) error {
|
||||||
|
into, check := "", false
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--check":
|
||||||
|
check = true
|
||||||
|
case "--into":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
return errors.New("--into needs a directory")
|
||||||
|
}
|
||||||
|
into = args[i+1]
|
||||||
|
i++
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("broker certificate [--check] --into <directory>: %q", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if into == "" {
|
||||||
|
return errors.New("broker certificate [--check] --into <directory>")
|
||||||
|
}
|
||||||
|
crt, key := filepath.Join(into, "tls.crt"), filepath.Join(into, "tls.key")
|
||||||
|
|
||||||
|
if usable, err := busCertificateUsable(crt, key); err != nil {
|
||||||
|
return err
|
||||||
|
} else if usable {
|
||||||
|
fmt.Printf("the bus already has a certificate at %s, and it was left alone\n", crt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if check {
|
||||||
|
// Said as a failure, because that is what the caller asked: a bootstrap's verify runs
|
||||||
|
// this and a false answer is what makes the step run.
|
||||||
|
return fmt.Errorf("no usable certificate and key at %s", into)
|
||||||
|
}
|
||||||
|
return writeBusCertificate(crt, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// busCertificateUsable says whether a certificate and its key are both there and parse.
|
||||||
|
//
|
||||||
|
// Both, and parsed rather than stat'ed: a half-written pair is the state a bootstrap interrupted
|
||||||
|
// between the two files leaves behind, and a step that treated it as done would hand the server a
|
||||||
|
// certificate with no key and report success.
|
||||||
|
func busCertificateUsable(crt, key string) (bool, error) {
|
||||||
|
certPEM, err := os.ReadFile(crt)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
keyPEM, err := os.ReadFile(key)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if _, err := tlsPairParses(certPEM, keyPEM); err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tlsPairParses(certPEM, keyPEM []byte) (*x509.Certificate, error) {
|
||||||
|
block, _ := pem.Decode(certPEM)
|
||||||
|
if block == nil || block.Type != "CERTIFICATE" {
|
||||||
|
return nil, errors.New("not a certificate")
|
||||||
|
}
|
||||||
|
certificate, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keyBlock, _ := pem.Decode(keyPEM)
|
||||||
|
if keyBlock == nil {
|
||||||
|
return nil, errors.New("not a key")
|
||||||
|
}
|
||||||
|
if _, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes); err != nil {
|
||||||
|
if _, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return certificate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBusCertificate(crt, key string) error {
|
||||||
|
private, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{CommonName: busCertificateNames[0]},
|
||||||
|
DNSNames: busCertificateNames,
|
||||||
|
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(busCertificateLife),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, template, template, &private.PublicKey, private)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pkcs8, err := x509.MarshalPKCS8PrivateKey(private)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// **The key first, and only then the certificate**, so the pair a reader finds is never a
|
||||||
|
// certificate whose key has not been written yet — the one order in which an interruption
|
||||||
|
// leaves something that looks finished (novox/hq 04-ISSUES/014, a key present and unusable).
|
||||||
|
if err := os.WriteFile(key, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}), 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(crt, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("made the bus a certificate for %v, valid until %s\n %s\n %s\n",
|
||||||
|
busCertificateNames, template.NotAfter.Format(time.RFC3339), crt, key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// busAccounts writes the mesh's composed user list to a file.
|
||||||
|
//
|
||||||
|
// **For genesis, where no declaration can deliver it** (novox/hq 04-ISSUES/146). Everywhere else
|
||||||
|
// the list reaches the machine running the bus as a resource of the module that holds it — which
|
||||||
|
// requires that machine to be an enrolled node, and at genesis it is not: the first node cannot
|
||||||
|
// enrol because the account it would enrol with cannot be composed onto a bus it has no declaration
|
||||||
|
// for. The installer breaks that circle by placing the file itself, once, and the module takes the
|
||||||
|
// file over from its first push.
|
||||||
|
//
|
||||||
|
// The same composition, not a second one: this asks the store for the same records and renders them
|
||||||
|
// with the same composer the declaration uses. A genesis that hand-wrote an account would be a
|
||||||
|
// second statement of who may say what, able to disagree with the first.
|
||||||
|
//
|
||||||
|
// **It writes to standard output unless told a file**, and that is the point: the control plane
|
||||||
|
// composes and says what it composed, and whoever is raising the machine puts it where that
|
||||||
|
// machine's bus reads it. A control plane that wrote into the bus's own directory would have to
|
||||||
|
// know where that is and how to make the server re-read it — which is the module's knowledge, and
|
||||||
|
// the module is what takes this over on the first push.
|
||||||
|
//
|
||||||
|
// broker accounts > /var/lib/mesh-bus-conf/accounts.conf
|
||||||
|
func busAccounts(ctx context.Context, args []string) error {
|
||||||
|
into := ""
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--into":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
return errors.New("--into needs a file")
|
||||||
|
}
|
||||||
|
into = args[i+1]
|
||||||
|
i++
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("broker accounts --into <file>: %q", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
|
||||||
|
records, err := open.inventory.BusRecords(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
users, err := broker.Users(records)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
kept, err := open.inventory.BusUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hashes := make(map[string]string, len(kept))
|
||||||
|
for name, u := range kept {
|
||||||
|
hashes[name] = u.PasswordHash
|
||||||
|
}
|
||||||
|
filled, missing := broker.WithPasswords(users, hashes)
|
||||||
|
if len(missing) > 0 {
|
||||||
|
// To standard error, always: the composed file may be going to standard output, and a
|
||||||
|
// remark in the middle of it is a configuration the server refuses to parse.
|
||||||
|
fmt.Fprintf(os.Stderr, "leaving out %d user(s) the mesh has minted no credential for: %s\n",
|
||||||
|
len(missing), strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
if len(filled) == 0 {
|
||||||
|
return errors.New("not one user has a credential, so this list would refuse every " +
|
||||||
|
"connection in the mesh")
|
||||||
|
}
|
||||||
|
accounts, err := broker.ComposeAccounts(filled)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if into == "" {
|
||||||
|
fmt.Print(accounts)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(into, []byte(accounts), 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("wrote %d user(s) to %s\n", len(filled), into)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq 04-ISSUES/146. The bootstrap could not make the bus a certificate: it asked an image for
|
||||||
|
// `openssl` and the image it asks has none. What replaces it is this command, so what is checked is
|
||||||
|
// what the bootstrap needs from it — a pair a TLS server can actually load, made once and only once.
|
||||||
|
|
||||||
|
func TestTheBusCertificateLoadsAsAServersWould(t *testing.T) {
|
||||||
|
into := t.TempDir()
|
||||||
|
if err := busCertificate([]string{"--into", into}); err != nil {
|
||||||
|
t.Fatalf("the bus could not be given a certificate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The check a cheaper test would not make. The key was present and valid and the server could
|
||||||
|
// not start, once, because nothing loaded the pair the way a server loads it
|
||||||
|
// (novox/hq 04-ISSUES/014).
|
||||||
|
pair, err := tls.LoadX509KeyPair(filepath.Join(into, "tls.crt"), filepath.Join(into, "tls.key"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a TLS server cannot load what was written: %v", err)
|
||||||
|
}
|
||||||
|
leaf := pair.Leaf
|
||||||
|
if leaf == nil {
|
||||||
|
if leaf, err = x509.ParseCertificate(pair.Certificate[0]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := leaf.VerifyHostname("mesh-broker"); err != nil {
|
||||||
|
t.Errorf("the certificate is not for the name the bus is reached by: %v", err)
|
||||||
|
}
|
||||||
|
if len(leaf.IPAddresses) == 0 || leaf.IPAddresses[0].String() != "127.0.0.1" {
|
||||||
|
t.Errorf("the certificate does not cover the loopback address the foundation dials: %v", leaf.IPAddresses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The key is not readable by anything else on the machine; the certificate is public and is.
|
||||||
|
key, err := os.Stat(filepath.Join(into, "tls.key"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if key.Mode().Perm() != 0o600 {
|
||||||
|
t.Errorf("the key is %v", key.Mode().Perm())
|
||||||
|
}
|
||||||
|
crt, err := os.Stat(filepath.Join(into, "tls.crt"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if crt.Mode().Perm() != 0o644 {
|
||||||
|
t.Errorf("the certificate is %v, which the server runs as another user cannot read", crt.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Made once.** The step is applied again on every reconcile, and a second certificate is one the
|
||||||
|
// hosts that pinned the first no longer believe (novox/hq ADR 0004).
|
||||||
|
func TestTheBusCertificateIsMadeOnce(t *testing.T) {
|
||||||
|
into := t.TempDir()
|
||||||
|
if err := busCertificate([]string{"--into", into}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first, err := os.ReadFile(filepath.Join(into, "tls.crt"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := busCertificate([]string{"--into", into}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, err := os.ReadFile(filepath.Join(into, "tls.crt"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(first) != string(again) {
|
||||||
|
t.Fatal("running it twice replaced the certificate every host had pinned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The verify half: false before, true after, which is what makes the bootstrap run the step at all.
|
||||||
|
func TestTheCheckIsFalseUntilThereIsAPair(t *testing.T) {
|
||||||
|
into := t.TempDir()
|
||||||
|
if err := busCertificate([]string{"--check", "--into", into}); err == nil {
|
||||||
|
t.Fatal("an empty directory reported a usable certificate")
|
||||||
|
}
|
||||||
|
if err := busCertificate([]string{"--into", into}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := busCertificate([]string{"--check", "--into", into}); err != nil {
|
||||||
|
t.Fatalf("the certificate it just made does not satisfy its own check: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A half-written pair is not a pair. An interrupted bootstrap leaves exactly this, and a step that
|
||||||
|
// called it done would hand the server a certificate with no key and report success.
|
||||||
|
func TestACertificateWithoutItsKeyIsNotUsable(t *testing.T) {
|
||||||
|
into := t.TempDir()
|
||||||
|
if err := busCertificate([]string{"--into", into}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(filepath.Join(into, "tls.key")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := busCertificate([]string{"--check", "--into", into}); err == nil {
|
||||||
|
t.Fatal("a certificate with no key passed the check")
|
||||||
|
}
|
||||||
|
if err := busCertificate([]string{"--into", into}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := tls.LoadX509KeyPair(filepath.Join(into, "tls.crt"), filepath.Join(into, "tls.key")); err != nil {
|
||||||
|
t.Fatalf("it did not replace the unusable pair: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhereToWriteIsRequired(t *testing.T) {
|
||||||
|
if err := busCertificate(nil); err == nil || !strings.Contains(err.Error(), "--into") {
|
||||||
|
t.Fatalf("it did not ask where to write: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// moduleCheck judges manifests where they are written, with no mesh (novox/hq ADR 0037, issue 148).
|
||||||
|
//
|
||||||
|
// **The same functions registration runs, and nothing the command line adds** (ADR 0035): the strict
|
||||||
|
// parse with every per-manifest problem, then the rules no single manifest can be judged against,
|
||||||
|
// over exactly the manifests given. Somebody describing their own application in their own
|
||||||
|
// repository runs this before pushing and finds out there, rather than when a running mesh refuses
|
||||||
|
// the registration or, later, when a machine applies something that resolved and should not have.
|
||||||
|
//
|
||||||
|
// **What it cannot know without a store, it says.** The mesh's own seat set is the store's (ADR
|
||||||
|
// 0122); this binary carries a compiled copy that the store overrides when loaded, so a claim on a
|
||||||
|
// mesh seat is judged fully only at registration. A seat another module declares is unknown unless
|
||||||
|
// that module's manifest is passed too. Both are printed as a note, not as a problem — a check that
|
||||||
|
// refused what it could not see would teach people to ignore it.
|
||||||
|
func moduleCheck(paths []string, out io.Writer) error {
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return errors.New("module check <manifest.json>... — one file per module; pass every " +
|
||||||
|
"manifest of a repository together so the rules between them are checked too")
|
||||||
|
}
|
||||||
|
shelf := catalogue.Shelf{}
|
||||||
|
faulted := map[string]bool{}
|
||||||
|
failed := 0
|
||||||
|
for _, path := range paths {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(out, "%s: %v\n", path, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m, err := catalogue.ParseManifest(raw)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(out, "%s: %v\n", path, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if first, twice := shelf[m.Module]; twice {
|
||||||
|
_ = first
|
||||||
|
fmt.Fprintf(out, "%s: %s was already given; two manifests name one module\n", path, m.Module)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A definition names no installation (novox/hq ADR 0112, ADR 0155): judged here, in the
|
||||||
|
// catalogue-wide test, and at registration, which refuses in the same words.
|
||||||
|
if named := catalogue.InstallationProblems(m); len(named) > 0 {
|
||||||
|
for _, p := range named {
|
||||||
|
fmt.Fprintf(out, "%s: %s\n", path, p)
|
||||||
|
}
|
||||||
|
failed += len(named)
|
||||||
|
faulted[m.Module] = true
|
||||||
|
}
|
||||||
|
shelf[m.Module] = m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Between the manifests: a seat declared twice, a use of a seat nothing declares, a claim on
|
||||||
|
// a seat that does not exist. Run only over what parsed, because a problem inside one manifest
|
||||||
|
// has already been said and would be said again here in a worse form.
|
||||||
|
problems := catalogue.CatalogueProblems(shelf)
|
||||||
|
sort.Strings(problems)
|
||||||
|
for _, p := range problems {
|
||||||
|
fmt.Fprintln(out, p)
|
||||||
|
}
|
||||||
|
failed += len(problems)
|
||||||
|
|
||||||
|
var names []string
|
||||||
|
for name := range shelf {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, name := range names {
|
||||||
|
m := shelf[name]
|
||||||
|
if faulted[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "%s: ok", name)
|
||||||
|
if n := len(m.Tools); n > 0 {
|
||||||
|
fmt.Fprintf(out, ", %d tool(s)", n)
|
||||||
|
}
|
||||||
|
if len(m.Invokes) > 0 {
|
||||||
|
fmt.Fprintf(out, ", invokes %s", joinInvokes(m.Invokes))
|
||||||
|
}
|
||||||
|
fmt.Fprintln(out)
|
||||||
|
}
|
||||||
|
if failed > 0 {
|
||||||
|
return fmt.Errorf("%d problem(s) in %d manifest(s)", failed, len(paths))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "%d manifest(s) checked. Judged against the seats this binary carries; a claim on "+
|
||||||
|
"one of the mesh's own seats is judged fully at registration, and a seat declared by a "+
|
||||||
|
"module not given here reads as unknown\n", len(paths))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinInvokes(invokes []string) string {
|
||||||
|
if len(invokes) == 1 && invokes[0] == "*" {
|
||||||
|
return "every tool"
|
||||||
|
}
|
||||||
|
s := ""
|
||||||
|
for i, t := range invokes {
|
||||||
|
if i > 0 {
|
||||||
|
s += ", "
|
||||||
|
}
|
||||||
|
s += t
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// manifestsUnder lists every module.json below a directory, for `module check <dir>`.
|
||||||
|
func manifestsUnder(dir string) ([]string, error) {
|
||||||
|
var found []string
|
||||||
|
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() && (d.Name() == "node_modules" || d.Name() == ".git" || d.Name() == "dist") {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
if !d.IsDir() && d.Name() == "module.json" {
|
||||||
|
found = append(found, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
sort.Strings(found)
|
||||||
|
return found, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The check anybody can run is the check registration runs (novox/hq issue 148, ADR 0037): a manifest
|
||||||
|
// with a known fault is named, and one without passes, with no store opened.
|
||||||
|
func TestModuleCheckNamesAFaultAndNeedsNoMesh(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
good := filepath.Join(dir, "good.json")
|
||||||
|
bad := filepath.Join(dir, "bad.json")
|
||||||
|
os.WriteFile(good, []byte(`{"module":"shop","version":"1","tools":["price"],"invokes":["mesh-catalog.catalog_modules"]}`), 0o600)
|
||||||
|
os.WriteFile(bad, []byte(`{"module":"till","version":"1","invokes":["shop"]}`), 0o600)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := moduleCheck([]string{good}, &out); err != nil {
|
||||||
|
t.Fatalf("a sound manifest was refused: %v\n%s", err, out.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "shop: ok, 1 tool(s), invokes mesh-catalog.catalog_modules") {
|
||||||
|
t.Fatalf("the report does not say what it checked:\n%s", out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Reset()
|
||||||
|
err := moduleCheck([]string{good, bad}, &out)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a manifest invoking a module and no tool passed")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), `till invokes "shop", which does not name a tool`) {
|
||||||
|
t.Fatalf("the fault is not named in the manifest's words:\n%s", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rules between manifests run over what was given together: a seat two modules declare is
|
||||||
|
// refused, which no single-manifest check can see.
|
||||||
|
func TestModuleCheckJudgesBetweenTheManifestsGiven(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := filepath.Join(dir, "a.json")
|
||||||
|
b := filepath.Join(dir, "b.json")
|
||||||
|
os.WriteFile(a, []byte(`{"module":"a","version":"1","seats":[{"name":"printer","scope":"mesh"}]}`), 0o600)
|
||||||
|
os.WriteFile(b, []byte(`{"module":"b","version":"1","seats":[{"name":"printer","scope":"mesh"}]}`), 0o600)
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := moduleCheck([]string{a, b}, &out); err == nil {
|
||||||
|
t.Fatalf("two declarations of one seat passed:\n%s", out.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "a seat name means one protocol") {
|
||||||
|
t.Fatalf("the cross-manifest rule was not the one named:\n%s", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The real catalogue passes the command, the way it passes the test that used to be the only check.
|
||||||
|
func TestModuleCheckPassesTheCatalogue(t *testing.T) {
|
||||||
|
root := filepath.Join("..", "..", "..", "mesh-catalog", "modules")
|
||||||
|
if _, err := os.Stat(root); err != nil {
|
||||||
|
t.Skipf("catalogue sibling not present: %v", err)
|
||||||
|
}
|
||||||
|
paths, err := manifestsUnder(root)
|
||||||
|
if err != nil || len(paths) == 0 {
|
||||||
|
t.Fatalf("no manifests under %s: %v", root, err)
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := moduleCheck(paths, &out); err != nil {
|
||||||
|
t.Fatalf("the catalogue does not pass its own check: %v\n%s", err, out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistrationRefusesADefinitionNamingAnInstallation(t *testing.T) {
|
||||||
|
// novox/hq ADR 0155: the check moves to registration once the catalogue passes it. Both
|
||||||
|
// ways in — `module add` and a build's result — go through this, and a name declared on
|
||||||
|
// purpose passes with its reason.
|
||||||
|
named := catalogue.Manifest{Module: "idp", Resources: []map[string]any{
|
||||||
|
{"id": "server", "type": "container", "image": "x@sha256:aa",
|
||||||
|
"env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}},
|
||||||
|
}}
|
||||||
|
err := namesNoInstallation(named)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "login.mesh-one.be") ||
|
||||||
|
!strings.Contains(err.Error(), catalogue.NamesOnPurpose) {
|
||||||
|
t.Fatalf("a definition naming an installation is refused with the name and the way out; got %v", err)
|
||||||
|
}
|
||||||
|
meant := catalogue.Manifest{Module: "site", Resources: []map[string]any{
|
||||||
|
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc",
|
||||||
|
catalogue.NamesOnPurpose: map[string]any{
|
||||||
|
"registry.mesh-one.be": "built outside the mesh until its repository is a build source here"}},
|
||||||
|
}}
|
||||||
|
if err := namesNoInstallation(meant); err != nil {
|
||||||
|
t.Fatalf("a name declared on purpose passes; got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A merge that rebuilds a base rebuilds what stands on it, through every layer, and nothing else
|
||||||
|
// (novox/hq issue 186): the runtime image moving means every module built on it moves too, and a
|
||||||
|
// module built on one of those moves as well.
|
||||||
|
func TestAMergeOfABaseTakesWhatStandsOnItAlong(t *testing.T) {
|
||||||
|
entry := func(name string) inventory.Entry {
|
||||||
|
return inventory.Entry{Manifest: catalogue.Manifest{Module: name}}
|
||||||
|
}
|
||||||
|
entries := []inventory.Entry{entry("mesh-tools"), entry("shop"), entry("shop-plugin"), entry("postgres"), entry("unrelated")}
|
||||||
|
against := map[string][]string{
|
||||||
|
"shop": {catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:a"},
|
||||||
|
"shop-plugin": {catalogue.ArtifactStoreScheme + "shop/runtime@sha256:b"},
|
||||||
|
"postgres": {catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:a"},
|
||||||
|
"unrelated": {catalogue.ArtifactStoreScheme + "alpine/base@sha256:c"},
|
||||||
|
}
|
||||||
|
got := dependentsOf([]inventory.Entry{entry("mesh-tools")}, entries, against)
|
||||||
|
var names []string
|
||||||
|
for _, e := range got {
|
||||||
|
names = append(names, e.Manifest.Module)
|
||||||
|
}
|
||||||
|
want := map[string]bool{"shop": true, "shop-plugin": true, "postgres": true}
|
||||||
|
if len(names) != len(want) {
|
||||||
|
t.Fatalf("rebuilt %v; wanted exactly the three that stand on the runtime, directly or through shop", names)
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
if !want[n] {
|
||||||
|
t.Fatalf("%s was rebuilt and stands on nothing that moved (%v)", n, names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The dependents come in base order when the merge orders them: the runtime, then shop, then
|
||||||
|
// the plugin that stands on shop.
|
||||||
|
ordered := orderByBases(append([]inventory.Entry{entry("mesh-tools")}, got...), against)
|
||||||
|
pos := map[string]int{}
|
||||||
|
for i, e := range ordered {
|
||||||
|
pos[e.Manifest.Module] = i
|
||||||
|
}
|
||||||
|
if !(pos["mesh-tools"] < pos["shop"] && pos["shop"] < pos["shop-plugin"]) {
|
||||||
|
t.Fatalf("not in base order: %v", ordered)
|
||||||
|
}
|
||||||
|
// Nothing moved: nothing follows.
|
||||||
|
if more := dependentsOf(nil, entries, against); len(more) != 0 {
|
||||||
|
t.Fatalf("with nothing moved, %d module(s) were rebuilt", len(more))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordDerivedHolders writes down who holds each mesh-scoped seat that nobody was ever recorded
|
||||||
|
// as holding.
|
||||||
|
//
|
||||||
|
// **A seat held by derivation is a seat held by accident of being alone** (novox/hq
|
||||||
|
// 04-ISSUES/170). ADR 0131 lets a holder on record settle a seat, and lets any other assignment
|
||||||
|
// whose module could hold it stand beside the holder, eligible and silent. But a seat nobody
|
||||||
|
// ever handed over has no record, so its holder is whichever assignment happened to be the sole
|
||||||
|
// claimant — and the day a second one is assigned, both claim, both are refused, and the first
|
||||||
|
// one's whole machine stops resolving. That is what assigning a second postgres did to the
|
||||||
|
// control plane's own store.
|
||||||
|
//
|
||||||
|
// So the mesh writes the derived answer down before it acts on an assignment: for every
|
||||||
|
// mesh-scoped seat with exactly one resolved holder and nothing on record, that holder is
|
||||||
|
// recorded as the standing one — the same record `seat <name> --to <node>/<module>` makes by
|
||||||
|
// hand, made from what the mesh already resolved. A seat with two derived claimants is left
|
||||||
|
// alone: that is the ambiguity a person settles, and recording either would be guessing.
|
||||||
|
//
|
||||||
|
// Node-scoped seats are untouched: a record is one holder per seat, and a node-scoped seat has
|
||||||
|
// one holder per machine (ADR 0121), so there is nothing for a record to settle there.
|
||||||
|
func recordDerivedHolders(ctx context.Context, open *stores) ([]string, error) {
|
||||||
|
inv := open.inventory
|
||||||
|
shelf, err := inv.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// exclude nobody: every node's claims, resolved with the holdings on record.
|
||||||
|
world, err := theRestOfTheMesh(ctx, inv, shelf, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
recorded, err := inv.Holdings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// A record is a row against a seat the store knows. A seat it does not — a mesh whose seats
|
||||||
|
// were never seeded, a seat a module declares for itself — stays held by derivation, as it
|
||||||
|
// always was; a missing row is not a reason an assignment fails.
|
||||||
|
known, err := inv.Seats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
recordable := map[string]bool{}
|
||||||
|
for _, s := range known {
|
||||||
|
recordable[s.Name] = true
|
||||||
|
}
|
||||||
|
onRecord := map[string]bool{}
|
||||||
|
for _, h := range recorded {
|
||||||
|
if s, ok := catalogue.SeatNamed(h.Claim); ok {
|
||||||
|
onRecord[s.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
holders := map[string][]catalogue.Held{}
|
||||||
|
for _, h := range world.Held {
|
||||||
|
if h.Scope != catalogue.ScopeMesh {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s, ok := catalogue.SeatNamed(h.Claim)
|
||||||
|
if !ok || onRecord[s.Name] || !recordable[s.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
holders[s.Name] = append(holders[s.Name], h)
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(holders))
|
||||||
|
for name := range holders {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
var said []string
|
||||||
|
for _, name := range names {
|
||||||
|
if len(holders[name]) != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h := holders[name][0]
|
||||||
|
if err := inv.HoldSeat(ctx, name, catalogue.ScopeMesh, h.Node, h.Module); err != nil {
|
||||||
|
return said, err
|
||||||
|
}
|
||||||
|
said = append(said, fmt.Sprintf(
|
||||||
|
"recorded %s on %s as the standing holder of %s, which it held only by being alone",
|
||||||
|
h.Module, h.Node, name))
|
||||||
|
}
|
||||||
|
return said, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A seat nobody ever handed over is held by whichever assignment happened to be alone — and the
|
||||||
|
// day a second module able to hold it is assigned, both claimed, both were refused, and the first
|
||||||
|
// one's machine stopped resolving (novox/hq 04-ISSUES/170). The mesh now writes the derived holder
|
||||||
|
// down before it acts, so the second assignment stands beside the holder on record.
|
||||||
|
|
||||||
|
func aSeatedStore() catalogue.Manifest {
|
||||||
|
return catalogue.Manifest{Module: "store", Version: "1",
|
||||||
|
Provides: []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh}},
|
||||||
|
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}},
|
||||||
|
Claims: []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASecondEligibleHolderStandsBesideTheOneHeldByBeingAlone(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
// Every deploy seeds the mesh's own seats; a record is a row against one of them.
|
||||||
|
if _, err := open.inventory.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
register(t, open, aSeatedStore())
|
||||||
|
|
||||||
|
if _, err := assign(ctx, open, "anchor", "store"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
said, err := assign(ctx, open, "laptop", "store")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a second store, eligible for the seat, was refused:\n%s\n%v", said, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(said, "cannot be worked out") {
|
||||||
|
t.Fatalf("assigning a second store unsettled the first one's machine:\n%s", said)
|
||||||
|
}
|
||||||
|
if !strings.Contains(said, "recorded store on anchor as the standing holder of mesh-store") {
|
||||||
|
t.Fatalf("the holder by derivation was not written down:\n%s", said)
|
||||||
|
}
|
||||||
|
|
||||||
|
holdings, err := open.inventory.Holdings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
for _, h := range holdings {
|
||||||
|
if h.Claim == "mesh-store" {
|
||||||
|
found = true
|
||||||
|
if h.Node != "anchor" || h.Module != "store" {
|
||||||
|
t.Fatalf("mesh-store is recorded on %s/%s, not on the one that held it", h.Node, h.Module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("mesh-store has no holder on record after assigning: %v", holdings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the record decides from here: the anchor's plan holds the seat, the laptop's does not.
|
||||||
|
for node, holds := range map[string]bool{"anchor": true, "laptop": false} {
|
||||||
|
plan, _, err := planFor(ctx, open, node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s no longer resolves: %v", node, err)
|
||||||
|
}
|
||||||
|
var claimed bool
|
||||||
|
for _, c := range plan.Claims {
|
||||||
|
if c.Claim == "mesh-store" {
|
||||||
|
claimed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if claimed != holds {
|
||||||
|
t.Fatalf("%s holds mesh-store: %v, want %v", node, claimed, holds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAHolderOnRecordIsNotRewrittenByDerivation(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
if _, err := open.inventory.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
register(t, open, aSeatedStore())
|
||||||
|
for _, node := range []string{"anchor", "laptop"} {
|
||||||
|
if _, err := assign(ctx, open, node, "store"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A person hands the seat to the laptop. From here the record decides, and what the mesh
|
||||||
|
// derives must never write over it.
|
||||||
|
if err := open.inventory.HoldSeat(ctx, "mesh-store", catalogue.ScopeMesh, "laptop", "store"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
said, err := recordDerivedHolders(ctx, open)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(said) != 0 {
|
||||||
|
t.Fatalf("a seat on record was written again from derivation: %v", said)
|
||||||
|
}
|
||||||
|
holdings, _ := open.inventory.Holdings(ctx)
|
||||||
|
for _, h := range holdings {
|
||||||
|
if h.Claim == "mesh-store" && h.Node != "laptop" {
|
||||||
|
t.Fatalf("the record moved to %s", h.Node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A host refuses a declaration carrying a field it does not know, and refuses it whole — so every new
|
||||||
|
// field is a flag day, and the mesh had no record of which host any machine ran (novox/hq
|
||||||
|
// 04-ISSUES/087). The order was kept by somebody remembering it.
|
||||||
|
|
||||||
|
func TestTheMeshNamesWhichMachinesRunWhichHost(t *testing.T) {
|
||||||
|
split := hostSplit([]inventory.Node{
|
||||||
|
{Name: "anchor", HostVersion: "04a27ca"},
|
||||||
|
{Name: "laptop", HostVersion: "ced54d4"},
|
||||||
|
{Name: "spare", HostVersion: "04a27ca"},
|
||||||
|
})
|
||||||
|
if len(split) != 2 {
|
||||||
|
t.Fatalf("two versions were reported and the split has %d: %v", len(split), split)
|
||||||
|
}
|
||||||
|
if got := strings.Join(split["04a27ca"], ","); got != "anchor,spare" && got != "spare,anchor" {
|
||||||
|
t.Fatalf("04a27ca is held by %q", got)
|
||||||
|
}
|
||||||
|
if got := strings.Join(split["ced54d4"], ","); got != "laptop" {
|
||||||
|
t.Fatalf("ced54d4 is held by %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheMeshDoesNotClaimWhichHostIsNewer(t *testing.T) {
|
||||||
|
// **The fault this replaced.** A host reports its version as a commit, and commits have no order.
|
||||||
|
// The first version compared them as strings and, on the live mesh, named the three machines
|
||||||
|
// running the NEWER host as the ones behind: `ced54d4` sorts above `04a27ca` and means nothing.
|
||||||
|
//
|
||||||
|
// There is no assertion to make about which is newer, and that is the point — the type says so.
|
||||||
|
// hostSplit returns who runs what, and nothing that could be read as an ordering.
|
||||||
|
split := hostSplit([]inventory.Node{
|
||||||
|
{Name: "old-but-sorts-high", HostVersion: "ced54d4"},
|
||||||
|
{Name: "new-but-sorts-low", HostVersion: "04a27ca"},
|
||||||
|
})
|
||||||
|
for version, machines := range split {
|
||||||
|
if len(machines) != 1 {
|
||||||
|
t.Fatalf("%s is held by %v", version, machines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMachineThatHasNotSaidIsNotAVersion(t *testing.T) {
|
||||||
|
// It may be running anything. Counting it as a version would invent a disagreement; `node show`
|
||||||
|
// says per machine that it has not said.
|
||||||
|
split := hostSplit([]inventory.Node{
|
||||||
|
{Name: "anchor", HostVersion: "04a27ca"},
|
||||||
|
{Name: "quiet"},
|
||||||
|
})
|
||||||
|
if split != nil {
|
||||||
|
t.Fatalf("one reported version and one silence read as a disagreement: %v", split)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMachinesAgreeingOnTheirHostAreNotADisagreement(t *testing.T) {
|
||||||
|
if split := hostSplit([]inventory.Node{
|
||||||
|
{Name: "anchor", HostVersion: "v2"},
|
||||||
|
{Name: "laptop", HostVersion: "v2"},
|
||||||
|
}); split != nil {
|
||||||
|
t.Fatalf("machines agreeing reported a split: %v", split)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMeshWhereNothingReportedAHostStatesNoDisagreement(t *testing.T) {
|
||||||
|
if split := hostSplit([]inventory.Node{{Name: "anchor"}, {Name: "laptop"}}); split != nil {
|
||||||
|
t.Fatalf("a mesh told no host version reported a split: %v", split)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAReportedHostVersionIsKeptAndReadBack(t *testing.T) {
|
||||||
|
// The machine has sent this since ADR 0141 and the controller's own copy of the report did not
|
||||||
|
// have the field, so it was unmarshalled into nothing. End to end through the store, because the
|
||||||
|
// fault was a field that existed on one side of the wire only.
|
||||||
|
open := aMesh(t)
|
||||||
|
record, err := open.inventory.NodeByName(t.Context(), "anchor")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if record.HostVersion != "" {
|
||||||
|
t.Fatalf("a machine that never reported one has host version %q", record.HostVersion)
|
||||||
|
}
|
||||||
|
if err := open.inventory.RecordHostVersion(t.Context(), record.ID, "ced54d4"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, err := open.inventory.NodeByName(t.Context(), "anchor")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if again.HostVersion != "ced54d4" {
|
||||||
|
t.Fatalf("the reported host version read back as %q", again.HostVersion)
|
||||||
|
}
|
||||||
|
// An empty report never clears what a machine last said: a bare word that the node is there says
|
||||||
|
// nothing about its host.
|
||||||
|
if err := open.inventory.RecordHostVersion(t.Context(), record.ID, " "); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
kept, err := open.inventory.NodeByName(t.Context(), "anchor")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if kept.HostVersion != "ced54d4" {
|
||||||
|
t.Fatalf("a report carrying no host version cleared what the machine had said: %q",
|
||||||
|
kept.HostVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,11 +72,16 @@ func run() error {
|
|||||||
return askCommand(ctx, args[1:])
|
return askCommand(ctx, args[1:])
|
||||||
case "builds":
|
case "builds":
|
||||||
return buildsCommand(ctx, args[1:])
|
return buildsCommand(ctx, args[1:])
|
||||||
|
case "plans":
|
||||||
|
return plansCommand(ctx, args[1:])
|
||||||
case "pin":
|
case "pin":
|
||||||
return pinCommand(ctx, args[1:], true)
|
return pinCommand(ctx, args[1:], true)
|
||||||
case "unpin":
|
case "unpin":
|
||||||
return pinCommand(ctx, args[1:], false)
|
return pinCommand(ctx, args[1:], false)
|
||||||
case "migrate":
|
// `prepare` is how the mesh asks any module to bring its state to the shape this version needs
|
||||||
|
// (novox/hq ADR 0135), and the control plane answers it the same way as everything else — its
|
||||||
|
// own schema is not a special case. `migrate` remains the word a person types.
|
||||||
|
case "prepare", "migrate":
|
||||||
return migrate(ctx)
|
return migrate(ctx)
|
||||||
case "node":
|
case "node":
|
||||||
return nodeCommand(ctx, args[1:])
|
return nodeCommand(ctx, args[1:])
|
||||||
@@ -85,7 +90,7 @@ func run() error {
|
|||||||
case "identity":
|
case "identity":
|
||||||
return identityCommand(ctx, args[1:])
|
return identityCommand(ctx, args[1:])
|
||||||
case "broker":
|
case "broker":
|
||||||
return brokerCommand(args[1:])
|
return brokerCommand(ctx, args[1:])
|
||||||
case "serve":
|
case "serve":
|
||||||
return serve(ctx)
|
return serve(ctx)
|
||||||
case "upgrade":
|
case "upgrade":
|
||||||
@@ -138,12 +143,16 @@ func usage() {
|
|||||||
fmt.Fprint(os.Stderr, `mesh-controller — the control plane
|
fmt.Fprint(os.Stderr, `mesh-controller — the control plane
|
||||||
|
|
||||||
migrate bring each context's schema up to date
|
migrate bring each context's schema up to date
|
||||||
|
prepare the same, asked the way the mesh asks any module (ADR 0135)
|
||||||
node add <name> [--adopted] create a node record; --adopted: the machine is in use
|
node add <name> [--adopted] create a node record; --adopted: the machine is in use
|
||||||
node list the nodes this mesh knows about
|
node list the nodes this mesh knows about
|
||||||
node show <name> what one machine reported it can do, and why
|
node show <name> what one machine reported it can do, and why
|
||||||
node public-domain <name> the domain it composes its routed names under
|
node public-domain <name> the domain it composes its routed names under
|
||||||
node public-domain <name> <d> ...set it to d
|
node public-domain <name> <d> ...set it to d
|
||||||
node public-domain <name> --clear ...it faces the outside no longer
|
node public-domain <name> --clear ...it faces the outside no longer
|
||||||
|
node networks <name> the networks it routes for what it hosts
|
||||||
|
node networks <name> <cidr>... ...set them; its filter forwards these too
|
||||||
|
node networks <name> --clear ...only the container runtime's own
|
||||||
token issue --node <name> a one-time right to join, for an existing record
|
token issue --node <name> a one-time right to join, for an existing record
|
||||||
token issue --new <name> create the record and issue for it
|
token issue --new <name> create the record and issue for it
|
||||||
token issue ... --adopted ...for a machine in use, which joins adopted
|
token issue ... --adopted ...for a machine in use, which joins adopted
|
||||||
@@ -154,6 +163,7 @@ func usage() {
|
|||||||
overlay place <node> [flags] say where a node is and how it is reached
|
overlay place <node> [flags] say where a node is and how it is reached
|
||||||
overlay show the private network, as the mesh computes it
|
overlay show the private network, as the mesh computes it
|
||||||
module add <file> register a module from its manifest
|
module add <file> register a module from its manifest
|
||||||
|
module check <file|dir>... judge manifests where they are written, with no mesh (exit 1 on any problem)
|
||||||
module list what modules this mesh knows about
|
module list what modules this mesh knows about
|
||||||
module moved <name> <commit> the source has a newer commit than the mesh built
|
module moved <name> <commit> the source has a newer commit than the mesh built
|
||||||
module forget <name> remove one, unless a node runs it or the mesh holds things for it
|
module forget <name> remove one, unless a node runs it or the mesh holds things for it
|
||||||
@@ -164,11 +174,14 @@ func usage() {
|
|||||||
upgrade <name> record ...record that they are behind, and send nothing
|
upgrade <name> record ...record that they are behind, and send nothing
|
||||||
status [--json] what is wrong, what is quiet, and what is out of date
|
status [--json] what is wrong, what is quiet, and what is out of date
|
||||||
seats [--json] every seat this mesh defines, what it delivers, and who holds it
|
seats [--json] every seat this mesh defines, what it delivers, and who holds it
|
||||||
|
seat rename <from> <to> rename a seat; its former name still resolves (ADR 0122)
|
||||||
|
seat <name> --to <node>/<module> hand a seat to that assignment as one act; never empty in between (ADR 0131)
|
||||||
board [--listen ADDR] the same three questions, as a page that holds nothing
|
board [--listen ADDR] the same three questions, as a page that holds nothing
|
||||||
api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here
|
api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here
|
||||||
assign <node> <module> put a module on a node
|
assign <node> <module> put a module on a node
|
||||||
unassign <node> <module> take it off
|
unassign <node> <module> take it off
|
||||||
take <node> <module> cut a module over on an adopted node, once its data has moved
|
take <node> <module> preview a module's cutover on an adopted node: what runs beside
|
||||||
|
what it declares; --yes <digest> cuts it over as previewed
|
||||||
converge <node> [--yes <digest>] [--filter nftables] preview, then make, an adopted node converged
|
converge <node> [--yes <digest>] [--filter nftables] preview, then make, an adopted node converged
|
||||||
adopt <node> return a converged node to adopted; what was taken stays taken
|
adopt <node> return a converged node to adopted; what was taken stays taken
|
||||||
settings set <module> <file> what a module's config should say, for the whole mesh
|
settings set <module> <file> what a module's config should say, for the whole mesh
|
||||||
@@ -184,6 +197,7 @@ func usage() {
|
|||||||
operator key show the operator key, and what it can recover
|
operator key show the operator key, and what it can recover
|
||||||
build <repository> [--ref R] have a build machine build it, and record what came out
|
build <repository> [--ref R] have a build machine build it, and record what came out
|
||||||
build --behind build every module the mesh holds older than its source
|
build --behind build every module the mesh holds older than its source
|
||||||
|
build --on <module> rebuild every module that stands on this module's artifacts, bases first
|
||||||
builds [<module>] what has been built lately, and what came of it
|
builds [<module>] what has been built lately, and what came of it
|
||||||
builder issue <name> a broker account for a build machine, scoped to build work,
|
builder issue <name> a broker account for a build machine, scoped to build work,
|
||||||
delivered as the builder module's broker secret (module add it first)
|
delivered as the builder module's broker secret (module add it first)
|
||||||
@@ -192,7 +206,8 @@ func usage() {
|
|||||||
licence refresh <name> mint a new access token and seal it to every holder
|
licence refresh <name> mint a new access token and seal it to every holder
|
||||||
rotate <provision> [--consumer <n>] a new credential for every holder, both ends at once
|
rotate <provision> [--consumer <n>] a new credential for every holder, both ends at once
|
||||||
ask <module> <tool> [json] call one of a module's tools over the broker, and print its answer
|
ask <module> <tool> [json] call one of a module's tools over the broker, and print its answer
|
||||||
pin <node> <provision> <from> which node this one gets a provision from
|
pin <node> <provision> <from-node> <module>
|
||||||
|
which provider this one gets a provision from: the module, and its node
|
||||||
unpin <node> <provision> put that question back
|
unpin <node> <provision> put that question back
|
||||||
plan <node> [--files|--json] what that node would run, and why
|
plan <node> [--files|--json] what that node would run, and why
|
||||||
push [<node>] [--behind] send a node everything it should be, or only those that need it
|
push [<node>] [--behind] send a node everything it should be, or only those that need it
|
||||||
@@ -232,6 +247,31 @@ func parseAround(set *flag.FlagSet, args []string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Built is the daemon hearing a build's outcome on the bus — its own asking, an announcement's, or
|
||||||
|
// a tool's that did not wait (novox/hq issue 176) — and taking it in: recorded, and the module
|
||||||
|
// registered, the same as the waiting command does. Said either way, so the daemon's log tells what
|
||||||
|
// became of a build nobody was watching.
|
||||||
func (b builds) Built(ctx context.Context, result link.BuildResult) error {
|
func (b builds) Built(ctx context.Context, result link.BuildResult) error {
|
||||||
return b.inv.RecordBuild(ctx, buildFrom(result))
|
manifest, _, err := takeIn(ctx, b.inv, result)
|
||||||
|
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)
|
||||||
|
} else {
|
||||||
|
planFailedBuild(ctx, b.open, result)
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
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, "")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+172
-109
@@ -2,8 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
@@ -56,7 +54,24 @@ var provided = providedModules()
|
|||||||
|
|
||||||
func moduleCommand(ctx context.Context, args []string) error {
|
func moduleCommand(ctx context.Context, args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return errors.New("module add <file>, module list, or module forget <name>")
|
return errors.New("module add <file>, module check <file>..., module list, or module forget <name>")
|
||||||
|
}
|
||||||
|
// `check` needs no mesh, and must not: it is what somebody runs in their own repository before
|
||||||
|
// there is a mesh in reach (novox/hq issue 148). A directory expands to every manifest under it.
|
||||||
|
if args[0] == "check" {
|
||||||
|
var paths []string
|
||||||
|
for _, a := range args[1:] {
|
||||||
|
if info, err := os.Stat(a); err == nil && info.IsDir() {
|
||||||
|
under, err := manifestsUnder(a)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
paths = append(paths, under...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
paths = append(paths, a)
|
||||||
|
}
|
||||||
|
return moduleCheck(paths, os.Stdout)
|
||||||
}
|
}
|
||||||
open, err := openStores(ctx)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -71,12 +86,20 @@ func moduleCommand(ctx context.Context, args []string) error {
|
|||||||
repo := set.String("source", "", "where this module comes from")
|
repo := set.String("source", "", "where this module comes from")
|
||||||
ref := set.String("ref", "", "the branch followed there")
|
ref := set.String("ref", "", "the branch followed there")
|
||||||
commit := set.String("commit", "", "the commit this manifest was read at")
|
commit := set.String("commit", "", "the commit this manifest was read at")
|
||||||
|
// **Where inside the repository the module is** (novox/hq ADR 0069). A module is a
|
||||||
|
// repository *and* a directory, and a record that carries only the repository names a
|
||||||
|
// module.json at its root — so every later build of it looks in the wrong place and fails
|
||||||
|
// with "no module.json at its root". Nine modules on this mesh were registered that way
|
||||||
|
// and none of them could be rebuilt (2026-09-28).
|
||||||
|
path := set.String("path", "", "the module's directory inside that repository")
|
||||||
|
self := set.Bool("self", false, "the source is a path on the forge holding the git seat")
|
||||||
positionals, err := parseAround(set, args[1:])
|
positionals, err := parseAround(set, args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(positionals) != 1 {
|
if len(positionals) != 1 {
|
||||||
return errors.New("module add <manifest.json> [--source <repo> --ref <branch> --commit <sha>]")
|
return errors.New("module add <manifest.json> [--source <repo> [--self] [--path P] " +
|
||||||
|
"--ref <branch> --commit <sha>]")
|
||||||
}
|
}
|
||||||
raw, err := os.ReadFile(positionals[0])
|
raw, err := os.ReadFile(positionals[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -86,23 +109,27 @@ func moduleCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Provenance together or not at all. A source with no commit cannot be compared against
|
from, err := whereItComesFrom(*repo, *ref, *commit, *path, *self)
|
||||||
// anything, so it would record where the module came from and still never be able to say
|
if err != nil {
|
||||||
// the mesh is behind it — which is the one thing recording it is for.
|
return err
|
||||||
if (*repo == "") != (*commit == "") {
|
|
||||||
return errors.New("--source and --commit go together: a source with no commit " +
|
|
||||||
"cannot be compared against anything, and a commit with no source has nothing " +
|
|
||||||
"to be compared with")
|
|
||||||
}
|
}
|
||||||
if err := inv.RegisterModule(ctx, m, inventory.Source{
|
if err := namesNoInstallation(m); err != nil {
|
||||||
Repository: *repo, Ref: *ref, BuiltFrom: *commit,
|
return err
|
||||||
}); err != nil {
|
}
|
||||||
|
if err := inv.RegisterModule(ctx, m, from); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("%s registered", m.Module)
|
fmt.Printf("%s registered", m.Module)
|
||||||
if *commit != "" {
|
if *commit != "" {
|
||||||
fmt.Printf(" from %s", short(*commit))
|
fmt.Printf(" from %s", short(*commit))
|
||||||
}
|
}
|
||||||
|
if *repo != "" && *path == "" {
|
||||||
|
// Said, not refused: a module really at the root is the ordinary case for a repository
|
||||||
|
// of its own. But a repository holding many modules and a record naming none of them is
|
||||||
|
// a module nothing can rebuild, and the person adding it is the one who knows which.
|
||||||
|
fmt.Printf("\n no directory inside %s, so it is built from that repository's root — "+
|
||||||
|
"`--path` if the module lives in a directory there", *repo)
|
||||||
|
}
|
||||||
if len(m.Provides) > 0 {
|
if len(m.Provides) > 0 {
|
||||||
fmt.Printf(", providing %s", describeOffers(m.Provides))
|
fmt.Printf(", providing %s", describeOffers(m.Provides))
|
||||||
}
|
}
|
||||||
@@ -261,80 +288,14 @@ func moduleCommand(ctx context.Context, args []string) error {
|
|||||||
// made in entirely different ways: on the bus the mesh runs on today an account is a
|
// made in entirely different ways: on the bus the mesh runs on today an account is a
|
||||||
// management call, and on the bus being built it is a row the next composition writes into
|
// management call, and on the bus being built it is a row the next composition writes into
|
||||||
// the server's user list (novox/hq design 25 §4).
|
// the server's user list (novox/hq design 25 §4).
|
||||||
busAddress, onNATS, err := broker.OnNATS()
|
busAddress, err := broker.BusAddress()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := broker.MustBeOneBus(os.Getenv(broker.AMQPVarName), busAddress); err != nil {
|
return issueOnTheNewBus(ctx, inv, m, *forNode, busAddress)
|
||||||
return err
|
|
||||||
}
|
|
||||||
if onNATS {
|
|
||||||
return issueOnTheNewBus(ctx, inv, m, *forNode, busAddress)
|
|
||||||
}
|
|
||||||
|
|
||||||
management, err := broker.ManagementFromEnvironment()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// The foundation owns the bus; make sure it exists before a module binds onto it.
|
|
||||||
if err := management.EnsureEventExchanges(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
secret := make([]byte, 32)
|
|
||||||
if _, err := rand.Read(secret); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
password := base64.RawURLEncoding.EncodeToString(secret)
|
|
||||||
account, err := management.CreateModuleAccount(ctx, *forNode, module, password, m.Emits, m.Consumes)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// A consumer's queue, with its dead-letter, is the foundation's to declare — its own account
|
|
||||||
// may not (ADR 0043). Made now, so it exists before the module binds onto it.
|
|
||||||
if len(m.Consumes) > 0 {
|
|
||||||
if err := management.EnsureModuleQueue(ctx, *forNode, module); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
known, err := broker.FromEnvironment()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot deliver a credential without knowing where the broker is: %w", err)
|
|
||||||
}
|
|
||||||
brokerAddr, err := brokerReachableAt(ctx, inv, known, *forNode)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// The URL and what verifies the broker, together — a mesh's broker presents its own
|
|
||||||
// certificate, in no public trust store, so a URL alone fails at TLS (as `builder issue`).
|
|
||||||
held, err := json.Marshal(struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Fingerprint string `json:"fingerprint,omitempty"`
|
|
||||||
Node string `json:"node"`
|
|
||||||
Module string `json:"module"`
|
|
||||||
}{
|
|
||||||
URL: fmt.Sprintf("amqps://%s:%s@%s/", account, password, brokerAddr),
|
|
||||||
Fingerprint: known.Fingerprint,
|
|
||||||
// The node and module the account is for, so the runtime names its queue as the mesh
|
|
||||||
// scoped it (<node>.<module>.events) without a manifest having to interpolate a node.
|
|
||||||
Node: *forNode,
|
|
||||||
Module: module,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := inv.AcceptSecretForModule(ctx, *forNode, module, "broker", string(held)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("broker account %s created for %s, scoped to what it emits and consumes\n",
|
|
||||||
account, module)
|
|
||||||
fmt.Printf(" sealed to %s. It arrives with the next push — `push %s` to send it\n",
|
|
||||||
*forNode, *forNode)
|
|
||||||
return nil
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("module has no %q; it has add, list, moved, forget and issue", args[0])
|
return fmt.Errorf("module has no %q; it has add, check, list, moved, forget and issue", args[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,8 +411,8 @@ func describeOffers(offers []catalogue.Offer) string {
|
|||||||
// database should not change where an existing machine gets its data the day a second one
|
// database should not change where an existing machine gets its data the day a second one
|
||||||
// arrives.
|
// arrives.
|
||||||
func pinCommand(ctx context.Context, args []string, setting bool) error {
|
func pinCommand(ctx context.Context, args []string, setting bool) error {
|
||||||
if setting && len(args) != 3 {
|
if setting && len(args) != 4 {
|
||||||
return errors.New("pin <node> <provision> <from-node>")
|
return errors.New("pin <node> <provision> <from-node> <module>")
|
||||||
}
|
}
|
||||||
if !setting && len(args) != 2 {
|
if !setting && len(args) != 2 {
|
||||||
return errors.New("unpin <node> <provision>")
|
return errors.New("unpin <node> <provision>")
|
||||||
@@ -470,17 +431,12 @@ func pinCommand(ctx context.Context, args []string, setting bool) error {
|
|||||||
fmt.Printf("%s is no longer told where to get %s from\n", args[0], args[1])
|
fmt.Printf("%s is no longer told where to get %s from\n", args[0], args[1])
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if args[0] == args[2] {
|
// The provider's node may be this same machine: two modules beside the consumer can both
|
||||||
// Allowed by nothing here, and worth saying rather than resolving into a confusing
|
// answer a provision, and then the module is the whole question (novox/hq #258).
|
||||||
// refusal later: a node providing something to itself is a node-scoped provision, and
|
if err := inv.PinProvision(ctx, args[0], args[1], args[2], args[3]); err != nil {
|
||||||
// this field is for the other kind.
|
|
||||||
return fmt.Errorf("%s cannot get %s from itself; that would be a provision this machine "+
|
|
||||||
"provides, which does not need saying", args[0], args[1])
|
|
||||||
}
|
|
||||||
if err := inv.PinProvision(ctx, args[0], args[1], args[2]); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("%s gets %s from %s\n", args[0], args[1], args[2])
|
fmt.Printf("%s gets %s from %s/%s\n", args[0], args[1], args[2], args[3])
|
||||||
fmt.Printf(" run `push %s` to send it\n", args[0])
|
fmt.Printf(" run `push %s` to send it\n", args[0])
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -614,16 +570,34 @@ func issueOnTheNewBus(ctx context.Context, inv *inventory.Inventory, m catalogue
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return issueWith(ctx, inv, m, node, busAddress, known, reachable, user, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueWith is the delivery half: the minted password sealed to the machine as the module's broker
|
||||||
|
// secret, and the module's consumer created where the bus can be reached. Split from the minting
|
||||||
|
// so the move can issue every module against a bus whose address it worked out itself
|
||||||
|
// (`rollout mint`, design 28 task 5.2) rather than the one in this process's environment.
|
||||||
|
func issueWith(ctx context.Context, inv *inventory.Inventory, m catalogue.Manifest,
|
||||||
|
node, busAddress string, known broker.Broker, reachable, user, password string) error {
|
||||||
|
// The seats this module claims, with the verbs each promises (novox/hq ADR 0159): the runtime
|
||||||
|
// serves a claimed seat's verbs with its tools of the same name, and the bus admits only the
|
||||||
|
// holder's subscription — so the runtime tries each claim and the grant decides. Written here
|
||||||
|
// because this file is the one thing the mesh writes that the runtime reads before it speaks.
|
||||||
|
claims, err := claimsFor(ctx, inv, m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
held, err := json.Marshal(struct {
|
held, err := json.Marshal(struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Fingerprint string `json:"fingerprint,omitempty"`
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
Module string `json:"module"`
|
Module string `json:"module"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
Claims []seatClaimed `json:"claims,omitempty"`
|
||||||
}{
|
}{
|
||||||
URL: "nats://" + reachable, Fingerprint: known.Fingerprint,
|
URL: "nats://" + reachable, Fingerprint: known.Fingerprint,
|
||||||
Node: node, Module: m.Module, User: user, Password: password,
|
Node: node, Module: m.Module, User: user, Password: password, Claims: claims,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -639,14 +613,19 @@ func issueOnTheNewBus(ctx context.Context, inv *inventory.Inventory, m catalogue
|
|||||||
Kind: broker.KindModule, Node: node, Module: m.Module,
|
Kind: broker.KindModule, Node: node, Module: m.Module,
|
||||||
Emits: m.Emits, Consumes: m.Consumes, Serves: m.Tools,
|
Emits: m.Emits, Consumes: m.Consumes, Serves: m.Tools,
|
||||||
}); needed {
|
}); needed {
|
||||||
js, err := broker.Dial(busAddress)
|
if busAddress == "" {
|
||||||
if err != nil {
|
fmt.Printf(" %s consumes; its consumer is created when the bus is reachable (`push`, then "+
|
||||||
return fmt.Errorf("the credential is minted and the mesh cannot reach the bus to create "+
|
"`rollout mint` again is harmless)\n", m.Module)
|
||||||
"how %s hears what it consumes: %w", m.Module, err)
|
} else {
|
||||||
}
|
js, err := broker.Dial(busAddress)
|
||||||
defer js.Close()
|
if err != nil {
|
||||||
if err := js.EnsureConsumer(consumer); err != nil {
|
return fmt.Errorf("the credential is minted and the mesh cannot reach the bus to create "+
|
||||||
return err
|
"how %s hears what it consumes: %w", m.Module, err)
|
||||||
|
}
|
||||||
|
defer js.Close()
|
||||||
|
if err := js.EnsureConsumer(consumer); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,3 +635,87 @@ func issueOnTheNewBus(ctx context.Context, inv *inventory.Inventory, m catalogue
|
|||||||
"machine holding mesh-broker\n")
|
"machine holding mesh-broker\n")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// whereItComesFrom is the provenance a module handed over by hand records, and what a record must
|
||||||
|
// say to be worth anything later.
|
||||||
|
//
|
||||||
|
// **A module is a repository and a directory inside it** (novox/hq ADR 0069). A record carrying only
|
||||||
|
// the repository names a module.json at its root, so every later build of it looks in the wrong
|
||||||
|
// place — nine modules on this mesh were registered that way and none of them could be rebuilt
|
||||||
|
// (2026-09-28). The directory cannot be checked from here, because the control plane does not clone;
|
||||||
|
// what can be checked is that the record is whole.
|
||||||
|
func whereItComesFrom(repository, ref, commit, path string, self bool) (inventory.Source, error) {
|
||||||
|
// Provenance together or not at all. A source with no commit cannot be compared against
|
||||||
|
// anything, so it would record where the module came from and still never be able to say the
|
||||||
|
// mesh is behind it — which is the one thing recording it is for.
|
||||||
|
if (repository == "") != (commit == "") {
|
||||||
|
return inventory.Source{}, errors.New("--source and --commit go together: a source with " +
|
||||||
|
"no commit cannot be compared against anything, and a commit with no source has " +
|
||||||
|
"nothing to be compared with")
|
||||||
|
}
|
||||||
|
// A directory or a forge with no repository is half a location, and the half it keeps is the
|
||||||
|
// half nothing can be found with.
|
||||||
|
if repository == "" && (path != "" || self) {
|
||||||
|
return inventory.Source{}, errors.New("--path and --self say where inside a source and " +
|
||||||
|
"which forge holds it, so they need --source: without one there is nothing for them " +
|
||||||
|
"to be part of")
|
||||||
|
}
|
||||||
|
from := inventory.Source{Repository: repository, Ref: ref, BuiltFrom: commit, Path: path}
|
||||||
|
if self {
|
||||||
|
if err := onASeat(repository); err != nil {
|
||||||
|
return inventory.Source{}, err
|
||||||
|
}
|
||||||
|
from.Seat = gitSeat
|
||||||
|
}
|
||||||
|
return from, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// namesNoInstallation is the mesh refusing a definition that names an installation, at the moment
|
||||||
|
// it would enter the catalogue (novox/hq ADR 0112, ADR 0155). `module check` says the same thing
|
||||||
|
// earlier, where the author is; this is the last moment the mesh can still say no, and a
|
||||||
|
// definition that got past the check — written elsewhere, or checked by nobody — is refused here
|
||||||
|
// in the same words. A name meant on purpose is declared with its reason and passes.
|
||||||
|
func namesNoInstallation(m catalogue.Manifest) error {
|
||||||
|
named := catalogue.InstallationProblems(m)
|
||||||
|
if len(named) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s names an installation, and a definition names none — declare a name meant "+
|
||||||
|
"on purpose under %s with its reason, or take it out:\n - %s",
|
||||||
|
m.Module, catalogue.NamesOnPurpose, strings.Join(named, "\n - "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatClaimed is one seat a module claims, as its runtime needs it: the name, the scope (a
|
||||||
|
// node-scoped seat's verb carries the machine, design 33 §4) and the verbs the seat promises.
|
||||||
|
type seatClaimed struct {
|
||||||
|
Seat string `json:"seat"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Serves []string `json:"serves,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimsFor joins a module's claims with the seats' protocols from the mesh's records.
|
||||||
|
func claimsFor(ctx context.Context, inv *inventory.Inventory, m catalogue.Manifest) ([]seatClaimed, error) {
|
||||||
|
if len(m.Claims) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
seats, err := inv.Seats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
byName := map[string]catalogue.Seat{}
|
||||||
|
for _, s := range seats {
|
||||||
|
byName[s.Name] = s
|
||||||
|
}
|
||||||
|
var out []seatClaimed
|
||||||
|
for _, c := range m.Claims {
|
||||||
|
claimed := seatClaimed{Seat: c.Name, Scope: c.At()}
|
||||||
|
if s, known := byName[c.Name]; known {
|
||||||
|
claimed.Scope = s.Scope
|
||||||
|
// The verbs the runtime serves for the seat: the claim's own when it names them
|
||||||
|
// (ADR 0160), else every verb the seat promises, which its tools then answer.
|
||||||
|
claimed.Serves = c.ServesFor(catalogue.Manifest{Tools: catalogue.VerbNames(s.Serves)})
|
||||||
|
}
|
||||||
|
out = append(out, claimed)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// A module that declares none is refused before the account exists, so the bus never carries an
|
// A module that declares none is refused before the account exists, so the bus never carries an
|
||||||
// account nothing reads (novox/hq 04-ISSUES/078).
|
// account nothing reads (novox/hq 04-ISSUES/078).
|
||||||
func TestAModuleWithNoBrokerSecretCannotBeIssued(t *testing.T) {
|
func TestAModuleWithNoBrokerSecretCannotBeIssued(t *testing.T) {
|
||||||
err := mayIssue(catalogue.Manifest{Module: "step-ca", OwnSecrets: map[string]string{"password": "/run/password"}})
|
err := mayIssue(catalogue.Manifest{Module: "step-ca", OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password"}}})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("a module with no broker own secret was issued an account")
|
t.Fatal("a module with no broker own secret was issued an account")
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ func TestAModuleWithNoBrokerSecretCannotBeIssued(t *testing.T) {
|
|||||||
t.Errorf("the refusal does not say %q: %v", want, err)
|
t.Errorf("the refusal does not say %q: %v", want, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := mayIssue(catalogue.Manifest{Module: "redis", OwnSecrets: map[string]string{"broker": "/run/broker"}}); err != nil {
|
if err := mayIssue(catalogue.Manifest{Module: "redis", OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}}}); err != nil {
|
||||||
t.Errorf("a module declaring its broker secret was refused: %v", err)
|
t.Errorf("a module declaring its broker secret was refused: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,9 +346,16 @@ func whoResolves(ctx context.Context, open *stores, requirement string) (
|
|||||||
refused := map[string]string{}
|
refused := map[string]string{}
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
plan, _, err := planFor(ctx, open, n.Name)
|
plan, _, err := planFor(ctx, open, n.Name)
|
||||||
if err != nil {
|
switch {
|
||||||
|
case unresolvable(err):
|
||||||
refused[n.Name] = err.Error()
|
refused[n.Name] = err.Error()
|
||||||
continue
|
continue
|
||||||
|
case err != nil:
|
||||||
|
// Not a node that does not resolve — a question that went unanswered. Recording it as a
|
||||||
|
// refusal would take the machine off the private network, and the generator that reads
|
||||||
|
// this would then write a roster and a filter without it (novox/hq 04-ISSUES/152).
|
||||||
|
return nil, nil, fmt.Errorf("whether %s answers %q cannot be read: %w",
|
||||||
|
n.Name, requirement, err)
|
||||||
}
|
}
|
||||||
for _, m := range plan.Modules {
|
for _, m := range plan.Modules {
|
||||||
for _, offered := range m.Offers() {
|
for _, offered := range m.Offers() {
|
||||||
@@ -537,6 +544,15 @@ func onTheNetwork(ctx context.Context, inv *inventory.Inventory,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// **With the seat holders on record**, or a machine running the next holder of a seat beside
|
||||||
|
// the current one resolves as two holders, is refused, and drops out of the map — taking the
|
||||||
|
// address every other machine composes for what it offers (novox/hq ADR 0131). Found live:
|
||||||
|
// the control node vanished from the private network the moment the new bus was assigned
|
||||||
|
// beside the old one.
|
||||||
|
holdings, err := inv.Holdings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
var out []inventory.Overlay
|
var out []inventory.Overlay
|
||||||
for _, p := range places {
|
for _, p := range places {
|
||||||
if p.Address == "" {
|
if p.Address == "" {
|
||||||
@@ -549,8 +565,11 @@ func onTheNetwork(ctx context.Context, inv *inventory.Inventory,
|
|||||||
caps, _ := inv.ProfileOf(ctx, p.Name)
|
caps, _ := inv.ProfileOf(ctx, p.Name)
|
||||||
got, err := catalogue.Resolve(shelf, assigned,
|
got, err := catalogue.Resolve(shelf, assigned,
|
||||||
catalogue.Node{Name: p.Name, Site: p.Site, Capabilities: caps},
|
catalogue.Node{Name: p.Name, Site: p.Site, Capabilities: caps},
|
||||||
catalogue.World{Unchecked: true})
|
catalogue.World{Unchecked: true, Holdings: holdings})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Said, not skipped in silence: a machine dropped here loses its address, and every
|
||||||
|
// plan that names it fails in another module's words (novox/hq issue 188).
|
||||||
|
fmt.Fprintf(os.Stderr, "%s is not counted as on the network: it does not resolve: %v\n", p.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, m := range got.Modules {
|
for _, m := range got.Modules {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
"github.com/novox/mesh-controller/internal/token"
|
"github.com/novox/mesh-controller/internal/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,6 +66,18 @@ func nodeCommand(ctx context.Context, args []string) error {
|
|||||||
// because the damage is already done by the time it prints.
|
// because the damage is already done by the time it prints.
|
||||||
return publicDomain(ctx, inv, args[1:])
|
return publicDomain(ctx, inv, args[1:])
|
||||||
|
|
||||||
|
case "networks":
|
||||||
|
// Removed by novox/hq ADR 0140, which superseded the record that added it. The filter no
|
||||||
|
// longer names any network: it constrains what arrives from outside the machine and says
|
||||||
|
// nothing about what did not, so there is no list to keep. Answered rather than met with
|
||||||
|
// "unknown command", because this was the documented way to stop a flip cutting a machine's
|
||||||
|
// containers off and somebody will reasonably still type it.
|
||||||
|
return errors.New("`node networks` is gone (novox/hq ADR 0140). The filter constrains what " +
|
||||||
|
"arrives from outside this machine and says nothing about traffic that did not, so no " +
|
||||||
|
"network is named anywhere and nothing needs to be said to keep a machine's own " +
|
||||||
|
"containers reaching outward. The machine reports which of its links face outside; see " +
|
||||||
|
"`node show <name>`")
|
||||||
|
|
||||||
case "account":
|
case "account":
|
||||||
// The operator's login on this machine (novox/hq to-be 29): what a home-scoped file is
|
// The operator's login on this machine (novox/hq to-be 29): what a home-scoped file is
|
||||||
// owned by and which account `ssh <node>` uses. Reports with no argument; sets with one;
|
// owned by and which account `ssh <node>` uses. Reports with no argument; sets with one;
|
||||||
@@ -258,15 +269,6 @@ func tokenCommand(ctx context.Context, args []string) error {
|
|||||||
// chicken-and-egg entirely: the mesh runs the broker, so a joining node's credentials can
|
// chicken-and-egg entirely: the mesh runs the broker, so a joining node's credentials can
|
||||||
// exist before it does. The one-time secret IS the password, so a node's first connection is
|
// exist before it does. The one-time secret IS the password, so a node's first connection is
|
||||||
// already authenticated and enrolment is what happens over it.
|
// already authenticated and enrolment is what happens over it.
|
||||||
if management, err := broker.ManagementFromEnvironment(); err == nil {
|
|
||||||
if err := management.CreateNodeAccount(ctx, issued.Node.Name, issued.Secret); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("broker account %s created, scoped to %s and the %s exchange\n\n",
|
|
||||||
issued.Node.Name, link.QueueFor(issued.Node.Name), link.Exchange)
|
|
||||||
} else if !errors.Is(err, broker.ErrNotConfigured) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
made := token.Token{Node: issued.Node.Name, Signer: key.Public, Secret: issued.Secret,
|
made := token.Token{Node: issued.Node.Name, Signer: key.Public, Secret: issued.Secret,
|
||||||
Adopted: issued.Node.Adopted}
|
Adopted: issued.Node.Adopted}
|
||||||
@@ -361,9 +363,15 @@ func identityCommand(ctx context.Context, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func brokerCommand(args []string) error {
|
func brokerCommand(ctx context.Context, args []string) error {
|
||||||
|
if len(args) > 0 && args[0] == "certificate" {
|
||||||
|
return busCertificate(args[1:])
|
||||||
|
}
|
||||||
|
if len(args) > 0 && args[0] == "accounts" {
|
||||||
|
return busAccounts(ctx, args[1:])
|
||||||
|
}
|
||||||
if len(args) == 0 || args[0] != "show" {
|
if len(args) == 0 || args[0] != "show" {
|
||||||
return errors.New("broker show")
|
return errors.New("broker show | broker certificate [--check] --into <directory> | broker accounts --into <file>")
|
||||||
}
|
}
|
||||||
known, err := broker.FromEnvironment()
|
known, err := broker.FromEnvironment()
|
||||||
if errors.Is(err, broker.ErrNotConfigured) {
|
if errors.Is(err, broker.ErrNotConfigured) {
|
||||||
@@ -428,6 +436,12 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error
|
|||||||
}
|
}
|
||||||
fmt.Printf("%s\n", node.Name)
|
fmt.Printf("%s\n", node.Name)
|
||||||
fmt.Printf(" last heard from %s\n", heardFrom(node))
|
fmt.Printf(" last heard from %s\n", heardFrom(node))
|
||||||
|
// Which host runs it, as it reported (novox/hq 04-ISSUES/087). Said whenever known, because a
|
||||||
|
// host refuses a declaration carrying a field it does not understand and refuses it WHOLE — so
|
||||||
|
// which host a machine runs is what decides whether the mesh can send it anything new, and
|
||||||
|
// nothing could say it. "not reported" rather than blank: a machine that has not said is a
|
||||||
|
// different thing from one running nothing.
|
||||||
|
fmt.Printf(" host %s\n", orNotReported(node.HostVersion))
|
||||||
if err := showMode(ctx, inv, node); err != nil {
|
if err := showMode(ctx, inv, node); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -478,3 +492,11 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// orNotReported is a fact a machine states about itself, or the fact that it has not.
|
||||||
|
func orNotReported(s string) string {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return "not reported — this machine has not said since the mesh began keeping it"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|||||||
@@ -189,13 +189,17 @@ func readPrivateKey(path string) (string, error) {
|
|||||||
func personIssue(ctx context.Context, args []string) error {
|
func personIssue(ctx context.Context, args []string) error {
|
||||||
set := flag.NewFlagSet("operator issue", flag.ContinueOnError)
|
set := flag.NewFlagSet("operator issue", flag.ContinueOnError)
|
||||||
invokes := set.String("invokes", "", "the tools this person may call, comma-separated, or * for every one")
|
invokes := set.String("invokes", "", "the tools this person may call, comma-separated, or * for every one")
|
||||||
if err := set.Parse(args); err != nil {
|
// Flags on either side of the name, because the usage this command prints puts them after it —
|
||||||
|
// and the standard parser stops at the first thing that is not a flag, so the order the command
|
||||||
|
// documents was the one order it refused (2026-09-28).
|
||||||
|
positionals, err := parseAround(set, args)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if set.NArg() != 1 {
|
if len(positionals) != 1 {
|
||||||
return errors.New("operator issue <name> --invokes <tool,tool|*>")
|
return errors.New("operator issue <name> --invokes <tool,tool|*>")
|
||||||
}
|
}
|
||||||
name := set.Arg(0)
|
name := positionals[0]
|
||||||
if *invokes == "" {
|
if *invokes == "" {
|
||||||
return errors.New(
|
return errors.New(
|
||||||
"say what this person may call: --invokes mesh-catalog.catalog_tools,gitea.repo_create, " +
|
"say what this person may call: --invokes mesh-catalog.catalog_tools,gitea.repo_create, " +
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// entry is a module as the catalogue holds it: built, so its manifest carries no `build` any more.
|
||||||
|
func entry(module string, _ ...string) inventory.Entry {
|
||||||
|
return inventory.Entry{Manifest: catalogue.Manifest{Module: module}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stoodOn is what each module's newest build recorded it was handed.
|
||||||
|
func stoodOn(edges map[string][]string) map[string][]string {
|
||||||
|
out := map[string][]string{}
|
||||||
|
for module, bases := range edges {
|
||||||
|
for _, b := range bases {
|
||||||
|
out[module] = append(out[module], catalogue.ArtifactStoreScheme+b+"/runtime@sha256:"+strings.Repeat("0", 64))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module built before the module it stands on is built against the old one and reports success
|
||||||
|
// (novox/hq 04-ISSUES/131). So bases come first, however the set arrived.
|
||||||
|
func TestBasesAreBuiltBeforeWhatStandsOnThem(t *testing.T) {
|
||||||
|
in := []inventory.Entry{entry("app"), entry("runtime"), entry("other"), entry("base")}
|
||||||
|
edges := stoodOn(map[string][]string{"app": {"runtime"}, "runtime": {"base"}})
|
||||||
|
got := orderByBases(in, edges)
|
||||||
|
pos := map[string]int{}
|
||||||
|
for i, e := range got {
|
||||||
|
pos[e.Manifest.Module] = i
|
||||||
|
}
|
||||||
|
if !(pos["base"] < pos["runtime"] && pos["runtime"] < pos["app"]) {
|
||||||
|
t.Fatalf("bases not first: %v", pos)
|
||||||
|
}
|
||||||
|
if len(got) != 4 {
|
||||||
|
t.Fatalf("an entry was lost or doubled: %d", len(got))
|
||||||
|
}
|
||||||
|
// A base outside the set is not waited for: it is not being rebuilt.
|
||||||
|
got = orderByBases([]inventory.Entry{entry("app")}, stoodOn(map[string][]string{"app": {"elsewhere"}}))
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("a dependency outside the set changed the set: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module registered from its manifest and never built still names its bases there; once built,
|
||||||
|
// the recorded edge is what says so. Both are read, and a module never stands on itself.
|
||||||
|
func TestWhatStandsOnAModuleIsReadFromItsBuildOrItsManifest(t *testing.T) {
|
||||||
|
built := entry("gitea")
|
||||||
|
edges := stoodOn(map[string][]string{"gitea": {"mesh-tools"}})
|
||||||
|
if !standsOnModule(built, "mesh-tools", edges) {
|
||||||
|
t.Fatal("a recorded edge was not read")
|
||||||
|
}
|
||||||
|
if standsOnModule(built, "gitea", edges) || standsOnModule(built, "postgres", edges) {
|
||||||
|
t.Fatal("an edge was invented")
|
||||||
|
}
|
||||||
|
fresh := inventory.Entry{Manifest: catalogue.Manifest{Module: "plex", Build: &catalogue.Build{
|
||||||
|
On: []catalogue.BuildsOn{{Arg: "RUNTIME_BASE", Module: "mesh-tools", Artifact: "runtime"}},
|
||||||
|
}}}
|
||||||
|
if !standsOnModule(fresh, "mesh-tools", nil) {
|
||||||
|
t.Fatal("a manifest's own base was not read")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A merge names a repository the way the forge does; a source is recorded the way a build was
|
||||||
|
// asked for. The two meet on owner/repo and branch, whichever form the record took.
|
||||||
|
func TestAMergeMatchesTheSourcesBuiltFromIt(t *testing.T) {
|
||||||
|
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main",
|
||||||
|
CloneURL: "http://forge.internal:20000/novox/mesh-controller.git"}
|
||||||
|
for _, s := range []inventory.Source{
|
||||||
|
{Repository: "http://forge.internal:20000/novox/mesh-controller.git", Ref: "main"},
|
||||||
|
{Repository: "novox/mesh-controller", Seat: "git", Ref: ""},
|
||||||
|
{Repository: "https://elsewhere.example/novox/mesh-controller", Ref: "main"},
|
||||||
|
} {
|
||||||
|
if !sourceIs(s, m) {
|
||||||
|
t.Errorf("%+v was not matched by the merge", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range []inventory.Source{
|
||||||
|
{Repository: "novox/mesh-host", Seat: "git"},
|
||||||
|
{Repository: "http://forge.internal:20000/novox/mesh-controller.git", Ref: "release"},
|
||||||
|
} {
|
||||||
|
if sourceIs(s, m) {
|
||||||
|
t.Errorf("%+v was matched by a merge that is not its", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A merge made before the source was last seen is history: it does not move the source, and a
|
||||||
|
// merge that says nothing about when it was made is taken as news.
|
||||||
|
func TestAMergeOlderThanTheLastLookIsHistory(t *testing.T) {
|
||||||
|
seen := time.Date(2026, 9, 28, 3, 0, 0, 0, time.UTC)
|
||||||
|
if !isHistory("2026-09-28T02:00:00Z", seen) {
|
||||||
|
t.Fatal("an older merge was taken as news")
|
||||||
|
}
|
||||||
|
if isHistory("2026-09-28T04:00:00Z", seen) {
|
||||||
|
t.Fatal("a newer merge was taken as history")
|
||||||
|
}
|
||||||
|
if isHistory("", seen) || isHistory("2026-09-28T02:00:00Z", time.Time{}) {
|
||||||
|
t.Fatal("a merge or a source with no time on it was refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module as the catalogue holds it: built from a repository, at a directory inside it.
|
||||||
|
func fromRepo(module, repository, path string) inventory.Entry {
|
||||||
|
return inventory.Entry{
|
||||||
|
Manifest: catalogue.Manifest{Module: module},
|
||||||
|
Source: inventory.Source{Repository: repository, Path: path, Ref: "main"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A merge rebuilds the modules whose own directories it changed, and everything when what it changed
|
||||||
|
// is shared. One repository holding many modules is the ordinary case here, and rebuilding all of
|
||||||
|
// them for a change to one is what exhausted a registry's pull limit the first night this ran.
|
||||||
|
func TestAMergeRebuildsTheModulesItChanged(t *testing.T) {
|
||||||
|
const repo = "http://forge.internal:20000/novox/mesh-catalog.git"
|
||||||
|
gitea := fromRepo("gitea", repo, "modules/gitea")
|
||||||
|
keycloak := fromRepo("keycloak", repo, "modules/keycloak")
|
||||||
|
known := []inventory.Entry{gitea, keycloak, fromRepo("plex", repo, "modules/plex")}
|
||||||
|
candidates := []inventory.Entry{gitea, keycloak}
|
||||||
|
merge := func(paths []string, truncated bool) link.SourceMoved {
|
||||||
|
return link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main",
|
||||||
|
Paths: paths, PathsTruncated: truncated}
|
||||||
|
}
|
||||||
|
named := func(entries []inventory.Entry) string {
|
||||||
|
var names []string
|
||||||
|
for _, e := range entries {
|
||||||
|
names = append(names, e.Manifest.Module)
|
||||||
|
}
|
||||||
|
return strings.Join(names, ",")
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
what string
|
||||||
|
m link.SourceMoved
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"one module's own files", merge([]string{"modules/gitea/index.ts", "modules/gitea/client.ts"}, false), "gitea"},
|
||||||
|
{"two modules' files", merge([]string{"modules/gitea/index.ts", "modules/keycloak/module.json"}, false), "gitea,keycloak"},
|
||||||
|
{"a file they share", merge([]string{"tsconfig.json"}, false), "gitea,keycloak"},
|
||||||
|
{"a module the mesh does not hold", merge([]string{"modules/plex/index.ts"}, false), ""},
|
||||||
|
{"nothing said about the files", merge(nil, false), "gitea,keycloak"},
|
||||||
|
{"more files than were listed", merge([]string{"modules/gitea/index.ts"}, true), "gitea,keycloak"},
|
||||||
|
} {
|
||||||
|
if got := named(whatTheMergeTouched(candidates, known, c.m)); got != c.want {
|
||||||
|
t.Errorf("%s: rebuilt %q, wanted %q", c.what, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module whose recipe packages source from another repository is affected when that repository
|
||||||
|
// moves — the manifest the mesh keeps says nothing about it, so the record of what the build read is
|
||||||
|
// the only thing that can say so.
|
||||||
|
func TestAModuleIsAffectedByTheRepositoryItPackages(t *testing.T) {
|
||||||
|
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main",
|
||||||
|
CloneURL: "http://forge.internal:20000/novox/mesh-controller.git"}
|
||||||
|
for _, read := range [][]inventory.ReadRepository{
|
||||||
|
{{Repository: "http://forge.internal:20000/novox/mesh-controller.git", Ref: "main"}},
|
||||||
|
{{Repository: "novox/mesh-controller"}},
|
||||||
|
{{Repository: "https://elsewhere.example/novox/other"}, {Repository: "novox/mesh-controller.git", Ref: "main"}},
|
||||||
|
} {
|
||||||
|
if !readsFrom(read, m) {
|
||||||
|
t.Errorf("%+v was not matched by the merge", read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, read := range [][]inventory.ReadRepository{
|
||||||
|
nil,
|
||||||
|
{{Repository: "novox/mesh-host", Ref: "main"}},
|
||||||
|
{{Repository: "novox/mesh-controller", Ref: "release"}},
|
||||||
|
} {
|
||||||
|
if readsFrom(read, m) {
|
||||||
|
t.Errorf("%+v was matched by a merge that is not its", read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a module handed over by hand records about where it came from, and what is refused.
|
||||||
|
func TestWhatAHandedOverModuleRecordsAboutItsSource(t *testing.T) {
|
||||||
|
// The whole location: a repository on the mesh's own forge, the directory inside it, the branch
|
||||||
|
// and the commit the manifest was read at.
|
||||||
|
from, err := whereItComesFrom("novox/mesh-catalog", "main", "c0ffee", "modules/gitea", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if from.Path != "modules/gitea" || from.Seat != "git" || from.Repository != "novox/mesh-catalog" {
|
||||||
|
t.Fatalf("the source records as %+v", from)
|
||||||
|
}
|
||||||
|
// A manifest with no provenance at all is legitimate: fixing something in a hurry.
|
||||||
|
if from, err := whereItComesFrom("", "", "", "", false); err != nil || from != (inventory.Source{}) {
|
||||||
|
t.Fatalf("a manifest handed over with no provenance was refused: %+v, %v", from, err)
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
what string
|
||||||
|
repository, ref, commit, path string
|
||||||
|
self bool
|
||||||
|
}{
|
||||||
|
{what: "a source with no commit", repository: "novox/mesh-catalog", commit: ""},
|
||||||
|
{what: "a commit with no source", commit: "c0ffee"},
|
||||||
|
{what: "a directory inside nothing", path: "modules/gitea"},
|
||||||
|
{what: "a forge holding nothing", self: true},
|
||||||
|
{what: "an address given as a path on the forge", repository: "http://forge.internal:20000/novox/x.git", commit: "c0ffee", self: true},
|
||||||
|
} {
|
||||||
|
if _, err := whereItComesFrom(c.repository, c.ref, c.commit, c.path, c.self); err == nil {
|
||||||
|
t.Errorf("%s was recorded as a source", c.what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+193
-58
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -25,7 +26,36 @@ import (
|
|||||||
// cheapest next step. That is how novox/hq ADR 0001 records `hal/sdk` reaching 34,636:
|
// cheapest next step. That is how novox/hq ADR 0001 records `hal/sdk` reaching 34,636:
|
||||||
// nothing in it was wrong, and no one edit was the one that should have been a new file.
|
// nothing in it was wrong, and no one edit was the one that should have been a new file.
|
||||||
|
|
||||||
|
// notResolvable marks the one failure in planFor that is a statement about the node: its assigned
|
||||||
|
// modules do not compose. Every other failure means the mesh could not be *asked* — the store was
|
||||||
|
// unreachable, a key could not be read — and says nothing about the node at all.
|
||||||
|
//
|
||||||
|
// The distinction exists because three callers gather something across every machine and must carry
|
||||||
|
// on when one machine's set is broken. Each of them read a plain error as "their set does not
|
||||||
|
// resolve", and so read a store that was briefly unreachable as a machine that runs nothing. On the
|
||||||
|
// roster of routed names that is not a degraded answer but a false one: it states, to every machine
|
||||||
|
// at once, that another machine's names do not exist. A control node spent hours replacing every
|
||||||
|
// container it ran, on a six-minute cycle, because each pass restarted the store this is read from,
|
||||||
|
// the read failed, one name left the roster, and the roster is part of every container's identity
|
||||||
|
// (novox/hq 04-ISSUES/152, and 04-ISSUES/151 for why a changed roster is a changed container).
|
||||||
|
//
|
||||||
|
// So: skip a node that cannot resolve, and never a node that could not be read.
|
||||||
|
type notResolvable struct{ err error }
|
||||||
|
|
||||||
|
func (n notResolvable) Error() string { return n.err.Error() }
|
||||||
|
func (n notResolvable) Unwrap() error { return n.err }
|
||||||
|
|
||||||
|
// unresolvable reports whether err is a node's own set failing to compose, rather than the mesh
|
||||||
|
// being unable to answer.
|
||||||
|
func unresolvable(err error) bool {
|
||||||
|
var n notResolvable
|
||||||
|
return errors.As(err, &n)
|
||||||
|
}
|
||||||
|
|
||||||
// planFor works out everything a node should run, from what was assigned to it.
|
// planFor works out everything a node should run, from what was assigned to it.
|
||||||
|
//
|
||||||
|
// A failure to compose the node's own modules is wrapped as notResolvable; every other failure is
|
||||||
|
// returned as it is. Callers gathering across the mesh must tell them apart — see notResolvable.
|
||||||
func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Resolution, catalogue.SettingsBy, error) {
|
func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Resolution, catalogue.SettingsBy, error) {
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
shelf, err := inv.Catalogue(ctx)
|
shelf, err := inv.Catalogue(ctx)
|
||||||
@@ -95,7 +125,9 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
|||||||
At: onNetwork[nodeName], PublicDomain: publicDomain,
|
At: onNetwork[nodeName], PublicDomain: publicDomain,
|
||||||
Account: who.Account, AccountHome: who.AccountHome}, world)
|
Account: who.Account, AccountHome: who.AccountHome}, world)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Resolution{}, nil, err
|
// The node's own set does not compose. Marked, because this is the only failure here that
|
||||||
|
// a mesh-wide gatherer may pass over — see notResolvable.
|
||||||
|
return catalogue.Resolution{}, nil, notResolvable{err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The credential for each thing this node takes from elsewhere. Made once and kept, so the
|
// The credential for each thing this node takes from elsewhere. Made once and kept, so the
|
||||||
@@ -132,7 +164,14 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
secret, err := inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From, n.Local)
|
var secret inventory.Secret
|
||||||
|
var err error
|
||||||
|
if n.SharedOwn != "" {
|
||||||
|
// The provider's one credential, sealed to this consumer too (novox/hq ADR 0158).
|
||||||
|
secret, err = inv.SharedSecretFor(ctx, n.Name, nodeName, n.For, n.From, providerModuleOf(resolved, open, ctx, n), n.Local, n.SharedOwn)
|
||||||
|
} else {
|
||||||
|
secret, err = inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From, n.Local)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Said rather than skipped. A machine that resolves cleanly and receives no
|
// Said rather than skipped. A machine that resolves cleanly and receives no
|
||||||
// credential is one that will fail to authenticate at some later, less obvious
|
// credential is one that will fail to authenticate at some later, less obvious
|
||||||
@@ -147,8 +186,12 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
|||||||
// Settings for everything that resolved, including modules nobody assigned directly: a
|
// Settings for everything that resolved, including modules nobody assigned directly: a
|
||||||
// requirement pulled in by something else is still configurable, and finding out that it is
|
// requirement pulled in by something else is still configurable, and finding out that it is
|
||||||
// not only when you try would be an arbitrary line nobody could predict.
|
// not only when you try would be an arbitrary line nobody could predict.
|
||||||
|
//
|
||||||
|
// A setting that reaches nothing, or cannot compose with the definition it was stored for,
|
||||||
|
// no longer refuses the machine here: it is judged where it is stored, and a definition that
|
||||||
|
// moved under it costs that module its place in the declaration, said by name (novox/hq ADR
|
||||||
|
// 0163, rule 6 — see Compose).
|
||||||
settings := catalogue.SettingsBy{}
|
settings := catalogue.SettingsBy{}
|
||||||
var stray []string
|
|
||||||
for _, m := range resolved.Modules {
|
for _, m := range resolved.Modules {
|
||||||
layers, err := inv.SettingsFor(ctx, nodeName, m.Module)
|
layers, err := inv.SettingsFor(ctx, nodeName, m.Module)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -158,13 +201,6 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
settings[m.Module] = layers
|
settings[m.Module] = layers
|
||||||
stray = append(stray, catalogue.UnusedSettings(m, layers)...)
|
|
||||||
}
|
|
||||||
if len(stray) > 0 {
|
|
||||||
// Somebody set something that reaches no file. Said here rather than discovered by the
|
|
||||||
// machine not behaving differently, which is the slowest way there is.
|
|
||||||
return catalogue.Resolution{}, nil, fmt.Errorf(
|
|
||||||
"these settings reach nothing:\n - %s", strings.Join(stray, "\n - "))
|
|
||||||
}
|
}
|
||||||
return resolved, settings, nil
|
return resolved, settings, nil
|
||||||
}
|
}
|
||||||
@@ -185,6 +221,15 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
|||||||
|
|
||||||
// Every node, not only the placed ones. A machine that was never put on the private network
|
// Every node, not only the placed ones. A machine that was never put on the private network
|
||||||
// still runs modules, still holds claims, and still offers whatever it offers.
|
// still runs modules, still holds claims, and still offers whatever it offers.
|
||||||
|
// **Who holds each seat on record, before anything is resolved** (novox/hq ADR 0131). Both
|
||||||
|
// passes below need it: without it, the assignment standing beside a seat's holder — the next
|
||||||
|
// holder, waiting for the handover — is refused as a second holder, and its node's whole set
|
||||||
|
// with it.
|
||||||
|
holdings, err := inv.Holdings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.World{}, err
|
||||||
|
}
|
||||||
|
|
||||||
nodes, err := inv.Nodes(ctx)
|
nodes, err := inv.Nodes(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.World{}, err
|
return catalogue.World{}, err
|
||||||
@@ -227,10 +272,11 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
|||||||
offered := map[string][]catalogue.Provider{}
|
offered := map[string][]catalogue.Provider{}
|
||||||
var firstHeld []catalogue.Held
|
var firstHeld []catalogue.Held
|
||||||
for _, o := range others {
|
for _, o := range others {
|
||||||
got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true})
|
got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true, Holdings: holdings})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Their set does not resolve for some other reason. Not this node's problem to
|
// Said, not skipped: a machine dropped here offers nothing and holds nothing as far
|
||||||
// report, and nothing of theirs is running, so it offers nothing.
|
// as every other machine's plan can tell (novox/hq issue 188).
|
||||||
|
fmt.Fprintf(os.Stderr, "%s is left out of the rest of the mesh: it does not resolve: %v\n", o.node.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
firstHeld = append(firstHeld, got.Claims...)
|
firstHeld = append(firstHeld, got.Claims...)
|
||||||
@@ -258,11 +304,25 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
|||||||
// with several providers (novox/hq ADR 0110), so a node consuming one resolves only once the
|
// with several providers (novox/hq ADR 0110), so a node consuming one resolves only once the
|
||||||
// holder is known. Without them its set is refused here, and a refused node's own claims drop
|
// holder is known. Without them its set is refused here, and a refused node's own claims drop
|
||||||
// out of what the mesh holds — so a second holder of one of its seats would pass unrefused.
|
// out of what the mesh holds — so a second holder of one of its seats would pass unrefused.
|
||||||
world := catalogue.World{Offered: offered, Held: firstHeld}
|
world := catalogue.World{Offered: offered, Held: firstHeld, Holdings: holdings}
|
||||||
var held []catalogue.Held
|
var held []catalogue.Held
|
||||||
for _, o := range others {
|
for _, o := range others {
|
||||||
got, err := catalogue.Resolve(shelf, o.assigned, o.node, world)
|
// Each machine is resolved with its own pins, as its plan is: a machine that needs one to
|
||||||
|
// settle two providers would otherwise be refused here and vanish from the mesh — every
|
||||||
|
// seat it holds unheld, every build that needs one refused (2026-10-01, the control node;
|
||||||
|
// novox/hq issue 188).
|
||||||
|
theirs := world
|
||||||
|
if pins, err := inv.PinsFor(ctx, o.node.Name); err == nil {
|
||||||
|
theirs.Pinned = pins
|
||||||
|
}
|
||||||
|
got, err := catalogue.Resolve(shelf, o.assigned, o.node, theirs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Said only for the whole-mesh view. With one machine excluded, the others are
|
||||||
|
// resolved without its offers, and one that consumes them cannot resolve here by
|
||||||
|
// design — that is not the machine being dropped, it is the view being partial.
|
||||||
|
if exclude == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s is left out of the rest of the mesh: it does not resolve: %v\n", o.node.Name, err)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
held = append(held, got.Claims...)
|
held = append(held, got.Claims...)
|
||||||
@@ -336,7 +396,32 @@ func declarationWith(ctx context.Context, open *stores, node string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return sendable{}, err
|
return sendable{}, err
|
||||||
}
|
}
|
||||||
return sendable{Resources: composed.Resources, Adoption: adoption}, nil
|
return sendable{Resources: composed.Resources, Adoption: adoption,
|
||||||
|
LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortedKeysOf is a map's keys, sorted — so what a declaration says it left out does not move
|
||||||
|
// for a reordering nobody made.
|
||||||
|
func sortedKeysOf(m map[string]string) []string {
|
||||||
|
if len(m) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportLeftOut says which of a machine's modules its declaration leaves out and why (novox/hq ADR
|
||||||
|
// 0163, rule 6), one line each: the machine is told everything else, and is told it was left out.
|
||||||
|
func reportLeftOut(node string, declared sendable) {
|
||||||
|
for _, m := range declared.LeftOut {
|
||||||
|
fmt.Printf("%s: %s left out — a setting stored for it cannot compose with its definition; "+
|
||||||
|
"what the machine holds for it is kept and its containers are untouched. %s\n",
|
||||||
|
node, m, declared.leftOutWhy[m])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderingFor is everything a node's declaration is composed with, and the node's record.
|
// renderingFor is everything a node's declaration is composed with, and the node's record.
|
||||||
@@ -376,7 +461,10 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
for _, m := range plan.Modules {
|
for _, m := range plan.Modules {
|
||||||
g, err := catalogue.GivenPorts(m, settings[m.Module])
|
g, err := catalogue.GivenPorts(m, settings[m.Module])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Rendering{}, inventory.Node{}, err
|
// A given port its definition no longer publishes: the module is left out of the
|
||||||
|
// declaration, by name, when it is composed (novox/hq ADR 0163, rule 6) — never the
|
||||||
|
// machine refused here for it.
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if g != nil {
|
if g != nil {
|
||||||
given[m.Module] = g
|
given[m.Module] = g
|
||||||
@@ -627,12 +715,23 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Rendering{}, inventory.Node{}, err
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
}
|
}
|
||||||
|
memberships, err := inv.BusMemberships(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
|
}
|
||||||
|
// Which of this machine's links face outside, which is what the derived filter is written
|
||||||
|
// around (novox/hq ADR 0140). Reported by the machine, never set.
|
||||||
|
outwardLinks, err := inv.OutwardLinksOf(ctx, node)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
|
}
|
||||||
return catalogue.Rendering{
|
return catalogue.Rendering{
|
||||||
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports,
|
BusMembership: memberships[node],
|
||||||
|
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports,
|
||||||
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
||||||
Machines: machines,
|
Machines: machines,
|
||||||
Suffix: overlay.Suffix(), MeshRange: meshRange, Accounts: accounts, Foundation: foundation,
|
Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation,
|
||||||
Kept: kept, Adopted: record.Adopted,
|
Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks,
|
||||||
Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built,
|
Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built,
|
||||||
BusUsers: busUsers,
|
BusUsers: busUsers,
|
||||||
}, record, nil
|
}, record, nil
|
||||||
@@ -651,11 +750,17 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
// routed name only because it carried a label the mesh composed, never because the mesh knows what
|
// 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
|
// "route" means. A node that does not resolve is skipped, so one machine's broken set does not cost
|
||||||
// the rest their names.
|
// 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) {
|
func routeNamesInTheMesh(ctx context.Context, open *stores) (map[string]string, error) {
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
places, err := inv.Overlays(ctx)
|
places, err := inv.Overlays(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("where the machines are cannot be read: %w", err)
|
||||||
}
|
}
|
||||||
address := map[string]string{}
|
address := map[string]string{}
|
||||||
for _, p := range places {
|
for _, p := range places {
|
||||||
@@ -666,47 +771,37 @@ func routeNamesInTheMesh(ctx context.Context, open *stores) (map[string]string,
|
|||||||
|
|
||||||
nodes, err := inv.Nodes(ctx)
|
nodes, err := inv.Nodes(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("which machines the mesh has cannot be read: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out := map[string]string{}
|
// 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 {
|
for _, n := range nodes {
|
||||||
plan, settings, err := planFor(ctx, open, n.Name)
|
plan, layers, err := planFor(ctx, open, n.Name)
|
||||||
if err != nil {
|
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
|
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)
|
||||||
}
|
}
|
||||||
for _, m := range plan.Modules {
|
plans[n.Name], settings[n.Name] = plan, layers
|
||||||
for to := range m.Contributes {
|
}
|
||||||
values, asks, err := plan.ContributionsFrom(to, m.Module, settings)
|
served, err := catalogue.NamesServed(plans, settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !asks {
|
out := map[string]string{}
|
||||||
continue
|
for name, node := range served {
|
||||||
}
|
if at := address[node]; at != "" {
|
||||||
// A routed name, and only that: a contribution the mesh composed a name for from a
|
out[name] = at
|
||||||
// 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 := values["label"]; !labelled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name, _ := values["name"].(string)
|
|
||||||
if name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// The node that serves it: whoever answers this consumer's route requirement, or
|
|
||||||
// this same node when the proxy is beside the consumer.
|
|
||||||
serving := n.Name
|
|
||||||
for _, need := range plan.Needs {
|
|
||||||
if need.Name == to && need.For == m.Module {
|
|
||||||
serving = need.From
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if at := address[serving]; at != "" {
|
|
||||||
out[strings.ToLower(name)] = at
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
@@ -803,11 +898,17 @@ func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Gran
|
|||||||
out := make([]catalogue.Grant, 0, len(issued))
|
out := make([]catalogue.Grant, 0, len(issued))
|
||||||
for _, s := range issued {
|
for _, s := range issued {
|
||||||
plan, settings, err := planFor(ctx, open, s.Consumer)
|
plan, settings, err := planFor(ctx, open, s.Consumer)
|
||||||
if err != nil {
|
switch {
|
||||||
|
case unresolvable(err):
|
||||||
// Their set does not resolve. Skipped rather than fatal: this node is not the place
|
// Their set does not resolve. Skipped rather than fatal: this node is not the place
|
||||||
// to report another machine's problem, and a grant for something that is not going to
|
// to report another machine's problem, and a grant for something that is not going to
|
||||||
// run would have the provider create a user nothing uses.
|
// run would have the provider create a user nothing uses.
|
||||||
continue
|
continue
|
||||||
|
case err != nil:
|
||||||
|
// The mesh could not be asked what they wanted, which is not the same as their wanting
|
||||||
|
// nothing — and withholding a grant on that reading takes a consumer's access away
|
||||||
|
// (novox/hq 04-ISSUES/152).
|
||||||
|
return nil, fmt.Errorf("what %s asked of %s cannot be read: %w", s.Consumer, s.Name, err)
|
||||||
}
|
}
|
||||||
values, asks, err := plan.ContributionsFrom(s.Name, s.ConsumerModule, settings)
|
values, asks, err := plan.ContributionsFrom(s.Name, s.ConsumerModule, settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -918,6 +1019,20 @@ func planCommand(ctx context.Context, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which modules a push would leave out, and why — said before the plan, since the plan is of
|
||||||
|
// what the machine would be told (novox/hq ADR 0163, rule 6). Judged, never composed: `plan`
|
||||||
|
// without --json allocates nothing.
|
||||||
|
if record, err := open.inventory.NodeByName(ctx, args[0]); err == nil {
|
||||||
|
left := plan.LeftOut(settings, record.Adopted)
|
||||||
|
reportLeftOut(args[0], sendable{LeftOut: sortedKeysOf(left), leftOutWhy: left})
|
||||||
|
}
|
||||||
|
// And a setting that reaches nothing — refused where it is stored, and said here for one
|
||||||
|
// stored before its definition moved from under it.
|
||||||
|
for _, m := range plan.Modules {
|
||||||
|
for _, stray := range catalogue.UnusedSettings(m, settings[m.Module]) {
|
||||||
|
fmt.Printf("%s: a setting reaches nothing — %s\n", args[0], stray)
|
||||||
|
}
|
||||||
|
}
|
||||||
fmt.Printf("%s would run:\n", args[0])
|
fmt.Printf("%s would run:\n", args[0])
|
||||||
for _, m := range plan.Modules {
|
for _, m := range plan.Modules {
|
||||||
fmt.Printf(" %-20s %s\n", m.Module, plan.Because[m.Module])
|
fmt.Printf(" %-20s %s\n", m.Module, plan.Because[m.Module])
|
||||||
@@ -1280,3 +1395,23 @@ func foundationPortsFor(brokerPort int, modules []catalogue.Manifest) []int {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// providerModuleOf is which module answers a need on the providing node: the one in this node's
|
||||||
|
// own set when the provider is here, else the one the catalogue says offers it.
|
||||||
|
func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.Context, n catalogue.Needed) string {
|
||||||
|
for _, m := range resolved.Modules {
|
||||||
|
if _, shared := m.SharedCredentialOf(n.Name); shared {
|
||||||
|
return m.Module
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shelf, err := open.inventory.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for name, m := range shelf {
|
||||||
|
if _, shared := m.SharedCredentialOf(n.Name); shared {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
+231
-34
@@ -4,9 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -38,6 +40,27 @@ func reportUnhostable(node string, plan catalogue.Resolution) {
|
|||||||
// nothing in it was wrong, and no one edit was the one that should have been a new file.
|
// nothing in it was wrong, and no one edit was the one that should have been a new file.
|
||||||
|
|
||||||
// serve is the control plane running: one connection to the broker, one queue, one consumer.
|
// serve is the control plane running: one connection to the broker, one queue, one consumer.
|
||||||
|
// connectLink opens the controller's link over whichever bus this process is on (design 25: one
|
||||||
|
// variable moves it). The streams and this controller's consumers are raised first on the new bus,
|
||||||
|
// so nothing served here finds them missing.
|
||||||
|
func connectLink(ctx context.Context, inv *inventory.Inventory, enroller link.Enroller, listener link.Listener) (*link.Server, error) {
|
||||||
|
busAddress, err := broker.BusAddress()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if inv != nil {
|
||||||
|
if err := raiseTheBus(ctx, inv, busAddress); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
js, err := broker.Dial(busAddress)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the mesh is on the bus at %s and this control plane cannot reach it: %w",
|
||||||
|
broker.BareAddress(busAddress), err)
|
||||||
|
}
|
||||||
|
return link.ConnectNats(js, enroller, listener), nil
|
||||||
|
}
|
||||||
|
|
||||||
func serve(ctx context.Context) error {
|
func serve(ctx context.Context) error {
|
||||||
open, err := openStores(ctx)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -61,11 +84,6 @@ func serve(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
fmt.Printf("signing as %s\n", key.Fingerprint()[:16])
|
fmt.Printf("signing as %s\n", key.Fingerprint()[:16])
|
||||||
|
|
||||||
management, err := broker.ManagementFromEnvironment()
|
|
||||||
if err != nil && !errors.Is(err, broker.ErrNotConfigured) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Where the broker is and what to expect there, so a node can be told how to come back
|
// Where the broker is and what to expect there, so a node can be told how to come back
|
||||||
// without a person and a new token.
|
// without a person and a new token.
|
||||||
known, err := broker.FromEnvironment()
|
known, err := broker.FromEnvironment()
|
||||||
@@ -80,17 +98,10 @@ func serve(ctx context.Context) error {
|
|||||||
// **Which bus this mesh is on, read once** (novox/hq ADR 0116 step 5). Both clients ship; both
|
// **Which bus this mesh is on, read once** (novox/hq ADR 0116 step 5). Both clients ship; both
|
||||||
// being live is refused, because a mesh half on each is one where a declaration goes out on one
|
// being live is refused, because a mesh half on each is one where a declaration goes out on one
|
||||||
// and the report comes back on the other, and every component logs success while it happens.
|
// and the report comes back on the other, and every component logs success while it happens.
|
||||||
busAddress, onNATS, err := broker.OnNATS()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := broker.MustBeOneBus(os.Getenv(broker.AMQPVarName), busAddress); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
work := link.Enrolment{Inventory: inv, Identity: ident, Management: management, Broker: known,
|
work := link.Enrolment{Inventory: inv, Identity: ident, Broker: known,
|
||||||
OnNATS: onNATS}
|
OnNATS: true}
|
||||||
server, err := link.Connect(work, work)
|
server, err := connectLink(ctx, inv, work, work)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -100,14 +111,12 @@ func serve(ctx context.Context) error {
|
|||||||
// somebody deleted, a mesh raised from a restored backup, or a bus whose data directory was
|
// somebody deleted, a mesh raised from a restored backup, or a bus whose data directory was
|
||||||
// replaced all have records and no objects — and a node whose consumer is missing hears nothing
|
// replaced all have records and no objects — and a node whose consumer is missing hears nothing
|
||||||
// while everything else about it looks correct.
|
// while everything else about it looks correct.
|
||||||
if onNATS {
|
|
||||||
if err := raiseTheBus(ctx, inv, busAddress); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// And build results nobody was waiting for. A build triggered any other way than `build`
|
// And build results nobody was waiting for. A build triggered any other way than `build`
|
||||||
// would otherwise be reported into the void, which is the same as not reporting it.
|
// would otherwise be reported into the void, which is the same as not reporting it.
|
||||||
server.Records(builds{inv})
|
server.Records(builds{inv, open})
|
||||||
|
// Open plans move on a timer as well as on outcomes (novox/hq ADR 0162): a tier waiting for
|
||||||
|
// machines to report moves when they have, and a plan left by a replaced controller resumes.
|
||||||
|
go planTicker(ctx, open)
|
||||||
// And what the catalogue decided a build meant. The builder's own result is already handled
|
// And what the catalogue decided a build meant. The builder's own result is already handled
|
||||||
// above; this is the other half — the control plane is the only one of the three that knows
|
// above; this is the other half — the control plane is the only one of the three that knows
|
||||||
// which machines run the thing, so it is the one that acts (novox/hq ADR 0072).
|
// which machines run the thing, so it is the one that acts (novox/hq ADR 0072).
|
||||||
@@ -120,6 +129,22 @@ func serve(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// And the mesh's own verbs, as the seat this control plane holds (novox/hq ADR 0154). Served
|
||||||
|
// from the store's row, so what the seat declares is what is answered.
|
||||||
|
handlers, err := seatToolHandlers()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bus, isNATS := server.Bus().(link.OverNATS)
|
||||||
|
if !isNATS {
|
||||||
|
return errors.New("the mesh's verbs are served over the bus, and this control plane is not on it")
|
||||||
|
}
|
||||||
|
stopServing, err := bus.ServeSeatTools(catalogue.ControllerSeatName, handlers, log.New(os.Stdout, "", log.LstdFlags))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stopServing()
|
||||||
|
|
||||||
return server.Serve(ctx)
|
return server.Serve(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,13 +183,19 @@ func declare(ctx context.Context, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
server, err := link.Connect(nil, nil)
|
// **With the inventory, so the bus is raised** (novox/hq ADR 0134, design 30). A module's
|
||||||
|
// declaration and how it hears what it consumes move together: its consumer is derived from the
|
||||||
|
// same records this declaration is composed from. Raised only when the control plane started
|
||||||
|
// serving, a module that gained a `consumes` was sent a declaration it could act on and a
|
||||||
|
// consumer that never delivered the event — and nothing anywhere said the two disagreed
|
||||||
|
// (found on review, 2026-09-28). Everything the raise does is idempotent.
|
||||||
|
server, err := connectLink(ctx, inv, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
if err := link.Declare(ctx, link.OverCurrent{Channel: server.Channel()}, ident, node, raw, 15*time.Second); err != nil {
|
if err := link.Declare(ctx, server.Bus(), ident, node, raw, 15*time.Second); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("sent %s a signed declaration (%d bytes)\n", node, len(raw))
|
fmt.Printf("sent %s a signed declaration (%d bytes)\n", node, len(raw))
|
||||||
@@ -271,7 +302,7 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
server, err := link.Connect(nil, nil)
|
server, err := connectLink(ctx, nil, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -322,17 +353,27 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
// The private network is in here with everything else. It used to be composed separately
|
// The private network is in here with everything else. It used to be composed separately
|
||||||
// and prepended, which meant every machine with an address was on it and no machine could
|
// and prepended, which meant every machine with an address was on it and no machine could
|
||||||
// be kept off. It is a module now, so it arrives the way a module does.
|
// be kept off. It is a module now, so it arrives the way a module does.
|
||||||
return declarationWith(held, open, node, plan, settings, gens, Allocating)
|
declared, err := declarationWith(held, open, node, plan, settings, gens, Allocating)
|
||||||
|
if err == nil {
|
||||||
|
reportLeftOut(node, declared)
|
||||||
|
}
|
||||||
|
return declared, err
|
||||||
})
|
})
|
||||||
|
|
||||||
sentDigest := map[string]string{}
|
sentDigest := map[string]string{}
|
||||||
defer release()
|
defer release()
|
||||||
for _, s := range sending {
|
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
|
||||||
|
}
|
||||||
body, err := s.declared.Body()
|
body, err := s.declared.Body()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := link.Declare(ctx, link.OverCurrent{Channel: server.Channel()}, ident, s.node, body, 15*time.Second); err != nil {
|
if err := link.Declare(ctx, server.Bus(), ident, s.node, body, 15*time.Second); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// After it is away, not before. A digest recorded for something that failed to send would
|
// After it is away, not before. A digest recorded for something that failed to send would
|
||||||
@@ -350,6 +391,15 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
release()
|
release()
|
||||||
fmt.Printf("\n%d node(s) told\n", len(sending))
|
fmt.Printf("\n%d node(s) told\n", len(sending))
|
||||||
|
// And each machine's memberships, as every other send does (ADR 0160): a push is the one most
|
||||||
|
// operators run, and on 2026-10-01 it was the one path that issued none.
|
||||||
|
var told []string
|
||||||
|
for _, s := range sending {
|
||||||
|
told = append(told, s.node)
|
||||||
|
}
|
||||||
|
if err := issueMemberships(ctx, open, server, told); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// **A named push leaves the mesh consistent, not just the machine it named** (novox/hq
|
// **A named push leaves the mesh consistent, not just the machine it named** (novox/hq
|
||||||
// issue 057, ADR 0083). Assigning a cross-node consumer mints a provision, and the PROVIDER's
|
// issue 057, ADR 0083). Assigning a cross-node consumer mints a provision, and the PROVIDER's
|
||||||
@@ -412,10 +462,14 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
return sendable{}, err
|
return sendable{}, err
|
||||||
}
|
}
|
||||||
reportUnhostable(node, plan)
|
reportUnhostable(node, plan)
|
||||||
return declarationWith(held, open, node, plan, settings, gens, Allocating)
|
declared, err := declarationWith(held, open, node, plan, settings, gens, Allocating)
|
||||||
|
if err == nil {
|
||||||
|
reportLeftOut(node, declared)
|
||||||
|
}
|
||||||
|
return declared, err
|
||||||
},
|
},
|
||||||
func(s readyNode, body []byte) error {
|
func(s readyNode, body []byte) error {
|
||||||
if err := link.Declare(ctx, link.OverCurrent{Channel: server.Channel()}, ident, s.node, body,
|
if err := link.Declare(ctx, server.Bus(), ident, s.node, body,
|
||||||
15*time.Second); err != nil {
|
15*time.Second); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -554,6 +608,9 @@ func sendRound(ctx context.Context, open *stores, names []string,
|
|||||||
return compose(held, node)
|
return compose(held, node)
|
||||||
})
|
})
|
||||||
for _, s := range sending {
|
for _, s := range sending {
|
||||||
|
if err := number(ctx, open.inventory, &s); err != nil {
|
||||||
|
return refused, err
|
||||||
|
}
|
||||||
body, err := s.declared.Body()
|
body, err := s.declared.Body()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return refused, err
|
return refused, err
|
||||||
@@ -621,6 +678,7 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
|||||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
reportLeftOut(name, declared)
|
||||||
sending = append(sending, readyNode{name, declared})
|
sending = append(sending, readyNode{name, declared})
|
||||||
}
|
}
|
||||||
if len(refusals) > 0 {
|
if len(refusals) > 0 {
|
||||||
@@ -628,18 +686,21 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
|||||||
len(refusals), strings.Join(refusals, "\n\n"))
|
len(refusals), strings.Join(refusals, "\n\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
server, err := link.Connect(nil, nil)
|
server, err := connectLink(ctx, nil, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
for _, s := range sending {
|
for _, s := range sending {
|
||||||
|
if err := number(ctx, inv, &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
body, err := s.declared.Body()
|
body, err := s.declared.Body()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := link.Declare(ctx, link.OverCurrent{Channel: server.Channel()}, ident, s.node, body, 15*time.Second); err != nil {
|
if err := link.Declare(ctx, server.Bus(), ident, s.node, body, 15*time.Second); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
record, err := inv.NodeByName(ctx, s.node)
|
record, err := inv.NodeByName(ctx, s.node)
|
||||||
@@ -651,6 +712,51 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
|||||||
}
|
}
|
||||||
fmt.Printf(" sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
fmt.Printf(" sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
||||||
}
|
}
|
||||||
|
// And every assignment on those machines its membership (novox/hq ADR 0160): composed from the
|
||||||
|
// same records the bus's accounts are, so what a runtime serves and what its account may are one
|
||||||
|
// composition. Issued after the declaration, because the runtime it is for arrives with it.
|
||||||
|
return issueMemberships(ctx, open, server, names)
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueMemberships publishes the membership of every module on the named machines.
|
||||||
|
func issueMemberships(ctx context.Context, open *stores, server *link.Server, names []string) error {
|
||||||
|
records, err := open.inventory.BusRecords(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
where := broker.PlacementsOf(records, records.Interchangeable)
|
||||||
|
bus, ok := server.Bus().(link.OverNATS)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The declarations are sent and recorded by now; a membership that cannot be issued is said
|
||||||
|
// and does not unsay them. Every runtime without one serves the shape it derives (ADR 0160), so
|
||||||
|
// the push stands, the first failure is named once, and the next push tries again.
|
||||||
|
issued, failed := 0, 0
|
||||||
|
var first error
|
||||||
|
for _, node := range names {
|
||||||
|
for _, d := range records.Assigned[node] {
|
||||||
|
body, err := json.Marshal(broker.MembershipFor(node, d, where))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := bus.PublishMembership(ctx, node, d.Module, body); err != nil {
|
||||||
|
if first == nil {
|
||||||
|
first = err
|
||||||
|
}
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
issued++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if issued > 0 {
|
||||||
|
fmt.Printf(" issued %d membership(s)\n", issued)
|
||||||
|
}
|
||||||
|
if failed > 0 {
|
||||||
|
fmt.Printf(" %d membership(s) could not be issued; the first: %v — the machines keep what "+
|
||||||
|
"they derive until the next push\n", failed, first)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,6 +790,12 @@ func wouldSend(ctx context.Context, open *stores,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Composed with the number the machine was LAST sent, so this is byte for byte what it was
|
||||||
|
// sent when nothing else changed. A fresh number here would make every machine read as
|
||||||
|
// behind for ever (novox/hq 04-ISSUES/107).
|
||||||
|
if declared.Sequence, err = open.inventory.Sequence(ctx, n.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
body, err := declared.Body()
|
body, err := declared.Body()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -704,9 +816,15 @@ func raiseTheBus(ctx context.Context, inv *inventory.Inventory, address string)
|
|||||||
js, err := broker.Dial(address)
|
js, err := broker.Dial(address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("the mesh is on the bus at %s and this control plane cannot reach it: %w",
|
return fmt.Errorf("the mesh is on the bus at %s and this control plane cannot reach it: %w",
|
||||||
address, err)
|
broker.BareAddress(address), err)
|
||||||
}
|
}
|
||||||
defer js.Close()
|
defer js.Close()
|
||||||
|
// What the raise decided not to fail over. Said, for the reason everything else here is said:
|
||||||
|
// a consumer kept as it was is a difference between what the mesh asked for and what the bus
|
||||||
|
// holds, and one nobody would find by reading either (novox/hq 04-ISSUES/156).
|
||||||
|
js.Note = func(format string, args ...any) {
|
||||||
|
fmt.Printf(" "+format+"\n", args...)
|
||||||
|
}
|
||||||
|
|
||||||
// **Its own user, before anything else.** The controller's account is created by the installer at
|
// **Its own user, before anything else.** The controller's account is created by the installer at
|
||||||
// a bootstrap password, before there is a controller to mint one — so nothing recorded a hash for
|
// a bootstrap password, before there is a controller to mint one — so nothing recorded a hash for
|
||||||
@@ -739,10 +857,89 @@ func raiseTheBus(ctx context.Context, inv *inventory.Inventory, address string)
|
|||||||
// The work queues of the mesh's own roles (novox/hq ADR 0121). The queue before the holder,
|
// The work queues of the mesh's own roles (novox/hq ADR 0121). The queue before the holder,
|
||||||
// deliberately: work queues until somebody arrives to do it, so assigning a build machine a week
|
// deliberately: work queues until somebody arrives to do it, so assigning a build machine a week
|
||||||
// after something started asking for builds flushes the backlog instead of having lost it.
|
// after something started asking for builds flushes the backlog instead of having lost it.
|
||||||
if err := broker.RaiseSeats(js, inventory.MeshSeats(), nil); err != nil {
|
// With the seats' holders, so each role's work queue gets the consumer its holder takes
|
||||||
|
// work from. Passed as nil until the first live raise, which left the build machine bound to a
|
||||||
|
// consumer nothing had created (2026-09-28).
|
||||||
|
holders, err := seatHolders(ctx, inv)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("the bus at %s has its streams, and %d machine(s) can hear a declaration\n",
|
if err := broker.RaiseSeats(js, inventory.MeshSeats(), holders); err != nil {
|
||||||
address, len(names))
|
return err
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
// by the rollout was issued on the old one, and came up with nothing to bind to (2026-09-28).
|
||||||
|
records, err := inv.BusRecords(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
users, err := broker.Users(records)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hearing := 0
|
||||||
|
for _, p := range users {
|
||||||
|
consumer, needed := broker.ConsumerFor(p)
|
||||||
|
if !needed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := js.EnsureConsumer(consumer); err != nil {
|
||||||
|
return fmt.Errorf("how %s on %s hears what it consumes: %w", p.Module, p.Node, err)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatHolders is who holds each of the mesh's seats, by seat name: the record where a handover
|
||||||
|
// wrote one, and the assigned module claiming the seat otherwise — the same derivation the
|
||||||
|
// resolver makes, read from the catalogue rather than re-resolved.
|
||||||
|
func seatHolders(ctx context.Context, inv *inventory.Inventory) (map[string]broker.Holder, error) {
|
||||||
|
out := map[string]broker.Holder{}
|
||||||
|
entries, err := inv.Catalogued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if len(e.On) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, c := range e.Manifest.Claims {
|
||||||
|
seat, known := catalogue.SeatNamed(c.Name)
|
||||||
|
if !known {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, taken := out[seat.Name]; !taken {
|
||||||
|
out[seat.Name] = broker.Holder{Node: e.On[0], Module: e.Manifest.Module}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recorded, err := inv.Holdings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, h := range recorded {
|
||||||
|
if seat, known := catalogue.SeatNamed(h.Claim); known {
|
||||||
|
out[seat.Name] = broker.Holder{Node: h.Node, Module: h.Module}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
seq, err := inv.NextSequence(ctx, record.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.declared.Sequence = seq
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ type meshStatus struct {
|
|||||||
// whose is older is still working — and Waiting cannot tell those apart, because the sent
|
// whose is older is still working — and Waiting cannot tell those apart, because the sent
|
||||||
// digest is recorded at send, not at apply.
|
// digest is recorded at send, not at apply.
|
||||||
Reported []machineReported `json:"reported"`
|
Reported []machineReported `json:"reported"`
|
||||||
|
// Plans is what the last merges produced and where each stands (novox/hq ADR 0162): the
|
||||||
|
// open ones first, each saying its tier, what it waits for, and whether it has waited too long.
|
||||||
|
Plans []planStatus `json:"plans"`
|
||||||
// Unresolved is every machine that cannot be worked out at all, with what the mesh said when
|
// Unresolved is every machine that cannot be worked out at all, with what the mesh said when
|
||||||
// it tried. **A machine here is in none of the lists above**: nothing was computed for it, so
|
// it tried. **A machine here is in none of the lists above**: nothing was computed for it, so
|
||||||
// there is nothing to compare it against and nothing it can be behind — which is why a
|
// there is nothing to compare it against and nothing it can be behind — which is why a
|
||||||
@@ -56,6 +59,23 @@ type meshStatus struct {
|
|||||||
Machines int `json:"machines"`
|
Machines int `json:"machines"`
|
||||||
// Adopted is every node still adopted (novox/hq ADR 0100); absent when none is.
|
// Adopted is every node still adopted (novox/hq ADR 0100); absent when none is.
|
||||||
Adopted []string `json:"adopted,omitempty"`
|
Adopted []string `json:"adopted,omitempty"`
|
||||||
|
// Untaken is every module assigned to a machine that is holding what it found rather than
|
||||||
|
// running what the module declares, because nothing took it (novox/hq 04-ISSUES/125). Absent
|
||||||
|
// when nothing is held.
|
||||||
|
//
|
||||||
|
// **A document without this said an outage was a well mesh.** Read from what each machine
|
||||||
|
// reported, so it is the machine's account and not the mesh's take-time listing.
|
||||||
|
Untaken []machineUntaken `json:"untaken,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// machineUntaken is one module a machine is holding rather than running, and how many resources of
|
||||||
|
// it are held.
|
||||||
|
type machineUntaken struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
// Held is how many of the module's resources the machine is keeping as it found them. Zero is
|
||||||
|
// impossible here: a module with nothing held is not in this list.
|
||||||
|
Held int `json:"held"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type machineUnresolved struct {
|
type machineUnresolved struct {
|
||||||
@@ -135,7 +155,24 @@ func statusAsJSON(asked answers) ([]byte, error) {
|
|||||||
out := meshStatus{Machines: len(nodes), Wrong: []machineDoing{},
|
out := meshStatus{Machines: len(nodes), Wrong: []machineDoing{},
|
||||||
Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{},
|
Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{},
|
||||||
Reported: []machineReported{}, Unresolved: []machineUnresolved{},
|
Reported: []machineReported{}, Unresolved: []machineUnresolved{},
|
||||||
Network: asked.network, Adopted: adoptedNodes(nodes)}
|
Network: asked.network, Adopted: adoptedNodes(nodes), Plans: planStatuses(asked.plans, time.Now())}
|
||||||
|
// In a stated order, so two readings of an unchanged mesh are the same document.
|
||||||
|
untakenNodes := make([]string, 0, len(asked.untaken))
|
||||||
|
for name := range asked.untaken {
|
||||||
|
untakenNodes = append(untakenNodes, name)
|
||||||
|
}
|
||||||
|
sort.Strings(untakenNodes)
|
||||||
|
for _, name := range untakenNodes {
|
||||||
|
modules := make([]string, 0, len(asked.untaken[name]))
|
||||||
|
for m := range asked.untaken[name] {
|
||||||
|
modules = append(modules, m)
|
||||||
|
}
|
||||||
|
sort.Strings(modules)
|
||||||
|
for _, m := range modules {
|
||||||
|
out.Untaken = append(out.Untaken,
|
||||||
|
machineUntaken{Node: name, Module: m, Held: asked.untaken[name][m]})
|
||||||
|
}
|
||||||
|
}
|
||||||
for name := range asked.refused {
|
for name := range asked.refused {
|
||||||
out.Unresolved = append(out.Unresolved, machineUnresolved{
|
out.Unresolved = append(out.Unresolved, machineUnresolved{
|
||||||
Node: name, Problem: asked.refused[name]})
|
Node: name, Problem: asked.refused[name]})
|
||||||
|
|||||||
@@ -0,0 +1,742 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A merge produces a tiered plan the mesh keeps (novox/hq ADR 0162).
|
||||||
|
//
|
||||||
|
// The handler that hears the merge computes the plan from the catalogue's one dependency relation,
|
||||||
|
// writes it to the store, asks the first tier and returns — the receive loop is never held by a
|
||||||
|
// build. Every outcome taken in advances the plan it belongs to; a ticker advances what outcomes
|
||||||
|
// alone cannot (a tier waiting for machines to report); a controller replaced mid-plan finds the
|
||||||
|
// plan where it left it.
|
||||||
|
|
||||||
|
// planWaitBound is how long a plan may wait on one thing before `status` names it red.
|
||||||
|
const planWaitBound = 30 * time.Minute
|
||||||
|
|
||||||
|
// tiersOf sorts a set of modules into tiers along the ordering edges among them: tier 0 depends
|
||||||
|
// on nothing else in the set, tier 1 only on tier 0, and so on. An edge to a module outside the set says
|
||||||
|
// nothing about the order inside it. A cycle — which the catalogue should never produce — puts
|
||||||
|
// what remains in one last tier rather than losing it, and is said by the caller.
|
||||||
|
func tiersOf(set []string, edges []inventory.Edge) [][]string {
|
||||||
|
in := map[string]bool{}
|
||||||
|
for _, m := range set {
|
||||||
|
in[m] = true
|
||||||
|
}
|
||||||
|
deps := map[string]map[string]bool{}
|
||||||
|
for _, m := range set {
|
||||||
|
deps[m] = map[string]bool{}
|
||||||
|
}
|
||||||
|
for _, e := range edges {
|
||||||
|
// A code dependency — B packages A's source — rebuilds B with A, in the same tier: B's
|
||||||
|
// build needs nothing of A's first. The other kinds order: stands-on and declared after
|
||||||
|
// the base is built, built-by after the build machine is built and running — except for
|
||||||
|
// what the build machine itself stands on. The runtime image is built by the builder and
|
||||||
|
// the builder is built on the runtime image; the image comes first, built by the builder
|
||||||
|
// that is running, which is the only one there could be.
|
||||||
|
if !in[e.From] || !in[e.To] || e.From == e.To || e.Kind == inventory.EdgePackages {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.Kind == inventory.EdgeBuiltBy && isBaseOf(e.From, e.To, edges, in) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deps[e.From][e.To] = true
|
||||||
|
}
|
||||||
|
placed := map[string]bool{}
|
||||||
|
var tiers [][]string
|
||||||
|
for len(placed) < len(set) {
|
||||||
|
var tier []string
|
||||||
|
for _, m := range set {
|
||||||
|
if placed[m] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
free := true
|
||||||
|
for d := range deps[m] {
|
||||||
|
if !placed[d] {
|
||||||
|
free = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if free {
|
||||||
|
tier = append(tier, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tier) == 0 {
|
||||||
|
// A cycle: everything left, together, and the caller says so.
|
||||||
|
for _, m := range set {
|
||||||
|
if !placed[m] {
|
||||||
|
tier = append(tier, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(tier)
|
||||||
|
for _, m := range tier {
|
||||||
|
placed[m] = true
|
||||||
|
}
|
||||||
|
tiers = append(tiers, tier)
|
||||||
|
}
|
||||||
|
return tiers
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBaseOf says whether `to` stands on `base`, directly or through other bases in the set, along
|
||||||
|
// the build edges alone.
|
||||||
|
func isBaseOf(base, to string, edges []inventory.Edge, in map[string]bool) bool {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var walk func(string) bool
|
||||||
|
walk = func(m string) bool {
|
||||||
|
if m == base {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if seen[m] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen[m] = true
|
||||||
|
for _, e := range edges {
|
||||||
|
if e.From == m && in[e.To] && (e.Kind == inventory.EdgeStandsOn || e.Kind == inventory.EdgeDeclared) && walk(e.To) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return walk(to)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reachableFrom is the moved modules plus everything that depends on them, through every layer:
|
||||||
|
// what a merge rebuilds. Along the code and build edges only: a module *built by* the build machine
|
||||||
|
// is not changed by a new build machine, so a built-by edge orders and gates a plan and never
|
||||||
|
// widens it — the first plan of 2026-10-01 took the whole catalogue along for a controller change.
|
||||||
|
func reachableFrom(moved []string, edges []inventory.Edge) []string {
|
||||||
|
in := map[string]bool{}
|
||||||
|
for _, m := range moved {
|
||||||
|
in[m] = true
|
||||||
|
}
|
||||||
|
for grew := true; grew; {
|
||||||
|
grew = false
|
||||||
|
for _, e := range edges {
|
||||||
|
if e.Kind == inventory.EdgeBuiltBy {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if in[e.To] && !in[e.From] {
|
||||||
|
in[e.From] = true
|
||||||
|
grew = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(in))
|
||||||
|
for m := range in {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasCycle says whether the tiers' last tier holds modules that still depend on each other.
|
||||||
|
func hasCycle(tiers [][]string, edges []inventory.Edge) bool {
|
||||||
|
if len(tiers) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
last := map[string]bool{}
|
||||||
|
for _, m := range tiers[len(tiers)-1] {
|
||||||
|
last[m] = true
|
||||||
|
}
|
||||||
|
for _, e := range edges {
|
||||||
|
if last[e.From] && last[e.To] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// planFor is the plan a merge produces: the moved modules and everything reachable from them,
|
||||||
|
// tiered, with the merge it answers.
|
||||||
|
func planOfMerge(m link.SourceMoved, moved []string, edges []inventory.Edge) inventory.Plan {
|
||||||
|
set := reachableFrom(moved, edges)
|
||||||
|
tiers := tiersOf(set, edges)
|
||||||
|
modules := map[string]*inventory.PlanModule{}
|
||||||
|
for _, name := range set {
|
||||||
|
modules[name] = &inventory.PlanModule{}
|
||||||
|
}
|
||||||
|
return inventory.Plan{
|
||||||
|
ID: fmt.Sprintf("plan-%d", time.Now().UnixNano()),
|
||||||
|
Repository: m.Owner + "/" + m.Repo,
|
||||||
|
Commit: m.Commit,
|
||||||
|
Created: time.Now().UTC(),
|
||||||
|
State: inventory.PlanBuilding,
|
||||||
|
Tiers: tiers,
|
||||||
|
Modules: modules,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gates is what the next tier needs running from this one: a module of the tier that a later
|
||||||
|
// tier is built by — the runtime dependency — and whose policy rolls it out, must be applied by
|
||||||
|
// the machines running it before the next tier is asked. A base an image stands on need only be
|
||||||
|
// built; a source another module packages need not even be that.
|
||||||
|
func gates(p inventory.Plan, edges []inventory.Edge, rollsOut func(string) bool) []string {
|
||||||
|
if p.Tier >= len(p.Tiers) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
inTier := map[string]bool{}
|
||||||
|
all := map[string]bool{}
|
||||||
|
for _, tier := range p.Tiers {
|
||||||
|
for _, m := range tier {
|
||||||
|
all[m] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range p.Tiers[p.Tier] {
|
||||||
|
inTier[m] = true
|
||||||
|
}
|
||||||
|
later := map[string]bool{}
|
||||||
|
for _, tier := range p.Tiers[p.Tier+1:] {
|
||||||
|
for _, m := range tier {
|
||||||
|
later[m] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
for _, e := range edges {
|
||||||
|
if later[e.From] && inTier[e.To] && e.Kind == inventory.EdgeBuiltBy && !seen[e.To] && rollsOut(e.To) &&
|
||||||
|
!isBaseOf(e.From, e.To, edges, all) {
|
||||||
|
seen[e.To] = true
|
||||||
|
out = append(out, e.To)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// applied says whether every machine running the module has reported since the module was built.
|
||||||
|
func applied(module string, builtAt time.Time, running []string, reports []inventory.Reported) (bool, []string) {
|
||||||
|
at := map[string]*time.Time{}
|
||||||
|
for _, r := range reports {
|
||||||
|
at[r.Node] = r.At
|
||||||
|
}
|
||||||
|
var waiting []string
|
||||||
|
for _, n := range running {
|
||||||
|
if t := at[n]; t == nil || t.Before(builtAt) {
|
||||||
|
waiting = append(waiting, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(waiting) == 0, waiting
|
||||||
|
}
|
||||||
|
|
||||||
|
// askTier asks the build machine for every module of the tier, and marks each asked. A module
|
||||||
|
// the catalogue no longer holds, or whose ask could not be made, is a failure of the plan: a tier
|
||||||
|
// half asked is a tier that will never complete.
|
||||||
|
func askTier(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan) error {
|
||||||
|
entries, err := inv.Catalogued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
byName := map[string]inventory.Entry{}
|
||||||
|
for _, e := range entries {
|
||||||
|
byName[e.Manifest.Module] = e
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for _, name := range p.Tiers[p.Tier] {
|
||||||
|
state := p.Modules[name]
|
||||||
|
if state == nil {
|
||||||
|
state = &inventory.PlanModule{}
|
||||||
|
p.Modules[name] = state
|
||||||
|
}
|
||||||
|
e, known := byName[name]
|
||||||
|
if !known {
|
||||||
|
state.State = "failed"
|
||||||
|
state.Why = "no longer in the catalogue"
|
||||||
|
p.State = inventory.PlanFailed
|
||||||
|
p.Note = name + " is no longer in the catalogue"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
state.State = "failed"
|
||||||
|
state.Why = err.Error()
|
||||||
|
p.State = inventory.PlanFailed
|
||||||
|
p.Note = fmt.Sprintf("%s could not be asked for: %v", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
state.State = "asked"
|
||||||
|
state.AskedAt = &now
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
inv := open.inventory
|
||||||
|
plans, err := inv.OpenPlans(ctx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("plans: cannot read them: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for i := range plans {
|
||||||
|
p := &plans[i]
|
||||||
|
if p.Tier >= len(p.Tiers) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inTier := false
|
||||||
|
for _, m := range p.Tiers[p.Tier] {
|
||||||
|
if m == module {
|
||||||
|
inTier = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !inTier {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
state := p.Modules[module]
|
||||||
|
if state == nil {
|
||||||
|
state = &inventory.PlanModule{}
|
||||||
|
p.Modules[module] = state
|
||||||
|
}
|
||||||
|
if failed != "" {
|
||||||
|
state.State = "failed"
|
||||||
|
state.Why = failed
|
||||||
|
p.State = inventory.PlanFailed
|
||||||
|
p.Note = fmt.Sprintf("%s failed to build in tier %d", module, p.Tier)
|
||||||
|
} else {
|
||||||
|
state.State = "built"
|
||||||
|
state.BuiltAt = &now
|
||||||
|
state.Commit = commit
|
||||||
|
}
|
||||||
|
if err := inv.SavePlan(ctx, *p); err != nil {
|
||||||
|
fmt.Printf("%s: cannot keep the plan: %v\n", p.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.State == inventory.PlanFailed {
|
||||||
|
fmt.Printf("%s: %s; the tiers after it are not asked\n", p.ID, p.Note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
advancePlans(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.
|
||||||
|
func advancePlans(ctx context.Context, open *stores) {
|
||||||
|
inv := open.inventory
|
||||||
|
plans, err := inv.OpenPlans(ctx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("plans: cannot read them: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(plans) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
edges, err := inv.Dependencies(ctx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("plans: cannot read the dependencies: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rollsOut := func(module string) bool {
|
||||||
|
u, err := inv.UpgradeOf(ctx, module)
|
||||||
|
return err == nil && u.RollOut
|
||||||
|
}
|
||||||
|
for i := range plans {
|
||||||
|
p := &plans[i]
|
||||||
|
for p.Open() {
|
||||||
|
moved, err := advanceOnce(ctx, open, p, edges, rollsOut)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("%s: %v\n", p.ID, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := inv.SavePlan(ctx, *p); err != nil {
|
||||||
|
fmt.Printf("%s: cannot keep the plan: %v\n", p.ID, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !moved {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceOnce takes one step of one plan and says whether anything changed.
|
||||||
|
func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||||
|
edges []inventory.Edge, rollsOut func(string) bool) (bool, error) {
|
||||||
|
inv := open.inventory
|
||||||
|
if p.Tier >= len(p.Tiers) {
|
||||||
|
p.State = inventory.PlanDone
|
||||||
|
fmt.Printf("%s: done — %s at %s, %d tier(s)\n", p.ID, p.Repository, short(p.Commit), len(p.Tiers))
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
tier := p.Tiers[p.Tier]
|
||||||
|
// Not yet asked: ask.
|
||||||
|
unasked := 0
|
||||||
|
for _, m := range tier {
|
||||||
|
if s := p.Modules[m]; s == nil || s.State == "" {
|
||||||
|
unasked++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if unasked == len(tier) {
|
||||||
|
if err := askTier(ctx, inv, p); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
// Asked: wait for every build.
|
||||||
|
var latest time.Time
|
||||||
|
for _, m := range tier {
|
||||||
|
s := p.Modules[m]
|
||||||
|
if s == nil || s.State != "built" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if s.BuiltAt != nil && s.BuiltAt.After(latest) {
|
||||||
|
latest = *s.BuiltAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Built: send every module of the tier whose policy rolls out, once, to the machines running
|
||||||
|
// it — whether or not its source commit moved. A dependent rebuilt because its base moved, or
|
||||||
|
// a module that packages another repository's source, keeps its commit; the catalogue announces
|
||||||
|
// no move for it and its machines would keep the old image until somebody pushed (novox/hq
|
||||||
|
// issue 189). A module whose policy records is built and left, as its policy says.
|
||||||
|
for _, m := range tier {
|
||||||
|
state := p.Modules[m]
|
||||||
|
if state == nil || state.SentAt != nil || !rollsOut(m) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
running, err := inv.Running(ctx, m)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
state.SentAt = &now
|
||||||
|
if len(running) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := sendTo(ctx, open, running); err != nil {
|
||||||
|
return false, fmt.Errorf("sending %s to %s after tier %d: %w", m, strings.Join(running, ", "), p.Tier, err)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: tier %d built; sent %s to %s\n", p.ID, p.Tier, m, strings.Join(running, ", "))
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
// And wait for what the next tier needs running.
|
||||||
|
needed := gates(*p, edges, rollsOut)
|
||||||
|
if len(needed) > 0 {
|
||||||
|
reports, err := inv.LastReports(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
var waiting []string
|
||||||
|
for _, m := range needed {
|
||||||
|
running, err := inv.Running(ctx, m)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
state := p.Modules[m]
|
||||||
|
if state == nil {
|
||||||
|
state = &inventory.PlanModule{}
|
||||||
|
p.Modules[m] = state
|
||||||
|
}
|
||||||
|
// The plan sends what it waits for. A rebuild from the same source commit is not a
|
||||||
|
// move the catalogue announces — the build machine rebuilt for a controller change
|
||||||
|
// is one — so the roll-out that opens this gate is the plan's to make, once, and
|
||||||
|
// the reports that open it are the ones after the send.
|
||||||
|
since := latest
|
||||||
|
if state.BuiltAt != nil {
|
||||||
|
since = *state.BuiltAt
|
||||||
|
}
|
||||||
|
if state.SentAt != nil && state.SentAt.After(since) {
|
||||||
|
since = *state.SentAt
|
||||||
|
}
|
||||||
|
if ok, on := applied(m, since, running, reports); !ok {
|
||||||
|
waiting = append(waiting, fmt.Sprintf("%s on %s", m, strings.Join(on, ", ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(waiting) > 0 {
|
||||||
|
note := "tier " + fmt.Sprint(p.Tier) + " built; waiting for " + strings.Join(waiting, "; ") + " to be applied"
|
||||||
|
changed := p.State != inventory.PlanRolling || p.Note != note
|
||||||
|
p.State = inventory.PlanRolling
|
||||||
|
p.Note = note
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Tier++
|
||||||
|
p.State = inventory.PlanBuilding
|
||||||
|
p.Note = ""
|
||||||
|
if p.Tier < len(p.Tiers) {
|
||||||
|
fmt.Printf("%s: tier %d done; asking tier %d: %s\n", p.ID, p.Tier-1, p.Tier, strings.Join(p.Tiers[p.Tier], ", "))
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// planTicker advances open plans on a timer, for the steps outcomes alone cannot take.
|
||||||
|
func planTicker(ctx context.Context, open *stores) {
|
||||||
|
advancePlans(ctx, open)
|
||||||
|
tick := time.NewTicker(30 * time.Second)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
advancePlans(ctx, open)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// planLine is one plan as `status` says it.
|
||||||
|
func planLine(p inventory.Plan, now time.Time) string {
|
||||||
|
where := fmt.Sprintf("tier %d of %d", min(p.Tier+1, len(p.Tiers)), len(p.Tiers))
|
||||||
|
switch p.State {
|
||||||
|
case inventory.PlanDone:
|
||||||
|
return fmt.Sprintf("%s %s done, %d tier(s)", p.Repository, short(p.Commit), len(p.Tiers))
|
||||||
|
case inventory.PlanFailed:
|
||||||
|
return fmt.Sprintf("%s %s FAILED at %s: %s", p.Repository, short(p.Commit), where, p.Note)
|
||||||
|
}
|
||||||
|
since := now.Sub(p.Updated).Round(time.Minute)
|
||||||
|
late := ""
|
||||||
|
if since > planWaitBound {
|
||||||
|
late = " — LATE"
|
||||||
|
}
|
||||||
|
what := "building"
|
||||||
|
if p.State == inventory.PlanRolling {
|
||||||
|
what = p.Note
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s %s %s, %s for %s%s", p.Repository, short(p.Commit), where, what, since, late)
|
||||||
|
}
|
||||||
|
|
||||||
|
// planFailedBuild marks the module a failed build was for when the result names no module: by the
|
||||||
|
// repository and path the plan's modules were asked at.
|
||||||
|
func planFailedBuild(ctx context.Context, open *stores, result link.BuildResult) {
|
||||||
|
entries, err := open.inventory.Catalogued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func repositoryMatches(a, b string) bool {
|
||||||
|
trim := func(s string) string { return strings.ToLower(strings.TrimSuffix(s, ".git")) }
|
||||||
|
return trim(a) == trim(b) || strings.HasSuffix(trim(a), "/"+trim(b)) || strings.HasSuffix(trim(b), "/"+trim(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
// planStatus is one plan as `status --json` says it.
|
||||||
|
type planStatus struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
Tiers int `json:"tiers"`
|
||||||
|
Waiting string `json:"waiting,omitempty"`
|
||||||
|
Since time.Time `json:"since"`
|
||||||
|
Late bool `json:"late"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func planStatuses(plans []inventory.Plan, now time.Time) []planStatus {
|
||||||
|
out := make([]planStatus, 0, len(plans))
|
||||||
|
for _, p := range plans {
|
||||||
|
ps := planStatus{ID: p.ID, Repository: p.Repository, Commit: p.Commit, State: p.State,
|
||||||
|
Tier: p.Tier, Tiers: len(p.Tiers), Since: p.Updated}
|
||||||
|
if p.Open() {
|
||||||
|
ps.Waiting = p.Note
|
||||||
|
if ps.Waiting == "" {
|
||||||
|
ps.Waiting = "builds of tier " + fmt.Sprint(p.Tier)
|
||||||
|
}
|
||||||
|
ps.Late = now.Sub(p.Updated) > planWaitBound
|
||||||
|
}
|
||||||
|
out = append(out, ps)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// openPlans is the open plans among the recent ones, and how many have waited past the bound.
|
||||||
|
func openPlans(plans []inventory.Plan) ([]inventory.Plan, int) {
|
||||||
|
var open []inventory.Plan
|
||||||
|
late := 0
|
||||||
|
for _, p := range plans {
|
||||||
|
if p.Open() {
|
||||||
|
open = append(open, p)
|
||||||
|
if time.Since(p.Updated) > planWaitBound {
|
||||||
|
late++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return open, late
|
||||||
|
}
|
||||||
|
|
||||||
|
// plansCommand says what the last merges produced and where each stands; given an id, one plan
|
||||||
|
// tier by tier with every module's state.
|
||||||
|
func plansCommand(ctx context.Context, args []string) error {
|
||||||
|
set := flag.NewFlagSet("plans", flag.ContinueOnError)
|
||||||
|
limit := set.Int("n", 10, "how many to show")
|
||||||
|
whatIf := set.String("what-if", "", "owner/repository: the plan a merge there would produce, saving nothing — with --paths or --modules")
|
||||||
|
paths := set.String("paths", "", "the files the merge would change, comma-separated, from the repository's root")
|
||||||
|
modules := set.String("modules", "", "or the modules it would change, comma-separated")
|
||||||
|
positionals, err := parseAround(set, args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
inv := open.inventory
|
||||||
|
now := time.Now()
|
||||||
|
if len(positionals) == 1 {
|
||||||
|
p, err := inv.PlanByID(ctx, positionals[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s — %s\n", p.ID, planLine(p, now))
|
||||||
|
for i, tier := range p.Tiers {
|
||||||
|
marker := " "
|
||||||
|
if i == p.Tier && p.Open() {
|
||||||
|
marker = ">"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s tier %d\n", marker, i)
|
||||||
|
for _, m := range tier {
|
||||||
|
s := p.Modules[m]
|
||||||
|
state := "not yet asked"
|
||||||
|
if s != nil && s.State != "" {
|
||||||
|
state = s.State
|
||||||
|
if s.Commit != "" {
|
||||||
|
state += " from " + short(s.Commit)
|
||||||
|
}
|
||||||
|
if s.Why != "" {
|
||||||
|
state += ": " + s.Why
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-22s %s\n", m, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if *whatIf != "" {
|
||||||
|
return planWhatIf(ctx, inv, *whatIf, splitList(*paths), splitList(*modules))
|
||||||
|
}
|
||||||
|
if len(positionals) == 2 && positionals[0] == "stop" {
|
||||||
|
p, err := inv.PlanByID(ctx, positionals[1])
|
||||||
|
if 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
|
||||||
|
}
|
||||||
|
fmt.Printf("%s stopped at tier %d of %d; what was asked still builds and registers, nothing further is asked\n",
|
||||||
|
p.ID, p.Tier, len(p.Tiers))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
plans, err := inv.RecentPlans(ctx, *limit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(plans) == 0 {
|
||||||
|
fmt.Println("no merge has produced a plan yet")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, p := range plans {
|
||||||
|
fmt.Printf("%-28s %s\n", p.ID, planLine(p, now))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// planWhatIf is the plan a merge would produce, computed the way the merge handler computes one
|
||||||
|
// and saved nowhere: the modules the repository's changed files touch (or the modules named), what
|
||||||
|
// packages their source, everything reachable from them, in tiers. For reading before merging.
|
||||||
|
func planWhatIf(ctx context.Context, inv *inventory.Inventory, repository string, paths, modules []string) error {
|
||||||
|
owner, repo, found := strings.Cut(repository, "/")
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("--what-if takes owner/repository, not %q", repository)
|
||||||
|
}
|
||||||
|
m := link.SourceMoved{Owner: owner, Repo: repo, Base: "main", Commit: "what-if", Paths: paths}
|
||||||
|
entries, err := inv.Catalogued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
read, err := inv.ReadRepositories(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var from, packaging []inventory.Entry
|
||||||
|
named := map[string]bool{}
|
||||||
|
for _, name := range modules {
|
||||||
|
named[name] = true
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
switch {
|
||||||
|
case named[e.Manifest.Module]:
|
||||||
|
from = append(from, e)
|
||||||
|
case len(named) == 0 && sourceIs(e.Source, m):
|
||||||
|
from = append(from, e)
|
||||||
|
case readsFrom(read[e.Manifest.Module], m):
|
||||||
|
packaging = append(packaging, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(named) == 0 {
|
||||||
|
from = whatTheMergeTouched(from, entries, m)
|
||||||
|
}
|
||||||
|
moved := append(append([]inventory.Entry{}, from...), packaging...)
|
||||||
|
if len(moved) == 0 {
|
||||||
|
fmt.Printf("a merge of %s changing %s would build nothing the mesh holds\n", repository,
|
||||||
|
orNone(strings.Join(append(paths, modules...), ", ")))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
edges, err := inv.Dependencies(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, e := range moved {
|
||||||
|
names = append(names, e.Manifest.Module)
|
||||||
|
}
|
||||||
|
p := planOfMerge(m, names, edges)
|
||||||
|
fmt.Printf("a merge of %s would build %d module(s) in %d tier(s):\n", repository, len(p.Modules), len(p.Tiers))
|
||||||
|
rolls := map[string]string{}
|
||||||
|
for i, tier := range p.Tiers {
|
||||||
|
fmt.Printf(" tier %d\n", i)
|
||||||
|
for _, name := range tier {
|
||||||
|
how := "built; its policy records, so nothing is sent"
|
||||||
|
if u, err := inv.UpgradeOf(ctx, name); err == nil && u.RollOut {
|
||||||
|
running, _ := inv.Running(ctx, name)
|
||||||
|
how = "built, then sent to " + orNone(strings.Join(running, ", "))
|
||||||
|
rolls[name] = how
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-22s %s\n", name, how)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasCycle(p.Tiers, edges) {
|
||||||
|
fmt.Println(" the last tier depends on itself and would be built together, in no order")
|
||||||
|
}
|
||||||
|
if len(packaging) > 0 {
|
||||||
|
var also []string
|
||||||
|
for _, e := range packaging {
|
||||||
|
also = append(also, e.Manifest.Module)
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s package source from %s, so they are rebuilt without their own source moving\n",
|
||||||
|
strings.Join(also, ", "), repository)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitList(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, part := range strings.Split(s, ",") {
|
||||||
|
if part = strings.TrimSpace(part); part != "" {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A merge produces a tiered plan (novox/hq ADR 0162): what moved and everything reachable from it,
|
||||||
|
// sorted so a tier depends only on earlier ones — with the three kinds of dependency told apart.
|
||||||
|
func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
|
||||||
|
edges := []inventory.Edge{
|
||||||
|
// build dependencies: images on the runtime, a plugin on one of them
|
||||||
|
{From: "shop", To: "mesh-tools", Kind: inventory.EdgeStandsOn},
|
||||||
|
{From: "postgres", To: "mesh-tools", Kind: inventory.EdgeStandsOn},
|
||||||
|
{From: "shop-plugin", To: "shop", Kind: inventory.EdgeDeclared},
|
||||||
|
// a code dependency: the proxy packages the controller's source — same tier
|
||||||
|
{From: "route-proxy", To: "mesh-controller", Kind: inventory.EdgePackages},
|
||||||
|
{From: "builder", To: "mesh-controller", Kind: inventory.EdgePackages},
|
||||||
|
// runtime dependencies: everything source-built is built by the builder
|
||||||
|
{From: "shop", To: "builder", Kind: inventory.EdgeBuiltBy},
|
||||||
|
{From: "postgres", To: "builder", Kind: inventory.EdgeBuiltBy},
|
||||||
|
{From: "shop-plugin", To: "builder", Kind: inventory.EdgeBuiltBy},
|
||||||
|
{From: "route-proxy", To: "builder", Kind: inventory.EdgeBuiltBy},
|
||||||
|
{From: "mesh-controller", To: "builder", Kind: inventory.EdgeBuiltBy},
|
||||||
|
{From: "mesh-tools", To: "builder", Kind: inventory.EdgeBuiltBy},
|
||||||
|
{From: "builder", To: "mesh-tools", Kind: inventory.EdgeStandsOn},
|
||||||
|
{From: "unrelated", To: "alpine", Kind: inventory.EdgeStandsOn},
|
||||||
|
}
|
||||||
|
// The runtime image moved: everything on it, and what is built by what is on it.
|
||||||
|
set := reachableFrom([]string{"mesh-tools"}, edges)
|
||||||
|
// What stands on the runtime, and the builder that stands on it; not the controller, which the
|
||||||
|
// builder merely builds, nor the proxy that packages the controller.
|
||||||
|
want := []string{"builder", "mesh-tools", "postgres", "shop", "shop-plugin"}
|
||||||
|
if len(set) != len(want) {
|
||||||
|
t.Fatalf("reachable from the runtime: %v, want %v", set, want)
|
||||||
|
}
|
||||||
|
tiers := tiersOf(set, edges)
|
||||||
|
pos := map[string]int{}
|
||||||
|
for i, tier := range tiers {
|
||||||
|
for _, m := range tier {
|
||||||
|
pos[m] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pos["mesh-tools"] != 0 || pos["builder"] != 1 {
|
||||||
|
t.Fatalf("the runtime then the builder: %v", tiers)
|
||||||
|
}
|
||||||
|
if !(pos["shop"] > pos["builder"] && pos["postgres"] > pos["builder"]) {
|
||||||
|
t.Fatalf("what the builder builds comes after the builder: %v", tiers)
|
||||||
|
}
|
||||||
|
if pos["shop-plugin"] <= pos["shop"] {
|
||||||
|
t.Fatalf("a plugin after what it is declared on: %v", tiers)
|
||||||
|
}
|
||||||
|
if hasCycle(tiers, edges) {
|
||||||
|
t.Fatalf("no cycle here: %v", tiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The controller alone moved: the proxy with it, nothing else.
|
||||||
|
small := reachableFrom([]string{"mesh-controller"}, edges)
|
||||||
|
if len(small) != 3 {
|
||||||
|
t.Fatalf("a controller merge rebuilds the controller and what packages it: %v", small)
|
||||||
|
}
|
||||||
|
// The builder packages the controller's source (same tier by that edge) and the controller is
|
||||||
|
// built by the builder (next tier by that one): the builder first, then the controller and the
|
||||||
|
// proxy together — a code dependency in one tier, a runtime dependency across tiers.
|
||||||
|
smallTiers := tiersOf(small, edges)
|
||||||
|
if len(smallTiers) != 2 || smallTiers[0][0] != "builder" || len(smallTiers[1]) != 2 {
|
||||||
|
t.Fatalf("the builder, then the controller and the proxy together: %v", smallTiers)
|
||||||
|
}
|
||||||
|
// The builder alone moved: the builder, and nothing it builds.
|
||||||
|
if only := reachableFrom([]string{"builder"}, edges); len(only) != 1 {
|
||||||
|
t.Fatalf("a build machine change rebuilds the build machine alone: %v", only)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a runtime dependency gates on deployment, and only when the module rolls out.
|
||||||
|
p := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-tools", Commit: "abc"}, []string{"mesh-tools"}, edges)
|
||||||
|
p.Tier = pos["builder"]
|
||||||
|
rollsOut := func(m string) bool { return m == "builder" }
|
||||||
|
if g := gates(p, edges, rollsOut); len(g) != 1 || g[0] != "builder" {
|
||||||
|
t.Fatalf("the builder gates the tier after it: %v", g)
|
||||||
|
}
|
||||||
|
p.Tier = 0
|
||||||
|
if g := gates(p, edges, rollsOut); len(g) != 0 {
|
||||||
|
t.Fatalf("the runtime image is a build dependency and gates nothing: %v", g)
|
||||||
|
}
|
||||||
|
if g := gates(p, edges, func(string) bool { return false }); len(g) != 0 {
|
||||||
|
t.Fatalf("a module that only records its upgrade gates nothing: %v", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A gate is open once every machine running the module has reported after it was built.
|
||||||
|
func TestAGateOpensWhenTheMachinesHaveReportedSinceTheBuild(t *testing.T) {
|
||||||
|
built := time.Date(2026, 10, 1, 15, 0, 0, 0, time.UTC)
|
||||||
|
before, after := built.Add(-time.Minute), built.Add(time.Minute)
|
||||||
|
reports := []inventory.Reported{{Node: "anchor", At: &after}, {Node: "home-server", At: &before}}
|
||||||
|
ok, waiting := applied("builder", built, []string{"anchor", "home-server"}, reports)
|
||||||
|
if ok || len(waiting) != 1 || waiting[0] != "home-server" {
|
||||||
|
t.Fatalf("one machine has not reported since the build: ok=%v waiting=%v", ok, waiting)
|
||||||
|
}
|
||||||
|
if ok, _ := applied("builder", built, []string{"anchor"}, reports); !ok {
|
||||||
|
t.Fatal("the machine that reported after the build holds the gate open")
|
||||||
|
}
|
||||||
|
if ok, _ := applied("builder", built, nil, reports); !ok {
|
||||||
|
t.Fatal("a module running nowhere gates nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cycle is not lost: what remains is one last tier, and the caller says so.
|
||||||
|
func TestACycleIsOneLastTierAndSaidSo(t *testing.T) {
|
||||||
|
edges := []inventory.Edge{{From: "a", To: "b", Kind: inventory.EdgeStandsOn}, {From: "b", To: "a", Kind: inventory.EdgeStandsOn}}
|
||||||
|
tiers := tiersOf([]string{"a", "b"}, edges)
|
||||||
|
if len(tiers) != 1 || len(tiers[0]) != 2 || !hasCycle(tiers, edges) {
|
||||||
|
t.Fatalf("a cycle should be one tier of two, said: %v", tiers)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,6 +14,7 @@ import (
|
|||||||
"github.com/novox/mesh-controller/internal/broker"
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/secrets"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Moving the mesh's own traffic to the bus being built (novox/hq ADR 0116 step 5).
|
// Moving the mesh's own traffic to the bus being built (novox/hq ADR 0116 step 5).
|
||||||
@@ -25,18 +28,27 @@ import (
|
|||||||
// against a mesh that is serving. It answers from records: what is missing, and what would happen.
|
// against a mesh that is serving. It answers from records: what is missing, and what would happen.
|
||||||
// `rollout` itself refuses unless the check is clean.
|
// `rollout` itself refuses unless the check is clean.
|
||||||
//
|
//
|
||||||
// **The old broker is not switched off by this.** It stays an ordinary provider of `amqp` for whatever
|
// **The old broker goes with the move, and goes last** (novox/hq ADR 0131): AMQP is not a provision,
|
||||||
// else uses it — on this installation, a whole automation layer that has nothing to do with the mesh
|
// so once every machine reports on the new bus its module is unassigned. Only the mesh's own traffic
|
||||||
// ([ADR 0119](../../02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md)). Only the mesh's own
|
// is what moves, which is why this is survivable at all: what breaks if it goes wrong is the mesh's
|
||||||
// traffic moves, which is why this is survivable at all: what breaks if it goes wrong is the mesh's
|
// ability to change things, not the services its modules are serving — measured on 2026-09-27, when
|
||||||
// ability to change things, not the services its modules are serving.
|
// a seat emptied mid-change and the control plane looped for two hours while every service stayed up.
|
||||||
|
|
||||||
const rolloutUsage = "rollout check | rollout --confirm"
|
const rolloutUsage = "rollout check | rollout mint [--again] | rollout hand <node> | rollout --confirm"
|
||||||
|
|
||||||
func rolloutCommand(ctx context.Context, args []string) error {
|
func rolloutCommand(ctx context.Context, args []string) error {
|
||||||
switch {
|
switch {
|
||||||
case len(args) == 1 && args[0] == "check":
|
case len(args) == 1 && args[0] == "check":
|
||||||
return rolloutCheck(ctx)
|
return rolloutCheck(ctx)
|
||||||
|
case len(args) == 1 && args[0] == "mint":
|
||||||
|
return rolloutMint(ctx, false)
|
||||||
|
case len(args) == 2 && args[0] == "hand":
|
||||||
|
return rolloutHand(ctx, args[1])
|
||||||
|
case len(args) == 2 && args[0] == "mint" && args[1] == "--again":
|
||||||
|
// Every credential minted afresh, whether or not one exists — for a mint that was wrong
|
||||||
|
// before anything was pushed. Afterwards nothing that received the old one still works,
|
||||||
|
// which is fine exactly when nothing received it.
|
||||||
|
return rolloutMint(ctx, true)
|
||||||
case len(args) == 1 && args[0] == "--confirm":
|
case len(args) == 1 && args[0] == "--confirm":
|
||||||
return errors.New(
|
return errors.New(
|
||||||
"the rollout itself is not built yet: `rollout check` answers whether it could run, and " +
|
"the rollout itself is not built yet: `rollout check` answers whether it could run, and " +
|
||||||
@@ -105,7 +117,6 @@ func readinessOf(ctx context.Context, inv *inventory.Inventory) (broker.Readines
|
|||||||
ModuleCredentialled: map[string]bool{},
|
ModuleCredentialled: map[string]bool{},
|
||||||
// The old broker keeps its other clients on this installation, and saying so is how the plan
|
// The old broker keeps its other clients on this installation, and saying so is how the plan
|
||||||
// stops reading as a retirement.
|
// stops reading as a retirement.
|
||||||
OldBusHasOtherClients: true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
address, _, err := broker.OnNATS()
|
address, _, err := broker.OnNATS()
|
||||||
@@ -116,9 +127,13 @@ func readinessOf(ctx context.Context, inv *inventory.Inventory) (broker.Readines
|
|||||||
if address != "" {
|
if address != "" {
|
||||||
// One dial, briefly. "Is it answering" is the one fact records cannot hold, and a mesh about
|
// One dial, briefly. "Is it answering" is the one fact records cannot hold, and a mesh about
|
||||||
// to move onto a server that is not there should hear it here rather than afterwards.
|
// to move onto a server that is not there should hear it here rather than afterwards.
|
||||||
if conn, err := nats.Connect(broker.BareAddress(address), nats.Timeout(5*time.Second)); err == nil {
|
//
|
||||||
|
// **Dialled the way the mesh dials it** — credential and pin — because a bare connect to a
|
||||||
|
// bus that requires TLS and a user fails at the handshake, and the check then reported a
|
||||||
|
// standing server as absent (seen live, 2026-09-28).
|
||||||
|
if js, err := broker.Dial(address, nats.Timeout(5*time.Second)); err == nil {
|
||||||
state.ServerStanding = true
|
state.ServerStanding = true
|
||||||
conn.Close()
|
js.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,3 +206,250 @@ func wasSentTheUserList(ctx context.Context, inv *inventory.Inventory, node stri
|
|||||||
// notReadyOf is the readiness reasoning, named here so a test can reach it without the command's
|
// notReadyOf is the readiness reasoning, named here so a test can reach it without the command's
|
||||||
// printing. The reasoning itself is the broker package's, where it is pure.
|
// printing. The reasoning itself is the broker package's, where it is pure.
|
||||||
func notReadyOf(state broker.Readiness) []string { return broker.NotReady(state) }
|
func notReadyOf(state broker.Readiness) []string { return broker.NotReady(state) }
|
||||||
|
|
||||||
|
// rolloutMint gives every principal the new bus will have a credential it does not yet have, and
|
||||||
|
// puts each where its owner reads it (novox/hq design 28, task 5.2): a machine's as a membership
|
||||||
|
// sealed into its declaration, a module's as its broker secret, the control plane's own as its
|
||||||
|
// `bus` secret. Idempotent: what already has a hash is left alone, so running it again is harmless.
|
||||||
|
//
|
||||||
|
// **Before anything moves, and it is what makes moving possible.** A machine moved without a
|
||||||
|
// credential cannot come back, and afterwards there is no bus to tell it anything over — which is
|
||||||
|
// why `rollout check` refuses until this has run. The bus's address is worked out here, from where
|
||||||
|
// the module that provides it is assigned, rather than read from this process's environment: this
|
||||||
|
// process is still on the old bus when this runs, and must be.
|
||||||
|
func rolloutMint(ctx context.Context, again bool) error {
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
inv := open.inventory
|
||||||
|
|
||||||
|
known, err := broker.FromEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("the bus's certificate is not known to this process, and every membership "+
|
||||||
|
"must carry its fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
shelf, err := inv.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entries, err := inv.Catalogued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var busNode, controllerNode string
|
||||||
|
for _, e := range entries {
|
||||||
|
switch {
|
||||||
|
case e.Manifest.ClaimsSeat("mesh-broker") && providesBus(e.Manifest) && len(e.On) > 0:
|
||||||
|
busNode = e.On[0]
|
||||||
|
case e.Manifest.Module == "mesh-controller" && len(e.On) > 0:
|
||||||
|
controllerNode = e.On[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if busNode == "" {
|
||||||
|
return errors.New("no assigned module provides mesh-bus and claims mesh-broker, so there is no " +
|
||||||
|
"bus to mint credentials for — register and assign it first")
|
||||||
|
}
|
||||||
|
onNetwork, err := whereEveryoneIs(ctx, inv, shelf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
busHost := onNetwork[busNode]
|
||||||
|
if busHost == "" {
|
||||||
|
// **The hub is not in that map.** The machine that took over the tunnel is where the current
|
||||||
|
// bus already answers, and every machine dials it at the address the mesh handed them — so
|
||||||
|
// when the new bus runs on the same machine, that address is the one to tell them, with the
|
||||||
|
// new port. Found live: the control node is the hub, and the map lists the machines placed
|
||||||
|
// around it.
|
||||||
|
// The host alone: no scheme (BareAddress adds one where none was, which is the wrong
|
||||||
|
// direction here — every URL built below adds its own) and no port.
|
||||||
|
_, _, host := broker.CredentialIn(known.Address)
|
||||||
|
if host == "" {
|
||||||
|
host = known.Address
|
||||||
|
}
|
||||||
|
if _, after, hasScheme := strings.Cut(host, "://"); hasScheme {
|
||||||
|
host = after
|
||||||
|
}
|
||||||
|
host = strings.TrimSpace(host)
|
||||||
|
if i := strings.LastIndex(host, ":"); i > 0 && !strings.Contains(host[i:], "]") {
|
||||||
|
host = host[:i]
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
return fmt.Errorf("%s runs the new bus and has no address on the private network, and the "+
|
||||||
|
"current bus's address is unknown too, so no machine could be told where it is", busNode)
|
||||||
|
}
|
||||||
|
busHost = host
|
||||||
|
}
|
||||||
|
busAddress := busHost + ":4222"
|
||||||
|
|
||||||
|
records, err := inv.BusRecords(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
users, err := broker.Users(records)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
kept, err := inv.BusUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hashes := make(map[string]string, len(kept))
|
||||||
|
for name, u := range kept {
|
||||||
|
hashes[name] = u.PasswordHash
|
||||||
|
}
|
||||||
|
_, missing := broker.WithPasswords(users, hashes)
|
||||||
|
wanted := map[string]bool{}
|
||||||
|
for _, m := range missing {
|
||||||
|
wanted[m] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var machines, modules, skipped int
|
||||||
|
for _, p := range users {
|
||||||
|
if !again && !wanted[p.Username()] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch p.Kind {
|
||||||
|
case broker.KindController:
|
||||||
|
if controllerNode == "" {
|
||||||
|
return errors.New("the control plane is not assigned anywhere, so its credential has nowhere to go")
|
||||||
|
}
|
||||||
|
password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: p.Username(), Kind: inventory.BusController})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
url := "nats://" + p.Username() + ":" + password + "@" + busAddress
|
||||||
|
if err := inv.AcceptSecretForModule(ctx, controllerNode, "mesh-controller", "bus", url); err != nil {
|
||||||
|
return fmt.Errorf("the control plane's credential is minted and could not be sealed to %s: %w", controllerNode, err)
|
||||||
|
}
|
||||||
|
fmt.Printf("control plane: credential minted, sealed to %s as its `bus` secret\n", controllerNode)
|
||||||
|
|
||||||
|
case broker.KindNode:
|
||||||
|
password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: p.Username(), Kind: inventory.BusNode, Node: p.Node})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
membership, _ := json.Marshal(map[string]string{
|
||||||
|
"broker": busAddress, "fingerprint": known.Fingerprint, "password": password, "transport": "nats",
|
||||||
|
})
|
||||||
|
key, err := inv.SealingKeyOf(ctx, p.Node)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s has no sealing key, so its membership cannot be sealed to it: %w", p.Node, err)
|
||||||
|
}
|
||||||
|
sealed, err := secrets.Seal(key, membership)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := inv.PutBusMembership(ctx, p.Node, sealed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
machines++
|
||||||
|
|
||||||
|
case broker.KindModule:
|
||||||
|
if p.Module == "mesh-controller" {
|
||||||
|
// The control plane is a module too, and its `broker` secret is the old bus's
|
||||||
|
// credential it is still using while this runs. Writing the new bus's blob there
|
||||||
|
// cut the mesh off from its own old bus mid-move (2026-09-28). Its new-bus credential
|
||||||
|
// is the controller principal's `bus` secret above; nothing else is needed here.
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m, inShelf := shelf[p.Module]
|
||||||
|
if !inShelf {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, reads := m.OwnSecrets["broker"]; !reads {
|
||||||
|
fmt.Printf(" %s on %s speaks on the bus but declares no `broker` secret to receive a credential in; skipped\n", p.Module, p.Node)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: p.Username(), Kind: inventory.BusModule, Node: p.Node, Module: p.Module})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := issueWith(ctx, inv, m, p.Node, "", known, busAddress, p.Username(), password); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
modules++
|
||||||
|
|
||||||
|
default:
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("minted for %d machine(s) and %d module runtime(s); %d skipped; the bus is at %s\n",
|
||||||
|
machines, modules, skipped, busAddress)
|
||||||
|
fmt.Println(" each machine's membership and each module's credential arrive with the next push of its machine;")
|
||||||
|
fmt.Println(" push the machine running the bus first, so the bus stands with its user list before anything dials it")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// providesBus is whether a manifest provides the mesh's bus.
|
||||||
|
func providesBus(m catalogue.Manifest) bool {
|
||||||
|
for _, o := range m.Provides {
|
||||||
|
if o.Name == "mesh-bus" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// rolloutHand mints a machine its credential for the new bus afresh and prints its membership
|
||||||
|
// once, for an operator to carry by hand — the rescue for a machine that cannot be reached over
|
||||||
|
// any bus: rotated while it still held the old password, or reachable only by ssh. The plaintext
|
||||||
|
// exists on this terminal and then only where it is written; the store keeps the hash, and the
|
||||||
|
// sealed copy in the machine's declaration is replaced too, so the next push says the same.
|
||||||
|
func rolloutHand(ctx context.Context, node string) error {
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
inv := open.inventory
|
||||||
|
|
||||||
|
known, err := broker.FromEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("the bus's certificate is not known to this process: %w", err)
|
||||||
|
}
|
||||||
|
busAddress, _, err := broker.OnNATS()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if busAddress == "" {
|
||||||
|
return errors.New("this control plane is not on the new bus, so there is no membership to hand out")
|
||||||
|
}
|
||||||
|
_, _, bare := broker.CredentialIn(busAddress)
|
||||||
|
if _, after, has := strings.Cut(bare, "://"); has {
|
||||||
|
bare = after
|
||||||
|
}
|
||||||
|
if _, err := inv.NodeByName(ctx, node); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p := broker.Principal{Kind: broker.KindNode, Node: node}
|
||||||
|
password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: p.Username(), Kind: inventory.BusNode, Node: node})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
membership, _ := json.Marshal(map[string]string{
|
||||||
|
"broker": bare, "fingerprint": known.Fingerprint, "password": password, "transport": "nats",
|
||||||
|
})
|
||||||
|
key, err := inv.SealingKeyOf(ctx, node)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sealed, err := secrets.Seal(key, membership)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := inv.PutBusMembership(ctx, node, sealed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The one line of output is the membership itself, so it can be piped to the machine without
|
||||||
|
// being read on the way. Everything else goes to stderr.
|
||||||
|
fmt.Fprintf(os.Stderr, "%s's credential is minted afresh. Write this to %s on it and restart its host; "+
|
||||||
|
"then push the machine running the bus so the user list carries the new hash.\n",
|
||||||
|
node, catalogue.BusMembershipPath)
|
||||||
|
fmt.Println(string(membership))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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.
|
||||||
|
|
||||||
|
func TestASetThatDoesNotComposeIsMarkedAsTheNodesOwnProblem(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err := planFor(t.Context(), open, "laptop")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("two modules claiming one seat composed anyway")
|
||||||
|
}
|
||||||
|
if !unresolvable(err) {
|
||||||
|
t.Fatalf("a set that cannot compose was not marked as the node's own problem: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAStoreThatCannotBeReadIsNotANodeThatDoesNotCompose(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
|
||||||
|
// Nothing is wrong with anchor. The question simply cannot be asked.
|
||||||
|
stopped, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, _, err := planFor(stopped, open, "anchor")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a plan composed against a store that could not be read")
|
||||||
|
}
|
||||||
|
if unresolvable(err) {
|
||||||
|
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,6 +6,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
@@ -85,7 +86,8 @@ func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []cata
|
|||||||
return rows, outside
|
return rows, outside
|
||||||
}
|
}
|
||||||
|
|
||||||
// seatCommand changes the set — the whole point of it being data (novox/hq ADR 0122).
|
// seatCommand changes the set — the whole point of it being data (novox/hq ADR 0122) — and, since
|
||||||
|
// ADR 0131, changes who holds a seat.
|
||||||
func seatCommand(ctx context.Context, args []string) error {
|
func seatCommand(ctx context.Context, args []string) error {
|
||||||
if len(args) == 3 && args[0] == "rename" {
|
if len(args) == 3 && args[0] == "rename" {
|
||||||
from, to := args[1], args[2]
|
from, to := args[1], args[2]
|
||||||
@@ -101,7 +103,101 @@ func seatCommand(ctx context.Context, args []string) error {
|
|||||||
"re-registered or frozen (novox/hq ADR 0122)\n", from, to)
|
"re-registered or frozen (novox/hq ADR 0122)\n", from, to)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("seat rename <from> <to>")
|
if len(args) == 3 && args[1] == "--to" {
|
||||||
|
return handOver(ctx, args[0], args[2])
|
||||||
|
}
|
||||||
|
return fmt.Errorf("seat rename <from> <to> | seat <name> --to <node>/<module>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handOver makes one assignment the holder of a seat, as one act, so the seat is never without a
|
||||||
|
// holder in between (novox/hq ADR 0131, design 28 task 5.3). The control plane finds its own bus
|
||||||
|
// through one of these seats; the day it was left empty mid-change is why this exists.
|
||||||
|
//
|
||||||
|
// Everything that could make the new holder wrong is refused here, before the row is written: the
|
||||||
|
// seat must exist, the assignment must exist, and the module must be able to hold the seat —
|
||||||
|
// claim it at its scope and provide what it delivers, judged against the store's row. What is
|
||||||
|
// **not** checked is whether the module is running yet: that is what `push` confirms afterwards,
|
||||||
|
// and refusing to record a handover to a module the node has not started would make the handover
|
||||||
|
// impossible to do before the switch instead of as the switch.
|
||||||
|
func handOver(ctx context.Context, seatName, to string) error {
|
||||||
|
nodeName, module, ok := strings.Cut(to, "/")
|
||||||
|
if !ok || nodeName == "" || module == "" {
|
||||||
|
return fmt.Errorf("the new holder is named <node>/<module>, not %q", to)
|
||||||
|
}
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
inv := open.inventory
|
||||||
|
|
||||||
|
seat, known := catalogue.SeatNamed(seatName)
|
||||||
|
if !known {
|
||||||
|
return fmt.Errorf("%q is not a seat this mesh defines — `seats` lists them", seatName)
|
||||||
|
}
|
||||||
|
assigned, err := inv.Assigned(ctx, nodeName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !slices.Contains(assigned, module) {
|
||||||
|
return fmt.Errorf("%s is not assigned to %s, so it cannot hold anything there — "+
|
||||||
|
"`assign %s %s` first", module, nodeName, nodeName, module)
|
||||||
|
}
|
||||||
|
entries, err := inv.Catalogued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var m *catalogue.Manifest
|
||||||
|
for i := range entries {
|
||||||
|
if entries[i].Manifest.Module == module {
|
||||||
|
m = &entries[i].Manifest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("%s is assigned but not in the catalogue, which should not happen", module)
|
||||||
|
}
|
||||||
|
var was string
|
||||||
|
holdings, err := inv.Holdings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, h := range holdings {
|
||||||
|
if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == seat.Name {
|
||||||
|
was = h.Node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Recording who already holds the seat is not making a new holder, and is not judged like
|
||||||
|
// one.** On a mesh that predates the record, the first handover has to begin by writing down
|
||||||
|
// the standing holder — otherwise the next holder cannot be assigned beside it, because two
|
||||||
|
// eligible claimants with nothing on record are refused. That standing holder may no longer
|
||||||
|
// satisfy what the seat delivers (the row moved under it, on purpose, as ADR 0131's first step),
|
||||||
|
// and it holds regardless: derivation never read that column. So when nothing is on record and
|
||||||
|
// the named assignment is the one holding by derivation, only the claim itself is checked here.
|
||||||
|
// Every *change* of holder is judged in full.
|
||||||
|
claimsIt := false
|
||||||
|
for _, c := range m.Claims {
|
||||||
|
if cs, ok := catalogue.SeatNamed(c.Name); ok && cs.Name == seat.Name && c.At() == seat.Scope {
|
||||||
|
claimsIt = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if was == "" && claimsIt {
|
||||||
|
fmt.Printf("nothing was on record for %s; recording %s on %s as its standing holder\n",
|
||||||
|
seat.Name, module, nodeName)
|
||||||
|
} else if err := catalogue.CanHold(*m, seat); err != nil {
|
||||||
|
return fmt.Errorf("%s cannot hold %s: %w", module, seat.Name, err)
|
||||||
|
}
|
||||||
|
if err := inv.HoldSeat(ctx, seat.Name, seat.Scope, nodeName, module); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s is held by %s on %s\n", seat.Name, module, nodeName)
|
||||||
|
if was != "" && was != nodeName {
|
||||||
|
fmt.Printf(" `push %s` and `push %s` send both machines what changed\n", was, nodeName)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" `push %s` sends the machine what changed; every other machine that reads the "+
|
||||||
|
"seat is re-declared by `push --behind`\n", nodeName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func seatsCommand(ctx context.Context, args []string) error {
|
func seatsCommand(ctx context.Context, args []string) error {
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The mesh's own verbs, served as the mesh-controller seat's tools (novox/hq ADR 0154, design 33).
|
||||||
|
//
|
||||||
|
// **Each tool runs the command it names, in this same binary, and answers what it printed.** That is
|
||||||
|
// ADR 0035 taken literally: the logic lives once, in the command, and a surface is an adapter with no
|
||||||
|
// decisions in it. Running a fresh process rather than calling the function keeps two things true
|
||||||
|
// that calling it would not — every command opens and closes its own stores the way it does from a
|
||||||
|
// shell, and nothing a command prints to the process's standard output can leak into another call's
|
||||||
|
// answer. It also means a refusal is the same refusal in the same words, because it is the same
|
||||||
|
// output.
|
||||||
|
|
||||||
|
// verbAnswer is what a verb answers: what the command printed, whether it succeeded, and — where the
|
||||||
|
// command speaks JSON — the same as data.
|
||||||
|
type verbAnswer struct {
|
||||||
|
Output string `json:"output"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Answer any `json:"answer,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// argvFor is the command line a verb and its arguments become. Only the verbs the seat declares, and
|
||||||
|
// only the arguments each declares: a caller cannot reach a flag the schema did not name.
|
||||||
|
func argvFor(verb string, args map[string]any) ([]string, error) {
|
||||||
|
str := func(key string) string {
|
||||||
|
v, _ := args[key].(string)
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
need := func(keys ...string) error {
|
||||||
|
for _, k := range keys {
|
||||||
|
if str(k) == "" {
|
||||||
|
return fmt.Errorf("%s needs %q", verb, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch verb {
|
||||||
|
case "status":
|
||||||
|
return []string{"status", "--json"}, nil
|
||||||
|
case "nodes":
|
||||||
|
return []string{"node", "list"}, 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
|
||||||
|
case "seats":
|
||||||
|
return []string{"seats", "--json"}, nil
|
||||||
|
case "builds":
|
||||||
|
if id := str("log"); id != "" {
|
||||||
|
return []string{"builds", "--log", id}, nil
|
||||||
|
}
|
||||||
|
if m := str("module"); m != "" {
|
||||||
|
return []string{"builds", m}, nil
|
||||||
|
}
|
||||||
|
return []string{"builds"}, nil
|
||||||
|
case "plans":
|
||||||
|
if r := str("repository"); r != "" {
|
||||||
|
argv := []string{"plans", "--what-if", r}
|
||||||
|
if p := str("paths"); p != "" {
|
||||||
|
argv = append(argv, "--paths", p)
|
||||||
|
}
|
||||||
|
if m := str("modules"); m != "" {
|
||||||
|
argv = append(argv, "--modules", m)
|
||||||
|
}
|
||||||
|
return argv, nil
|
||||||
|
}
|
||||||
|
if id := str("stop"); id != "" {
|
||||||
|
return []string{"plans", "stop", id}, nil
|
||||||
|
}
|
||||||
|
if id := str("id"); id != "" {
|
||||||
|
return []string{"plans", id}, nil
|
||||||
|
}
|
||||||
|
return []string{"plans"}, nil
|
||||||
|
case "plan":
|
||||||
|
if err := need("node"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []string{"plan", str("node"), "--json"}, nil
|
||||||
|
case "assign", "unassign":
|
||||||
|
if err := need("node", "module"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []string{verb, str("node"), str("module")}, nil
|
||||||
|
case "pin":
|
||||||
|
if err := need("node", "provision", "from", "module"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []string{"pin", str("node"), str("provision"), str("from"), str("module")}, nil
|
||||||
|
case "unpin":
|
||||||
|
if err := need("node", "provision"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []string{"unpin", str("node"), str("provision")}, nil
|
||||||
|
case "push":
|
||||||
|
// Sent and not waited for: the asker reads `status` for what the machine did, which is
|
||||||
|
// what a person at a shell does too. A tool call that blocked for a push's whole apply would
|
||||||
|
// time out on every machine that takes a minute, and say nothing about the ones that did not.
|
||||||
|
if n := str("node"); n != "" {
|
||||||
|
return []string{"push", n, "--wait", "0"}, nil
|
||||||
|
}
|
||||||
|
return []string{"push", "--behind", "--wait", "0"}, nil
|
||||||
|
case "rotate":
|
||||||
|
if p := str("provision"); p != "" {
|
||||||
|
argv := []string{"rotate", p}
|
||||||
|
if c := str("consumer"); c != "" {
|
||||||
|
argv = append(argv, "--consumer", c)
|
||||||
|
}
|
||||||
|
return argv, nil
|
||||||
|
}
|
||||||
|
if str("node") != "" && str("module") != "" && str("secret") != "" {
|
||||||
|
return []string{"secret", "rotate", str("node"), str("module"), str("secret")}, nil
|
||||||
|
}
|
||||||
|
// Half of either shape: the command says its usage, which names both shapes, and that is
|
||||||
|
// the answer the caller needs.
|
||||||
|
return []string{"rotate"}, nil
|
||||||
|
case "build":
|
||||||
|
if err := need("repository"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Not waited for: a tool call cannot hold a connection for the minutes a build takes; the
|
||||||
|
// daemon takes the outcome in when it comes and the id follows the build (issue 176). A
|
||||||
|
// repository given without a scheme is a path on the forge holding the git seat.
|
||||||
|
argv := []string{"build", str("repository"), "--wait", "0"}
|
||||||
|
if !strings.Contains(str("repository"), "://") && !strings.HasPrefix(str("repository"), "git@") {
|
||||||
|
argv = append(argv, "--self")
|
||||||
|
}
|
||||||
|
if p := str("path"); p != "" {
|
||||||
|
argv = append(argv, "--path", p)
|
||||||
|
}
|
||||||
|
if r := str("ref"); r != "" {
|
||||||
|
argv = append(argv, "--ref", r)
|
||||||
|
}
|
||||||
|
return argv, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%q is not a verb the %s seat serves", verb, catalogue.ControllerSeatName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonVerbs are the verbs whose command speaks JSON, so the answer carries it as data as well.
|
||||||
|
var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true}
|
||||||
|
|
||||||
|
// runVerb runs this binary with the given command line and gathers what it said.
|
||||||
|
func runVerb(ctx context.Context, argv []string) (verbAnswer, error) {
|
||||||
|
self, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return verbAnswer{}, err
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, self, argv...)
|
||||||
|
// The same environment: the stores' credentials, the bus, the broker — everything a command run
|
||||||
|
// from a shell in this container would have, because it is that.
|
||||||
|
cmd.Env = os.Environ()
|
||||||
|
// Two buffers, one answer. What the command *says* is both streams, in the order a person at
|
||||||
|
// a shell would read them; what it *answers as data* is standard output alone — `status --json`
|
||||||
|
// prints its warnings beside the document, and a JSON parsed from the two together parsed
|
||||||
|
// nothing (2026-09-30, the first status asked through the console had no `answer`).
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
runErr := cmd.Run()
|
||||||
|
answer := verbAnswer{Output: stdout.String() + stderr.String(), OK: runErr == nil}
|
||||||
|
if jsonVerbs[argv[0]] && runErr == nil {
|
||||||
|
var parsed any
|
||||||
|
if json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &parsed) == nil {
|
||||||
|
answer.Answer = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var exit *exec.ExitError
|
||||||
|
if runErr != nil && !errors.As(runErr, &exit) {
|
||||||
|
// Not the command refusing — the command not running at all, which is this process's fault.
|
||||||
|
return answer, fmt.Errorf("could not run %s: %w", strings.Join(argv, " "), runErr)
|
||||||
|
}
|
||||||
|
return answer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatToolHandlers are the handlers for every verb the mesh-controller seat declares, from the
|
||||||
|
// store's row, so a verb the row does not carry is not served and a verb it carries that this binary
|
||||||
|
// cannot run is said at start rather than at the first call.
|
||||||
|
func seatToolHandlers() (map[string]link.ToolHandler, error) {
|
||||||
|
seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName)
|
||||||
|
if !known {
|
||||||
|
return nil, fmt.Errorf("this mesh defines no %s seat", catalogue.ControllerSeatName)
|
||||||
|
}
|
||||||
|
handlers := map[string]link.ToolHandler{}
|
||||||
|
for _, v := range seat.Serves {
|
||||||
|
verb := v.Name
|
||||||
|
if verb == "tools" {
|
||||||
|
handlers[verb] = func(ctx context.Context, _ json.RawMessage) (any, error) {
|
||||||
|
return seatTools(), nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := argvFor(verb, sampleArguments(v)); err != nil {
|
||||||
|
return nil, fmt.Errorf("the %s seat's row declares %q, which this control plane cannot run: %w",
|
||||||
|
catalogue.ControllerSeatName, verb, err)
|
||||||
|
}
|
||||||
|
handlers[verb] = func(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||||
|
args := map[string]any{}
|
||||||
|
if len(raw) > 0 {
|
||||||
|
if err := json.Unmarshal(raw, &args); err != nil {
|
||||||
|
return nil, fmt.Errorf("the arguments are not a JSON object: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
argv, err := argvFor(verb, args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return runVerb(ctx, argv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return handlers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatTools is what `tools` answers: every seat with a protocol, and the tools each serves, from the
|
||||||
|
// mesh's own records — no holder in the path, so it is true while a holder restarts (design 33 §5).
|
||||||
|
func seatTools() map[string]any {
|
||||||
|
var seats []map[string]any
|
||||||
|
for _, s := range catalogue.SeatsWithAProtocol() {
|
||||||
|
if len(s.Serves) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var tools []map[string]any
|
||||||
|
for _, v := range s.Serves {
|
||||||
|
tools = append(tools, map[string]any{
|
||||||
|
"name": v.Name, "description": v.Description, "input": v.Input, "output": v.Output,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
seats = append(seats, map[string]any{"seat": s.Name, "scope": s.Scope, "tools": tools})
|
||||||
|
}
|
||||||
|
return map[string]any{"seats": seats}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sampleArguments is one of every argument a verb's schema requires, so the check at start proves the
|
||||||
|
// verb runnable rather than that it happens to want the arguments the check guessed.
|
||||||
|
func sampleArguments(v catalogue.Verb) map[string]any {
|
||||||
|
sample := map[string]any{"node": "x", "module": "x", "repository": "x"}
|
||||||
|
switch required := v.Input["required"].(type) {
|
||||||
|
case []string:
|
||||||
|
for _, k := range required {
|
||||||
|
sample[k] = "x"
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, k := range required {
|
||||||
|
if name, ok := k.(string); ok {
|
||||||
|
sample[name] = "x"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sample
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every verb the mesh-controller seat declares is one this binary can run, with the arguments the
|
||||||
|
// schema names and no other (novox/hq ADR 0154, ADR 0035).
|
||||||
|
func TestEveryDeclaredVerbHasACommandLine(t *testing.T) {
|
||||||
|
for _, v := range catalogue.ControllerVerbs {
|
||||||
|
if v.Name == "tools" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
args := map[string]any{}
|
||||||
|
props, _ := v.Input["properties"].(map[string]any)
|
||||||
|
for name := range props {
|
||||||
|
args[name] = "x"
|
||||||
|
}
|
||||||
|
argv, err := argvFor(v.Name, args)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: %v", v.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if argv[0] == "" {
|
||||||
|
t.Errorf("%s: empty command", v.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `builds` given a build's id reads that build's log from the bus rather than listing builds
|
||||||
|
// (novox/hq ADR 0157).
|
||||||
|
func TestBuildsWithAnIdReadsThatBuildsLog(t *testing.T) {
|
||||||
|
argv, err := argvFor("builds", map[string]any{"log": "build-17"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Join(argv, " ") != "builds --log build-17" {
|
||||||
|
t.Fatalf("builds with a log id became %q", strings.Join(argv, " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The build tool takes a repository as a URL or as its path on the forge holding the git seat, and
|
||||||
|
// says which it was given, so the command reads the path as a seat source rather than handing it to
|
||||||
|
// git as written (novox/hq issue 176). And it never waits: the id follows the build.
|
||||||
|
func TestTheBuildToolTellsAForgePathFromAURL(t *testing.T) {
|
||||||
|
argv, _ := argvFor("build", map[string]any{"repository": "novox/mesh-catalog", "path": "modules/x"})
|
||||||
|
if line := strings.Join(argv, " "); !strings.Contains(line, "--self") || !strings.Contains(line, "--wait 0") {
|
||||||
|
t.Fatalf("a forge path is a seat source, not waited for; got %q", line)
|
||||||
|
}
|
||||||
|
argv, _ = argvFor("build", map[string]any{"repository": "https://example.tld/o/r.git"})
|
||||||
|
if line := strings.Join(argv, " "); strings.Contains(line, "--self") {
|
||||||
|
t.Fatalf("a URL is cloned as given; got %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `rotate` is one verb with two shapes (ADR 0114, issue 180): a pair credential by provision, or a
|
||||||
|
// module's own secret by machine, module and name.
|
||||||
|
func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) {
|
||||||
|
argv, _ := argvFor("rotate", map[string]any{"provision": "postgres-database", "consumer": "ace"})
|
||||||
|
if strings.Join(argv, " ") != "rotate postgres-database --consumer ace" {
|
||||||
|
t.Fatalf("a pair credential: %v", argv)
|
||||||
|
}
|
||||||
|
argv, _ = argvFor("rotate", map[string]any{"node": "ace", "module": "nodered", "secret": "api-token"})
|
||||||
|
if strings.Join(argv, " ") != "secret rotate ace nodered api-token" {
|
||||||
|
t.Fatalf("an own secret: %v", argv)
|
||||||
|
}
|
||||||
|
argv, _ = argvFor("rotate", map[string]any{"node": "ace"})
|
||||||
|
if strings.Join(argv, " ") != "rotate" {
|
||||||
|
t.Fatalf("half an own secret falls to the command's usage: %v", argv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A required argument missing is refused in the verb's own words, before anything runs.
|
||||||
|
func TestAVerbMissingWhatItNeedsIsRefused(t *testing.T) {
|
||||||
|
if _, err := argvFor("node", map[string]any{}); err == nil || !strings.Contains(err.Error(), `node needs "node"`) {
|
||||||
|
t.Fatalf("node without a machine was accepted: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := argvFor("upgrade", map[string]any{}); err == nil {
|
||||||
|
t.Fatal("a verb the seat does not serve was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A push and a build are sent, not waited for: the asker reads status, or the build's log by its
|
||||||
|
// id, for what happened. A repository given as a forge path is said to be one (issue 176).
|
||||||
|
func TestActsDoNotBlockTheCall(t *testing.T) {
|
||||||
|
argv, _ := argvFor("push", map[string]any{"node": "one"})
|
||||||
|
if strings.Join(argv, " ") != "push one --wait 0" {
|
||||||
|
t.Fatalf("push waits: %v", argv)
|
||||||
|
}
|
||||||
|
argv, _ = argvFor("build", map[string]any{"repository": "novox/x", "path": "modules/x"})
|
||||||
|
if strings.Join(argv, " ") != "build novox/x --wait 0 --self --path modules/x" {
|
||||||
|
t.Fatalf("build: %v", argv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What `tools` answers is the seats' records, with each verb's schema.
|
||||||
|
func TestToolsAnswersTheSeatsRecords(t *testing.T) {
|
||||||
|
handlers, err := seatToolHandlers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(handlers) != len(catalogue.ControllerVerbs) {
|
||||||
|
t.Fatalf("%d handlers for %d verbs", len(handlers), len(catalogue.ControllerVerbs))
|
||||||
|
}
|
||||||
|
answer := seatTools()
|
||||||
|
seats, _ := answer["seats"].([]map[string]any)
|
||||||
|
var found bool
|
||||||
|
for _, s := range seats {
|
||||||
|
if s["seat"] == catalogue.ControllerSeatName {
|
||||||
|
found = true
|
||||||
|
tools, _ := s["tools"].([]map[string]any)
|
||||||
|
if len(tools) != len(catalogue.ControllerVerbs) || tools[0]["input"] == nil {
|
||||||
|
t.Fatalf("the controller seat's tools are not listed in full: %v", tools)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("the mesh-controller seat is not in the listing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A JSON verb's answer is parsed from what the command wrote to standard output alone; a warning it
|
||||||
|
// printed beside the document does not take the document away. The test binary stands in for the
|
||||||
|
// controller: `-test.run` with a name that matches nothing prints `ok` and a warning about no tests.
|
||||||
|
func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) {
|
||||||
|
jsonVerbs["-test.run"] = true
|
||||||
|
t.Cleanup(func() { delete(jsonVerbs, "-test.run") })
|
||||||
|
answer, err := runVerb(t.Context(), []string{"-test.run", "TestAnswerEcho", "-test.v"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !answer.OK {
|
||||||
|
t.Fatalf("the command failed: %s", answer.Output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(answer.Output, "PASS") {
|
||||||
|
t.Fatalf("stderr and stdout are both what the command said: %s", answer.Output)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
@@ -38,6 +39,8 @@ func secretCommand(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "accept":
|
case "accept":
|
||||||
|
case "rotate":
|
||||||
|
return secretRotate(ctx, args[1:])
|
||||||
case "recover":
|
case "recover":
|
||||||
return secretRecover(ctx, args[1:])
|
return secretRecover(ctx, args[1:])
|
||||||
case "export":
|
case "export":
|
||||||
@@ -101,7 +104,8 @@ func secretCommand(ctx context.Context, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const secretUsage = "secret accept <node> <module> <name> [--from <file>] [--provider <node> [--local <name>]]\n" +
|
const secretUsage = "secret rotate <node> <module> <name>\n" +
|
||||||
|
"secret accept <node> <module> <name> [--from <file>] [--provider <node> [--local <name>]]\n" +
|
||||||
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
|
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
|
||||||
"secret export [--out <file>]"
|
"secret export [--out <file>]"
|
||||||
|
|
||||||
@@ -359,3 +363,59 @@ func valueFor(node, module, name, from string) (string, error) {
|
|||||||
return line, nil
|
return line, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// secretRotate makes a module's own secret anew and sends the machine, so the module starts again on
|
||||||
|
// the new value (novox/hq ADR 0114, issue 180). A pair credential rotates with `rotate <provision>`;
|
||||||
|
// this is the secret with one party. Said in the log with who asked and when, never the value.
|
||||||
|
func secretRotate(ctx context.Context, args []string) error {
|
||||||
|
rest, _ := split(args)
|
||||||
|
if len(rest) != 3 {
|
||||||
|
return errors.New(secretUsage)
|
||||||
|
}
|
||||||
|
node, module, name := rest[0], rest[1], rest[2]
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
if err := open.inventory.RotateModuleSecret(ctx, node, module, name); err != nil {
|
||||||
|
var refused inventory.ErrNotRotatable
|
||||||
|
if errors.As(err, &refused) {
|
||||||
|
return fmt.Errorf("not rotated: %s", refused.Why)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("rotated %q of %s on %s at %s, asked by %s; the value is sealed and not shown\n",
|
||||||
|
name, module, node, time.Now().UTC().Format(time.RFC3339), whoAsked())
|
||||||
|
// A shared credential (ADR 0158) has as many holders as the provision has consumers, and all
|
||||||
|
// of them are sent in one act, so no machine is left reading a value the provider no longer takes.
|
||||||
|
machines, err := open.inventory.SharedHolders(ctx, node, module, name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(machines) == 0 {
|
||||||
|
machines = []string{node}
|
||||||
|
}
|
||||||
|
if len(machines) == 1 {
|
||||||
|
fmt.Printf("sending %s, so %s starts again on the new value:\n", node, module)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("shared with every consumer; sending %s together:\n", strings.Join(machines, ", "))
|
||||||
|
}
|
||||||
|
if err := sendTo(ctx, open, machines); err != nil {
|
||||||
|
return fmt.Errorf("%w\n\nThe new value is sealed and not yet delivered; what runs keeps the old "+
|
||||||
|
"one until the machines next apply. Fix the cause and run `push --behind`", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// whoAsked names the caller for the log: the account the command runs as, which for a tool call
|
||||||
|
// through the console is the mesh's own.
|
||||||
|
func whoAsked() string {
|
||||||
|
if u := os.Getenv("SUDO_USER"); u != "" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
if u := os.Getenv("USER"); u != "" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
return "the mesh"
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,9 +18,21 @@ import (
|
|||||||
// make a machine look out of date for ever, or send something `plan` never showed.
|
// make a machine look out of date for ever, or send something `plan` never showed.
|
||||||
type sendable struct {
|
type sendable struct {
|
||||||
Resources []map[string]any
|
Resources []map[string]any
|
||||||
|
// Sequence orders this send against every other to the same node: one higher each time, taken
|
||||||
|
// under the node's hold just before the body is made (novox/hq 04-ISSUES/107). Zero is not sent
|
||||||
|
// at all, which a host reads as "no order claimed" — the shape of every declaration before this.
|
||||||
|
Sequence int64
|
||||||
// Adoption is nil for a converged node, and then the body is byte for byte what it was before
|
// Adoption is nil for a converged node, and then the body is byte for byte what it was before
|
||||||
// adoption existed: an older host parses the envelope strictly and would refuse the key.
|
// adoption existed: an older host parses the envelope strictly and would refuse the key.
|
||||||
Adoption *adoptionEnvelope
|
Adoption *adoptionEnvelope
|
||||||
|
// LeftOut is every module of the machine's set left out of this declaration because a stored
|
||||||
|
// setting cannot compose with its definition (novox/hq ADR 0163, rule 6), sorted. The host
|
||||||
|
// keeps that module's held things and touches none of its containers; a machine is told
|
||||||
|
// everything or nothing about what it IS told, and what it is not told is said. Absent from
|
||||||
|
// the body when empty, so a declaration that leaves nothing out is byte for byte what it was.
|
||||||
|
LeftOut []string
|
||||||
|
// leftOutWhy is why each was, for push and plan to say; never on the wire.
|
||||||
|
leftOutWhy map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// adoptionEnvelope is what an adopted node is told about its mode. Taken is every module taken on
|
// adoptionEnvelope is what an adopted node is told about its mode. Taken is every module taken on
|
||||||
@@ -38,6 +50,12 @@ func (s sendable) Body() ([]byte, error) {
|
|||||||
if s.Adoption != nil {
|
if s.Adoption != nil {
|
||||||
envelope["adoption"] = s.Adoption
|
envelope["adoption"] = s.Adoption
|
||||||
}
|
}
|
||||||
|
if s.Sequence > 0 {
|
||||||
|
envelope["sequence"] = s.Sequence
|
||||||
|
}
|
||||||
|
if len(s.LeftOut) > 0 {
|
||||||
|
envelope["left_out"] = s.LeftOut
|
||||||
|
}
|
||||||
// An empty declaration is deliberate here — the node owns nothing the mesh put there
|
// An empty declaration is deliberate here — the node owns nothing the mesh put there
|
||||||
// (novox/hq issue 127) — and the host refuses an empty body unless it is told the emptiness
|
// (novox/hq issue 127) — and the host refuses an empty body unless it is told the emptiness
|
||||||
// is meant, so a truncated or mis-composed body is never mistaken for "own nothing".
|
// is meant, so a truncated or mis-composed body is never mistaken for "own nothing".
|
||||||
|
|||||||
@@ -47,7 +47,12 @@ func composed(t *testing.T, open *stores, node string) sendable {
|
|||||||
// aMesh's laptop with the private network taken off it, so nothing in the declaration is random:
|
// aMesh's laptop with the private network taken off it, so nothing in the declaration is random:
|
||||||
// what changes this string is a change to what a converged machine is sent, which is the thing an
|
// what changes this string is a change to what a converged machine is sent, which is the thing an
|
||||||
// older host would refuse.
|
// older host would refuse.
|
||||||
const convergedBefore = `{"declaration":1,"resources":[{"content":"hello","id":"hello-web.page","path":"/var/lib/hello-web/index.html","type":"file"},{"hosts":["anchor.internal:10.77.0.1"],"id":"hello-web.server","image":"registry.example/hello@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","name":"hello-web","type":"container"},{"id":"hello-web.served","path":"/var/lib/hello-web","type":"directory"}]}`
|
//
|
||||||
|
// Re-captured 2026-10-01 (novox/hq issue 177): c978aa7 took `hosts` off every container — a
|
||||||
|
// machine's own resolver knows the mesh's names now — and left this string carrying it, so the
|
||||||
|
// guard failed for a day and nothing ran it. A field an older host never sees is the one change
|
||||||
|
// this guard permits; a field it would refuse is the one it exists to catch.
|
||||||
|
const convergedBefore = `{"declaration":1,"resources":[{"content":"hello","id":"hello-web.page","path":"/var/lib/hello-web/index.html","type":"file"},{"id":"hello-web.server","image":"registry.example/hello@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","name":"hello-web","type":"container"},{"id":"hello-web.served","path":"/var/lib/hello-web","type":"directory"}]}`
|
||||||
|
|
||||||
func TestAConvergedDeclarationIsByteForByteWhatItWas(t *testing.T) {
|
func TestAConvergedDeclarationIsByteForByteWhatItWas(t *testing.T) {
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
@@ -377,3 +382,62 @@ func TestAnEmptyDeclarationSaysOwnsNothing(t *testing.T) {
|
|||||||
t.Fatalf("a non-empty declaration must not mark owns_nothing; got %v", env)
|
t.Fatalf("a non-empty declaration must not mark owns_nothing; got %v", env)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A setting is judged where it is stored, and an impossible one costs a module, not a machine
|
||||||
|
// (novox/hq ADR 0163, rule 6): stored while it composed, a setting whose definition then moved from
|
||||||
|
// under it leaves that module out of the declaration — said in the envelope, so the host keeps the
|
||||||
|
// module's things — and the machine is told everything else.
|
||||||
|
func TestADefinitionMovingUnderAStoredSettingLeavesThatModuleOutNotTheMachine(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
web := helloWeb()
|
||||||
|
web.Resources[1]["ports"] = []any{"8080"}
|
||||||
|
register(t, open, web)
|
||||||
|
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
|
||||||
|
Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/notes.conf", "content": "x"}}})
|
||||||
|
for _, m := range []string{"hello-web", "notes"} {
|
||||||
|
if _, err := assign(ctx, open, "laptop", m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Judged where it is stored: a port the module does not publish is refused by name.
|
||||||
|
err := open.inventory.SetSettings(ctx, "laptop", "hello-web",
|
||||||
|
map[string]any{catalogue.PortsSetting: map[string]any{"9999": 10000}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "hello-web on laptop") || !strings.Contains(err.Error(), "9999") {
|
||||||
|
t.Fatalf("an impossible setting was stored: %v", err)
|
||||||
|
}
|
||||||
|
if err := open.inventory.SetSettings(ctx, "laptop", "hello-web",
|
||||||
|
map[string]any{catalogue.PortsSetting: map[string]any{"8080": 10000}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if declared := composed(t, open, "laptop"); len(declared.LeftOut) != 0 {
|
||||||
|
t.Fatalf("a setting that composes left a module out: %v", declared.LeftOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The definition moves: the container publishes another port now.
|
||||||
|
web.Version = "2"
|
||||||
|
web.Resources[1]["ports"] = []any{"9090"}
|
||||||
|
register(t, open, web)
|
||||||
|
declared := composed(t, open, "laptop")
|
||||||
|
if len(declared.LeftOut) != 1 || declared.LeftOut[0] != "hello-web" {
|
||||||
|
t.Fatalf("hello-web is not left out: %v", declared.LeftOut)
|
||||||
|
}
|
||||||
|
if !strings.Contains(declared.leftOutWhy["hello-web"], "no container of its publishes 8080") {
|
||||||
|
t.Fatalf("why it was left out is not said: %v", declared.leftOutWhy)
|
||||||
|
}
|
||||||
|
if hasID(declared.Resources, "hello-web.server") || !hasID(declared.Resources, "notes.conf") {
|
||||||
|
t.Fatalf("the machine was not told everything else: %v", declared.Resources)
|
||||||
|
}
|
||||||
|
body, err := declared.Body()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var env map[string]any
|
||||||
|
if err := json.Unmarshal(body, &env); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
left, _ := env["left_out"].([]any)
|
||||||
|
if len(left) != 1 || left[0] != "hello-web" {
|
||||||
|
t.Fatalf("the envelope does not say what was left out: %v", env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A declaration's only identity was the digest of its bytes; the controller already held a per-node
|
||||||
|
// lock and recorded each send, so the order existed and was thrown away at the wire (novox/hq
|
||||||
|
// 04-ISSUES/107).
|
||||||
|
|
||||||
|
func TestASendCarriesItsNumberInsideTheSignedBytes(t *testing.T) {
|
||||||
|
body, err := sendable{Resources: []map[string]any{{"id": "x", "type": "file"}}, Sequence: 7}.Body()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var env map[string]any
|
||||||
|
if err := json.Unmarshal(body, &env); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, _ := env["sequence"].(float64); got != 7 {
|
||||||
|
t.Fatalf("the body carries sequence %v, wanted 7", env["sequence"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUnnumberedSendIsByteForByteWhatItWasBefore(t *testing.T) {
|
||||||
|
// Zero is not sent at all. A host reads absence as "no order claimed" — the shape of every
|
||||||
|
// declaration before this — so an older host, or the read-only comparison against a machine
|
||||||
|
// sent nothing since sends were numbered, sees exactly the bytes it always saw.
|
||||||
|
body, err := sendable{Resources: []map[string]any{{"id": "x", "type": "file"}}}.Body()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var env map[string]any
|
||||||
|
if err := json.Unmarshal(body, &env); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, present := env["sequence"]; present {
|
||||||
|
t.Fatalf("a send numbered zero put a sequence on the wire: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEachSendToANodeIsOneHigherAndReadable(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
record, err := open.inventory.NodeByName(t.Context(), "anchor")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Sent nothing since numbering existed: what it would be sent is composed with zero, which is
|
||||||
|
// not on the wire, which is what it was actually sent.
|
||||||
|
if n, err := open.inventory.Sequence(t.Context(), record.ID); err != nil || n != 0 {
|
||||||
|
t.Fatalf("a fresh node reads sequence %d, %v", n, err)
|
||||||
|
}
|
||||||
|
first, err := open.inventory.NextSequence(t.Context(), record.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := open.inventory.NextSequence(t.Context(), record.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first != 1 || second != 2 {
|
||||||
|
t.Fatalf("two sends were numbered %d and %d", first, second)
|
||||||
|
}
|
||||||
|
// And the read path sees the last one taken, so the comparison composes what was sent.
|
||||||
|
if n, err := open.inventory.Sequence(t.Context(), record.ID); err != nil || n != 2 {
|
||||||
|
t.Fatalf("after two sends the node reads sequence %d, %v", n, err)
|
||||||
|
}
|
||||||
|
// Another node counts on its own.
|
||||||
|
other, err := open.inventory.NodeByName(t.Context(), "laptop")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n, err := open.inventory.NextSequence(t.Context(), other.ID); err != nil || n != 1 {
|
||||||
|
t.Fatalf("a second node's first send was numbered %d, %v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,16 @@ func cloneFrom(ctx context.Context, source buildSource) (string, error) {
|
|||||||
// serves no scheme or port has nothing to compose from — a default port here would be the forge's
|
// serves no scheme or port has nothing to compose from — a default port here would be the forge's
|
||||||
// address guessed, which is the thing this exists to stop.
|
// address guessed, which is the thing this exists to stop.
|
||||||
func clonedFromSeat(world catalogue.World, seatName, repository string) (string, error) {
|
func clonedFromSeat(world catalogue.World, seatName, repository string) (string, error) {
|
||||||
|
base, err := seatBase(world, seatName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := strings.TrimSuffix(strings.Trim(repository, "/"), ".git")
|
||||||
|
return fmt.Sprintf("%s/%s.git", base, path), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatBase is `scheme://host:port` of a seat's holder as the mesh reaches it, for cloning.
|
||||||
|
func seatBase(world catalogue.World, seatName string) (string, error) {
|
||||||
seat, known := catalogue.SeatNamed(seatName)
|
seat, known := catalogue.SeatNamed(seatName)
|
||||||
if !known || seat.Delivers == "" {
|
if !known || seat.Delivers == "" {
|
||||||
return "", fmt.Errorf("%q is not a seat a repository can live on", seatName)
|
return "", fmt.Errorf("%q is not a seat a repository can live on", seatName)
|
||||||
@@ -94,9 +104,9 @@ func clonedFromSeat(world catalogue.World, seatName, repository string) (string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if holder == nil {
|
if holder == nil {
|
||||||
return "", fmt.Errorf("nobody holds the %s seat, so %s cannot be cloned from this mesh's "+
|
return "", fmt.Errorf("nobody holds the %s seat, so nothing can be cloned from this mesh's "+
|
||||||
"forge — assign a module that claims it, or build from the repository's URL without --self",
|
"forge — assign a module that claims it, or build from the repository's URL without --self",
|
||||||
seat.Name, repository)
|
seat.Name)
|
||||||
}
|
}
|
||||||
var provider *catalogue.Provider
|
var provider *catalogue.Provider
|
||||||
for i, p := range world.Offered[seat.Delivers] {
|
for i, p := range world.Offered[seat.Delivers] {
|
||||||
@@ -118,8 +128,33 @@ func clonedFromSeat(world catalogue.World, seatName, repository string) (string,
|
|||||||
return "", fmt.Errorf("%s on %s holds the %s seat and does not serve a scheme and a port for %q",
|
return "", fmt.Errorf("%s on %s holds the %s seat and does not serve a scheme and a port for %q",
|
||||||
holder.Module, holder.Node, seat.Name, seat.Delivers)
|
holder.Module, holder.Node, seat.Name, seat.Delivers)
|
||||||
}
|
}
|
||||||
path := strings.TrimSuffix(strings.Trim(repository, "/"), ".git")
|
return fmt.Sprintf("%s://%s:%s", scheme, provider.At, port), nil
|
||||||
return fmt.Sprintf("%s://%s:%s/%s.git", scheme, provider.At, port, path), nil
|
}
|
||||||
|
|
||||||
|
// seatBases is the clone base of every seat a recipe's context may name, for a build request
|
||||||
|
// (novox/hq ADR 0155). A seat nobody holds is left out rather than refused here: the build may not
|
||||||
|
// name it at all, and if it does the builder refuses with the seat's name.
|
||||||
|
func seatBases(ctx context.Context) map[string]string {
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
shelf, err := open.inventory.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
world, err := theRestOfTheMesh(ctx, open.inventory, shelf, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
bases := map[string]string{}
|
||||||
|
for _, seatName := range []string{gitSeat} {
|
||||||
|
if base, err := seatBase(world, seatName); err == nil {
|
||||||
|
bases[seatName] = base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bases
|
||||||
}
|
}
|
||||||
|
|
||||||
// servedPort is a served port as text, however the manifest and the node's settings carried it.
|
// servedPort is a served port as text, however the manifest and the node's settings carried it.
|
||||||
|
|||||||
@@ -46,15 +46,21 @@ func statusCommand(ctx context.Context, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer open.Close()
|
defer open.Close()
|
||||||
|
return statusFor(ctx, open, *asJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusFor asks and answers, against stores somebody else opened.
|
||||||
|
//
|
||||||
|
// Split from the command so what it prints can be read by a test. The sentence it prints when nothing
|
||||||
|
// is wrong has been acted on and been misleading (novox/hq 04-ISSUES/145, 125), which makes its exact
|
||||||
|
// words the thing worth holding still.
|
||||||
|
func statusFor(ctx context.Context, open *stores, asJSON bool) error {
|
||||||
asked, err := theThreeQuestions(ctx, open)
|
asked, err := theThreeQuestions(ctx, open)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
wrong, nodes, quiet := asked.wrong, asked.nodes, asked.quiet
|
|
||||||
behind, sources := asked.behind, asked.sources
|
|
||||||
|
|
||||||
if *asJSON {
|
if asJSON {
|
||||||
body, err := statusAsJSON(asked)
|
body, err := statusAsJSON(asked)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -62,6 +68,18 @@ func statusCommand(ctx context.Context, args []string) error {
|
|||||||
fmt.Println(string(body))
|
fmt.Println(string(body))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
return printStatus(asked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// printStatus is the words, separated from the questions.
|
||||||
|
//
|
||||||
|
// **Its exact sentences have been acted on and been misleading twice** — a held module reading as a
|
||||||
|
// machine doing what it was told (novox/hq 04-ISSUES/125), and "all doing what they were told" being
|
||||||
|
// true of a mesh in which no module could reach another (04-ISSUES/145). So they are written where a
|
||||||
|
// test can read them without a store, a bus or a machine.
|
||||||
|
func printStatus(asked answers) error {
|
||||||
|
wrong, nodes, quiet := asked.wrong, asked.nodes, asked.quiet
|
||||||
|
behind, sources := asked.behind, asked.sources
|
||||||
|
|
||||||
if len(asked.refused) > 0 {
|
if len(asked.refused) > 0 {
|
||||||
// First, above everything else. A machine that cannot be worked out is not running an old
|
// First, above everything else. A machine that cannot be worked out is not running an old
|
||||||
@@ -120,6 +138,18 @@ func statusCommand(ctx context.Context, args []string) error {
|
|||||||
len(quiet), strings.Join(said, "\n "))
|
len(quiet), strings.Join(said, "\n "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if open, late := openPlans(asked.plans); len(open) > 0 {
|
||||||
|
fmt.Printf("%d plan(s) open", len(open))
|
||||||
|
if late > 0 {
|
||||||
|
fmt.Printf(", %d waiting past %s", late, planWaitBound)
|
||||||
|
}
|
||||||
|
fmt.Println(":")
|
||||||
|
for _, p := range open {
|
||||||
|
fmt.Printf(" %s\n", planLine(p, time.Now()))
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
if len(behind) > 0 {
|
if len(behind) > 0 {
|
||||||
var names []string
|
var names []string
|
||||||
for m := range behind {
|
for m := range behind {
|
||||||
@@ -171,6 +201,65 @@ func statusCommand(ctx context.Context, args []string) error {
|
|||||||
fmt.Printf("\n `push --behind` sends them\n\n")
|
fmt.Printf("\n `push --behind` sends them\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if split := hostSplit(nodes); len(split) > 1 {
|
||||||
|
// **Before a declaration gains a field, every machine has to understand it** (novox/hq
|
||||||
|
// 04-ISSUES/087). A host refuses a declaration carrying a field it does not know, and refuses
|
||||||
|
// it whole, so every new field is a flag day: hosts first, then the controller. The mesh had
|
||||||
|
// no record of which host any machine ran, so that order was kept by somebody remembering it.
|
||||||
|
//
|
||||||
|
// **Disagreement, and deliberately not "behind".** A host reports its version as a commit, and
|
||||||
|
// commits have no order — the first version of this said "N machines run an older host" and
|
||||||
|
// named the three that were newer, because it compared two hashes as strings. What the mesh
|
||||||
|
// can say truthfully is that the machines do not all run the same host, and which machines
|
||||||
|
// hold which. Ordering needs a version that is ordered, and that is the host's to report.
|
||||||
|
versions := make([]string, 0, len(split))
|
||||||
|
for v := range split {
|
||||||
|
versions = append(versions, v)
|
||||||
|
}
|
||||||
|
sort.Strings(versions)
|
||||||
|
fmt.Printf("%d machine(s) do not all run the same host:\n", len(nodes))
|
||||||
|
for _, v := range versions {
|
||||||
|
sort.Strings(split[v])
|
||||||
|
fmt.Printf(" %-12s %s\n", v, strings.Join(split[v], ", "))
|
||||||
|
}
|
||||||
|
fmt.Printf("\n a host refuses a declaration carrying a field it does not know, whole — so the\n" +
|
||||||
|
" mesh may send only what every one of these understands. Which of them is newer is\n" +
|
||||||
|
" not readable from a commit; that needs a version the host reports as ordered\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(asked.untaken) > 0 {
|
||||||
|
// **Before the adopted line, and it breaks "all well".** An adopted machine is a state
|
||||||
|
// somebody chose and can leave alone; a module assigned to one and never taken is work
|
||||||
|
// outstanding that reads exactly like work finished. That reading is what stopped a
|
||||||
|
// predecessor's proxy on the strength of four green surfaces (novox/hq 04-ISSUES/125).
|
||||||
|
machines := make([]string, 0, len(asked.untaken))
|
||||||
|
for name := range asked.untaken {
|
||||||
|
machines = append(machines, name)
|
||||||
|
}
|
||||||
|
sort.Strings(machines)
|
||||||
|
total := 0
|
||||||
|
for _, held := range asked.untaken {
|
||||||
|
for _, n := range held {
|
||||||
|
total += n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("%d resource(s) are held as found, because their module was assigned and never "+
|
||||||
|
"taken — so it is running none of what it declares:\n", total)
|
||||||
|
for _, name := range machines {
|
||||||
|
modules := make([]string, 0, len(asked.untaken[name]))
|
||||||
|
for m := range asked.untaken[name] {
|
||||||
|
modules = append(modules, m)
|
||||||
|
}
|
||||||
|
sort.Strings(modules)
|
||||||
|
parts := make([]string, 0, len(modules))
|
||||||
|
for _, m := range modules {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s (%d)", m, asked.untaken[name][m]))
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-12s %s\n", name, strings.Join(parts, ", "))
|
||||||
|
}
|
||||||
|
fmt.Printf("\n `take <node> <module>` compares what runs against what it declares, and runs it\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
if adopted := adoptedNodes(nodes); len(adopted) > 0 {
|
if adopted := adoptedNodes(nodes); len(adopted) > 0 {
|
||||||
// Said, because nothing forces the flip: a node left adopted is visible here rather than
|
// Said, because nothing forces the flip: a node left adopted is visible here rather than
|
||||||
// read as converged (novox/hq ADR 0100). Not a fault, so it does not break "all well".
|
// read as converged (novox/hq ADR 0100). Not a fault, so it does not break "all well".
|
||||||
@@ -178,12 +267,23 @@ func statusCommand(ctx context.Context, args []string) error {
|
|||||||
fmt.Printf("\n `converge <node>` previews the flip\n\n")
|
fmt.Printf("\n `converge <node>` previews the flip\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(wrong) == 0 && len(quiet) == 0 && len(behind) == 0 && len(asked.waiting) == 0 &&
|
if asked.well() {
|
||||||
len(asked.refused) == 0 && asked.network == "" {
|
|
||||||
// Said plainly. "Nothing to report" and "nothing was checked" must never look the same,
|
// Said plainly. "Nothing to report" and "nothing was checked" must never look the same,
|
||||||
// and getting here means every question was asked and answered.
|
// and getting here means every question was asked and answered.
|
||||||
fmt.Printf("%d machine(s), all doing what they were told, all heard from, running what "+
|
fmt.Printf("%d machine(s), all doing what they were told, all heard from, running what "+
|
||||||
"the mesh would send them, and every module current with its source\n", len(nodes))
|
"the mesh would send them, and every module current with its source\n", len(nodes))
|
||||||
|
// **And what that sentence does not cover**, because for eleven hours it was true of a mesh
|
||||||
|
// in which no module could reach another (novox/hq 04-ISSUES/145). Every question above is
|
||||||
|
// about the relationship between the mesh and a machine — applied what it was sent, matches
|
||||||
|
// what would be sent, built from what the source has. None of them asks whether a module can
|
||||||
|
// reach what it requires, and the mesh composes every one of those grants itself.
|
||||||
|
//
|
||||||
|
// Said here rather than left to be inferred. A reader who acts on the line above is acting on
|
||||||
|
// "the machines are as the mesh described them", and the distance between that and "it works"
|
||||||
|
// is where the eleven hours went.
|
||||||
|
fmt.Printf("\n That is the mesh and the machines agreeing. Nothing here dials a provision:\n" +
|
||||||
|
" no grant the mesh composed has been tested, so a module unable to reach what it\n" +
|
||||||
|
" requires would not appear above (04-ISSUES/145)\n")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -201,7 +301,10 @@ func firstLine(s string) string {
|
|||||||
// A type of its own rather than a method on the enrolment, because they are unrelated things
|
// A type of its own rather than a method on the enrolment, because they are unrelated things
|
||||||
// arriving on one queue and an implementation of one should not have to say anything about the
|
// arriving on one queue and an implementation of one should not have to say anything about the
|
||||||
// other.
|
// other.
|
||||||
type builds struct{ inv *inventory.Inventory }
|
type builds struct {
|
||||||
|
inv *inventory.Inventory
|
||||||
|
open *stores
|
||||||
|
}
|
||||||
|
|
||||||
// theThreeQuestions reads what anything answering "is the mesh alright" needs.
|
// theThreeQuestions reads what anything answering "is the mesh alright" needs.
|
||||||
//
|
//
|
||||||
@@ -247,6 +350,17 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return answers{}, err
|
return answers{}, err
|
||||||
}
|
}
|
||||||
|
// And what each machine is holding rather than running, by the module that would run it. Read
|
||||||
|
// from what the machine itself last reported, not from what take-time computed: the machine is
|
||||||
|
// the only thing that knows what it found (novox/hq 04-ISSUES/125).
|
||||||
|
out.untaken, err = untakenModules(ctx, inv, out.nodes)
|
||||||
|
if err != nil {
|
||||||
|
return answers{}, err
|
||||||
|
}
|
||||||
|
out.plans, err = inv.RecentPlans(ctx, 5)
|
||||||
|
if err != nil {
|
||||||
|
return answers{}, err
|
||||||
|
}
|
||||||
|
|
||||||
// And which machines are not running what the mesh would send them. The same question as a
|
// And which machines are not running what the mesh would send them. The same question as a
|
||||||
// module being behind its source, one level down: that one says the catalogue is out of date,
|
// module being behind its source, one level down: that one says the catalogue is out of date,
|
||||||
@@ -281,3 +395,82 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// untakenModules is, per machine, each module whose resources that machine is holding as found, and
|
||||||
|
// how many.
|
||||||
|
//
|
||||||
|
// **The machine's own account, not the mesh's.** An adopted node decides at apply time what it found
|
||||||
|
// and reports it; the mesh's take-time listing is a different thing and was the one this command used
|
||||||
|
// to have, which is why a module assigned after the listing showed nothing at all
|
||||||
|
// (novox/hq 04-ISSUES/125).
|
||||||
|
//
|
||||||
|
// A machine that reports no holds contributes nothing, so a converged mesh answers an empty map and
|
||||||
|
// the caller prints nothing.
|
||||||
|
func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inventory.Node) (
|
||||||
|
map[string]map[string]int, error) {
|
||||||
|
|
||||||
|
out := map[string]map[string]int{}
|
||||||
|
for _, n := range nodes {
|
||||||
|
said, err := inv.AdoptionOf(ctx, n.Name)
|
||||||
|
if err != nil {
|
||||||
|
// A machine whose record cannot be read is not a machine holding nothing. Said, because
|
||||||
|
// answering "nothing held" from a failed read is the shape this whole issue is about.
|
||||||
|
return nil, fmt.Errorf("what %s is holding cannot be read: %w", n.Name, err)
|
||||||
|
}
|
||||||
|
for _, h := range said.Held {
|
||||||
|
if h.Module == "" {
|
||||||
|
continue // a hold the mesh cannot attribute to a module has nothing to take
|
||||||
|
}
|
||||||
|
if out[n.Name] == nil {
|
||||||
|
out[n.Name] = map[string]int{}
|
||||||
|
}
|
||||||
|
out[n.Name][h.Module]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// well is whether every question this command asks came back with nothing to say.
|
||||||
|
//
|
||||||
|
// Named, and in one place, because it is the sentence an operator acts on and it has been wrong
|
||||||
|
// twice. It is deliberately NOT "nothing is broken": a machine holding what it found is not broken
|
||||||
|
// and is not doing what it was told either.
|
||||||
|
//
|
||||||
|
// **A hold suppresses it; being adopted does not.** Adopted is a mode somebody chose and can leave
|
||||||
|
// alone. A module assigned to a machine and never taken is a half-finished action with nothing left
|
||||||
|
// to finish it — it runs none of what it declares, and "all doing what they were told" was true and
|
||||||
|
// read as success for the whole of the edge cut-over outage (novox/hq 04-ISSUES/125).
|
||||||
|
func (a answers) well() bool {
|
||||||
|
return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 &&
|
||||||
|
len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostSplit is which machines report which host version, for every version more than one machine
|
||||||
|
// could disagree about.
|
||||||
|
//
|
||||||
|
// **It does not say which is newer, because it cannot.** A host reports its version as a commit, and
|
||||||
|
// commits have no order. The first version of this returned "the machines behind the newest" by
|
||||||
|
// comparing versions as strings, and on the live mesh it named the three machines running the NEWER
|
||||||
|
// host as the ones behind — an arbitrary lexicographic result presented as a fact
|
||||||
|
// (novox/hq 04-ISSUES/087). A report that confidently says the opposite of the truth is worse than one
|
||||||
|
// that says less, which is the whole subject of 04-ISSUES/145.
|
||||||
|
//
|
||||||
|
// So this answers what is checkable: who runs what. The reader sees the split and the mesh claims no
|
||||||
|
// ordering. Ordering wants an ordered version, and that is the host's to report rather than this
|
||||||
|
// function's to infer.
|
||||||
|
//
|
||||||
|
// Machines that have not reported a version are left out entirely: they are not a version, and
|
||||||
|
// counting them as one would invent a disagreement. `node show` says per machine that it has not said.
|
||||||
|
func hostSplit(nodes []inventory.Node) map[string][]string {
|
||||||
|
out := map[string][]string{}
|
||||||
|
for _, n := range nodes {
|
||||||
|
if n.HostVersion == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[n.HostVersion] = append(out[n.HostVersion], n.Name)
|
||||||
|
}
|
||||||
|
if len(out) < 2 {
|
||||||
|
return nil // one version, or none reported: nothing to disagree about
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What the forge's take compares, as a machine would report it.
|
||||||
|
func aForgeComparison() comparison {
|
||||||
|
return comparison{reported: inventory.Adoption{
|
||||||
|
Firewall: "ufw",
|
||||||
|
Held: []inventory.Held{
|
||||||
|
{ID: "forge.server", Module: "forge", Kind: "container", Target: "forge", Facts: map[string]any{
|
||||||
|
"image": "forge:1.27.3", "image_created": "2026-09-17T10:00:00Z",
|
||||||
|
"declared_image": "forge:1.22.6", "declared_image_created": "2026-08-20T10:00:00Z", "downgrade": true,
|
||||||
|
"networks": map[string]any{"predecessor_default": []any{"office", "db"}},
|
||||||
|
"ports": []any{"3000/tcp>0.0.0.0:3000"}, "declared_ports": []any{"3000:3000"},
|
||||||
|
}},
|
||||||
|
{ID: "forge.config", Module: "forge", Kind: "file", Target: "/etc/forge/app.ini", Kept: "/var/lib/mesh/kept/app.ini",
|
||||||
|
Facts: map[string]any{"differs": true, "difference": []any{"- private scope: local", "+ upstream: public"}}},
|
||||||
|
{ID: "other.server", Module: "other", Kind: "container", Target: "other"},
|
||||||
|
},
|
||||||
|
Reachable: []inventory.Reach{
|
||||||
|
{Protocol: "tcp", Address: "0.0.0.0", Port: 3000, By: "forge", Published: true, ContainerPort: 3000},
|
||||||
|
{Protocol: "tcp", Address: "0.0.0.0", Port: 22, By: "sshd"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A take is a comparison (novox/hq ADR 0163): the preview puts what runs beside what the module
|
||||||
|
// declares, and an older image or a differing file refuses unless named.
|
||||||
|
func TestATakePreviewsTheComparisonAndRefusesWhatIsNotNamed(t *testing.T) {
|
||||||
|
c := aForgeComparison()
|
||||||
|
preview, refusals, saw := comparisonOf("forge", c, takeOptions{})
|
||||||
|
for _, want := range []string{"runs forge:1.27.3 (made 2026-09-17)", "declares forge:1.22.6 (made 2026-08-20)", "DOWNGRADE",
|
||||||
|
"on the network predecessor_default with office, db", "will not once it moves to the module's own network",
|
||||||
|
"publishes 3000/tcp>0.0.0.0:3000; the module declares 3000:3000",
|
||||||
|
// How far the port reaches now, as the machine reported it (rule 1).
|
||||||
|
"reachable now at 0.0.0.0:3000 (tcp, container port 3000), behind the found firewall (ufw)",
|
||||||
|
"- private scope: local", "original kept at /var/lib/mesh/kept/app.ini"} {
|
||||||
|
if !strings.Contains(preview, want) {
|
||||||
|
t.Errorf("the preview lacks %q:\n%s", want, preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(preview, "other") {
|
||||||
|
t.Errorf("another module's held things are in the preview:\n%s", preview)
|
||||||
|
}
|
||||||
|
if len(refusals) != 2 || !strings.Contains(refusals[0], "--downgrade") || !strings.Contains(refusals[1], "--replace /etc/forge/app.ini") {
|
||||||
|
t.Fatalf("the downgrade and the differing file refuse, each naming its override: %v", refusals)
|
||||||
|
}
|
||||||
|
if len(saw) != 12 {
|
||||||
|
t.Fatalf("the preview's digest is %q", saw)
|
||||||
|
}
|
||||||
|
// Named, they pass.
|
||||||
|
if _, refusals, _ := comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"/etc/forge/app.ini": true}}); len(refusals) != 0 {
|
||||||
|
t.Fatalf("named differences still refused: %v", refusals)
|
||||||
|
}
|
||||||
|
if _, refusals, _ := comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}}); len(refusals) != 0 {
|
||||||
|
t.Fatalf("replace * did not cover the file: %v", refusals)
|
||||||
|
}
|
||||||
|
// A held thing with no facts yet — a host older than this — refuses nothing and says what it can.
|
||||||
|
if preview, refusals, _ := comparisonOf("other", c, takeOptions{}); len(refusals) != 0 || !strings.Contains(preview, "container other") {
|
||||||
|
t.Fatalf("a factless hold: %q %v", preview, refusals)
|
||||||
|
}
|
||||||
|
// The digest is of what the preview says: a fact changing changes it.
|
||||||
|
c.reported.Held[0].Facts["image"] = "forge:1.27.4"
|
||||||
|
if _, _, again := comparisonOf("forge", c, takeOptions{}); again == saw {
|
||||||
|
t.Fatal("the found image changed and the digest did not")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A secret the mesh minted for a service whose data was found refuses: the running service already
|
||||||
|
// has a value (rule 2). Accepted, it is carried in; `--mint` says the service shall take the new one.
|
||||||
|
func TestAMintedSecretForFoundDataRefusesUnlessAcceptedOrMinted(t *testing.T) {
|
||||||
|
c := aForgeComparison()
|
||||||
|
c.secrets = []inventory.SecretState{
|
||||||
|
{Name: "admin", Origin: inventory.OriginMade},
|
||||||
|
{Name: "postgres-database", Origin: inventory.OriginMade, Provider: "anchor"},
|
||||||
|
{Name: "broker", Origin: inventory.OriginAccepted},
|
||||||
|
}
|
||||||
|
preview, refusals, _ := comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}})
|
||||||
|
for _, want := range []string{
|
||||||
|
"own secret admin: MINTED by the mesh and not accepted",
|
||||||
|
"secret from anchor postgres-database: MINTED by the mesh and not accepted",
|
||||||
|
"own secret broker: accepted from a person, carried in as it is",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(preview, want) {
|
||||||
|
t.Errorf("the preview lacks %q:\n%s", want, preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(refusals) != 2 {
|
||||||
|
t.Fatalf("two minted secrets refuse: %v", refusals)
|
||||||
|
}
|
||||||
|
if !strings.Contains(refusals[0], "`secret accept <node> forge admin`") || !strings.Contains(refusals[0], "`--mint admin`") {
|
||||||
|
t.Errorf("the own secret's refusal names accepting it and minting it: %s", refusals[0])
|
||||||
|
}
|
||||||
|
if !strings.Contains(refusals[1], "`secret accept <node> forge postgres-database --provider anchor`") {
|
||||||
|
t.Errorf("the required secret's refusal names its provider: %s", refusals[1])
|
||||||
|
}
|
||||||
|
preview, refusals, _ = comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true},
|
||||||
|
Mint: map[string]bool{"admin": true, "postgres-database": true}})
|
||||||
|
if len(refusals) != 0 || !strings.Contains(preview, "admin: minted by the mesh; the service takes the new value, as --mint said") {
|
||||||
|
t.Fatalf("--mint did not pass the minted secrets: %v\n%s", refusals, preview)
|
||||||
|
}
|
||||||
|
// With no found data — only a file held — the service has no value of its own, and a minted
|
||||||
|
// secret is simply said.
|
||||||
|
c.reported.Held = c.reported.Held[1:2]
|
||||||
|
if _, refusals, _ := comparisonOf("forge", c, takeOptions{Replace: map[string]bool{"*": true}}); len(refusals) != 0 {
|
||||||
|
t.Fatalf("a minted secret refused with no data found: %v", refusals)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A found network a per-machine setting keeps is named in the preview (rule 4), and the module's
|
||||||
|
// settings are said with where each came from, composed or not (rules 1 and 6).
|
||||||
|
func TestTheKeptNetworkAndTheSettingsAreInThePreview(t *testing.T) {
|
||||||
|
c := aForgeComparison()
|
||||||
|
c.keeps = map[string][]string{"forge.server": {"predecessor_default"}}
|
||||||
|
c.layers = []catalogue.Layer{
|
||||||
|
{From: catalogue.MeshWideLayer, Values: map[string]any{"site": "x"}},
|
||||||
|
{From: "anchor", Values: map[string]any{catalogue.NetworksSetting: map[string]any{"server": []any{"predecessor_default"}}}},
|
||||||
|
}
|
||||||
|
preview, _, _ := comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}})
|
||||||
|
for _, want := range []string{
|
||||||
|
"on the network predecessor_default with office, db — kept by this machine's setting, so they still reach it by name once taken",
|
||||||
|
"settings from the mesh: site",
|
||||||
|
"settings from anchor: networks",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(preview, want) {
|
||||||
|
t.Errorf("the preview lacks %q:\n%s", want, preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(preview, "will not once it moves") {
|
||||||
|
t.Errorf("a kept network is still said to be lost:\n%s", preview)
|
||||||
|
}
|
||||||
|
c.settingsRefused = "forge: ports is a { port: machine-port } map"
|
||||||
|
preview, _, _ = comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}})
|
||||||
|
if !strings.Contains(preview, "SETTINGS DO NOT COMPOSE with the module's definition, so the push leaves it out: forge: ports") {
|
||||||
|
t.Errorf("settings that cannot compose are not said:\n%s", preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module assigned to an adopted machine and never taken runs none of what it declares, and every
|
||||||
|
// surface called that success — a push reporting sent, a journal reporting applied, status reporting
|
||||||
|
// a machine doing what it was told (novox/hq 04-ISSUES/125). The holds were only ever in the
|
||||||
|
// machine's own state file.
|
||||||
|
|
||||||
|
// heldOn makes a machine report that it is holding resources for a module, the way an adopted node
|
||||||
|
// does after an apply.
|
||||||
|
func heldOn(t *testing.T, open *stores, node, module string, ids ...string) {
|
||||||
|
t.Helper()
|
||||||
|
record, err := open.inventory.NodeByName(t.Context(), node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
held := make([]inventory.Held, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
held = append(held, inventory.Held{ID: id, Module: module, Kind: "container", Target: id})
|
||||||
|
}
|
||||||
|
if err := open.inventory.RecordAdoption(t.Context(), record.ID, held, "ufw", nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusNamesAModuleHeldBecauseNothingTookIt(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
heldOn(t, open, "anchor", "route-proxy", "ca", "certs", "server")
|
||||||
|
|
||||||
|
asked, err := theThreeQuestions(t.Context(), open)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := asked.untaken["anchor"]["route-proxy"]; got != 3 {
|
||||||
|
t.Fatalf("status counted %d resources held for route-proxy, wanted 3", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAHeldModuleStopsTheMeshReadingAsWell(t *testing.T) {
|
||||||
|
// The whole of the fault. "all doing what they were told" was true throughout the outage, and
|
||||||
|
// true is not the same as safe to act on: the machine was doing what it was told, and what it
|
||||||
|
// was told had not started. Asserted against the production condition, not a copy of it.
|
||||||
|
quiet := answers{}
|
||||||
|
if !quiet.well() {
|
||||||
|
t.Fatal("a mesh with nothing to say does not read as well, so nothing below means anything")
|
||||||
|
}
|
||||||
|
holding := answers{untaken: map[string]map[string]int{"anchor": {"route-proxy": 3}}}
|
||||||
|
if holding.well() {
|
||||||
|
t.Fatal("a machine holding a module's resources still reads as doing what it was told, " +
|
||||||
|
"which is the sentence that cost every public name on the machine")
|
||||||
|
}
|
||||||
|
// And being adopted does not suppress it: that is a mode somebody chose, not work outstanding.
|
||||||
|
// Kept as an assertion so the difference between the two is deliberate rather than incidental.
|
||||||
|
if !quiet.well() {
|
||||||
|
t.Fatal("the well condition is not stable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAHeldModuleIsFoundFromWhatTheMachineReported(t *testing.T) {
|
||||||
|
// End to end through the store, so the condition above is reached by real data and not only by
|
||||||
|
// a constructed value: the machine reports, the mesh records, status asks.
|
||||||
|
open := aMesh(t)
|
||||||
|
heldOn(t, open, "anchor", "route-proxy", "ca", "server")
|
||||||
|
|
||||||
|
asked, err := theThreeQuestions(t.Context(), open)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(asked.untaken) == 0 {
|
||||||
|
t.Fatal("what the machine reported holding did not reach status")
|
||||||
|
}
|
||||||
|
if asked.well() {
|
||||||
|
t.Fatal("a mesh whose machine reported holds reads as well")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheJSONStatusCarriesWhatIsHeldAndForWhichModule(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
heldOn(t, open, "anchor", "route-proxy", "ca", "certs")
|
||||||
|
|
||||||
|
asked, err := theThreeQuestions(t.Context(), open)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, err := statusAsJSON(asked)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var doc struct {
|
||||||
|
Untaken []struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
Held int `json:"held"`
|
||||||
|
} `json:"untaken"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &doc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(doc.Untaken) != 1 {
|
||||||
|
t.Fatalf("the document carries %d untaken rows, wanted 1: %s", len(doc.Untaken), body)
|
||||||
|
}
|
||||||
|
row := doc.Untaken[0]
|
||||||
|
if row.Node != "anchor" || row.Module != "route-proxy" || row.Held != 2 {
|
||||||
|
t.Fatalf("the row is %+v, wanted anchor/route-proxy/2", row)
|
||||||
|
}
|
||||||
|
// Absent rather than empty when nothing is held, so a well mesh's document does not carry a
|
||||||
|
// field a reader has to interpret.
|
||||||
|
clean, err := statusAsJSON(answers{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(clean), "untaken") {
|
||||||
|
t.Fatalf("a mesh holding nothing still names untaken: %s", clean)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
)
|
)
|
||||||
@@ -218,3 +220,342 @@ func notNow(err error) error {
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SourceMoved is the forge announcing a merge: every module recorded as built from that
|
||||||
|
// repository and branch is marked as moved to the merge commit, and built — bases first, so a
|
||||||
|
// module that stands on another's artifact is built after it and not against the old one
|
||||||
|
// (novox/hq 04-ISSUES/131). Nothing is pushed here: what a finished build does to the machines
|
||||||
|
// running the module is the upgrade's decision, taken when the catalogue announces it.
|
||||||
|
func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
||||||
|
inv := f.open.inventory
|
||||||
|
entries, err := inv.Catalogued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return notNow(err)
|
||||||
|
}
|
||||||
|
read, err := inv.ReadRepositories(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return notNow(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two kinds of module are affected by one merge, and they are affected differently.
|
||||||
|
//
|
||||||
|
// A module **built from** this repository and branch has moved: the mesh records the new commit
|
||||||
|
// as what its source now has, and only what the merge actually changed is rebuilt. A module that
|
||||||
|
// only **packages source from** it has not moved — its own source is somewhere else, at the
|
||||||
|
// commit it already records — so it is rebuilt and its record left alone. Writing this commit as
|
||||||
|
// its source would make it permanently behind a repository its manifest does not come from.
|
||||||
|
var from, packaging []inventory.Entry
|
||||||
|
already := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
switch {
|
||||||
|
case sourceIs(e.Source, m):
|
||||||
|
if e.Source.BuiltFrom == m.Commit {
|
||||||
|
already++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// **A merge older than the last look at the source is history, not a move.** The forge
|
||||||
|
// announces what it finds merged, and an old merge surfacing late would otherwise move
|
||||||
|
// the recorded head backwards and rebuild everything built from that repository, once
|
||||||
|
// per old merge (2026-09-28).
|
||||||
|
if isHistory(m.MergedAt, e.Source.Seen) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
from = append(from, e)
|
||||||
|
case readsFrom(read[e.Manifest.Module], m):
|
||||||
|
packaging = append(packaging, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(from) == 0 && len(packaging) == 0 {
|
||||||
|
// "Already built from it" and "nothing reads it" are different facts, and reading the first
|
||||||
|
// as the second sends somebody looking for a broken trigger when the mesh is up to date.
|
||||||
|
if already > 0 {
|
||||||
|
fmt.Printf("%s/%s merged into %s (%.8s); %d module(s) the mesh holds are already built "+
|
||||||
|
"from it\n", m.Owner, m.Repo, m.Base, m.Commit, already)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("%s/%s merged into %s (%.8s); nothing the mesh holds reads it\n",
|
||||||
|
m.Owner, m.Repo, m.Base, m.Commit)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The same judgement for the packaging kind, against the newest look at that repository by
|
||||||
|
// anything built from it: they keep no record of it themselves, and a replayed old merge should
|
||||||
|
// not rebuild them either.
|
||||||
|
if isHistory(m.MergedAt, lastLookAt(entries, m)) {
|
||||||
|
packaging = nil
|
||||||
|
}
|
||||||
|
touched := whatTheMergeTouched(from, entries, m)
|
||||||
|
for _, e := range touched {
|
||||||
|
if err := inv.SourceMoved(ctx, e.Manifest.Module, m.Commit); err != nil {
|
||||||
|
return notNow(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moved := append(append([]inventory.Entry{}, touched...), packaging...)
|
||||||
|
if len(moved) == 0 {
|
||||||
|
fmt.Printf("%s/%s merged into %s (%.8s); it changed nothing any module the mesh holds is "+
|
||||||
|
"built from\n", m.Owner, m.Repo, m.Base, m.Commit)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// A merge produces a plan the mesh keeps (novox/hq ADR 0162): what moved and everything that
|
||||||
|
// depends on it, along the catalogue's one dependency relation, sorted into tiers. The plan is
|
||||||
|
// written before any build is asked; the first tier is asked; this returns. Outcomes advance it.
|
||||||
|
edges, err := inv.Dependencies(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return notNow(err)
|
||||||
|
}
|
||||||
|
var movedNames []string
|
||||||
|
for _, e := range moved {
|
||||||
|
movedNames = append(movedNames, e.Manifest.Module)
|
||||||
|
}
|
||||||
|
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",
|
||||||
|
strings.Join(plan.Tiers[len(plan.Tiers)-1], ", "))
|
||||||
|
}
|
||||||
|
if err := inv.SavePlan(ctx, plan); err != nil {
|
||||||
|
return notNow(err)
|
||||||
|
}
|
||||||
|
var tiers []string
|
||||||
|
for i, t := range plan.Tiers {
|
||||||
|
tiers = append(tiers, fmt.Sprintf("%d: %s", i, strings.Join(t, ", ")))
|
||||||
|
}
|
||||||
|
fmt.Printf("%s/%s merged into %s (%.8s); plan %s, %d module(s) in %d tier(s)\n %s\n",
|
||||||
|
m.Owner, m.Repo, m.Base, m.Commit, plan.ID, len(plan.Modules), len(plan.Tiers), strings.Join(tiers, "\n "))
|
||||||
|
if len(packaging) > 0 {
|
||||||
|
var also []string
|
||||||
|
for _, e := range packaging {
|
||||||
|
also = append(also, e.Manifest.Module)
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s package source from it, so they are rebuilt and their own source record "+
|
||||||
|
"is left where it is\n", strings.Join(also, ", "))
|
||||||
|
}
|
||||||
|
if err := askTier(ctx, inv, &plan); err != nil {
|
||||||
|
return notNow(err)
|
||||||
|
}
|
||||||
|
if err := inv.SavePlan(ctx, plan); err != nil {
|
||||||
|
return notNow(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceIs is whether a recorded source is the repository and branch a merge announced. A source on
|
||||||
|
// the git seat is recorded as its path on the forge; one elsewhere as the URL it was cloned from.
|
||||||
|
// An empty recorded ref is the repository's default branch, which is what a merge into the base
|
||||||
|
// branch of the forge's default means.
|
||||||
|
func sourceIs(s inventory.Source, m link.SourceMoved) bool {
|
||||||
|
if !sameRepository(s.Repository, m) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.Ref == "" || s.Ref == m.Base
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameRepository is whether a recorded repository is the one a merge names, in either spelling it
|
||||||
|
// may have been recorded in: a path on the git seat, or the URL it was cloned from.
|
||||||
|
func sameRepository(repository string, m link.SourceMoved) bool {
|
||||||
|
want := strings.ToLower(m.Owner + "/" + m.Repo)
|
||||||
|
repo := strings.ToLower(strings.TrimSuffix(repository, ".git"))
|
||||||
|
return repo == want || strings.HasSuffix(repo, "/"+want) ||
|
||||||
|
(m.CloneURL != "" && repo == strings.ToLower(strings.TrimSuffix(m.CloneURL, ".git")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// readsFrom is whether a module's build read the repository a merge names: the second repository its
|
||||||
|
// recipe packages source from. Its ref must be the branch that moved, or unset — the same rule a
|
||||||
|
// module's own source follows.
|
||||||
|
func readsFrom(read []inventory.ReadRepository, m link.SourceMoved) bool {
|
||||||
|
for _, r := range read {
|
||||||
|
if sameRepository(r.Repository, m) && (r.Ref == "" || r.Ref == m.Base) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastLookAt is the most recent look at this repository by anything built from it.
|
||||||
|
func lastLookAt(entries []inventory.Entry, m link.SourceMoved) time.Time {
|
||||||
|
var newest time.Time
|
||||||
|
for _, e := range entries {
|
||||||
|
if sameRepository(e.Source.Repository, m) && e.Source.Seen.After(newest) {
|
||||||
|
newest = e.Source.Seen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newest
|
||||||
|
}
|
||||||
|
|
||||||
|
// whatTheMergeTouched narrows the modules built from a repository to the ones the merge changed.
|
||||||
|
//
|
||||||
|
// **A change inside no module's own directory is a change to what they share.** The forge lists the
|
||||||
|
// files a merge changed; a module is affected when one of them is inside its own directory, when it
|
||||||
|
// is built from the repository's root — everything there is its source — or when some changed file
|
||||||
|
// belongs to no module's directory at all, which is how a shared file, a build recipe or a
|
||||||
|
// dependency at the root rebuilds everything built from that repository.
|
||||||
|
//
|
||||||
|
// A change inside *another* module's directory is that module's business and not this one's, even
|
||||||
|
// when the mesh does not hold that module: `known` is every module this repository is known to hold,
|
||||||
|
// whatever branch it was registered from. That is also the limit of this — a repository whose shared
|
||||||
|
// code sits inside a directory the mesh has never seen a module in reads as shared, and everything
|
||||||
|
// is rebuilt. Rebuilding too much is the safe direction: the fault this whole path exists for is a
|
||||||
|
// mesh that believes it is current and is not (novox/hq 04-ISSUES/131).
|
||||||
|
func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved) []inventory.Entry {
|
||||||
|
// Nothing said about the files, or not all of them said: everything built from it is affected.
|
||||||
|
if len(m.Paths) == 0 || m.PathsTruncated {
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
var dirs []string
|
||||||
|
for _, e := range known {
|
||||||
|
if e.Source.Path != "" && sameRepository(e.Source.Repository, m) {
|
||||||
|
dirs = append(dirs, e.Source.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range m.Paths {
|
||||||
|
if !insideAny(p, dirs) {
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []inventory.Entry
|
||||||
|
for _, e := range candidates {
|
||||||
|
if e.Source.Path == "" || anyInside(m.Paths, e.Source.Path) {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// inside is whether a changed file is in a directory: that directory itself, or under it.
|
||||||
|
func inside(path, dir string) bool {
|
||||||
|
dir = strings.Trim(dir, "/")
|
||||||
|
path = strings.TrimPrefix(path, "/")
|
||||||
|
return path == dir || strings.HasPrefix(path, dir+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// insideAny is whether a changed file is in any of these directories.
|
||||||
|
func insideAny(path string, dirs []string) bool {
|
||||||
|
for _, dir := range dirs {
|
||||||
|
if inside(path, dir) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// anyInside is whether any of these changed files is in a directory.
|
||||||
|
func anyInside(paths []string, dir string) bool {
|
||||||
|
for _, p := range paths {
|
||||||
|
if inside(p, dir) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderByBases is the entries with every base before what stands on it: a module whose build stood
|
||||||
|
// on another's artifact comes after that module. Entries outside the set are not waited for — they
|
||||||
|
// are not being rebuilt. Stable for what has no order between it.
|
||||||
|
//
|
||||||
|
// `against` is what each module's newest build stood on (inventory.BuiltAgainst): the edges are
|
||||||
|
// derived from builds, not declared, because a recorded manifest no longer carries `build.on`.
|
||||||
|
func orderByBases(entries []inventory.Entry, against map[string][]string) []inventory.Entry {
|
||||||
|
inSet := map[string]bool{}
|
||||||
|
for _, e := range entries {
|
||||||
|
inSet[e.Manifest.Module] = true
|
||||||
|
}
|
||||||
|
var out []inventory.Entry
|
||||||
|
placed := map[string]bool{}
|
||||||
|
var place func(e inventory.Entry, seen map[string]bool)
|
||||||
|
place = func(e inventory.Entry, seen map[string]bool) {
|
||||||
|
name := e.Manifest.Module
|
||||||
|
if placed[name] || seen[name] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
for _, base := range entries {
|
||||||
|
if base.Manifest.Module != name && inSet[base.Manifest.Module] && standsOnModule(e, base.Manifest.Module, against) {
|
||||||
|
place(base, seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placed[name] = true
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
place(e, map[string]bool{})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// standsOn is whether anything in the set is built on the named module's artifacts.
|
||||||
|
func standsOn(entries []inventory.Entry, module string, against map[string][]string) bool {
|
||||||
|
for _, e := range entries {
|
||||||
|
if standsOnModule(e, module, against) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// standsOnModule is whether an entry's build stood on the named module: by what its newest build
|
||||||
|
// recorded it was handed (`artifact-store://<module>/<artifact>@…`, the module's own artifact), or
|
||||||
|
// — for a module registered from a manifest and not yet built — by the base its manifest names.
|
||||||
|
func standsOnModule(e inventory.Entry, module string, against map[string][]string) bool {
|
||||||
|
if e.Manifest.Module == module {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if e.Manifest.Build != nil {
|
||||||
|
for _, on := range e.Manifest.Build.On {
|
||||||
|
if on.Module == module {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefix := catalogue.ArtifactStoreScheme + module + "/"
|
||||||
|
for _, ref := range against[e.Manifest.Module] {
|
||||||
|
if strings.HasPrefix(ref, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHistory is whether a merge made at mergedAt predates the last time the source was seen. A merge
|
||||||
|
// with no time on it is taken as news: refusing it would silence a forge that says less.
|
||||||
|
func isHistory(mergedAt string, seen time.Time) bool {
|
||||||
|
if mergedAt == "" || seen.IsZero() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
at, err := time.Parse(time.RFC3339, mergedAt)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return at.Before(seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dependentsOf is every catalogued module that stands on one of the moved modules, directly or
|
||||||
|
// through another dependent, and is not itself among them — in the catalogue's order, so the
|
||||||
|
// answer is the same each time. A module standing on nothing that moved is left alone: a merge
|
||||||
|
// rebuilds what it changed and what is built on top of that, not the catalogue.
|
||||||
|
func dependentsOf(moved, entries []inventory.Entry, against map[string][]string) []inventory.Entry {
|
||||||
|
bases := map[string]bool{}
|
||||||
|
for _, e := range moved {
|
||||||
|
bases[e.Manifest.Module] = true
|
||||||
|
}
|
||||||
|
var out []inventory.Entry
|
||||||
|
taken := map[string]bool{}
|
||||||
|
for grew := true; grew; {
|
||||||
|
grew = false
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Manifest.Module
|
||||||
|
if bases[name] || taken[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for base := range bases {
|
||||||
|
if standsOnModule(e, base, against) {
|
||||||
|
taken[name] = true
|
||||||
|
out = append(out, e)
|
||||||
|
grew = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range out {
|
||||||
|
bases[e.Manifest.Module] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// "4 machine(s), all doing what they were told, all heard from, running what the mesh would send
|
||||||
|
// them, and every module current with its source" was true for eleven hours of a mesh in which no
|
||||||
|
// module could reach another (novox/hq 04-ISSUES/145). Every question it answers is about the mesh
|
||||||
|
// and a machine agreeing; none of them dials anything.
|
||||||
|
|
||||||
|
// printed captures what a function writes to stdout.
|
||||||
|
func printed(t *testing.T, f func() error) string {
|
||||||
|
t.Helper()
|
||||||
|
old := os.Stdout
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
os.Stdout = w
|
||||||
|
runErr := f()
|
||||||
|
_ = w.Close()
|
||||||
|
os.Stdout = old
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if _, err := io.Copy(&buf, r); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
t.Fatal(runErr)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheAllWellSentenceSaysWhatItDoesNotCover(t *testing.T) {
|
||||||
|
// A mesh with nothing to say. The sentence below was true of a mesh in which no module could
|
||||||
|
// reach another, for eleven hours.
|
||||||
|
got := printed(t, func() error {
|
||||||
|
return printStatus(answers{nodes: []inventory.Node{{Name: "anchor"}, {Name: "laptop"}}})
|
||||||
|
})
|
||||||
|
if !strings.Contains(got, "all doing what they were told") {
|
||||||
|
t.Fatalf("a mesh with nothing to say did not print the all-well sentence:\n%s", got)
|
||||||
|
}
|
||||||
|
// And now says what it is not a claim about.
|
||||||
|
for _, want := range []string{"Nothing here dials a provision", "04-ISSUES/145"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("the all-well sentence does not say %q:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMeshWithSomethingToSayDoesNotPrintTheScopeLine(t *testing.T) {
|
||||||
|
// The scope belongs to the all-well sentence. A mesh with something wrong has specific things to
|
||||||
|
// read, and appending a caveat to those is noise.
|
||||||
|
got := printed(t, func() error {
|
||||||
|
return printStatus(answers{
|
||||||
|
nodes: []inventory.Node{{Name: "anchor"}},
|
||||||
|
untaken: map[string]map[string]int{"anchor": {"route-proxy": 3}},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if strings.Contains(got, "Nothing here dials a provision") {
|
||||||
|
t.Fatalf("a mesh with a held module printed the all-well scope line:\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "all doing what they were told") {
|
||||||
|
t.Fatalf("a mesh with a held module printed the all-well sentence:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "route-proxy") {
|
||||||
|
t.Fatalf("the held module is not named:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ go 1.26.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/rabbitmq/amqp091-go v1.14.0
|
github.com/nats-io/nats.go v1.54.0
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,7 +13,6 @@ require (
|
|||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
github.com/nats-io/nats.go v1.54.0 // indirect
|
|
||||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
golang.org/x/net v0.58.0 // indirect
|
golang.org/x/net v0.58.0 // indirect
|
||||||
|
|||||||
@@ -19,33 +19,19 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
|||||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rabbitmq/amqp091-go v1.14.0 h1:RSaT7aOKt/OrkVUyswPDW29lnRz9psuGmfZFBmLqLek=
|
|
||||||
github.com/rabbitmq/amqp091-go v1.14.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
|
||||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
|
||||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
|
||||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
|
||||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
|
||||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|
||||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
|
||||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
|
||||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func TestTheAgreementCheckCatchesASubscriptionThatMatchesNothing(t *testing.T) {
|
|||||||
// An event published under a seat's name is real even though no module declares it as its own.
|
// An event published under a seat's name is real even though no module declares it as its own.
|
||||||
if bad := Disagreements(nil,
|
if bad := Disagreements(nil,
|
||||||
[]AConsumer{{Module: "watcher", Consumes: []string{"mesh-artifact-store.image.pushed"}}},
|
[]AConsumer{{Module: "watcher", Consumes: []string{"mesh-artifact-store.image.pushed"}}},
|
||||||
[]DeclaredSeat{{Name: "the-artifact-store", Emits: []string{"image.pushed"}}}); len(bad) != 0 {
|
[]DeclaredSeat{{Name: "mesh-artifact-store", Emits: []string{"image.pushed"}}}); len(bad) != 0 {
|
||||||
t.Fatalf("an event a seat emits was reported as matching nothing: %v", bad)
|
t.Fatalf("an event a seat emits was reported as matching nothing: %v", bad)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
package broker_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The names are written twice, so a test keeps them agreeing.
|
|
||||||
//
|
|
||||||
// `link` imports `broker`, so `broker` cannot import `link` — the queue and exchange names
|
|
||||||
// therefore exist in both. A scoped account naming a queue nothing publishes to produces a
|
|
||||||
// builder that takes no work and says nothing about why, which is the worst kind of silence.
|
|
||||||
//
|
|
||||||
// An external test package, because it may import both without either importing the other.
|
|
||||||
func TestTheNamesTheBrokerScopesAreTheNamesTheLinkUses(t *testing.T) {
|
|
||||||
for _, agreed := range []struct {
|
|
||||||
what string
|
|
||||||
scoped string
|
|
||||||
actually string
|
|
||||||
}{
|
|
||||||
{"the build queue", broker.BuildQueueName, link.BuildQueue},
|
|
||||||
{"the exchange", broker.ExchangeName, link.Exchange},
|
|
||||||
{"a node's queue", broker.QueueFor("somewhere"), link.QueueFor("somewhere")},
|
|
||||||
} {
|
|
||||||
if agreed.scoped != agreed.actually {
|
|
||||||
t.Errorf("%s: the broker scopes %q and the link uses %q",
|
|
||||||
agreed.what, agreed.scoped, agreed.actually)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestABuilderMayWriteToAReplyQueueAndReadNoNodesDeclarations(t *testing.T) {
|
|
||||||
// The scoping, checked as patterns rather than by connecting: what it may write must include
|
|
||||||
// the queue an asker actually waits on, and what it may read must not include any node's.
|
|
||||||
//
|
|
||||||
// This exists because the first version scoped writes to `amq.gen-*` — the name one broker
|
|
||||||
// happens to generate — and the builder built, could not answer, and the connection simply
|
|
||||||
// closed saying only "not allowed to publish to exchange \'\'". Answering through the
|
|
||||||
// exchange is what removed the need for any of that.
|
|
||||||
write := regexp.MustCompile("^" + regexp.QuoteMeta(broker.ExchangeName) + "$")
|
|
||||||
if !write.MatchString(broker.ExchangeName) {
|
|
||||||
t.Error("a builder may not write to the exchange, so it can take work and never answer")
|
|
||||||
}
|
|
||||||
// And not the default exchange, where permission is per exchange rather than per queue — a
|
|
||||||
// builder allowed to use it could publish into any node's queue.
|
|
||||||
//
|
|
||||||
// Confirmed against a real broker as well, and worth recording how that nearly went wrong:
|
|
||||||
// an unconfirmed publish is asynchronous, so a refusal arrives as a channel close afterwards
|
|
||||||
// and a naive check reports success. With publisher confirms the broker's refusal is
|
|
||||||
// immediate. **A negative security assertion made against an asynchronous call is not an
|
|
||||||
// assertion.**
|
|
||||||
if write.MatchString("") {
|
|
||||||
t.Error("a builder may publish to the default exchange, and so into any node's queue")
|
|
||||||
}
|
|
||||||
|
|
||||||
read := regexp.MustCompile("^" + regexp.QuoteMeta(broker.BuildQueueName) + "$")
|
|
||||||
if !read.MatchString(broker.BuildQueueName) {
|
|
||||||
t.Error("a builder may not read the build queue")
|
|
||||||
}
|
|
||||||
if read.MatchString(link.QueueFor("someone-else")) {
|
|
||||||
// A build machine is not a node, and a node's queue carries its declarations.
|
|
||||||
t.Error("a builder may read another machine's declarations")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -66,6 +66,19 @@ func FromEnvironment() (Broker, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Broker{}, err
|
return Broker{}, err
|
||||||
}
|
}
|
||||||
|
// **One bus, one address** (novox/hq ADR 0131). Everything the mesh hands out — a token, a
|
||||||
|
// machine's membership, a person's credential — must name the bus the control plane itself is
|
||||||
|
// connected to; the setting above predates the move and, on a mesh that has moved, still names
|
||||||
|
// the broker it moved from. The first person issued after the move was handed the retired
|
||||||
|
// broker's port and could not connect to anything (2026-09-28).
|
||||||
|
//
|
||||||
|
// Read from the credential rather than from a second setting somebody keeps in step: the
|
||||||
|
// control plane cannot be wrong about where it is connected.
|
||||||
|
if bus, on, err := OnNATS(); err == nil && on {
|
||||||
|
if where := strings.TrimPrefix(BareAddress(bus), "nats://"); where != "" {
|
||||||
|
address = where
|
||||||
|
}
|
||||||
|
}
|
||||||
return Broker{Address: address, Fingerprint: fingerprint}, nil
|
return Broker{Address: address, Fingerprint: fingerprint}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,16 +194,3 @@ func TestTheAddressPortFollowsThePortTwin(t *testing.T) {
|
|||||||
t.Fatalf("the address is %q; the node put the bus on 5679", b.Address)
|
t.Fatalf("the address is %q; the node put the bus on 5679", b.Address)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheManagementPortFollowsThePortTwin(t *testing.T) {
|
|
||||||
t.Setenv(ManagementVar, "http://guest:guest@127.0.0.1:15672")
|
|
||||||
t.Setenv(ManagementVar+"_FILE", "")
|
|
||||||
t.Setenv(ManagementVar+"_PORT", "15673")
|
|
||||||
m, err := ManagementFromEnvironment()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if m.base.Host != "127.0.0.1:15673" {
|
|
||||||
t.Fatalf("the management API is at %q; the node put it on 15673", m.base.Host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package broker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
)
|
||||||
|
|
||||||
|
const twoSeconds = 2 * time.Second
|
||||||
|
|
||||||
|
// A running mesh already holds consumers made before the delivery subject carried the stream
|
||||||
|
// (novox/hq 04-ISSUES/146). The server will not change a push consumer's delivery subject in place,
|
||||||
|
// so bringing one to match must replace it — and must not replay what it already acknowledged
|
||||||
|
// (novox/hq 04-ISSUES/156).
|
||||||
|
//
|
||||||
|
// docker run -d --rm --name t -p 14231:4222 nats:2.10-alpine -js
|
||||||
|
// MESH_TEST_NATS=nats://127.0.0.1:14231 go test ./internal/broker/ -run TestUpgrading
|
||||||
|
func TestUpgradingAConsumerWhoseDeliverySubjectMoved(t *testing.T) {
|
||||||
|
url := os.Getenv("MESH_TEST_NATS")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("MESH_TEST_NATS unset")
|
||||||
|
}
|
||||||
|
js, err := Dial(url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer js.Close()
|
||||||
|
|
||||||
|
// The stream exactly as the mesh's own is — one declaration per node, always the newest.
|
||||||
|
// Reproduced rather than approximated: the first version of this test used a plain stream and
|
||||||
|
// a plain consumer, and the server accepted the update it refuses in a running mesh, so the
|
||||||
|
// test passed against the very code that was crash-looping on the control node.
|
||||||
|
const stream, name = "NODES", "novox"
|
||||||
|
subject := "mesh.node." + name + ".declare"
|
||||||
|
_ = js.js.DeleteStream(stream)
|
||||||
|
if _, err := js.js.AddStream(&nats.StreamConfig{
|
||||||
|
Name: stream, Subjects: []string{"mesh.node.*.declare"},
|
||||||
|
MaxMsgsPerSubject: 1, Storage: nats.MemoryStorage,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = js.js.DeleteStream(stream) }()
|
||||||
|
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
if _, err := js.js.Publish(subject, []byte(fmt.Sprint(i))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The consumer as a running mesh holds it: made before the subject carried the stream, and
|
||||||
|
// otherwise exactly what NodeConsumer asks for.
|
||||||
|
if _, err := js.js.AddConsumer(stream, &nats.ConsumerConfig{
|
||||||
|
Durable: name, AckPolicy: nats.AckExplicitPolicy,
|
||||||
|
AckWait: 300 * time.Second, MaxDeliver: -1,
|
||||||
|
FilterSubject: subject,
|
||||||
|
DeliverSubject: "_DELIVER." + name,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// It acknowledged the first four. Those must not come back.
|
||||||
|
sub, err := js.js.SubscribeSync(subject, nats.Bind(stream, name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 1; i++ {
|
||||||
|
m, err := sub.NextMsg(twoSeconds)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("message %d never arrived: %v", i, err)
|
||||||
|
}
|
||||||
|
if err := m.AckSync(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// **The subscription stays up.** In a running mesh the machine is attached to this consumer
|
||||||
|
// the whole time — that is what a node listening for its declaration IS. The first version of
|
||||||
|
// this test unsubscribed first, and the server then accepted an update it refuses while a
|
||||||
|
// subscriber is bound, so the test passed against the code that was crash-looping.
|
||||||
|
defer func() { _ = sub.Unsubscribe() }()
|
||||||
|
|
||||||
|
// Now the upgrade: the consumer the controller asserts on every start, with the subject that
|
||||||
|
// carries the stream.
|
||||||
|
want := NodeConsumer(name)
|
||||||
|
var notes []string
|
||||||
|
js.Note = func(f string, a ...any) { notes = append(notes, fmt.Sprintf(f, a...)) }
|
||||||
|
|
||||||
|
if err := js.EnsureConsumer(want); err != nil {
|
||||||
|
t.Fatalf("a consumer the mesh already held could not be brought to match, which is the "+
|
||||||
|
"control plane failing to start: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := js.js.ConsumerInfo(stream, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// It KEEPS the subject it had. Moving it would need the holder's grant to have widened first,
|
||||||
|
// and that grant travels in the bus's user list, which a machine applies minutes later.
|
||||||
|
if got := info.Config.DeliverSubject; got != "_DELIVER."+name {
|
||||||
|
t.Fatalf("the consumer a machine is bound to was moved to %q; a machine not yet allowed "+
|
||||||
|
"to subscribe there is a machine that hears nothing", got)
|
||||||
|
}
|
||||||
|
if len(notes) != 1 {
|
||||||
|
t.Fatalf("keeping it was not reported, so it would be invisible: %v", notes)
|
||||||
|
}
|
||||||
|
if !strings.Contains(notes[0], "keeps working") {
|
||||||
|
t.Fatalf("the note does not say the consumer still works: %q", notes[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the machine bound to it is still being delivered to — the point of keeping it.
|
||||||
|
if _, err := js.js.Publish(subject, []byte("after the assertion")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m, err := sub.NextMsg(twoSeconds)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the machine stopped hearing its declarations after the assertion: %v", err)
|
||||||
|
}
|
||||||
|
if string(m.Data) != "after the assertion" {
|
||||||
|
t.Fatalf("delivered %q", m.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asserting again is a no-op, or the controller crash-loops on its own restart.
|
||||||
|
if err := js.EnsureConsumer(want); err != nil {
|
||||||
|
t.Fatalf("the second assertion failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And where nothing is bound, the subject DOES move — that is 04-ISSUES/146's fix, which this must
|
||||||
|
// not undo. The controller's own two consumers are in exactly this position: it asserts them before
|
||||||
|
// it subscribes.
|
||||||
|
func TestAConsumerNothingIsBoundToDoesMove(t *testing.T) {
|
||||||
|
url := os.Getenv("MESH_TEST_NATS")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("MESH_TEST_NATS unset")
|
||||||
|
}
|
||||||
|
js, err := Dial(url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer js.Close()
|
||||||
|
|
||||||
|
const stream, name = "NODES", "shanks"
|
||||||
|
subject := "mesh.node." + name + ".declare"
|
||||||
|
_ = js.js.DeleteStream(stream)
|
||||||
|
if _, err := js.js.AddStream(&nats.StreamConfig{
|
||||||
|
Name: stream, Subjects: []string{"mesh.node.*.declare"},
|
||||||
|
MaxMsgsPerSubject: 1, Storage: nats.MemoryStorage,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = js.js.DeleteStream(stream) }()
|
||||||
|
|
||||||
|
if _, err := js.js.AddConsumer(stream, &nats.ConsumerConfig{
|
||||||
|
Durable: name, AckPolicy: nats.AckExplicitPolicy,
|
||||||
|
AckWait: 300 * time.Second, MaxDeliver: -1,
|
||||||
|
FilterSubject: subject,
|
||||||
|
DeliverSubject: "_DELIVER." + name,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := NodeConsumer(name)
|
||||||
|
if err := js.EnsureConsumer(want); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, err := js.js.ConsumerInfo(stream, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := info.Config.DeliverSubject; got != DeliverSubjectFor(want) {
|
||||||
|
t.Fatalf("delivery subject is %q, wanted %q -- issue 146's fix no longer applies to a "+
|
||||||
|
"consumer nothing is holding", got, DeliverSubjectFor(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,14 @@ type Consumer struct {
|
|||||||
AckWaitSeconds int
|
AckWaitSeconds int
|
||||||
// MaxDeliver before the message is dead-lettered; zero for the mesh's default.
|
// MaxDeliver before the message is dead-lettered; zero for the mesh's default.
|
||||||
MaxDeliver int
|
MaxDeliver int
|
||||||
Why string
|
// MaxAckPending is how many deliveries the server lets stand unacknowledged at once; zero for
|
||||||
|
// the server's default, which is many. **One, for a consumer handled one at a time**
|
||||||
|
// (novox/hq issue 175): a handler that builds for minutes keeps its own message alive with a
|
||||||
|
// heartbeat, but everything handed over behind it times out unacknowledged and comes back —
|
||||||
|
// and a merge that came back rebuilt what it had just built, five times over on 2026-09-30.
|
||||||
|
// With one outstanding, the server holds the rest, and the heartbeat is keeping the message.
|
||||||
|
MaxAckPending int
|
||||||
|
Why string
|
||||||
}
|
}
|
||||||
|
|
||||||
// seatStreamName is the stream holding a seat's inbound work. Named after the seat rather than
|
// seatStreamName is the stream holding a seat's inbound work. Named after the seat rather than
|
||||||
@@ -154,8 +161,15 @@ func HolderConsumerFor(node, module string, seat DeclaredSeat) (Consumer, bool)
|
|||||||
Queue: "holders",
|
Queue: "holders",
|
||||||
AckWaitSeconds: 60,
|
AckWaitSeconds: 60,
|
||||||
MaxDeliver: 5,
|
MaxDeliver: 5,
|
||||||
|
// **One in flight.** A holder works one ask at a time, so the server hands it one at a
|
||||||
|
// time: with the default of many, every ask behind the one being worked was delivered,
|
||||||
|
// left unacknowledged for the length of the work, redelivered after the ack wait, and
|
||||||
|
// after the fifth time dropped — on 2026-10-01 twenty-six of forty-three builds asked in
|
||||||
|
// two minutes were never built, and the queue read as empty (novox/hq issue 186).
|
||||||
|
MaxAckPending: 1,
|
||||||
Why: fmt.Sprintf("%s on %s holds %s; it acknowledges after the work is done, so a "+
|
Why: fmt.Sprintf("%s on %s holds %s; it acknowledges after the work is done, so a "+
|
||||||
"crash mid-work redelivers rather than loses", module, node, seat.Name),
|
"crash mid-work redelivers rather than loses; one in flight, so a queue of asks is a "+
|
||||||
|
"queue and not a race against the ack wait", module, node, seat.Name),
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,3 +153,16 @@ func TestANodesDeclarationConsumerIsWhatItsOwnGrantAllows(t *testing.T) {
|
|||||||
has(t, perms.Publish, "$JS.ACK.NODES."+c.Name+".>")
|
has(t, perms.Publish, "$JS.ACK.NODES."+c.Name+".>")
|
||||||
has(t, perms.Subscribe, c.Filters[0])
|
has(t, perms.Subscribe, c.Filters[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A holder works one ask at a time, so the server hands it one at a time (novox/hq issue 186):
|
||||||
|
// asks queued behind the one being worked wait in the stream rather than being delivered,
|
||||||
|
// left to expire and dropped after the fifth redelivery.
|
||||||
|
func TestAHoldersWorkerTakesOneAskAtATime(t *testing.T) {
|
||||||
|
c, found := HolderConsumerFor("anchor", "builder", DeclaredSeat{Name: "mesh-build-machine", Accepts: []string{"build"}})
|
||||||
|
if !found {
|
||||||
|
t.Fatal("a seat that accepts work has no worker")
|
||||||
|
}
|
||||||
|
if c.MaxAckPending != 1 {
|
||||||
|
t.Fatalf("the worker may have %d asks in flight; one, so a queue is a queue", c.MaxAckPending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,6 +62,22 @@ func TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose(t *testing.T)
|
|||||||
|
|
||||||
// theCarriedAccounts is the accounts file the installer's template writes at genesis.
|
// theCarriedAccounts is the accounts file the installer's template writes at genesis.
|
||||||
func theCarriedAccounts(t *testing.T) string {
|
func theCarriedAccounts(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, r := range theTemplate(t) {
|
||||||
|
if r["id"] == "bus-accounts" {
|
||||||
|
content, _ := r["content"].(string)
|
||||||
|
if content == "" {
|
||||||
|
t.Fatal("the template's accounts file is empty, so the bus would refuse every connection")
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("the template carries no accounts file, so a mesh raised from it has a bus nobody may use")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// theTemplate is the installer's bundle, as resources.
|
||||||
|
func theTemplate(t *testing.T) []map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join("..", "..", "..", "mesh-host", "examples", "foundation-first-node-nats.lock")
|
path := filepath.Join("..", "..", "..", "mesh-host", "examples", "foundation-first-node-nats.lock")
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
@@ -81,17 +97,7 @@ func theCarriedAccounts(t *testing.T) string {
|
|||||||
if err := json.Unmarshal([]byte(strings.Join(lines, "\n")), &bundle); err != nil {
|
if err := json.Unmarshal([]byte(strings.Join(lines, "\n")), &bundle); err != nil {
|
||||||
t.Fatalf("the template is not readable: %v", err)
|
t.Fatalf("the template is not readable: %v", err)
|
||||||
}
|
}
|
||||||
for _, r := range bundle.Resources {
|
return bundle.Resources
|
||||||
if r["id"] == "bus-accounts" {
|
|
||||||
content, _ := r["content"].(string)
|
|
||||||
if content == "" {
|
|
||||||
t.Fatal("the template's accounts file is empty, so the bus would refuse every connection")
|
|
||||||
}
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Fatal("the template carries no accounts file, so a mesh raised from it has a bus nobody may use")
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// subjectsIn reads one allow-list out of a composed accounts file.
|
// subjectsIn reads one allow-list out of a composed accounts file.
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package broker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module that says it calls a tool may publish exactly that subject (novox/hq ADR 0152): the same
|
||||||
|
// grant a person gets, derived the same way, so one list answers "what may this ask" for everybody.
|
||||||
|
func TestAModuleMayAskOnlyTheToolsItInvokes(t *testing.T) {
|
||||||
|
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "desk", Module: "mesh-console",
|
||||||
|
Invokes: []string{"shop.price"}, PasswordHash: "x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
has(t, perms.Publish, "mesh.mod.shop.tool.price")
|
||||||
|
hasNot(t, perms.Publish, "mesh.mod.shop.tool.refund")
|
||||||
|
hasNot(t, perms.Publish, "mesh.mod.*.tool.>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The console's grant: every tool, as one subject, and it reads as one.
|
||||||
|
func TestAModuleInvokingEverythingMayAskAnyTool(t *testing.T) {
|
||||||
|
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "desk", Module: "mesh-console",
|
||||||
|
Invokes: []string{"*"}, PasswordHash: "x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
has(t, perms.Publish, "mesh.mod.*.tool.>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// **A grant to call widens nothing else.** A module that invokes may not publish an event it did not
|
||||||
|
// declare, may not answer as another module, and subscribes nothing it did not consume — the
|
||||||
|
// difference between the console and a person is that the console is on a machine, not that it may
|
||||||
|
// do more.
|
||||||
|
func TestInvokingGrantsNothingButTheCall(t *testing.T) {
|
||||||
|
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "desk", Module: "mesh-console",
|
||||||
|
Invokes: []string{"*"}, PasswordHash: "x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, p := range perms.Publish {
|
||||||
|
if strings.Contains(p, ".event.") {
|
||||||
|
t.Errorf("a module that only invokes may publish %q, an event it never declared", p)
|
||||||
|
}
|
||||||
|
// A role's tools are tools (ADR 0132); a role's work queue and events are not.
|
||||||
|
if strings.HasPrefix(p, "mesh.seat.") && !strings.Contains(p, ".tool.") {
|
||||||
|
t.Errorf("a module that only invokes may publish %q, a seat it neither holds nor uses", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range perms.Subscribe {
|
||||||
|
if strings.Contains(s, ".tool.") && !strings.HasPrefix(s, "mesh.mod.mesh-console.") {
|
||||||
|
t.Errorf("a module that invokes may subscribe %q, another module's tools", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module that declares no invokes calls nothing, which is every module but the console.
|
||||||
|
func TestAModuleThatInvokesNothingCallsNothing(t *testing.T) {
|
||||||
|
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop",
|
||||||
|
Emits: []string{"order.placed"}, PasswordHash: "x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, p := range perms.Publish {
|
||||||
|
if strings.Contains(p, ".tool.") {
|
||||||
|
t.Errorf("a module with no invokes may publish %q", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The malformed entry is refused for a module as it is for a person, and in the same words.
|
||||||
|
func TestAModulesToolGrantThatNamesNoToolIsRefused(t *testing.T) {
|
||||||
|
if _, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop",
|
||||||
|
Invokes: []string{"telegram"}, PasswordHash: "x"}); err == nil {
|
||||||
|
t.Fatal("a grant naming a module but no tool was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a declaration says reaches the composed user, so a manifest's `invokes` is the grant.
|
||||||
|
func TestADeclaredInvokeReachesTheComposedUser(t *testing.T) {
|
||||||
|
users, err := Users(Records{
|
||||||
|
Nodes: []string{"desk"},
|
||||||
|
Assigned: map[string][]Declared{"desk": {{Module: "mesh-console", Invokes: []string{"*"}}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
perms, err := PermissionsFor(users[len(users)-1])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
has(t, perms.Publish, "mesh.mod.*.tool.>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module's tool is addressed two ways (novox/hq ADR 0159): to whichever instance answers, and to
|
||||||
|
// the instance on one machine. A grant for the tool covers both and nothing wider.
|
||||||
|
func TestInvokingAToolMayAddressTheMachineToo(t *testing.T) {
|
||||||
|
got, err := invokedSubjects([]string{"postgres.postgres_query"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"mesh.mod.postgres.tool.postgres_query", "mesh.mod.postgres.tool.postgres_query.*"}
|
||||||
|
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
|
||||||
|
t.Fatalf("the grant is %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
package broker
|
package broker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
@@ -20,25 +26,92 @@ import (
|
|||||||
type JetStream struct {
|
type JetStream struct {
|
||||||
conn *nats.Conn
|
conn *nats.Conn
|
||||||
js nats.JetStreamContext
|
js nats.JetStreamContext
|
||||||
|
// Note is how this says something it decided not to fail over. Nil is silent, which is only
|
||||||
|
// right for a caller that has no way to report; the controller sets it.
|
||||||
|
Note func(string, ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// note reports without requiring a caller to have set one.
|
||||||
|
func (j *JetStream) note(format string, args ...any) {
|
||||||
|
if j.Note != nil {
|
||||||
|
j.Note(format, args...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dial connects and returns the controller's JetStream handle.
|
// Dial connects and returns the controller's JetStream handle.
|
||||||
func Dial(url string, opts ...nats.Option) (*JetStream, error) {
|
func Dial(url string, opts ...nats.Option) (*JetStream, error) {
|
||||||
// A name, because a connection nobody can identify in the server's own monitoring is one
|
|
||||||
// nobody can attribute a problem to.
|
|
||||||
opts = append(opts, nats.Name("mesh-controller"), nats.Timeout(10*time.Second))
|
opts = append(opts, nats.Name("mesh-controller"), nats.Timeout(10*time.Second))
|
||||||
|
// **Pinned, not named.** The bus presents the mesh's own certificate, which names nothing a
|
||||||
|
// public verifier would accept (design 25 §4: a host pins the server's exact certificate and
|
||||||
|
// checks nothing else, and so does this). Without this, the first connection failed with
|
||||||
|
// "certificate is not valid for any names" against a bus that was answering (2026-09-28).
|
||||||
|
if path := strings.TrimSpace(os.Getenv(CertificateVar)); path != "" {
|
||||||
|
pinned, err := pinnedTo(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
opts = append(opts, nats.Secure(pinned))
|
||||||
|
}
|
||||||
|
// **Its own inbox, and nothing wider.** Every principal is granted `_INBOX.<its user>.>` and
|
||||||
|
// no other inbox; the client's default prefix is random, and the server refused the first
|
||||||
|
// subscription to it (2026-09-28). The user is in the URL, so the prefix follows from it.
|
||||||
|
if user, _, _ := CredentialIn(url); user != "" {
|
||||||
|
opts = append(opts, nats.CustomInboxPrefix("_INBOX."+user))
|
||||||
|
}
|
||||||
|
// The address in an error is the address alone. The URL carries this controller's password,
|
||||||
|
// and an error here is written on the assumption it will be logged.
|
||||||
|
where := BareAddress(url)
|
||||||
conn, err := nats.Connect(url, opts...)
|
conn, err := nats.Connect(url, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("connecting to the bus at %s: %w", url, err)
|
return nil, fmt.Errorf("connecting to the bus at %s: %w", where, err)
|
||||||
}
|
}
|
||||||
js, err := conn.JetStream()
|
js, err := conn.JetStream()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return nil, fmt.Errorf("the bus at %s has no JetStream: %w", url, err)
|
return nil, fmt.Errorf("the bus at %s has no JetStream: %w", where, err)
|
||||||
}
|
}
|
||||||
return &JetStream{conn: conn, js: js}, nil
|
return &JetStream{conn: conn, js: js}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pinnedTo is a TLS configuration that accepts exactly the certificate in the file and no other:
|
||||||
|
// the leaf's SHA-256, compared on every handshake, with the name and the chain deliberately not
|
||||||
|
// consulted — a self-signed certificate with no names is the ordinary case for a mesh's bus.
|
||||||
|
func pinnedTo(path string) (*tls.Config, error) {
|
||||||
|
want, err := FingerprintOf(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return PinnedToFingerprint(want), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DialPinned is Dial with the server's certificate pinned by a fingerprint the caller already holds
|
||||||
|
// — a module or a build machine that was handed one beside its credential, and has no file.
|
||||||
|
func DialPinned(url, fingerprint string, opts ...nats.Option) (*JetStream, error) {
|
||||||
|
if strings.TrimSpace(fingerprint) != "" {
|
||||||
|
opts = append(opts, nats.Secure(PinnedToFingerprint(fingerprint)))
|
||||||
|
}
|
||||||
|
return Dial(url, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PinnedToFingerprint accepts exactly the certificate with this SHA-256 and no other.
|
||||||
|
func PinnedToFingerprint(want string) *tls.Config {
|
||||||
|
return &tls.Config{
|
||||||
|
InsecureSkipVerify: true, //nolint:gosec // replaced by the pin below, which is stricter
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
||||||
|
if len(rawCerts) == 0 {
|
||||||
|
return errors.New("the bus presented no certificate")
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(rawCerts[0])
|
||||||
|
got := "sha256:" + hex.EncodeToString(sum[:])
|
||||||
|
if got != want {
|
||||||
|
return fmt.Errorf("the bus presented a certificate this mesh does not know (%s…), expected %s…", got[:23], want[:23])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Conn is the connection itself, for what the mesh keeps off JetStream on purpose — a heartbeat,
|
// Conn is the connection itself, for what the mesh keeps off JetStream on purpose — a heartbeat,
|
||||||
// a tool call — where a lost message is answered by the next one or by a timeout the caller
|
// a tool call — where a lost message is answered by the next one or by a timeout the caller
|
||||||
// already handles (design 25 §3).
|
// already handles (design 25 §3).
|
||||||
@@ -71,6 +144,7 @@ func (j *JetStream) EnsureStream(s Stream) error {
|
|||||||
MaxMsgsPerSubject: int64(s.MaxMsgsPerSubject),
|
MaxMsgsPerSubject: int64(s.MaxMsgsPerSubject),
|
||||||
Description: s.Why,
|
Description: s.Why,
|
||||||
}
|
}
|
||||||
|
want.AllowDirect = s.Direct
|
||||||
if s.Retention == RetentionLastPerSubject {
|
if s.Retention == RetentionLastPerSubject {
|
||||||
// Last-per-subject is a limits stream with one message kept per subject, not a
|
// Last-per-subject is a limits stream with one message kept per subject, not a
|
||||||
// retention policy of its own — the state shape, spelled the way the server spells it.
|
// retention policy of its own — the state shape, spelled the way the server spells it.
|
||||||
@@ -106,6 +180,7 @@ func (j *JetStream) EnsureConsumer(c Consumer) error {
|
|||||||
AckPolicy: nats.AckExplicitPolicy,
|
AckPolicy: nats.AckExplicitPolicy,
|
||||||
AckWait: time.Duration(c.AckWaitSeconds) * time.Second,
|
AckWait: time.Duration(c.AckWaitSeconds) * time.Second,
|
||||||
MaxDeliver: c.MaxDeliver,
|
MaxDeliver: c.MaxDeliver,
|
||||||
|
MaxAckPending: c.MaxAckPending,
|
||||||
DeliverGroup: c.Queue,
|
DeliverGroup: c.Queue,
|
||||||
DeliverSubject: "",
|
DeliverSubject: "",
|
||||||
Description: c.Why,
|
Description: c.Why,
|
||||||
@@ -121,12 +196,60 @@ func (j *JetStream) EnsureConsumer(c Consumer) error {
|
|||||||
// without the other is refused by the server with a message that does not say which half is
|
// without the other is refused by the server with a message that does not say which half is
|
||||||
// missing.
|
// missing.
|
||||||
if c.Queue != "" || c.Push {
|
if c.Queue != "" || c.Push {
|
||||||
want.DeliverSubject = "_DELIVER." + c.Name
|
// **Per consumer, which means per stream as well as per name** (novox/hq 04-ISSUES/146).
|
||||||
|
// A push consumer delivers onto an ordinary subject, and everything subscribed to that
|
||||||
|
// subject gets a copy. The controller holds a consumer called `controller` on CONTROL and
|
||||||
|
// another called `controller` on EVENTS, and both were given `_DELIVER.controller` — so the
|
||||||
|
// one process, holding both subscriptions, acted on every message twice. It enrolled a
|
||||||
|
// joining machine twice from one request, minting a second credential that replaced the one
|
||||||
|
// the machine had just been given; the same doubling applied to every report and every
|
||||||
|
// event the controller follows.
|
||||||
|
//
|
||||||
|
// The stream is in the name because the pair is what identifies a consumer — the server
|
||||||
|
// scopes a durable's name to its stream, and this subject is the only place that scoping
|
||||||
|
// was dropped. Already within what the controller may subscribe (`_DELIVER.controller.>`),
|
||||||
|
// so no permission moves.
|
||||||
|
want.DeliverSubject = DeliverSubjectFor(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch _, err := j.js.ConsumerInfo(c.Stream, c.Name); {
|
switch have, err := j.js.ConsumerInfo(c.Stream, c.Name); {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
|
// Where an existing consumer starts is its history, not something an assertion may move:
|
||||||
|
// the server refuses a changed deliver policy outright. Carried across, so asserting twice
|
||||||
|
// is the no-op a restart depends on.
|
||||||
|
want.DeliverPolicy = have.Config.DeliverPolicy
|
||||||
|
want.OptStartSeq = have.Config.OptStartSeq
|
||||||
|
want.OptStartTime = have.Config.OptStartTime
|
||||||
|
|
||||||
if _, err := j.js.UpdateConsumer(c.Stream, want); err != nil {
|
if _, err := j.js.UpdateConsumer(c.Stream, want); err != nil {
|
||||||
|
// **A consumer that works is not replaced to make its name tidier**
|
||||||
|
// (novox/hq 04-ISSUES/156).
|
||||||
|
//
|
||||||
|
// The server will not move a push consumer's delivery subject while a subscriber is
|
||||||
|
// bound to it, and answers `consumer name already in use` — a message about the name,
|
||||||
|
// for a conflict about the subject. A node is bound to its declaration consumer the
|
||||||
|
// whole time it is up; that IS a node listening. So when 04-ISSUES/146 put the stream
|
||||||
|
// into the subject, every node consumer in a running mesh became one this could not
|
||||||
|
// bring to match, and the control plane crash-looped on the assertion it makes before
|
||||||
|
// it serves. A fresh mesh showed nothing: nothing was bound.
|
||||||
|
//
|
||||||
|
// Kept rather than deleted and re-made. Re-making moves the subject, and a holder may
|
||||||
|
// not be allowed to subscribe to the new one yet — the wider grant travels in the bus's
|
||||||
|
// user list, which this same control plane composes and a machine applies minutes
|
||||||
|
// later. Re-making here would have silenced every machine in the mesh, which is worse
|
||||||
|
// than the collision it was fixing and harder to undo.
|
||||||
|
//
|
||||||
|
// Kept rather than fatal, which is what 146's change intended and did not do: the bare
|
||||||
|
// subject it replaces still delivers, and it collides only where one holder has two
|
||||||
|
// consumers of one name. That is the controller's own pair, and the controller is not
|
||||||
|
// bound to them while it asserts, so those do move. A node has one consumer and nothing
|
||||||
|
// to collide with.
|
||||||
|
if have.Config.DeliverSubject != want.DeliverSubject {
|
||||||
|
j.note("consumer %s on %s still delivers to %q and not %q: %v. It keeps working; "+
|
||||||
|
"the subject moves on an assertion made while nothing is bound to it",
|
||||||
|
c.Name, c.Stream, have.Config.DeliverSubject, want.DeliverSubject, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return fmt.Errorf("bringing consumer %s on %s to match: %w", c.Name, c.Stream, err)
|
return fmt.Errorf("bringing consumer %s on %s to match: %w", c.Name, c.Stream, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,366 +0,0 @@
|
|||||||
package broker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"github.com/novox/mesh-controller/internal/envfile"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The mesh runs the broker, so there is no chicken-and-egg in a node needing an account before it
|
|
||||||
// can connect: the account is created when the token is issued, and the one-time secret in that
|
|
||||||
// token IS the password. A node's first connection is already authenticated, and enrolment is
|
|
||||||
// what happens over it.
|
|
||||||
//
|
|
||||||
// novox/hq ADR 0004's *a node holds its own identity and nothing else* is why the account is per
|
|
||||||
// node rather than shared. A shared enrolment account would let any node consume another's queue,
|
|
||||||
// which is the shared-credential fault that record exists to remove, reappearing at the transport.
|
|
||||||
|
|
||||||
// ManagementVar holds the broker's management API, credentials included.
|
|
||||||
const ManagementVar = "MESH_BROKER_MANAGEMENT"
|
|
||||||
|
|
||||||
// safeName is what a node may be called at the broker.
|
|
||||||
//
|
|
||||||
// The name goes into a URL path and into permission patterns, which are regular expressions. A
|
|
||||||
// name carrying a `.` or a `*` would silently widen what that node may reach — so it is
|
|
||||||
// constrained here rather than escaped later, because an escape that is forgotten once is a node
|
|
||||||
// reading everybody's queues.
|
|
||||||
var safeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
|
||||||
|
|
||||||
// Management is the broker's administrative interface.
|
|
||||||
type Management struct {
|
|
||||||
base *url.URL
|
|
||||||
client *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// ManagementFromEnvironment reads where the management API is, if it is configured.
|
|
||||||
//
|
|
||||||
// On the port MESH_BROKER_MANAGEMENT_PORT names when the node moved it (novox/hq 04-ISSUES/102);
|
|
||||||
// the URL's own port otherwise.
|
|
||||||
func ManagementFromEnvironment() (*Management, error) {
|
|
||||||
raw, err := envfile.Placed(ManagementVar)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if raw == "" {
|
|
||||||
return nil, ErrNotConfigured
|
|
||||||
}
|
|
||||||
base, err := url.Parse(raw)
|
|
||||||
if err != nil || base.Host == "" {
|
|
||||||
// The value carries a password, so it is not quoted back.
|
|
||||||
return nil, fmt.Errorf("%s is not a usable URL", ManagementVar)
|
|
||||||
}
|
|
||||||
return &Management{base: base, client: &http.Client{Timeout: 15 * time.Second}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueueFor is the queue a node consumes from. One per node, named after it.
|
|
||||||
func QueueFor(node string) string { return "node." + node }
|
|
||||||
|
|
||||||
// ExchangeName is where nodes publish what they have to say. One exchange, and the control plane
|
|
||||||
// is the only consumer behind it (novox/hq ADR 0006 — one consumer, so two cannot silently split
|
|
||||||
// the traffic between them).
|
|
||||||
const ExchangeName = "mesh"
|
|
||||||
|
|
||||||
// BuildQueueName is where build work waits. Duplicated from `link` rather than imported, for the
|
|
||||||
// same reason QueueFor above is: this package must not depend on the one that uses it, and a
|
|
||||||
// constant that differed would be caught by the test that asserts they agree.
|
|
||||||
const BuildQueueName = "builds"
|
|
||||||
|
|
||||||
// The events bus (novox/hq ADR 0042): one topic exchange every event rides, a second for tool
|
|
||||||
// RPC kept apart, and a dead-letter home for a poison event. The foundation owns these — a module's
|
|
||||||
// account cannot declare them, only bind its own queue to the events one.
|
|
||||||
const (
|
|
||||||
EventsExchangeName = "mesh.events"
|
|
||||||
RPCExchangeName = "mesh.rpc"
|
|
||||||
DeadExchangeName = "mesh.events.dead"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ModuleQueueFor is the durable queue a module consumes its events from — one per module per node
|
|
||||||
// (novox/hq ADR 0042), named so the account that may read it is exactly this module's.
|
|
||||||
func ModuleQueueFor(node, module string) string { return node + "." + module + ".events" }
|
|
||||||
|
|
||||||
// modulePermissions is a module's authority on the bus, derived from its manifest (novox/hq
|
|
||||||
// ADR 0043): what it consumes and what it emits, and nothing else. Pure, so the scope is tested as
|
|
||||||
// patterns without a broker — the way a builder's is.
|
|
||||||
//
|
|
||||||
// A note on the limit: the broker's write permission is per exchange, not per routing key (LavinMQ
|
|
||||||
// has no topic permissions), so an emitting module is granted the events exchange whole. ADR 0042's
|
|
||||||
// origin reservation — a module publishes only under `module.<self>.*` — is stamped by the sdk, not
|
|
||||||
// enforced here; that gap is the broker's, and is recorded rather than hidden. A pure consumer like
|
|
||||||
// the audit logger is unaffected: it is granted no write to the exchange at all.
|
|
||||||
func modulePermissions(node, module string, emits, consumes []string) (configure, write, read string) {
|
|
||||||
queue := regexp.QuoteMeta(ModuleQueueFor(node, module))
|
|
||||||
events := regexp.QuoteMeta(EventsExchangeName)
|
|
||||||
rpc := regexp.QuoteMeta(RPCExchangeName)
|
|
||||||
// A module serves each of its tools on its own queue, namespaced by the module (novox/hq
|
|
||||||
// ADR 0047) — serve.<module>.<tool> — so the account may declare, bind and read exactly its own,
|
|
||||||
// and no other module's.
|
|
||||||
serve := "serve\\." + regexp.QuoteMeta(module) + "\\..*"
|
|
||||||
|
|
||||||
// Declare its own events queue and its own tool serve queues.
|
|
||||||
configure = "^(" + queue + "|" + serve + ")$"
|
|
||||||
|
|
||||||
// Write to bind its queue and serve queues (binding is a write on the queue), and to the RPC
|
|
||||||
// exchange to publish replies (ADR 0047: replies ride mesh.rpc, never the default exchange, which
|
|
||||||
// would let it publish into any queue). To the events exchange only if it emits.
|
|
||||||
writes := []string{queue, serve, rpc}
|
|
||||||
if len(emits) > 0 {
|
|
||||||
writes = append(writes, events)
|
|
||||||
}
|
|
||||||
write = "^(" + strings.Join(writes, "|") + ")$"
|
|
||||||
|
|
||||||
// Read its own queue and serve queues to consume them, and the RPC exchange to bind its serve
|
|
||||||
// queues onto. The events exchange to bind onto only if it consumes.
|
|
||||||
reads := []string{queue, serve, rpc}
|
|
||||||
if len(consumes) > 0 {
|
|
||||||
reads = append(reads, events)
|
|
||||||
}
|
|
||||||
read = "^(" + strings.Join(reads, "|") + ")$"
|
|
||||||
return configure, write, read
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateModuleAccount gives an assigned module its own broker account, scoped by what it emits and
|
|
||||||
// consumes (novox/hq ADR 0043). The account name carries the node so the same module on two machines
|
|
||||||
// holds two accounts, each sealed to its own; the permissions carry the module so one module cannot
|
|
||||||
// read another's queue. Generic — the builder is one instance of this rule, not a separate kind.
|
|
||||||
func (m *Management) CreateModuleAccount(ctx context.Context, node, module, password string, emits, consumes []string) (string, error) {
|
|
||||||
if !safeName.MatchString(node) {
|
|
||||||
return "", fmt.Errorf("%q cannot be part of a broker account: it is a permission pattern", node)
|
|
||||||
}
|
|
||||||
if !safeName.MatchString(module) {
|
|
||||||
return "", fmt.Errorf("%q cannot be part of a broker account: it is a permission pattern", module)
|
|
||||||
}
|
|
||||||
account := node + "-" + module
|
|
||||||
if !safeName.MatchString(account) {
|
|
||||||
return "", fmt.Errorf("%q is not a usable broker account name", account)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.put(ctx, "/api/users/"+url.PathEscape(account),
|
|
||||||
map[string]string{"password": password, "tags": ""}); err != nil {
|
|
||||||
return "", fmt.Errorf("cannot create the broker account for %s on %s: %w", module, node, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
configure, write, read := modulePermissions(node, module, emits, consumes)
|
|
||||||
if err := m.put(ctx, "/api/permissions/%2f/"+url.PathEscape(account), map[string]string{
|
|
||||||
"configure": configure, "write": write, "read": read,
|
|
||||||
}); err != nil {
|
|
||||||
return "", fmt.Errorf("cannot scope the broker account for %s on %s: %w", module, node, err)
|
|
||||||
}
|
|
||||||
return account, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureModuleQueue declares a consuming module's queue with its dead-letter exchange, idempotently.
|
|
||||||
// The foundation declares it because a scoped module account may not: the broker refuses a queue with
|
|
||||||
// a dead-letter exchange to a non-administrator (novox/hq ADR 0043), so a consumer passively checks
|
|
||||||
// the queue the mesh made rather than declaring its own.
|
|
||||||
func (m *Management) EnsureModuleQueue(ctx context.Context, node, module string) error {
|
|
||||||
queue := ModuleQueueFor(node, module)
|
|
||||||
if err := m.put(ctx, "/api/queues/%2f/"+url.PathEscape(queue), map[string]any{
|
|
||||||
"durable": true,
|
|
||||||
"arguments": map[string]any{"x-dead-letter-exchange": DeadExchangeName},
|
|
||||||
}); err != nil {
|
|
||||||
return fmt.Errorf("cannot declare the queue for %s on %s: %w", module, node, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureEventExchanges declares the bus's exchanges and the dead-letter home, idempotently. The
|
|
||||||
// foundation owns them (a module's account may not declare an exchange), and a dead-letter exchange
|
|
||||||
// with no queue behind it drops what it receives — so a durable queue bound to `#` retains a poison
|
|
||||||
// event for inspection, which is the whole reason the trail exists.
|
|
||||||
func (m *Management) EnsureEventExchanges(ctx context.Context) error {
|
|
||||||
for _, exchange := range []string{EventsExchangeName, RPCExchangeName, DeadExchangeName} {
|
|
||||||
if err := m.put(ctx, "/api/exchanges/%2f/"+url.PathEscape(exchange),
|
|
||||||
map[string]any{"type": "topic", "durable": true}); err != nil {
|
|
||||||
return fmt.Errorf("cannot declare the %s exchange: %w", exchange, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := m.put(ctx, "/api/queues/%2f/"+url.PathEscape(DeadExchangeName),
|
|
||||||
map[string]any{"durable": true}); err != nil {
|
|
||||||
return fmt.Errorf("cannot declare the dead-letter queue: %w", err)
|
|
||||||
}
|
|
||||||
if err := m.post(ctx, "/api/bindings/%2f/e/"+url.PathEscape(DeadExchangeName)+
|
|
||||||
"/q/"+url.PathEscape(DeadExchangeName), map[string]string{"routing_key": "#"}); err != nil {
|
|
||||||
return fmt.Errorf("cannot bind the dead-letter queue: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateNodeAccount gives a node its own broker account, with the token's secret as the password.
|
|
||||||
//
|
|
||||||
// Scoped so a node can reach its own queue and the one exchange, and nothing else. The patterns
|
|
||||||
// are anchored: a node called `laptop` must not be able to read `laptop-of-somebody-else`.
|
|
||||||
func (m *Management) CreateNodeAccount(ctx context.Context, node, password string) error {
|
|
||||||
if !safeName.MatchString(node) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%q cannot be a broker account name: it becomes part of a permission pattern, so it "+
|
|
||||||
"is lower-case letters, digits and dashes", node)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.put(ctx, "/api/users/"+url.PathEscape(node),
|
|
||||||
map[string]string{"password": password, "tags": ""}); err != nil {
|
|
||||||
return fmt.Errorf("cannot create the broker account for %s: %w", node, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
queue := regexp.QuoteMeta(QueueFor(node))
|
|
||||||
if err := m.put(ctx, "/api/permissions/%2f/"+url.PathEscape(node), map[string]string{
|
|
||||||
"configure": "^" + queue + "$",
|
|
||||||
"write": "^(" + regexp.QuoteMeta(ExchangeName) + "|" + queue + ")$",
|
|
||||||
"read": "^" + queue + "$",
|
|
||||||
}); err != nil {
|
|
||||||
return fmt.Errorf("cannot scope the broker account for %s: %w", node, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateBuilderAccount scopes an account to taking build work and answering it.
|
|
||||||
//
|
|
||||||
// **A build machine is not a node**, and giving it a node's account would let it read another
|
|
||||||
// machine's declarations. What it needs is narrower and different: read the build queue, and
|
|
||||||
// write to the exchange and to whatever temporary queue an asker is waiting on.
|
|
||||||
//
|
|
||||||
// The reply queues are the reason `write` is not simply the exchange. `RequestBuild` declares an
|
|
||||||
// exclusive queue with a generated name and waits on it, so a builder that could not write to it
|
|
||||||
// could take work and never answer — which is the failure that looks like a builder that is not
|
|
||||||
// running.
|
|
||||||
func (m *Management) CreateBuilderAccount(ctx context.Context, name, password string) error {
|
|
||||||
if !safeName.MatchString(name) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%q cannot be a broker account name: it becomes part of a permission pattern, so it "+
|
|
||||||
"is lower-case letters, digits and dashes", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.put(ctx, "/api/users/"+url.PathEscape(name),
|
|
||||||
map[string]string{"password": password, "tags": ""}); err != nil {
|
|
||||||
return fmt.Errorf("cannot create the broker account for %s: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
builds := regexp.QuoteMeta(BuildQueueName)
|
|
||||||
if err := m.put(ctx, "/api/permissions/%2f/"+url.PathEscape(name), map[string]string{
|
|
||||||
// It declares the build queue, because whichever builder starts first must be able to —
|
|
||||||
// and a queue nobody may declare is a queue that exists only if the control plane has
|
|
||||||
// already run, which makes the order they start in matter.
|
|
||||||
"configure": "^" + builds + "$",
|
|
||||||
// Two exchanges, and nothing else. **Not the default exchange**: permission there is
|
|
||||||
// granted per exchange rather than per queue, so a builder allowed to use it could
|
|
||||||
// publish into any node's queue — the privilege a build machine most obviously should
|
|
||||||
// not have. Answers go through the node exchange; announcing what was built goes through
|
|
||||||
// the events exchange, which is a different act with a different audience (novox/hq
|
|
||||||
// ADR 0072). A builder that could answer and not announce would leave the module graph
|
|
||||||
// knowing less than the registry does.
|
|
||||||
"write": "^(" + regexp.QuoteMeta(ExchangeName) + "|" + regexp.QuoteMeta(EventsExchangeName) + ")$",
|
|
||||||
// The build queue and nothing else. Not another machine's declarations.
|
|
||||||
"read": "^" + builds + "$",
|
|
||||||
}); err != nil {
|
|
||||||
return fmt.Errorf("cannot scope the broker account for %s: %w", name, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveNodeAccount withdraws a node's access.
|
|
||||||
func (m *Management) RemoveNodeAccount(ctx context.Context, node string) error {
|
|
||||||
if !safeName.MatchString(node) {
|
|
||||||
return fmt.Errorf("%q is not a broker account name", node)
|
|
||||||
}
|
|
||||||
return m.do(ctx, http.MethodDelete, "/api/users/"+url.PathEscape(node), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accounts lists the broker's users, so a picture can be read from the system rather than assumed
|
|
||||||
// (novox/hq ADR 0018).
|
|
||||||
func (m *Management) Accounts(ctx context.Context) ([]string, error) {
|
|
||||||
body, err := m.get(ctx, "/api/users")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var users []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &users); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
names := make([]string, 0, len(users))
|
|
||||||
for _, u := range users {
|
|
||||||
names = append(names, u.Name)
|
|
||||||
}
|
|
||||||
return names, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Management) put(ctx context.Context, path string, body any) error {
|
|
||||||
return m.do(ctx, http.MethodPut, path, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Management) post(ctx context.Context, path string, body any) error {
|
|
||||||
return m.do(ctx, http.MethodPost, path, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Management) get(ctx context.Context, path string) ([]byte, error) {
|
|
||||||
request, err := m.request(ctx, http.MethodGet, path, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
response, err := m.client.Do(request)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer response.Body.Close()
|
|
||||||
if response.StatusCode >= 300 {
|
|
||||||
return nil, fmt.Errorf("the broker's management API answered %s to GET %s",
|
|
||||||
response.Status, path)
|
|
||||||
}
|
|
||||||
return io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Management) do(ctx context.Context, method, path string, body any) error {
|
|
||||||
request, err := m.request(ctx, method, path, body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
response, err := m.client.Do(request)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer response.Body.Close()
|
|
||||||
if response.StatusCode >= 300 {
|
|
||||||
detail, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
|
|
||||||
return fmt.Errorf("the broker's management API answered %s to %s %s: %s",
|
|
||||||
response.Status, method, path, strings.TrimSpace(string(detail)))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Management) request(ctx context.Context, method, path string, body any) (*http.Request, error) {
|
|
||||||
var payload io.Reader
|
|
||||||
if body != nil {
|
|
||||||
raw, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
payload = bytes.NewReader(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Path joined by hand rather than through url.Parse: %2f is the default vhost and must reach
|
|
||||||
// the broker still encoded. Parsing would decode it to a slash and address a different route.
|
|
||||||
target := strings.TrimSuffix(m.base.String(), "/")
|
|
||||||
if user := m.base.User; user != nil {
|
|
||||||
target = strings.TrimSuffix(m.base.Scheme+"://"+m.base.Host, "/")
|
|
||||||
}
|
|
||||||
request, err := http.NewRequestWithContext(ctx, method, target+path, payload)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if user := m.base.User; user != nil {
|
|
||||||
password, _ := user.Password()
|
|
||||||
request.SetBasicAuth(user.Username(), password)
|
|
||||||
}
|
|
||||||
if body != nil {
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
return request, nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package broker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What the mesh issues an assignment to serve and to reach (novox/hq ADR 0160).
|
||||||
|
//
|
||||||
|
// A module's code names its tools and its events; **where they land is the mesh's to decide**, and
|
||||||
|
// it decided it twice — once in the runtime, once here, by one rule compiled into both. Now the
|
||||||
|
// controller composes a membership for every module on every machine and publishes it to a subject
|
||||||
|
// only that assignment reads; the runtime serves exactly what the membership says, and the account's
|
||||||
|
// grant is the same composition read the other way. The shape issued today is the shape the mesh
|
||||||
|
// already had, so nothing moves when a membership first arrives; only who decides it moves.
|
||||||
|
|
||||||
|
// Membership is one assignment's subjects: what this instance of a module on this machine serves,
|
||||||
|
// and what it may reach.
|
||||||
|
type Membership struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
// Serves is every address a tool of this instance answers on. `{tool}` stands for the tool's
|
||||||
|
// own name, which the module knows and the mesh does not need to: the mesh issues the address,
|
||||||
|
// the runtime fills the name. An address with a queue is shared with the module's other
|
||||||
|
// instances, and the bus hands each call to one of them; an address without is this instance's.
|
||||||
|
Serves []Served `json:"serves"`
|
||||||
|
// Seats is every verb of a seat this instance holds, at the subject the seat's callers use.
|
||||||
|
Seats []SeatServed `json:"seats,omitempty"`
|
||||||
|
// Emits is where an event of this module lands; `{event}` stands for the event's name.
|
||||||
|
Emits string `json:"emits"`
|
||||||
|
// Reaches is each tool this module may call, `<module>.<tool>`, to the subjects that reach it:
|
||||||
|
// the first is whichever instance answers, when the mesh issued one; the rest name a machine.
|
||||||
|
Reaches map[string][]string `json:"reaches,omitempty"`
|
||||||
|
// Tools is where this instance answers what it serves — the runtime's one verb of its own.
|
||||||
|
Tools string `json:"tools"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Served is one address a tool is answered on.
|
||||||
|
type Served struct {
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Queue string `json:"queue,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeatServed is one verb of a held seat, where its callers ask.
|
||||||
|
type SeatServed struct {
|
||||||
|
Seat string `json:"seat"`
|
||||||
|
Verb string `json:"verb"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MembershipSubject is the one address a runtime derives for itself: where its own membership is
|
||||||
|
// published, from the two names its credential carries. Everything else is in the membership.
|
||||||
|
func MembershipSubject(node, module string) string {
|
||||||
|
return "mesh.assignment." + node + "." + module
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placements is where every module runs, for deciding which instance answers for the module.
|
||||||
|
type Placements struct {
|
||||||
|
// Nodes is each module's machines.
|
||||||
|
Nodes map[string][]string
|
||||||
|
// Interchangeable is each module whose definition says its instances are the same anywhere,
|
||||||
|
// so the module's plain subject is issued to all of them in one queue.
|
||||||
|
Interchangeable map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnswersForTheModule says whether an instance of a module on one machine is issued the module's
|
||||||
|
// plain subject: when it is the only instance, or when the definition says instances are
|
||||||
|
// interchangeable. A stateful module on two machines gets only its machines' subjects, so a call
|
||||||
|
// that names none reaches nothing rather than the wrong store.
|
||||||
|
func (p Placements) AnswersForTheModule(module string) bool {
|
||||||
|
return len(p.Nodes[module]) <= 1 || p.Interchangeable[module]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MembershipFor composes one assignment's membership from what it declared and where everything
|
||||||
|
// runs. The subjects are the ones PermissionsFor grants, derived here once more only until the
|
||||||
|
// grant itself is read from the membership — which is the next step, not this one.
|
||||||
|
func MembershipFor(node string, d Declared, where Placements) Membership {
|
||||||
|
own := "mesh.mod." + d.Module
|
||||||
|
m := Membership{
|
||||||
|
Node: node, Module: d.Module,
|
||||||
|
Emits: own + ".event.{event}",
|
||||||
|
Tools: own + ".tool.tools",
|
||||||
|
}
|
||||||
|
// This machine's address always; the module's when this instance answers for the module.
|
||||||
|
m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}." + node})
|
||||||
|
if where.AnswersForTheModule(d.Module) {
|
||||||
|
m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}", Queue: "serve." + d.Module})
|
||||||
|
}
|
||||||
|
for _, s := range d.Holds {
|
||||||
|
for _, verb := range s.Serves {
|
||||||
|
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(d.Invokes) > 0 {
|
||||||
|
m.Reaches = map[string][]string{}
|
||||||
|
for _, t := range d.Invokes {
|
||||||
|
if t == "*" || strings.HasPrefix(t, "seat:") {
|
||||||
|
continue // every tool, or a role's: addressed by name, not resolved per instance
|
||||||
|
}
|
||||||
|
module, tool, ok := strings.Cut(t, ".")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var reach []string
|
||||||
|
if where.AnswersForTheModule(module) {
|
||||||
|
reach = append(reach, "mesh.mod."+module+".tool."+tool)
|
||||||
|
}
|
||||||
|
nodes := append([]string{}, where.Nodes[module]...)
|
||||||
|
sort.Strings(nodes)
|
||||||
|
for _, n := range nodes {
|
||||||
|
reach = append(reach, "mesh.mod."+module+".tool."+tool+"."+n)
|
||||||
|
}
|
||||||
|
m.Reaches[t] = reach
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlacementsOf reads where everything runs from the records the bus's accounts are composed from.
|
||||||
|
func PlacementsOf(r Records, interchangeable map[string]bool) Placements {
|
||||||
|
p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable}
|
||||||
|
for node, declared := range r.Assigned {
|
||||||
|
for _, d := range declared {
|
||||||
|
p.Nodes[d.Module] = append(p.Nodes[d.Module], node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, nodes := range p.Nodes {
|
||||||
|
sort.Strings(nodes)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package broker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The mesh issues an assignment's subjects (novox/hq ADR 0160): a module alone on one machine
|
||||||
|
// answers for the module and for its machine; a stateful module on two machines answers only for
|
||||||
|
// each machine; one that says its instances are interchangeable answers for the module everywhere;
|
||||||
|
// a holder serves its seat's verbs; and what a module may reach is resolved the same way.
|
||||||
|
func TestAMembershipIsIssuedFromWhereEverythingRuns(t *testing.T) {
|
||||||
|
records := Records{Assigned: map[string][]Declared{
|
||||||
|
"anchor": {
|
||||||
|
{Module: "postgres", Serves: []string{"query"}, Holds: []Seat{{Name: "mesh-store", Scope: "mesh", Serves: []string{"databases", "query"}}}},
|
||||||
|
{Module: "catalog", Invokes: []string{"postgres.query", "search.find"}},
|
||||||
|
},
|
||||||
|
"home-server": {
|
||||||
|
{Module: "postgres"},
|
||||||
|
{Module: "search"},
|
||||||
|
{Module: "dashboard", Invokes: []string{"postgres.query"}},
|
||||||
|
},
|
||||||
|
"laptop": {{Module: "search"}},
|
||||||
|
}, Interchangeable: map[string]bool{"search": true}}
|
||||||
|
where := PlacementsOf(records, records.Interchangeable)
|
||||||
|
|
||||||
|
pg := MembershipFor("anchor", records.Assigned["anchor"][0], where)
|
||||||
|
if !reflect.DeepEqual(pg.Serves, []Served{{Subject: "mesh.mod.postgres.tool.{tool}.anchor"}}) {
|
||||||
|
t.Fatalf("a stateful module on two machines answers only for its machine: %+v", pg.Serves)
|
||||||
|
}
|
||||||
|
if len(pg.Seats) != 2 || pg.Seats[0].Subject != "mesh.seat.mesh-store.tool.databases" {
|
||||||
|
t.Fatalf("the holder serves the seat's verbs at the seat's subjects: %+v", pg.Seats)
|
||||||
|
}
|
||||||
|
if pg.Emits != "mesh.mod.postgres.event.{event}" || pg.Tools != "mesh.mod.postgres.tool.tools" {
|
||||||
|
t.Fatalf("events and the tools verb: %+v", pg)
|
||||||
|
}
|
||||||
|
|
||||||
|
search := MembershipFor("laptop", records.Assigned["laptop"][0], where)
|
||||||
|
if !reflect.DeepEqual(search.Serves, []Served{
|
||||||
|
{Subject: "mesh.mod.search.tool.{tool}.laptop"},
|
||||||
|
{Subject: "mesh.mod.search.tool.{tool}", Queue: "serve.search"},
|
||||||
|
}) {
|
||||||
|
t.Fatalf("an interchangeable module answers for the module in the queue too: %+v", search.Serves)
|
||||||
|
}
|
||||||
|
|
||||||
|
dashboard := MembershipFor("home-server", records.Assigned["home-server"][2], where)
|
||||||
|
if !reflect.DeepEqual(dashboard.Serves, []Served{
|
||||||
|
{Subject: "mesh.mod.dashboard.tool.{tool}.home-server"},
|
||||||
|
{Subject: "mesh.mod.dashboard.tool.{tool}", Queue: "serve.dashboard"},
|
||||||
|
}) {
|
||||||
|
t.Fatalf("a module alone on one machine answers for the module: %+v", dashboard.Serves)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(dashboard.Reaches["postgres.query"],
|
||||||
|
[]string{"mesh.mod.postgres.tool.query.anchor", "mesh.mod.postgres.tool.query.home-server"}) {
|
||||||
|
t.Fatalf("reaching a stateful module names each machine and no plain subject: %v", dashboard.Reaches)
|
||||||
|
}
|
||||||
|
catalog := MembershipFor("anchor", records.Assigned["anchor"][1], where)
|
||||||
|
if !reflect.DeepEqual(catalog.Reaches["search.find"],
|
||||||
|
[]string{"mesh.mod.search.tool.find", "mesh.mod.search.tool.find.home-server", "mesh.mod.search.tool.find.laptop"}) {
|
||||||
|
t.Fatalf("reaching an interchangeable module offers the plain subject first: %v", catalog.Reaches)
|
||||||
|
}
|
||||||
|
if MembershipSubject("anchor", "postgres") != "mesh.assignment.anchor.postgres" {
|
||||||
|
t.Fatal("the one subject a runtime derives for itself")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAccountMayReadItsOwnMembershipAndNoOthers(t *testing.T) {
|
||||||
|
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "anchor", Module: "postgres", PasswordHash: "x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
has(t, perms.Subscribe, "mesh.assignment.anchor.postgres")
|
||||||
|
has(t, perms.Publish, "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.anchor.postgres")
|
||||||
|
hasNot(t, perms.Subscribe, "mesh.assignment.>")
|
||||||
|
}
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
package broker_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The audit logger consumes everything and emits nothing. Its account must let it declare and read
|
|
||||||
// its own queue and read the events exchange to bind onto — and must not reach another module's
|
|
||||||
// queue, nor grant any write to the events exchange (novox/hq ADR 0043).
|
|
||||||
func TestAConsumerReadsItsOwnQueueAndTheEventsExchangeAndNoOthers(t *testing.T) {
|
|
||||||
// Rebuilt through CreateModuleAccount's own path by asking for the same scope it would apply.
|
|
||||||
// The queue this module reads:
|
|
||||||
mine := broker.ModuleQueueFor("anchor", "audit-logger")
|
|
||||||
other := broker.ModuleQueueFor("anchor", "plex")
|
|
||||||
|
|
||||||
read := scope(t, "read", "anchor", "audit-logger", nil, []string{"#"})
|
|
||||||
if !read.MatchString(mine) {
|
|
||||||
t.Error("the audit logger may not read its own queue, so it consumes nothing")
|
|
||||||
}
|
|
||||||
if !read.MatchString(broker.EventsExchangeName) {
|
|
||||||
t.Error("the audit logger may not read the events exchange, so it cannot bind onto it")
|
|
||||||
}
|
|
||||||
if read.MatchString(other) {
|
|
||||||
t.Error("the audit logger may read another module's queue")
|
|
||||||
}
|
|
||||||
|
|
||||||
// It emits nothing, so it is granted no write to the events exchange — only its own queue, to bind.
|
|
||||||
write := scope(t, "write", "anchor", "audit-logger", nil, []string{"#"})
|
|
||||||
if write.MatchString(broker.EventsExchangeName) {
|
|
||||||
t.Error("a pure consumer was granted write to the events exchange")
|
|
||||||
}
|
|
||||||
if !write.MatchString(mine) {
|
|
||||||
t.Error("the audit logger may not write to its own queue, so it cannot bind it")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An emitter is granted the events exchange to write; a consumer is not.
|
|
||||||
func TestAnEmitterMayWriteTheEventsExchangeAndAConsumerMayNot(t *testing.T) {
|
|
||||||
emitter := scope(t, "write", "anchor", "umami", []string{"module.umami.site.created"}, nil)
|
|
||||||
if !emitter.MatchString(broker.EventsExchangeName) {
|
|
||||||
t.Error("an emitting module may not write the events exchange, so it cannot emit")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// scope reconstructs one of the three permission patterns CreateModuleAccount would apply, by
|
|
||||||
// reading it back from a captured request against a stub management API.
|
|
||||||
func scope(t *testing.T, which, node, module string, emits, consumes []string) *regexp.Regexp {
|
|
||||||
t.Helper()
|
|
||||||
pat := capturePermission(t, which, node, module, emits, consumes)
|
|
||||||
re, err := regexp.Compile(pat)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("the %s pattern does not compile: %v", which, err)
|
|
||||||
}
|
|
||||||
return re
|
|
||||||
}
|
|
||||||
|
|
||||||
// capturePermission runs CreateModuleAccount against a stub management API and returns the pattern
|
|
||||||
// it set for `which` ("configure"/"write"/"read"). The scope is tested where it is applied, not
|
|
||||||
// reconstructed by the test — so a change to the mapping cannot pass a test that hard-codes the old
|
|
||||||
// one.
|
|
||||||
func capturePermission(t *testing.T, which, node, module string, emits, consumes []string) string {
|
|
||||||
t.Helper()
|
|
||||||
var captured map[string]string
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if strings.HasPrefix(r.URL.Path, "/api/permissions/") {
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&captured)
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
t.Setenv(broker.ManagementVar, server.URL)
|
|
||||||
m, err := broker.ManagementFromEnvironment()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stub management not usable: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := m.CreateModuleAccount(context.Background(), node, module, "pw", emits, consumes); err != nil {
|
|
||||||
t.Fatalf("CreateModuleAccount: %v", err)
|
|
||||||
}
|
|
||||||
if captured == nil {
|
|
||||||
t.Fatal("no permissions were set")
|
|
||||||
}
|
|
||||||
pattern, ok := captured[which]
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("no %s permission was set; got %v", which, captured)
|
|
||||||
}
|
|
||||||
return pattern
|
|
||||||
}
|
|
||||||
+156
-28
@@ -39,7 +39,11 @@ const (
|
|||||||
// Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it
|
// Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it
|
||||||
// emits (novox/hq ADR 0118, design 29 §5).
|
// emits (novox/hq ADR 0118, design 29 §5).
|
||||||
type Seat struct {
|
type Seat struct {
|
||||||
Name string
|
Name string
|
||||||
|
// Scope is where the seat has one holder. A node-scoped seat's tool carries the node in its
|
||||||
|
// subject, because one subject reaching six machines' holders is not an address
|
||||||
|
// (novox/hq ADR 0132, design 33 §4). Empty reads as mesh.
|
||||||
|
Scope string
|
||||||
Accepts []string
|
Accepts []string
|
||||||
Emits []string
|
Emits []string
|
||||||
Serves []string
|
Serves []string
|
||||||
@@ -70,12 +74,14 @@ type Principal struct {
|
|||||||
// a namespace no such module owns. Every service started and the graph stayed empty.
|
// a namespace no such module owns. Every service started and the graph stayed empty.
|
||||||
Watches []Seat
|
Watches []Seat
|
||||||
|
|
||||||
// Invokes are the tools a person may call, as `<module>.<tool>`; a single `*` is every tool,
|
// Invokes are the tools this principal may call, as `<module>.<tool>`; a single `*` is every
|
||||||
// for an administrator. Only meaningful for KindPerson.
|
// tool. A person's whole authority (design 25 §7), and a module's only if its manifest says so
|
||||||
|
// (novox/hq ADR 0152) — the console's does, and nothing else's.
|
||||||
//
|
//
|
||||||
// **A list, not a role.** A person is not a module and holds no seat: nothing is addressed
|
// **A list, not a role.** A person is not a module and holds no seat: nothing is addressed
|
||||||
// to them, nothing is delivered to them, and they have no durable consumer to acknowledge.
|
// to them, nothing is delivered to them, and they have no durable consumer to acknowledge.
|
||||||
// What they have is permission to ask.
|
// What they have is permission to ask. A module that invokes gains exactly the same
|
||||||
|
// permission and nothing beside it.
|
||||||
Invokes []string
|
Invokes []string
|
||||||
|
|
||||||
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
||||||
@@ -167,9 +173,15 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
switch p.Kind {
|
switch p.Kind {
|
||||||
case KindController:
|
case KindController:
|
||||||
// The controller owns the mesh's own traffic and the streams. It is the only writer of
|
// The controller owns the mesh's own traffic and the streams. It is the only writer of
|
||||||
// stream definitions (design 25 §3), so it alone reaches the JetStream API.
|
// stream definitions (design 25 §3), so it alone reaches the JetStream API — and it alone
|
||||||
pub = []string{"mesh.control.>", "mesh.node.>", "$JS.API.>"}
|
// issues memberships (novox/hq ADR 0160), which it publishes into the assignments stream
|
||||||
sub = []string{"mesh.control.>", "$JS.API.>"}
|
// after each push; refused by the server on 2026-10-01 until this line named them.
|
||||||
|
pub = []string{"mesh.control.>", "mesh.node.>", "mesh.assignment.>", "$JS.API.>"}
|
||||||
|
// **And where its consumers deliver.** A push consumer delivers on `_DELIVER.<its name>`,
|
||||||
|
// and a client bound to it subscribes exactly that; the server refused it for every
|
||||||
|
// principal the first time one bound a consumer (2026-09-28). Each kind below is granted
|
||||||
|
// its own consumers' delivery subjects and no other's.
|
||||||
|
sub = []string{"mesh.control.>", "$JS.API.>", "_DELIVER." + ControllerName, "_DELIVER." + ControllerName + ".>"}
|
||||||
|
|
||||||
// Work the mesh's own flows submit to a role, and the outcomes they wait on (ADR 0121). A
|
// Work the mesh's own flows submit to a role, and the outcomes they wait on (ADR 0121). A
|
||||||
// build is the one today: the controller asks, and reads the answer from the seat's event
|
// build is the one today: the controller asks, and reads the answer from the seat's event
|
||||||
@@ -177,6 +189,26 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
for _, seat := range meshSeatsTheControllerUses {
|
for _, seat := range meshSeatsTheControllerUses {
|
||||||
pub = append(pub, "mesh.seat."+seat+".accept.>")
|
pub = append(pub, "mesh.seat."+seat+".accept.>")
|
||||||
}
|
}
|
||||||
|
// **And what the mesh says it did** (novox/hq ADR 0134). The control plane states its own
|
||||||
|
// facts under the seat it holds, because a role's events belong to the role and keep their
|
||||||
|
// address while the holder is replaced. Named one by one rather than as a whole namespace:
|
||||||
|
// least authority, and a fact nothing states is authority nobody uses.
|
||||||
|
for _, event := range ControllerStates {
|
||||||
|
pub = append(pub, seatEventSubject(ControllerSeat, event))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every module's tools: **the control plane is the way in** (novox/hq ADR 0095). A person
|
||||||
|
// or an agent asks through it and every question passes one process where an audit
|
||||||
|
// belongs — so it, alone among principals, may call any tool by name. The first `ask` on
|
||||||
|
// the new bus was refused the publish (2026-09-28).
|
||||||
|
pub = append(pub, "mesh.mod.*.tool.>")
|
||||||
|
|
||||||
|
// **And the mesh's own verbs, as the seat it holds** (novox/hq ADR 0132, ADR 0154):
|
||||||
|
// `status`, `push`, `assign` are the mesh-controller seat's tools, served by its holder. The
|
||||||
|
// whole verb namespace of its own seat rather than a list: the list is the seat's protocol,
|
||||||
|
// 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.>")
|
||||||
|
|
||||||
// The two events it reacts to, and its ack subject on the stream they arrive from
|
// 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
|
// (streams.go). **Each named, not a pattern**: `mesh.mod.*.event.>` would make the
|
||||||
@@ -207,18 +239,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
case KindPerson:
|
case KindPerson:
|
||||||
// Tools, and nothing else. Every subject a person may publish is a tool call; a person
|
// Tools, and nothing else. Every subject a person may publish is a tool call; a person
|
||||||
// who could publish an event would be able to claim a module said something.
|
// who could publish an event would be able to claim a module said something.
|
||||||
for _, t := range p.Invokes {
|
invoked, err := invokedSubjects(p.Invokes)
|
||||||
if t == "*" {
|
if err != nil {
|
||||||
pub = append(pub, "mesh.mod.*.tool.>")
|
return Permissions{}, err
|
||||||
continue
|
|
||||||
}
|
|
||||||
module, tool, ok := strings.Cut(t, ".")
|
|
||||||
if !ok {
|
|
||||||
return Permissions{}, fmt.Errorf(
|
|
||||||
"%q does not name a tool: a person invokes <module>.<tool>, or * for every one", t)
|
|
||||||
}
|
|
||||||
pub = append(pub, "mesh.mod."+module+".tool."+tool)
|
|
||||||
}
|
}
|
||||||
|
pub = append(pub, invoked...)
|
||||||
|
|
||||||
case KindEnrolment:
|
case KindEnrolment:
|
||||||
// A leaked token is useless for anything but enrolling: it cannot read a declaration, hear
|
// A leaked token is useless for anything but enrolling: it cannot read a declaration, hear
|
||||||
@@ -244,8 +269,21 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
case KindNode:
|
case KindNode:
|
||||||
// A host publishes its own node's control traffic and subscribes its own declaration —
|
// A host publishes its own node's control traffic and subscribes its own declaration —
|
||||||
// and nothing of any other node's.
|
// and nothing of any other node's.
|
||||||
pub = []string{"mesh.control." + p.Node + ".>"}
|
// And binding to its consumer, which asks the server about it (CONSUMER.INFO) — the one
|
||||||
sub = []string{"mesh.node." + p.Node + ".declare"}
|
// thing the host does that nothing granted. Found the first time a machine dialled a
|
||||||
|
// permissioned server: "this node cannot read its declarations" (2026-09-28). The ack and
|
||||||
|
// the inbox are granted below with every principal's.
|
||||||
|
pub = []string{
|
||||||
|
"mesh.control." + p.Node + ".>",
|
||||||
|
"$JS.API.CONSUMER.INFO.NODES." + p.Node,
|
||||||
|
}
|
||||||
|
// The deliver subject carries the stream as well as the consumer's name, so what a
|
||||||
|
// subscriber is permitted has to carry it too (novox/hq 04-ISSUES/146). The bare name
|
||||||
|
// stays: an existing consumer keeps delivering where it always did until the controller's
|
||||||
|
// next assertion moves it, and a permission that only allowed the new shape would refuse
|
||||||
|
// every node in the mesh for exactly as long as that took.
|
||||||
|
sub = []string{"mesh.node." + p.Node + ".declare",
|
||||||
|
"_DELIVER." + p.Node, "_DELIVER." + p.Node + ".>"}
|
||||||
|
|
||||||
case KindModule:
|
case KindModule:
|
||||||
// 1. Its own namespace: it publishes its events there and serves its tools there. Nothing
|
// 1. Its own namespace: it publishes its events there and serves its tools there. Nothing
|
||||||
@@ -255,9 +293,27 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
for _, e := range p.Emits {
|
for _, e := range p.Emits {
|
||||||
pub = append(pub, own+".event."+e)
|
pub = append(pub, own+".event."+e)
|
||||||
}
|
}
|
||||||
for _, t := range p.Serves {
|
// Every tool under its own name, not a list: the tools a module serves are what its code
|
||||||
sub = append(sub, own+".tool."+t)
|
// answers, and a second copy of that list in the manifest would be a second source of
|
||||||
|
// truth for the mesh to keep in step (2026-09-28: every module that served a tool was
|
||||||
|
// refused the subscription, because none had written the list twice). Nothing is given
|
||||||
|
// 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.>")
|
||||||
|
// 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))
|
||||||
|
pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+"."+MembershipSubject(p.Node, p.Module))
|
||||||
|
|
||||||
|
// 1b. The tools it calls, if its manifest says it calls any (novox/hq ADR 0152). The same
|
||||||
|
// grant a person gets and derived the same way, so "what may this module ask" is
|
||||||
|
// answered by the one list that answers it for everybody. Publish only: an answer
|
||||||
|
// arrives on its own inbox, which every principal has below.
|
||||||
|
invoked, err := invokedSubjects(p.Invokes)
|
||||||
|
if err != nil {
|
||||||
|
return Permissions{}, err
|
||||||
}
|
}
|
||||||
|
pub = append(pub, invoked...)
|
||||||
|
|
||||||
// 2. What it consumes, by the emitter's own subject — an event is addressed to its
|
// 2. What it consumes, by the emitter's own subject — an event is addressed to its
|
||||||
// emitter, because the emitter's identity is the meaning (ADR 0118).
|
// emitter, because the emitter's identity is the meaning (ADR 0118).
|
||||||
@@ -277,8 +333,26 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2c. Its own consumer, which it **pulls**: the runtime asks for the next message and is
|
||||||
|
// answered on its own inbox, so what it needs is to ask about the consumer and to ask it
|
||||||
|
// for messages — its own consumer's name, and no other's. Pulled rather than pushed
|
||||||
|
// because that is the one shape a runtime's client binds without creating anything; the
|
||||||
|
// controller and the hosts are pushed to. Named here rather than through ConsumerFor,
|
||||||
|
// which asks for these permissions to build the consumer and would ask forever. A
|
||||||
|
// subject for a consumer that turns out not to exist grants nothing anybody can use.
|
||||||
|
pub = append(pub,
|
||||||
|
"$JS.API.CONSUMER.INFO."+consumerStream(p)+"."+consumerDurable(p),
|
||||||
|
"$JS.API.CONSUMER.MSG.NEXT."+consumerStream(p)+"."+consumerDurable(p))
|
||||||
|
|
||||||
// 3. Seats it holds: full participation.
|
// 3. Seats it holds: full participation.
|
||||||
for _, s := range p.Holds {
|
for _, s := range p.Holds {
|
||||||
|
// Taking work from the role's queue: the worker consumer it binds (asked about,
|
||||||
|
// delivered on, acknowledged), each on the seat's own stream. The first machine to
|
||||||
|
// take work over the new bus was refused the asking (2026-09-28).
|
||||||
|
worker := "SEAT_" + upperSnake(s.Name) + "_worker"
|
||||||
|
stream := seatStreamName(s.Name)
|
||||||
|
sub = append(sub, "_DELIVER."+worker, "_DELIVER."+worker+".>")
|
||||||
|
pub = append(pub, "$JS.API.CONSUMER.INFO."+stream+"."+worker, "$JS.ACK."+stream+"."+worker+".>")
|
||||||
for _, a := range s.Accepts {
|
for _, a := range s.Accepts {
|
||||||
sub = append(sub, seatSubject(s, "accept", a))
|
sub = append(sub, seatSubject(s, "accept", a))
|
||||||
}
|
}
|
||||||
@@ -286,7 +360,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
pub = append(pub, seatSubject(s, "event", e))
|
pub = append(pub, seatSubject(s, "event", e))
|
||||||
}
|
}
|
||||||
for _, t := range s.Serves {
|
for _, t := range s.Serves {
|
||||||
sub = append(sub, seatSubject(s, "tool", t))
|
sub = append(sub, seatToolSubject(s, t, p.Node))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +372,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
pub = append(pub, seatSubject(s, "accept", a))
|
pub = append(pub, seatSubject(s, "accept", a))
|
||||||
}
|
}
|
||||||
for _, t := range s.Serves {
|
for _, t := range s.Serves {
|
||||||
pub = append(pub, seatSubject(s, "tool", t))
|
pub = append(pub, seatToolSubject(s, t, "*"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,9 +400,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
return Permissions{
|
return Permissions{
|
||||||
Publish: pub,
|
Publish: pub,
|
||||||
Subscribe: sub,
|
Subscribe: sub,
|
||||||
// Only something that serves is ever answering. A pure consumer is granted nothing here.
|
// A module answers what it was asked — a tool call reaches it on its own namespace, so the
|
||||||
AllowResponses: p.Kind == KindModule && (len(p.Serves) > 0 || len(p.Holds) > 0) ||
|
// authority is bounded by having been asked — and so does the controller. A node and a
|
||||||
p.Kind == KindController,
|
// person are never asked anything, and are granted nothing here.
|
||||||
|
AllowResponses: p.Kind == KindModule || p.Kind == KindController,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,6 +422,18 @@ func seatSubject(s Seat, kind, verb string) string {
|
|||||||
return "mesh.seat." + s.Name + "." + kind + "." + verb
|
return "mesh.seat." + s.Name + "." + kind + "." + verb
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seatToolSubject is where a role's tool is asked. Mesh-wide for a mesh-scoped seat; a node-scoped
|
||||||
|
// seat carries the node it is asked of, because a flat subject would reach every machine's holder
|
||||||
|
// and the queue group would silently pick a winner (novox/hq ADR 0132, design 33 §4). A holder
|
||||||
|
// subscribes its own node's; a user publishes any node's (`*`) and names the machine in the subject.
|
||||||
|
func seatToolSubject(s Seat, verb, node string) string {
|
||||||
|
base := seatSubject(s, "tool", verb)
|
||||||
|
if s.Scope == "node" && node != "" {
|
||||||
|
return base + "." + node
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
// consumerStream and consumerDurable are the two halves of a consumer's identity, and they are
|
// consumerStream and consumerDurable are the two halves of a consumer's identity, and they are
|
||||||
// two functions because conflating them was a real bug.
|
// two functions because conflating them was a real bug.
|
||||||
//
|
//
|
||||||
@@ -514,7 +601,10 @@ func ComposeAccounts(principals []Principal) (string, error) {
|
|||||||
// One account for the mesh: accounts in NATS isolate subject spaces entirely, and the mesh is
|
// One account for the mesh: accounts in NATS isolate subject spaces entirely, and the mesh is
|
||||||
// one space (design 25 §4). The cost of that — that permissions are the only isolation — is
|
// one space (design 25 §4). The cost of that — that permissions are the only isolation — is
|
||||||
// paid in the scoping of every inbox and every ack subject.
|
// paid in the scoping of every inbox and every ack subject.
|
||||||
b.WriteString("accounts {\n MESH {\n users = [\n")
|
// JetStream is enabled per account once accounts exist at all: with only the global block set,
|
||||||
|
// a user in MESH is told "JetStream not enabled for account" the first time it binds a
|
||||||
|
// consumer, which is the first thing every host does (2026-09-28).
|
||||||
|
b.WriteString("accounts {\n MESH {\n jetstream: enabled\n users = [\n")
|
||||||
for _, p := range sorted {
|
for _, p := range sorted {
|
||||||
perms, err := PermissionsFor(p)
|
perms, err := PermissionsFor(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -545,3 +635,41 @@ func quoted(values []string) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(out, ", ")
|
return strings.Join(out, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// invokedSubjects is the publish side of a grant to call tools: one subject per `<module>.<tool>`,
|
||||||
|
// or the whole tool namespace for `*`. A person's authority and a module's `invokes` are both this
|
||||||
|
// (novox/hq ADR 0152), so a malformed entry is refused in one place, before it could be widened into
|
||||||
|
// something that happens to parse.
|
||||||
|
func invokedSubjects(invokes []string) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
for _, t := range invokes {
|
||||||
|
if t == "*" {
|
||||||
|
// Every module's tools and every role's (novox/hq ADR 0132): a role's verb is a tool
|
||||||
|
// like any other, addressed to the seat instead of a module.
|
||||||
|
out = append(out, "mesh.mod.*.tool.>", "mesh.seat.*.tool.>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rest, isSeat := strings.CutPrefix(t, "seat:"); isSeat {
|
||||||
|
// A role's tool, `seat:<seat>.<verb>`. Both address shapes, because the grant is
|
||||||
|
// written without knowing the seat's scope: a mesh seat's verb is flat and a node
|
||||||
|
// seat's carries the machine (design 33 §4).
|
||||||
|
seat, verb, ok := strings.Cut(rest, ".")
|
||||||
|
if !ok || seat == "" || verb == "" {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%q does not name a role's tool: one invokes seat:<seat>.<verb>", t)
|
||||||
|
}
|
||||||
|
out = append(out, "mesh.seat."+seat+".tool."+verb, "mesh.seat."+seat+".tool."+verb+".*")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
module, tool, ok := strings.Cut(t, ".")
|
||||||
|
if !ok || module == "" || tool == "" {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%q does not name a tool: one invokes <module>.<tool>, seat:<seat>.<verb>, or * for every one", t)
|
||||||
|
}
|
||||||
|
// Both ways a module's tool is addressed (novox/hq ADR 0159): to whichever instance
|
||||||
|
// answers, and to the instance on one machine, which is the same subject with the machine
|
||||||
|
// as its last token.
|
||||||
|
out = append(out, "mesh.mod."+module+".tool."+tool, "mesh.mod."+module+".tool."+tool+".*")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package broker
|
package broker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -70,6 +71,18 @@ func TestHoldingASeatIsTheMirrorOfUsingIt(t *testing.T) {
|
|||||||
hasNot(t, perms.Publish, "mesh.seat.telegram-sender.accept.send")
|
hasNot(t, perms.Publish, "mesh.seat.telegram-sender.accept.send")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A build machine may say everything about a build as it happens (novox/hq ADR 0157): that it
|
||||||
|
// started, and every line under the build's own id — the seat's `log.*` becomes a publish over
|
||||||
|
// one token, so a reader follows one build by subject and the holder can name no other subject.
|
||||||
|
func TestTheBuildMachineMaySayWhatItDoesUnderTheBuildsId(t *testing.T) {
|
||||||
|
seat := Seat{Name: "mesh-build-machine", Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}}
|
||||||
|
perms, _ := PermissionsFor(Principal{Kind: KindModule, Node: "anchor", Module: "builder",
|
||||||
|
Holds: []Seat{seat}, PasswordHash: "x"})
|
||||||
|
has(t, perms.Publish, "mesh.seat.mesh-build-machine.event.started")
|
||||||
|
has(t, perms.Publish, "mesh.seat.mesh-build-machine.event.log.*")
|
||||||
|
hasNot(t, perms.Publish, "mesh.seat.mesh-build-machine.event.>")
|
||||||
|
}
|
||||||
|
|
||||||
// Without an ack permission a durable consumer never really consumes: every message it receives is
|
// Without an ack permission a durable consumer never really consumes: every message it receives is
|
||||||
// redelivered forever, refused by the permission list it already has (design 25 §4).
|
// redelivered forever, refused by the permission list it already has (design 25 §4).
|
||||||
func TestAModuleMayAckItsOwnDeliveriesAndNoOthers(t *testing.T) {
|
func TestAModuleMayAckItsOwnDeliveriesAndNoOthers(t *testing.T) {
|
||||||
@@ -89,17 +102,30 @@ func TestAnInboxIsScopedToItsOwner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A responder answers on the caller's inbox, which it has no permission for. allow_responses is
|
// A responder answers on the caller's inbox, which it has no permission for. allow_responses is
|
||||||
// what makes a scoped inbox workable at all — the authority is bounded by having been asked.
|
// what makes a scoped inbox workable at all — the authority is bounded by having been asked. A
|
||||||
func TestOnlySomethingThatServesMayAnswer(t *testing.T) {
|
// module is asked on its own namespace and may answer; a node and a person are never asked.
|
||||||
serving, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "billing",
|
func TestOnlyWhatCanBeAskedMayAnswer(t *testing.T) {
|
||||||
Serves: []string{"status"}, PasswordHash: "x"})
|
module, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "audit",
|
||||||
if !serving.AllowResponses {
|
|
||||||
t.Fatal("a module serving a tool cannot answer the caller's inbox")
|
|
||||||
}
|
|
||||||
consumer, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "audit",
|
|
||||||
Consumes: []string{"shop.order.placed"}, PasswordHash: "x"})
|
Consumes: []string{"shop.order.placed"}, PasswordHash: "x"})
|
||||||
if consumer.AllowResponses {
|
if !module.AllowResponses {
|
||||||
t.Fatal("a pure consumer was granted the right to answer, which nothing asked it to do")
|
t.Fatal("a module cannot answer a tool call on its own namespace")
|
||||||
|
}
|
||||||
|
node, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", PasswordHash: "x"})
|
||||||
|
if node.AllowResponses {
|
||||||
|
t.Fatal("a node was granted the right to answer, and nothing asks a node anything")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module serves every tool under its own name, and no other module's.
|
||||||
|
func TestAModuleServesItsOwnNamespaceAndNoOthers(t *testing.T) {
|
||||||
|
p, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "gitea", PasswordHash: "x"})
|
||||||
|
if !slices.Contains(p.Subscribe, "mesh.mod.gitea.tool.>") {
|
||||||
|
t.Fatalf("a module may not serve its own tools: %v", p.Subscribe)
|
||||||
|
}
|
||||||
|
for _, s := range p.Subscribe {
|
||||||
|
if strings.HasPrefix(s, "mesh.mod.") && !strings.HasPrefix(s, "mesh.mod.gitea.") {
|
||||||
|
t.Fatalf("a module may subscribe another's namespace: %s", s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,3 +350,24 @@ func admits(pattern, subject []string) bool {
|
|||||||
}
|
}
|
||||||
return len(pattern) == len(subject)
|
return len(pattern) == len(subject)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A module pulls its own consumer — asks about it, asks it for messages — and no other module's.
|
||||||
|
func TestAModulePullsItsOwnConsumerAndNoOthers(t *testing.T) {
|
||||||
|
p, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "audit",
|
||||||
|
Consumes: []string{"shop.order.placed"}, PasswordHash: "x"})
|
||||||
|
for _, want := range []string{"$JS.API.CONSUMER.INFO.EVENTS.one_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_audit"} {
|
||||||
|
if !slices.Contains(p.Publish, want) {
|
||||||
|
t.Errorf("a module cannot bind its own consumer: %v lacks %s", p.Publish, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range p.Publish {
|
||||||
|
if strings.Contains(s, "CONSUMER.") && !strings.HasSuffix(s, ".one_audit") {
|
||||||
|
t.Errorf("a module may reach another consumer: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range p.Subscribe {
|
||||||
|
if strings.HasPrefix(s, "_DELIVER.") {
|
||||||
|
t.Errorf("a module is granted a push delivery it never binds: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+11
-19
@@ -68,24 +68,16 @@ func BareAddress(address string) string {
|
|||||||
return "nats://" + bare
|
return "nats://" + bare
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustBeOneBus refuses a configuration that names both buses for the mesh's own traffic.
|
// BusAddress is where the mesh's bus is, with this process's credential, and refuses to be empty:
|
||||||
//
|
// there is one bus, and a control plane without it can hold records and answer nothing (novox/hq
|
||||||
// **Both clients ship and that is the point; both being live is not.** The rollout moves every node
|
// ADR 0131, design 28 task 5.5).
|
||||||
// at once (ADR 0116 step 5): a mesh half on each is one where a declaration goes out on one bus and
|
func BusAddress() (string, error) {
|
||||||
// the report comes back on the other, and nothing anywhere says so — every component would log
|
address, _, err := OnNATS()
|
||||||
// success. Refused at start, where it can be said in one sentence.
|
if err != nil {
|
||||||
func MustBeOneBus(amqp, nats string) error {
|
return "", err
|
||||||
if strings.TrimSpace(amqp) != "" && strings.TrimSpace(nats) != "" {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"this control plane is told about both buses (%s and %s) and can only be on one. A mesh "+
|
|
||||||
"half on each is one where a declaration goes out on one and the report comes back "+
|
|
||||||
"on the other, and every component reports success while it happens. The rollout "+
|
|
||||||
"moves every node at once: unset %s to stay, or unset %s to move",
|
|
||||||
AMQPVarName, NATSVar, NATSVar, AMQPVarName)
|
|
||||||
}
|
}
|
||||||
return nil
|
if address == "" {
|
||||||
|
return "", fmt.Errorf("this control plane has no %s, so it cannot reach the mesh's bus", NATSVar)
|
||||||
|
}
|
||||||
|
return address, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AMQPVarName is the variable naming the bus the mesh runs on today. Named here rather than
|
|
||||||
// imported from the link package, for the one direction of dependency.
|
|
||||||
const AMQPVarName = "MESH_BROKER_AMQP"
|
|
||||||
|
|||||||
@@ -1,43 +1,11 @@
|
|||||||
package broker
|
package broker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Which bus the mesh is on is one fact, and being told about both is refused.
|
// Which bus the mesh is on is one fact, and being told about both is refused.
|
||||||
//
|
//
|
||||||
// **Not a warning.** A mesh half on each bus is one where a declaration goes out on one and the
|
|
||||||
// report comes back on the other, and every component reports success while it happens — which is
|
|
||||||
// the exact failure ADR 0074 exists to catch, arriving through configuration instead of through code.
|
|
||||||
func TestBeingToldAboutBothBusesIsRefused(t *testing.T) {
|
|
||||||
err := MustBeOneBus("amqps://broker:5671/", "nats://bus:4222")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a control plane told about both buses was allowed to start")
|
|
||||||
}
|
|
||||||
// The remedy is in the words, because whoever reads this has to choose one and the wrong choice
|
|
||||||
// is a rollout half done.
|
|
||||||
for _, want := range []string{AMQPVarName, NATSVar, "unset"} {
|
|
||||||
if !strings.Contains(err.Error(), want) {
|
|
||||||
t.Errorf("the refusal does not mention %s: %v", want, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// One bus, or none, is ordinary. None is a control plane that publishes nothing and holds records,
|
|
||||||
// which several of its own commands are.
|
|
||||||
func TestOneBusOrNeitherIsAllowed(t *testing.T) {
|
|
||||||
for _, c := range []struct{ what, amqp, nats string }{
|
|
||||||
{"the bus the mesh runs on today", "amqps://broker:5671/", ""},
|
|
||||||
{"the bus being built", "", "nats://bus:4222"},
|
|
||||||
{"neither", "", ""},
|
|
||||||
{"neither, with whitespace for an address", " ", "\t"},
|
|
||||||
} {
|
|
||||||
if err := MustBeOneBus(c.amqp, c.nats); err != nil {
|
|
||||||
t.Errorf("%s was refused: %v", c.what, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The controller's own credential arrives in its address, and has to be readable out of it — its user
|
// The controller's own credential arrives in its address, and has to be readable out of it — its user
|
||||||
// is created by the installer at a bootstrap password, before the controller exists to mint one.
|
// is created by the installer at a bootstrap password, before the controller exists to mint one.
|
||||||
|
|||||||
@@ -35,10 +35,6 @@ type Readiness struct {
|
|||||||
Modules []string
|
Modules []string
|
||||||
// ModuleCredentialled is which of those has one.
|
// ModuleCredentialled is which of those has one.
|
||||||
ModuleCredentialled map[string]bool
|
ModuleCredentialled map[string]bool
|
||||||
// StillOnTheOldBus is whether anything of the mesh's own still needs the bus it is leaving —
|
|
||||||
// which is not a reason to stop, because that broker stays as an ordinary provider of `amqp`
|
|
||||||
// (ADR 0119). Recorded so nobody reads the move as a retirement.
|
|
||||||
OldBusHasOtherClients bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotReady is every reason this mesh cannot move its bus yet, in the order somebody would fix them.
|
// NotReady is every reason this mesh cannot move its bus yet, in the order somebody would fix them.
|
||||||
@@ -120,10 +116,11 @@ func WhatMoves(r Readiness) []string {
|
|||||||
out = append(out, fmt.Sprintf("move %d module runtime(s), and confirm each answers",
|
out = append(out, fmt.Sprintf("move %d module runtime(s), and confirm each answers",
|
||||||
len(r.Modules)))
|
len(r.Modules)))
|
||||||
}
|
}
|
||||||
if r.OldBusHasOtherClients {
|
// **The old broker goes, and it goes last** (novox/hq ADR 0131). AMQP is not a provision, so once
|
||||||
out = append(out, "leave the old broker running: it stays an ordinary provider of `amqp` for "+
|
// every machine reports on the new bus nothing of the mesh is left speaking to it, and its module
|
||||||
"whatever else uses it (ADR 0119), and this move is not its retirement")
|
// is unassigned. Said as a step so nobody reads the move as leaving a second bus behind.
|
||||||
}
|
out = append(out, "then unassign the old broker's module: AMQP is not a provision (ADR 0131), and "+
|
||||||
|
"once every machine reports on the new bus nothing of the mesh speaks to it")
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,9 +79,8 @@ func TestEachThingMissingNamesItsOwnRemedy(t *testing.T) {
|
|||||||
|
|
||||||
// What the move would do is written out rather than summarised, because this is the one step with
|
// What the move would do is written out rather than summarised, because this is the one step with
|
||||||
// nothing to inspect afterwards — so reading it is the last chance to disagree.
|
// nothing to inspect afterwards — so reading it is the last chance to disagree.
|
||||||
func TestWhatMovesNamesEveryMachineAndSaysTheOldBrokerStays(t *testing.T) {
|
func TestWhatMovesNamesEveryMachineAndEndsWithTheOldBrokerGoing(t *testing.T) {
|
||||||
r := aMeshReadyToMove()
|
r := aMeshReadyToMove()
|
||||||
r.OldBusHasOtherClients = true
|
|
||||||
steps := strings.Join(WhatMoves(r), "\n")
|
steps := strings.Join(WhatMoves(r), "\n")
|
||||||
|
|
||||||
for _, want := range []string{"anchor", "laptop", "user list", "module runtime"} {
|
for _, want := range []string{"anchor", "laptop", "user list", "module runtime"} {
|
||||||
@@ -89,9 +88,11 @@ func TestWhatMovesNamesEveryMachineAndSaysTheOldBrokerStays(t *testing.T) {
|
|||||||
t.Errorf("the plan does not mention %q:\n%s", want, steps)
|
t.Errorf("the plan does not mention %q:\n%s", want, steps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Said explicitly, so nobody reads the move as switching the old broker off — it stays serving
|
// Said explicitly, and last: AMQP is not a provision (novox/hq ADR 0131), so the move ends with
|
||||||
// whatever else uses it, and that is a decision already taken.
|
// the old broker's module unassigned, not left behind as a second bus. An earlier version of this
|
||||||
if !strings.Contains(steps, "not its retirement") {
|
// test pinned the opposite, under a record 0131 superseded.
|
||||||
t.Errorf("the plan does not say the old broker stays:\n%s", steps)
|
lines := WhatMoves(r)
|
||||||
|
if last := lines[len(lines)-1]; !strings.Contains(last, "unassign the old broker") {
|
||||||
|
t.Errorf("the plan does not end with the old broker going:\n%s", steps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package broker
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A node-scoped seat's tool carries the node (novox/hq ADR 0132, design 33 §4): two nodes holding one
|
||||||
|
// node-scoped seat derive two addresses, and a user of the seat may publish any node's.
|
||||||
|
func TestTwoNodesHoldingOneNodeSeatDeriveTwoToolAddresses(t *testing.T) {
|
||||||
|
seat := Seat{Name: "node-dns-resolver", Scope: "node", Serves: []string{"lookup"}}
|
||||||
|
one, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "dnsmasq", Holds: []Seat{seat}, PasswordHash: "x"})
|
||||||
|
two, _ := PermissionsFor(Principal{Kind: KindModule, Node: "two", Module: "dnsmasq", Holds: []Seat{seat}, PasswordHash: "x"})
|
||||||
|
has(t, one.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup.one")
|
||||||
|
has(t, two.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup.two")
|
||||||
|
hasNot(t, one.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup")
|
||||||
|
hasNot(t, one.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup.two")
|
||||||
|
|
||||||
|
user, _ := PermissionsFor(Principal{Kind: KindModule, Node: "three", Module: "asker", Uses: []Seat{seat}, PasswordHash: "x"})
|
||||||
|
has(t, user.Publish, "mesh.seat.node-dns-resolver.tool.lookup.*")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mesh-scoped seat's tool stays flat: nothing about it changes.
|
||||||
|
func TestAMeshSeatsToolIsAddressedToTheSeatAlone(t *testing.T) {
|
||||||
|
seat := Seat{Name: "git", Scope: "mesh", Serves: []string{"list_repos"}}
|
||||||
|
holder, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "gitea", Holds: []Seat{seat}, PasswordHash: "x"})
|
||||||
|
has(t, holder.Subscribe, "mesh.seat.git.tool.list_repos")
|
||||||
|
user, _ := PermissionsFor(Principal{Kind: KindModule, Node: "two", Module: "asker", Uses: []Seat{seat}, PasswordHash: "x"})
|
||||||
|
has(t, user.Publish, "mesh.seat.git.tool.list_repos")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The controller serves its own seat's verbs and may answer them (novox/hq ADR 0154).
|
||||||
|
func TestTheControllerServesItsSeatsToolsAndMayAnswer(t *testing.T) {
|
||||||
|
perms, err := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
has(t, perms.Subscribe, "mesh.seat.mesh-controller.tool.>")
|
||||||
|
if !perms.AllowResponses {
|
||||||
|
t.Fatal("the controller serves tools and may not answer one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A grant to every tool reaches a role's tools too, and a role's tool is granted by name.
|
||||||
|
func TestAGrantReachesARolesTools(t *testing.T) {
|
||||||
|
all, _ := PermissionsFor(Principal{Kind: KindModule, Node: "desk", Module: "mesh-console", Invokes: []string{"*"}, PasswordHash: "x"})
|
||||||
|
has(t, all.Publish, "mesh.seat.*.tool.>")
|
||||||
|
|
||||||
|
one, err := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", Invokes: []string{"seat:mesh-controller.status"}, PasswordHash: "x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
has(t, one.Publish, "mesh.seat.mesh-controller.tool.status")
|
||||||
|
hasNot(t, one.Publish, "mesh.seat.mesh-controller.tool.push")
|
||||||
|
hasNot(t, one.Publish, "mesh.mod.*.tool.>")
|
||||||
|
|
||||||
|
if _, err := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", Invokes: []string{"seat:mesh-controller"}, PasswordHash: "x"}); err == nil {
|
||||||
|
t.Fatal("a role grant naming no verb was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package broker_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The facts the control plane states are named twice — in the grant that permits them and in the code
|
||||||
|
// that states them — because `link` imports `broker` and the dependency cannot go the other way. So a
|
||||||
|
// test keeps them agreeing: a subject the grant omits is refused at the moment the mesh has something
|
||||||
|
// to say, and one the grant adds that nothing states is authority nobody uses.
|
||||||
|
//
|
||||||
|
// An external test package, because it may import both while neither imports the other.
|
||||||
|
func TestTheFactsTheGrantPermitsAreTheFactsTheMeshStates(t *testing.T) {
|
||||||
|
if broker.ControllerSeat != link.MeshControllerSeat {
|
||||||
|
t.Fatalf("the grant is written for the %q seat and the mesh states its facts under %q",
|
||||||
|
broker.ControllerSeat, link.MeshControllerSeat)
|
||||||
|
}
|
||||||
|
for _, event := range []string{link.KeyApplied, link.KeyRefused, link.KeyBuiltBefore} {
|
||||||
|
if !slices.Contains(broker.ControllerStates, event) {
|
||||||
|
t.Errorf("the mesh states %q and its account may not publish it", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(broker.ControllerStates) != 3 {
|
||||||
|
t.Errorf("the grant permits %v, which is more than the mesh states", broker.ControllerStates)
|
||||||
|
}
|
||||||
|
// **And the seat says it.** A seat carries the protocol of its role (novox/hq ADR 0129), so the
|
||||||
|
// facts the control plane states are the seat's `emits` — which is what lets anything else declare
|
||||||
|
// that it consumes them, and what the subject-agreement check reads to know they have an owner.
|
||||||
|
var declared []string
|
||||||
|
for _, seat := range catalogue.SeatsWithAProtocol() {
|
||||||
|
if seat.Name == broker.ControllerSeat {
|
||||||
|
declared = seat.Emits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !slices.Equal(declared, broker.ControllerStates) {
|
||||||
|
t.Errorf("the %s seat emits %v and the grant permits %v", broker.ControllerSeat,
|
||||||
|
declared, broker.ControllerStates)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,8 +47,14 @@ type Stream struct {
|
|||||||
// Why is carried into the assertion so an operator reading the server's own state finds the
|
// Why is carried into the assertion so an operator reading the server's own state finds the
|
||||||
// reason there, rather than only in a repository they may not have.
|
// reason there, rather than only in a repository they may not have.
|
||||||
Why string
|
Why string
|
||||||
|
// Direct lets a client read a subject's last message without a consumer, which is how a
|
||||||
|
// runtime reads its own membership with no JetStream API beyond one request (ADR 0160).
|
||||||
|
Direct bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AssignmentsStream holds every assignment's membership, the newest per subject.
|
||||||
|
const AssignmentsStream = "ASSIGNMENTS"
|
||||||
|
|
||||||
// MeshStreams is the foundation set, in the order a person reads it.
|
// MeshStreams is the foundation set, in the order a person reads it.
|
||||||
//
|
//
|
||||||
// **CONTROL names its subjects rather than taking `mesh.control.>`**, because heartbeats live
|
// **CONTROL names its subjects rather than taking `mesh.control.>`**, because heartbeats live
|
||||||
@@ -79,7 +85,15 @@ func MeshStreams() []Stream {
|
|||||||
"n-1 by construction (issue 107)",
|
"n-1 by construction (issue 107)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "EVENTS",
|
Name: AssignmentsStream,
|
||||||
|
Subjects: []string{"mesh.assignment.*.*"},
|
||||||
|
Retention: RetentionLastPerSubject,
|
||||||
|
Direct: true,
|
||||||
|
Why: "one membership per assignment, always the newest: what the mesh issued this module " +
|
||||||
|
"on this machine to serve and to reach (ADR 0160); read directly by the runtime it is for",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: EventsStream,
|
||||||
// A seat's own events ride here too: they are 1:many like any event, and the
|
// A seat's own events ride here too: they are 1:many like any event, and the
|
||||||
// `event` token keeps them clear of both the seat's work queue (`accept`) and its
|
// `event` token keeps them clear of both the seat's work queue (`accept`) and its
|
||||||
// tools (`tool`), which must not be persisted.
|
// tools (`tool`), which must not be persisted.
|
||||||
@@ -94,6 +108,24 @@ func MeshStreams() []Stream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeliverSubjectFor is where a push consumer's messages land.
|
||||||
|
//
|
||||||
|
// **Per consumer, which means per stream as well as per name** (novox/hq 04-ISSUES/146). A push
|
||||||
|
// consumer delivers onto an ordinary subject, and everything subscribed to that subject gets a
|
||||||
|
// copy. The controller holds a consumer called `controller` on CONTROL and another called
|
||||||
|
// `controller` on EVENTS; while both were given `_DELIVER.controller`, the one process holding
|
||||||
|
// both subscriptions acted on every message twice — a joining machine was enrolled twice from one
|
||||||
|
// request, and the second enrolment minted a credential that replaced the one the machine had just
|
||||||
|
// been handed. Every report and every followed event doubled the same way, silently: nothing is
|
||||||
|
// redelivered, no count is wrong, the work simply happens twice.
|
||||||
|
//
|
||||||
|
// The stream belongs in it because the pair is what identifies a consumer — the server scopes a
|
||||||
|
// durable's name to its stream, and this subject was the one place that scoping was dropped. It
|
||||||
|
// stays inside what a controller may already subscribe (`_DELIVER.controller.>`).
|
||||||
|
func DeliverSubjectFor(c Consumer) string {
|
||||||
|
return "_DELIVER." + c.Name + "." + c.Stream
|
||||||
|
}
|
||||||
|
|
||||||
// An Asserter is the part of a JetStream connection stream assertion needs. Narrow on purpose: it
|
// An Asserter is the part of a JetStream connection stream assertion needs. Narrow on purpose: it
|
||||||
// keeps this testable without a server, and keeps the client library out of everything that only
|
// keeps this testable without a server, and keeps the client library out of everything that only
|
||||||
// wants to know what the streams are.
|
// wants to know what the streams are.
|
||||||
@@ -160,6 +192,17 @@ func Overlaps() []string {
|
|||||||
// ack subject is derived from (nats.go: `$JS.ACK.<stream>.controller.>`).
|
// ack subject is derived from (nats.go: `$JS.ACK.<stream>.controller.>`).
|
||||||
const ControllerName = "controller"
|
const ControllerName = "controller"
|
||||||
|
|
||||||
|
// ControllerSeat is the role the control plane holds, and ControllerStates are the facts it states
|
||||||
|
// under it (novox/hq ADR 0134).
|
||||||
|
//
|
||||||
|
// **Written here as well as in `link`, and a test keeps them agreeing.** `link` imports `broker`, so
|
||||||
|
// `broker` cannot import `link`; a grant naming a subject the controller never publishes is authority
|
||||||
|
// nobody uses, and a controller publishing one the grant omits is refused at the moment it has
|
||||||
|
// something to say.
|
||||||
|
const ControllerSeat = "mesh-controller"
|
||||||
|
|
||||||
|
var ControllerStates = []string{"applied", "refused", "built-before"}
|
||||||
|
|
||||||
// ControllerFollows are the events the controller reacts to: the catalogue saying a module's
|
// ControllerFollows are the events the controller reacts to: the catalogue saying a module's
|
||||||
// current version moved, and a catalogue that has just started saying it may have missed builds.
|
// current version moved, and a catalogue that has just started saying it may have missed builds.
|
||||||
//
|
//
|
||||||
@@ -175,6 +218,9 @@ var ControllerFollows = []string{
|
|||||||
// message on the control branch. Same three audiences, one publish: whoever asked, this, and the
|
// message on the control branch. Same three audiences, one publish: whoever asked, this, and the
|
||||||
// catalogue.
|
// catalogue.
|
||||||
seatEventSubject("mesh-build-machine", "built"),
|
seatEventSubject("mesh-build-machine", "built"),
|
||||||
|
// The forge's merges: what moved a source, so the mesh builds what that source produces
|
||||||
|
// without anybody telling it (novox/hq 04-ISSUES/131). Appended, because the index is a name.
|
||||||
|
moduleEventSubject("gitea", "pull.merged"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// moduleEventSubject is where one module's event lands. The same derivation PermissionsFor uses, so
|
// moduleEventSubject is where one module's event lands. The same derivation PermissionsFor uses, so
|
||||||
@@ -188,6 +234,9 @@ func seatEventSubject(seat, verb string) string {
|
|||||||
return "mesh.seat." + seat + ".event." + verb
|
return "mesh.seat." + seat + ".event." + verb
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EventsStream holds every module's and every role's events, a build's log among them.
|
||||||
|
const EventsStream = "EVENTS"
|
||||||
|
|
||||||
// MeshConsumers is what the controller consumes, in the order a person reads it.
|
// MeshConsumers is what the controller consumes, in the order a person reads it.
|
||||||
//
|
//
|
||||||
// **Unlimited redelivery on CONTROL, deliberately.** The store window's bound is the controller's,
|
// **Unlimited redelivery on CONTROL, deliberately.** The store window's bound is the controller's,
|
||||||
@@ -212,8 +261,13 @@ func MeshConsumers() []Consumer {
|
|||||||
Push: true,
|
Push: true,
|
||||||
AckWaitSeconds: 30,
|
AckWaitSeconds: 30,
|
||||||
MaxDeliver: 5,
|
MaxDeliver: 5,
|
||||||
Why: "the two events the mesh's own controller reacts to; after max-deliver it " +
|
// One at a time (novox/hq issue 175): acting on a merge builds for minutes, and an
|
||||||
"dead-letters, because an announcement it cannot act on will not become actionable",
|
// announcement handed over behind it must wait on the server, not time out on the
|
||||||
|
// client and come back to be acted on again.
|
||||||
|
MaxAckPending: 1,
|
||||||
|
Why: "the two events the mesh's own controller reacts to, one at a time; after " +
|
||||||
|
"max-deliver it dead-letters, because an announcement it cannot act on will not " +
|
||||||
|
"become actionable",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,9 +123,10 @@ func subjectMatches(filter, subject string) bool {
|
|||||||
// Each relationship's retention is the thing that makes it what it is (design 29 §4).
|
// Each relationship's retention is the thing that makes it what it is (design 29 §4).
|
||||||
func TestEachStreamCarriesTheRetentionItsShapeNeeds(t *testing.T) {
|
func TestEachStreamCarriesTheRetentionItsShapeNeeds(t *testing.T) {
|
||||||
want := map[string]Retention{
|
want := map[string]Retention{
|
||||||
"CONTROL": RetentionWorkQueue,
|
"CONTROL": RetentionWorkQueue,
|
||||||
"NODES": RetentionLastPerSubject,
|
"NODES": RetentionLastPerSubject,
|
||||||
"EVENTS": RetentionLimits,
|
"EVENTS": RetentionLimits,
|
||||||
|
"ASSIGNMENTS": RetentionLastPerSubject,
|
||||||
}
|
}
|
||||||
got := map[string]Retention{}
|
got := map[string]Retention{}
|
||||||
for _, s := range MeshStreams() {
|
for _, s := range MeshStreams() {
|
||||||
@@ -233,3 +234,41 @@ func containsStep(steps []string, want string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **Two consumers may share a name, and must not share a delivery subject** (novox/hq
|
||||||
|
// 04-ISSUES/146).
|
||||||
|
//
|
||||||
|
// A push consumer delivers onto an ordinary subject and everything subscribed to it gets a copy.
|
||||||
|
// The controller holds a consumer called `controller` on CONTROL and another called `controller` on
|
||||||
|
// EVENTS; while both were given `_DELIVER.controller`, the one process holding both subscriptions
|
||||||
|
// acted on every message twice — a joining machine enrolled twice from one request, with the second
|
||||||
|
// enrolment minting a credential that replaced the one the machine had just been handed.
|
||||||
|
//
|
||||||
|
// Checked here rather than against a server because it is a property of what the mesh asks for, and
|
||||||
|
// because the failure it produces is silent: every count is right, nothing is redelivered, and the
|
||||||
|
// work simply happens twice.
|
||||||
|
func TestNoTwoConsumersDeliverOntoTheSameSubject(t *testing.T) {
|
||||||
|
seen := map[string]string{}
|
||||||
|
for _, c := range MeshConsumers() {
|
||||||
|
if !c.Push && c.Queue == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subject := DeliverSubjectFor(c)
|
||||||
|
if other, taken := seen[subject]; taken {
|
||||||
|
t.Errorf("%s on %s and %s deliver onto %s, so whoever holds both acts on every "+
|
||||||
|
"message twice", c.Name, c.Stream, other, subject)
|
||||||
|
}
|
||||||
|
seen[subject] = c.Name + " on " + c.Stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The controller's events consumer is handed one announcement at a time (novox/hq issue 175): a
|
||||||
|
// merge's handler builds for minutes, and what is queued behind it must wait on the server rather
|
||||||
|
// than time out on the client and be acted on twice.
|
||||||
|
func TestTheControllerTakesOneAnnouncementAtATime(t *testing.T) {
|
||||||
|
for _, c := range MeshConsumers() {
|
||||||
|
if c.Stream == "EVENTS" && c.Name == ControllerName && c.MaxAckPending != 1 {
|
||||||
|
t.Fatalf("the events consumer may have %d outstanding; one announcement at a time", c.MaxAckPending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+13
-10
@@ -21,10 +21,11 @@ jetstream {
|
|||||||
|
|
||||||
accounts {
|
accounts {
|
||||||
MESH {
|
MESH {
|
||||||
|
jetstream: enabled
|
||||||
users = [
|
users = [
|
||||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.control.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>"] }
|
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"] }
|
||||||
subscribe: { allow: ["$JS.API.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built"] }
|
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.>"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
|
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
|
||||||
@@ -32,21 +33,23 @@ accounts {
|
|||||||
subscribe: { allow: ["_INBOX.enrol.one.>"] }
|
subscribe: { allow: ["_INBOX.enrol.one.>"] }
|
||||||
} }
|
} }
|
||||||
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
|
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.NODES.one.>", "mesh.control.one.>"] }
|
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.INFO.NODES.one", "mesh.control.one.>"] }
|
||||||
subscribe: { allow: ["_INBOX.node.one.>", "mesh.node.one.declare"] }
|
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.declare"] }
|
||||||
} }
|
} }
|
||||||
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
|
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
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.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.mod.telegram.tool.status", "mesh.seat.telegram-sender.accept.send"] }
|
subscribe: { allow: ["_DELIVER.SEAT_TELEGRAM_SENDER_worker", "_DELIVER.SEAT_TELEGRAM_SENDER_worker.>", "_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
|
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.EVENTS.two_audit.>"] }
|
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.mod.shop.event.order.placed"] }
|
subscribe: { allow: ["_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: {
|
{ user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] }
|
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.>"] }
|
subscribe: { allow: ["_INBOX.two.shop.>", "mesh.assignment.two.shop", "mesh.mod.shop.tool.>"] }
|
||||||
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ type Declared struct {
|
|||||||
Uses []Seat
|
Uses []Seat
|
||||||
// Watches are the seats whose events it consumes.
|
// Watches are the seats whose events it consumes.
|
||||||
Watches []Seat
|
Watches []Seat
|
||||||
|
// Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152).
|
||||||
|
Invokes []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
||||||
@@ -45,6 +47,9 @@ type Records struct {
|
|||||||
Enrolling []string
|
Enrolling []string
|
||||||
// People is each person's name against the tools they may invoke, `*` for an administrator.
|
// People is each person's name against the tools they may invoke, `*` for an administrator.
|
||||||
People map[string][]string
|
People map[string][]string
|
||||||
|
// Interchangeable is each module whose definition says its instances are the same anywhere
|
||||||
|
// (ADR 0160), which decides whether the module's plain subject is issued to every instance.
|
||||||
|
Interchangeable map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Users is every user the composed file should contain, in the order it will be written.
|
// Users is every user the composed file should contain, in the order it will be written.
|
||||||
@@ -61,7 +66,7 @@ func Users(r Records) ([]Principal, error) {
|
|||||||
out = append(out, Principal{
|
out = append(out, Principal{
|
||||||
Kind: KindModule, Node: node, Module: d.Module,
|
Kind: KindModule, Node: node, Module: d.Module,
|
||||||
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
|
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
|
||||||
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches,
|
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,7 +186,13 @@ func TestWhatTheMeshWritesIsUsersAndNothingAboutTheServer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// None of the server's own settings. Each of these in the mesh's file is a value the controller
|
// None of the server's own settings. Each of these in the mesh's file is a value the controller
|
||||||
// would then own, and the module could no longer change its own image without the mesh agreeing.
|
// would then own, and the module could no longer change its own image without the mesh agreeing.
|
||||||
for _, absent := range []string{"port:", "http:", "jetstream", "tls {", "store_dir", "cert_file"} {
|
// `jetstream {` is the server's block (its store, its limits); `jetstream: enabled` inside the
|
||||||
|
// account is the account's, and the mesh owns the account — a user in it is told "JetStream
|
||||||
|
// not enabled for account" without it (2026-09-28).
|
||||||
|
if !strings.Contains(got, "jetstream: enabled") {
|
||||||
|
t.Errorf("the account does not enable JetStream, so no user in it can bind a consumer")
|
||||||
|
}
|
||||||
|
for _, absent := range []string{"port:", "http:", "jetstream {", "tls {", "store_dir", "cert_file"} {
|
||||||
if strings.Contains(got, absent) {
|
if strings.Contains(got, absent) {
|
||||||
t.Errorf("the accounts file contains %q, which belongs to the module that raises the "+
|
t.Errorf("the accounts file contains %q, which belongs to the module that raises the "+
|
||||||
"server, not to the mesh", absent)
|
"server, not to the mesh", absent)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The name a machine runs a binary by is not always the name of the package that built it. The host's
|
||||||
|
// command is cmd/mesh-host and every machine runs it as nox-mesh-host — the path it is installed at,
|
||||||
|
// the name in its unit, and the name its launcher looks for inside a delivered version.
|
||||||
|
//
|
||||||
|
// A bundle carrying the package's name was delivered to a machine correctly, reported "created … 1
|
||||||
|
// file(s)", and was invisible to the launcher (novox/hq 04-ISSUES/142). Found by reading the delivered
|
||||||
|
// directory rather than by trusting the line that said it worked.
|
||||||
|
|
||||||
|
func TestACompiledArtifactNamesTheBinaryAMachineWillRun(t *testing.T) {
|
||||||
|
got := binaryName(catalogue.Artifact{
|
||||||
|
Name: "host-arch", From: "cmd/mesh-host", Binary: "nox-mesh-host",
|
||||||
|
})
|
||||||
|
if got != "nox-mesh-host" {
|
||||||
|
t.Fatalf("the binary is named %q, and the launcher looks for nox-mesh-host", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSayingNothingKeepsWhatTheCompilerWouldHaveChosen(t *testing.T) {
|
||||||
|
// go build names its output after the package, so an artifact that says nothing gets the same
|
||||||
|
// thing it got before this existed.
|
||||||
|
if got := binaryName(catalogue.Artifact{Name: "host-arch", From: "cmd/mesh-host"}); got != "mesh-host" {
|
||||||
|
t.Fatalf("an artifact naming no binary produced %q", got)
|
||||||
|
}
|
||||||
|
if got := binaryName(catalogue.Artifact{Name: "host-arch", From: "./cmd/agent/"}); got != "agent" {
|
||||||
|
t.Fatalf("a from with slashes produced %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABundleBuiltFromTheModuleRootFallsBackToItsArtifactName(t *testing.T) {
|
||||||
|
// A single-command repository names no package, and `go build -o <dir>` would then write a file
|
||||||
|
// named after the module directory — which is not something the manifest states. The artifact's
|
||||||
|
// own name is what the manifest does state.
|
||||||
|
if got := binaryName(catalogue.Artifact{Name: "tool"}); got != "tool" {
|
||||||
|
t.Fatalf("a bundle built from the root produced %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+188
-32
@@ -61,6 +61,12 @@ type Result struct {
|
|||||||
Commit string
|
Commit string
|
||||||
// Built is each artifact, for reporting.
|
// Built is each artifact, for reporting.
|
||||||
Built []catalogue.Built
|
Built []catalogue.Built
|
||||||
|
|
||||||
|
// Read is every repository this build read source from besides the module's own — the second
|
||||||
|
// repository an artifact's recipe names (ArtifactContext). Reported because the manifest the
|
||||||
|
// mesh keeps carries no build section, so nothing else could say that a merge there is a
|
||||||
|
// change to this module (novox/hq 04-ISSUES/131).
|
||||||
|
Read []catalogue.ArtifactContext
|
||||||
}
|
}
|
||||||
|
|
||||||
// GitCredential is the forge credential a clone may present when the server asks for one.
|
// GitCredential is the forge credential a clone may present when the server asks for one.
|
||||||
@@ -84,7 +90,13 @@ type GitCredential struct {
|
|||||||
// records — reachable, unreferenced, and indistinguishable from something in use.
|
// records — reachable, unreferenced, and indistinguishable from something in use.
|
||||||
func Build(ctx context.Context, run Runner, publish Publisher,
|
func Build(ctx context.Context, run Runner, publish Publisher,
|
||||||
repository, path, ref, workspace string, held map[string]string, npmrc Npmrc,
|
repository, path, ref, workspace string, held map[string]string, npmrc Npmrc,
|
||||||
forge GitCredential, log Log) (Result, error) {
|
forge GitCredential, log Log, seats ...map[string]string) (Result, error) {
|
||||||
|
// The clone base of each seat a context may name (novox/hq ADR 0155); variadic so the callers
|
||||||
|
// that hand none — tests of everything but contexts — read as they did.
|
||||||
|
var seatBases map[string]string
|
||||||
|
if len(seats) > 0 {
|
||||||
|
seatBases = seats[0]
|
||||||
|
}
|
||||||
|
|
||||||
say := logging(log)
|
say := logging(log)
|
||||||
say("clone", "%s%s at %s", repository, describePath(path), refOrHead(ref))
|
say("clone", "%s%s at %s", repository, describePath(path), refOrHead(ref))
|
||||||
@@ -169,6 +181,8 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
}
|
}
|
||||||
|
|
||||||
var built []catalogue.Built
|
var built []catalogue.Built
|
||||||
|
// stoodOn is every base the build was handed, as resolved — the edges the catalogue derives.
|
||||||
|
var stoodOn []string
|
||||||
if manifest.Build != nil {
|
if manifest.Build != nil {
|
||||||
// What this module said it stands on, answered with what this mesh actually holds. Done
|
// What this module said it stands on, answered with what this mesh actually holds. Done
|
||||||
// before anything is built, so a missing base is refused in front of the person who can
|
// before anything is built, so a missing base is refused in front of the person who can
|
||||||
@@ -186,11 +200,12 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
}
|
}
|
||||||
return from, nil
|
return from, nil
|
||||||
}
|
}
|
||||||
args, err := standingOn(ctx, manifest, held, mirror)
|
args, bases, err := standingOn(ctx, manifest, held, mirror)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
say("bases", "UNMET: %v", err)
|
say("bases", "UNMET: %v", err)
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
|
stoodOn = bases
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
say("bases", "%d resolved from what the mesh holds", len(args)/2)
|
say("bases", "%d resolved from what the mesh holds", len(args)/2)
|
||||||
}
|
}
|
||||||
@@ -200,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 })
|
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
|
||||||
for _, a := range artifacts {
|
for _, a := range artifacts {
|
||||||
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
|
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, say)
|
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, seatBases, say)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
say("artifact", "%s FAILED: %v", a.Name, err)
|
say("artifact", "%s FAILED: %v", a.Name, err)
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
@@ -217,7 +232,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
}
|
}
|
||||||
say("done", "%s at %s — %d artifact(s) pinned", manifest.Module, short(commit), len(built))
|
say("done", "%s at %s — %d artifact(s) pinned", manifest.Module, short(commit), len(built))
|
||||||
return Result{Manifest: resolved, Commit: commit, Built: built,
|
return Result{Manifest: resolved, Commit: commit, Built: built,
|
||||||
Against: against(within, manifest)}, nil
|
Against: against(within, manifest, stoodOn), Read: readBy(manifest)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log is where a build says what it is doing, step by step. Nil is silent — the tests pass none,
|
// Log is where a build says what it is doing, step by step. Nil is silent — the tests pass none,
|
||||||
@@ -237,14 +252,18 @@ func logging(log Log) func(step, format string, args ...any) {
|
|||||||
// module's own repository — a fresh tree, the same way the module's own is, keyed by artifact
|
// module's own repository — a fresh tree, the same way the module's own is, keyed by artifact
|
||||||
// name so two artifacts of one module naming different contexts do not collide.
|
// name so two artifacts of one module naming different contexts do not collide.
|
||||||
func contextFrom(ctx context.Context, run Runner, workspace, artifact, credentials string,
|
func contextFrom(ctx context.Context, run Runner, workspace, artifact, credentials string,
|
||||||
from catalogue.ArtifactContext, say func(step, format string, args ...any)) (string, error) {
|
from catalogue.ArtifactContext, seats map[string]string, say func(step, format string, args ...any)) (string, error) {
|
||||||
say("context", "cloning %s at %s for %s", from.Repository, refOrHead(from.Ref), artifact)
|
url, err := contextURL(from, seats)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
say("context", "cloning %s at %s for %s", url, refOrHead(from.Ref), artifact)
|
||||||
dir := filepath.Join(workspace, "context-"+artifact)
|
dir := filepath.Join(workspace, "context-"+artifact)
|
||||||
if err := os.RemoveAll(dir); err != nil {
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if _, err := run(ctx, workspace, "git", cloneWith(credentials, "clone", "--quiet", from.Repository, dir)...); err != nil {
|
if _, err := run(ctx, workspace, "git", cloneWith(credentials, "clone", "--quiet", url, dir)...); err != nil {
|
||||||
return "", fmt.Errorf("cannot clone %s: %w", from.Repository, err)
|
return "", fmt.Errorf("cannot clone %s: %w", url, err)
|
||||||
}
|
}
|
||||||
if from.Ref != "" {
|
if from.Ref != "" {
|
||||||
if _, err := run(ctx, dir, "git", "checkout", "--quiet", from.Ref); err != nil {
|
if _, err := run(ctx, dir, "git", "checkout", "--quiet", from.Ref); err != nil {
|
||||||
@@ -255,6 +274,23 @@ func contextFrom(ctx context.Context, run Runner, workspace, artifact, credentia
|
|||||||
return dir, nil
|
return dir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// contextURL is what a context is cloned from: its URL, or — for a context on a seat — the seat's
|
||||||
|
// clone base the mesh sent with the request joined to the repository's path (novox/hq ADR 0155).
|
||||||
|
// Refused, never guessed, when the mesh sent no base for that seat: a builder that guessed a forge
|
||||||
|
// would be the literal this removes, one layer down.
|
||||||
|
func contextURL(from catalogue.ArtifactContext, seats map[string]string) (string, error) {
|
||||||
|
if from.Seat == "" {
|
||||||
|
return from.Repository, nil
|
||||||
|
}
|
||||||
|
base, told := seats[from.Seat]
|
||||||
|
if !told || base == "" {
|
||||||
|
return "", fmt.Errorf("the context is %s on the %s seat, and this build was told no clone "+
|
||||||
|
"base for that seat — nothing holds it in this mesh, or the control plane predates the word",
|
||||||
|
from.Repository, from.Seat)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(base, "/") + "/" + strings.TrimSuffix(strings.Trim(from.Repository, "/"), ".git") + ".git", nil
|
||||||
|
}
|
||||||
|
|
||||||
// cloneWith is a git invocation that may offer a stored credential.
|
// cloneWith is a git invocation that may offer a stored credential.
|
||||||
//
|
//
|
||||||
// The first `-c credential.helper=` clears every helper the environment might carry, so exactly
|
// The first `-c credential.helper=` clears every helper the environment might carry, so exactly
|
||||||
@@ -339,14 +375,27 @@ func describe(path string) string {
|
|||||||
// allowed to name — a tag is something somebody else can move under you.
|
// allowed to name — a tag is something somebody else can move under you.
|
||||||
var pinnedImage = regexp.MustCompile(`[A-Za-z0-9][A-Za-z0-9._/:-]*@sha256:[0-9a-f]{64}`)
|
var pinnedImage = regexp.MustCompile(`[A-Za-z0-9][A-Za-z0-9._/:-]*@sha256:[0-9a-f]{64}`)
|
||||||
|
|
||||||
// against reads what this module's image artifacts are built on top of, out of the files that
|
// against is what this module's image artifacts are built on top of: every base the mesh resolved
|
||||||
// build them. Nothing is guessed: a reference that is not written down is not reported.
|
// and handed the recipe as a build argument (`build.on`), and any image a recipe pins by digest
|
||||||
func against(within string, manifest catalogue.Manifest) []string {
|
// itself. Nothing is guessed: a reference that was neither resolved nor written down is not
|
||||||
|
// reported.
|
||||||
|
//
|
||||||
|
// **The resolved bases are the edges.** A recipe reads its base from an argument (`FROM
|
||||||
|
// ${RUNTIME_BASE}`), so the digest is never in the file, and a derivation that read files alone
|
||||||
|
// recorded no edge for any module on the mesh — which is why nothing knew what a changed base
|
||||||
|
// meant to rebuild (novox/hq 04-ISSUES/131).
|
||||||
|
func against(within string, manifest catalogue.Manifest, resolved []string) []string {
|
||||||
if manifest.Build == nil {
|
if manifest.Build == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
var out []string
|
var out []string
|
||||||
|
for _, r := range resolved {
|
||||||
|
if r != "" && !seen[r] {
|
||||||
|
seen[r] = true
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, a := range manifest.Build.Artifacts {
|
for _, a := range manifest.Build.Artifacts {
|
||||||
if a.Kind != catalogue.ArtifactImage || a.From == "" {
|
if a.Kind != catalogue.ArtifactImage || a.From == "" {
|
||||||
continue
|
continue
|
||||||
@@ -394,7 +443,8 @@ func wantsPackages(manifest catalogue.Manifest, within string) bool {
|
|||||||
|
|
||||||
func one(ctx context.Context, run Runner, publish Publisher,
|
func one(ctx context.Context, run Runner, publish Publisher,
|
||||||
module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string,
|
module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string,
|
||||||
held map[string]string, npmrc string, say func(step, format string, args ...any)) (catalogue.Built, error) {
|
held map[string]string, npmrc string, seats map[string]string,
|
||||||
|
say func(step, format string, args ...any)) (catalogue.Built, error) {
|
||||||
|
|
||||||
switch a.Kind {
|
switch a.Kind {
|
||||||
case catalogue.ArtifactUpstream:
|
case catalogue.ArtifactUpstream:
|
||||||
@@ -471,7 +521,7 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
recipePath := a.From
|
recipePath := a.From
|
||||||
buildDir := tree
|
buildDir := tree
|
||||||
if a.Context != nil {
|
if a.Context != nil {
|
||||||
cloned, err := contextFrom(ctx, run, workspace, a.Name, credentials, *a.Context, say)
|
cloned, err := contextFrom(ctx, run, workspace, a.Name, credentials, *a.Context, seats, say)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Built{}, fmt.Errorf("%s: %s's context: %w", module, a.Name, err)
|
return catalogue.Built{}, fmt.Errorf("%s: %s's context: %w", module, a.Name, err)
|
||||||
}
|
}
|
||||||
@@ -669,6 +719,21 @@ func short(commit string) string {
|
|||||||
return commit
|
return commit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Said is where the lines Command speaks go, beside the build's own Log: what runs, how long it
|
||||||
|
// took, and that it failed. Nil prints them to stderr, as a build machine with nobody listening
|
||||||
|
// should. The machine sets it per build so every line reaches the bus too (novox/hq ADR 0157) —
|
||||||
|
// the step is "run", and the message is the line as it has always been printed.
|
||||||
|
var Said Log
|
||||||
|
|
||||||
|
func tell(step, format string, args ...any) {
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
if Said == nil {
|
||||||
|
fmt.Fprintf(os.Stderr, " %s\n", message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Said(step, message)
|
||||||
|
}
|
||||||
|
|
||||||
// Command is a Runner that actually runs things.
|
// Command is a Runner that actually runs things.
|
||||||
func Command(ctx context.Context, dir, name string, args ...string) (string, error) {
|
func Command(ctx context.Context, dir, name string, args ...string) (string, error) {
|
||||||
// **Every command is echoed before it runs**, with where. On a build that hangs, the last line
|
// **Every command is echoed before it runs**, with where. On a build that hangs, the last line
|
||||||
@@ -676,16 +741,23 @@ func Command(ctx context.Context, dir, name string, args ...string) (string, err
|
|||||||
// nothing" and "git clone is waiting on a network that will not answer". Silent on success is
|
// nothing" and "git clone is waiting on a network that will not answer". Silent on success is
|
||||||
// what made an empty workspace unreadable.
|
// what made an empty workspace unreadable.
|
||||||
started := timeNow()
|
started := timeNow()
|
||||||
fmt.Fprintf(os.Stderr, " $ (%s) %s %s\n", short(filepath.Base(dir)), name, strings.Join(args, " "))
|
tell("run", "$ (%s) %s %s", short(filepath.Base(dir)), name, strings.Join(args, " "))
|
||||||
cmd := exec.CommandContext(ctx, name, args...)
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
cmd.Dir = dir
|
cmd.Dir = dir
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, " ! %s %s failed after %s\n", name, args[0], since(started))
|
tell("run", "! %s %s failed after %s", name, args[0], since(started))
|
||||||
|
// The command's own output is part of what a reader needs — the compiler's error, the
|
||||||
|
// clone's refusal — and a line per output line keeps it readable on the bus.
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
|
if line != "" {
|
||||||
|
tell("output", "%s", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
return string(out), fmt.Errorf("%s %s: %w\n%s",
|
return string(out), fmt.Errorf("%s %s: %w\n%s",
|
||||||
name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, " ✓ %s %s (%s)\n", name, firstArg(args), since(started))
|
tell("run", "✓ %s %s (%s)", name, firstArg(args), since(started))
|
||||||
return string(out), nil
|
return string(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,40 +776,42 @@ var _ io.Writer = (*stringWriter)(nil)
|
|||||||
// built cannot be built here yet, and the useful sentence names which module is missing — not the
|
// built cannot be built here yet, and the useful sentence names which module is missing — not the
|
||||||
// one a container runtime produces when a recipe's first line refers to an image nobody has.
|
// one a container runtime produces when a recipe's first line refers to an image nobody has.
|
||||||
//
|
//
|
||||||
// The order is fixed so two builds of one commit invoke the same command.
|
// The order is fixed so two builds of one commit invoke the same command. Returned alongside the
|
||||||
|
// arguments is every reference they resolved to, which is what the build stood on.
|
||||||
func standingOn(ctx context.Context, manifest catalogue.Manifest, held map[string]string,
|
func standingOn(ctx context.Context, manifest catalogue.Manifest, held map[string]string,
|
||||||
mirror func(ctx context.Context, from, repository string) (string, error)) ([]string, error) {
|
mirror func(ctx context.Context, from, repository string) (string, error)) ([]string, []string, error) {
|
||||||
if manifest.Build == nil || len(manifest.Build.On) == 0 {
|
if manifest.Build == nil || len(manifest.Build.On) == 0 {
|
||||||
return nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
on := append([]catalogue.BuildsOn{}, manifest.Build.On...)
|
on := append([]catalogue.BuildsOn{}, manifest.Build.On...)
|
||||||
sort.Slice(on, func(i, j int) bool { return on[i].Arg < on[j].Arg })
|
sort.Slice(on, func(i, j int) bool { return on[i].Arg < on[j].Arg })
|
||||||
|
|
||||||
var args []string
|
var args, resolved []string
|
||||||
for _, base := range on {
|
for _, base := range on {
|
||||||
if base.Image != "" {
|
if base.Image != "" {
|
||||||
// A vendor's image, declared (novox/hq 04-ISSUES/064, ADR 0097). Pinned, because a tag
|
// A vendor's image, declared (novox/hq 04-ISSUES/064, ADR 0097). Pinned, because a tag
|
||||||
// is what somebody else can move; copied into the mesh's registry, because a build
|
// is what somebody else can move; copied into the mesh's registry, because a build
|
||||||
// that reaches a public registry on its own is a build that works sometimes.
|
// that reaches a public registry on its own is a build that works sometimes.
|
||||||
if base.Arg == "" || base.Module != "" || base.Artifact != "" {
|
if base.Arg == "" || base.Module != "" || base.Artifact != "" {
|
||||||
return nil, fmt.Errorf(
|
return nil, nil, fmt.Errorf(
|
||||||
"%s stands on the image %s, and a base is either a module's artifact or an "+
|
"%s stands on the image %s, and a base is either a module's artifact or an "+
|
||||||
"image — never both — read from one build argument", manifest.Module, base.Image)
|
"image — never both — read from one build argument", manifest.Module, base.Image)
|
||||||
}
|
}
|
||||||
if !strings.Contains(base.Image, "@sha256:") {
|
if !strings.Contains(base.Image, "@sha256:") {
|
||||||
return nil, fmt.Errorf(
|
return nil, nil, fmt.Errorf(
|
||||||
"%s stands on the image %q, which is not pinned by digest. A tag is what "+
|
"%s stands on the image %q, which is not pinned by digest. A tag is what "+
|
||||||
"somebody else can move; name it as <image>@sha256:…", manifest.Module, base.Image)
|
"somebody else can move; name it as <image>@sha256:…", manifest.Module, base.Image)
|
||||||
}
|
}
|
||||||
reference, err := mirror(ctx, base.Image, manifest.Module+"/on-"+strings.ToLower(base.Arg))
|
reference, err := mirror(ctx, base.Image, manifest.Module+"/on-"+strings.ToLower(base.Arg))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s stands on %s: %w", manifest.Module, base.Image, err)
|
return nil, nil, fmt.Errorf("%s stands on %s: %w", manifest.Module, base.Image, err)
|
||||||
}
|
}
|
||||||
args = append(args, "--build-arg", base.Arg+"="+reference)
|
args = append(args, "--build-arg", base.Arg+"="+reference)
|
||||||
|
resolved = append(resolved, reference)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if base.Arg == "" || base.Module == "" || base.Artifact == "" {
|
if base.Arg == "" || base.Module == "" || base.Artifact == "" {
|
||||||
return nil, fmt.Errorf(
|
return nil, nil, fmt.Errorf(
|
||||||
"%s says its build stands on something, and does not say all of what: a base "+
|
"%s says its build stands on something, and does not say all of what: a base "+
|
||||||
"needs the module, the artifact, and the build argument the recipe reads it "+
|
"needs the module, the artifact, and the build argument the recipe reads it "+
|
||||||
"from", manifest.Module)
|
"from", manifest.Module)
|
||||||
@@ -745,14 +819,15 @@ func standingOn(ctx context.Context, manifest catalogue.Manifest, held map[strin
|
|||||||
key := base.Module + "/" + base.Artifact
|
key := base.Module + "/" + base.Artifact
|
||||||
reference, has := held[key]
|
reference, has := held[key]
|
||||||
if !has {
|
if !has {
|
||||||
return nil, fmt.Errorf(
|
return nil, nil, fmt.Errorf(
|
||||||
"%s builds on %s, and this mesh has not built it. Build %s first — every module "+
|
"%s builds on %s, and this mesh has not built it. Build %s first — every module "+
|
||||||
"in this toolchain stands on it, so it is the thing to have before anything "+
|
"in this toolchain stands on it, so it is the thing to have before anything "+
|
||||||
"else", manifest.Module, key, base.Module)
|
"else", manifest.Module, key, base.Module)
|
||||||
}
|
}
|
||||||
args = append(args, "--build-arg", base.Arg+"="+reference)
|
args = append(args, "--build-arg", base.Arg+"="+reference)
|
||||||
|
resolved = append(resolved, reference)
|
||||||
}
|
}
|
||||||
return args, nil
|
return args, resolved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// compile runs a module's own code through its toolchain, and says where the result is.
|
// compile runs a module's own code through its toolchain, and says where the result is.
|
||||||
@@ -836,6 +911,15 @@ func compile(ctx context.Context, run Runner, tree string, chain Toolchain,
|
|||||||
// each other and then be packed together, so each bundle compiles and packs alone.
|
// each other and then be packed together, so each bundle compiles and packs alone.
|
||||||
out := Out(a.Name)
|
out := Out(a.Name)
|
||||||
|
|
||||||
|
// **The output directory exists before the compiler is told about it.** `tsc --outDir` makes
|
||||||
|
// one; `go build -o` writes a file into a directory and does not create it, failing with a
|
||||||
|
// message about a path rather than about a build. Made here for every toolchain, because which
|
||||||
|
// compilers happen to be forgiving is not a thing a reader should have to know
|
||||||
|
// (novox/hq 04-ISSUES/142).
|
||||||
|
if err := os.MkdirAll(filepath.Join(tree, out), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("making the output directory for %s: %w", a.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
invocation := []string{
|
invocation := []string{
|
||||||
"run", "--rm",
|
"run", "--rm",
|
||||||
"--volume", tree + ":" + within,
|
"--volume", tree + ":" + within,
|
||||||
@@ -843,13 +927,43 @@ func compile(ctx context.Context, run Runner, tree string, chain Toolchain,
|
|||||||
base,
|
base,
|
||||||
}
|
}
|
||||||
invocation = append(invocation, chain.Compile...)
|
invocation = append(invocation, chain.Compile...)
|
||||||
|
// **One `-ldflags`, composed here.** A repeated flag is not a merged one: the Go command takes
|
||||||
|
// the last and drops the first, so passing the toolchain's flags and then the system stamp as a
|
||||||
|
// second `-ldflags` produced a binary that knew its system and had lost `-s -w` — half again the
|
||||||
|
// size, with its debug info (novox/hq 04-ISSUES/161).
|
||||||
|
//
|
||||||
|
// What it was built for is the one thing taken from the artifact, and ADR 0142 says why: the
|
||||||
|
// target is a property of the artifact rather than of the recipe. A host with no system refuses
|
||||||
|
// every declaration before it applies anything.
|
||||||
|
linker := append([]string(nil), chain.LinkerFlags...)
|
||||||
|
if chain.SystemStamp != "" && strings.TrimSpace(a.System) != "" {
|
||||||
|
linker = append(linker, "-X", chain.SystemStamp+"="+strings.TrimSpace(a.System))
|
||||||
|
}
|
||||||
|
if len(linker) > 0 {
|
||||||
|
invocation = append(invocation, "-ldflags", strings.Join(linker, " "))
|
||||||
|
}
|
||||||
if chain.OutputFlag != "" {
|
if chain.OutputFlag != "" {
|
||||||
invocation = append(invocation, chain.OutputFlag, out)
|
// A compiler pointed at a package is told the file to write, not the directory: the name a
|
||||||
|
// machine runs it by is not always the name of the package that built it. The host's command
|
||||||
|
// is `cmd/mesh-host` and every machine runs it as `nox-mesh-host` — so a bundle carrying the
|
||||||
|
// package's name lands correctly, reports success, and is invisible to whatever looks for it
|
||||||
|
// (novox/hq 04-ISSUES/142).
|
||||||
|
target := out
|
||||||
|
if chain.Unit == UnitPackage {
|
||||||
|
target = filepath.Join(out, binaryName(a))
|
||||||
|
}
|
||||||
|
invocation = append(invocation, chain.OutputFlag, target)
|
||||||
}
|
}
|
||||||
// What to compile. Named by the module rather than discovered, so adding a file does not
|
// What to compile. Named by the module rather than discovered, so adding a file does not
|
||||||
// silently change what a build produces.
|
// silently change what a build produces.
|
||||||
if len(a.Entrypoints) > 0 {
|
switch {
|
||||||
invocation = append(invocation, sourcesFor(a.Entrypoints, out)...)
|
case chain.Unit == UnitPackage:
|
||||||
|
// One directory, compiled whole: the thing the artifact is built `from`. Relative, because
|
||||||
|
// the compiler runs with the module's own root as its working directory and a package path
|
||||||
|
// that looked absolute would name one inside the toolchain image.
|
||||||
|
invocation = append(invocation, "./"+strings.Trim(a.From, "./"))
|
||||||
|
case len(a.Entrypoints) > 0:
|
||||||
|
invocation = append(invocation, sourcesFor(a.Entrypoints, out, chain.SourceExt)...)
|
||||||
}
|
}
|
||||||
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -862,14 +976,16 @@ func compile(ctx context.Context, run Runner, tree string, chain Toolchain,
|
|||||||
// A module names what a tool host should LOAD — compiled paths under the bundle's root — because
|
// A module names what a tool host should LOAD — compiled paths under the bundle's root — because
|
||||||
// that is the thing anything else needs to know. What to compile is the same list with the
|
// that is the thing anything else needs to know. What to compile is the same list with the
|
||||||
// language's own extension, which is the toolchain's business rather than the module's.
|
// language's own extension, which is the toolchain's business rather than the module's.
|
||||||
func sourcesFor(entrypoints []string, out string) []string {
|
func sourcesFor(entrypoints []string, out, ext string) []string {
|
||||||
sources := make([]string, 0, len(entrypoints))
|
sources := make([]string, 0, len(entrypoints))
|
||||||
for _, e := range entrypoints {
|
for _, e := range entrypoints {
|
||||||
// An entrypoint is named as it will be FOUND — a path inside the unpacked bundle — so the
|
// An entrypoint is named as it will be FOUND — a path inside the unpacked bundle — so the
|
||||||
// source is the same path with the output directory taken off the front and the language's
|
// source is the same path with the output directory taken off the front and the language's
|
||||||
// own extension on the end.
|
// own extension on the end. **The extension is the toolchain's**, where it used to be the
|
||||||
|
// literal `.ts`: one language's file extension written into the code that serves every
|
||||||
|
// language is a wall the next one hits (novox/hq 04-ISSUES/142).
|
||||||
at := strings.TrimPrefix(strings.TrimPrefix(e, out), "/")
|
at := strings.TrimPrefix(strings.TrimPrefix(e, out), "/")
|
||||||
sources = append(sources, strings.TrimSuffix(at, filepath.Ext(at))+".ts")
|
sources = append(sources, strings.TrimSuffix(at, filepath.Ext(at))+ext)
|
||||||
}
|
}
|
||||||
return sources
|
return sources
|
||||||
}
|
}
|
||||||
@@ -997,3 +1113,43 @@ func instructions(recipe string) []string {
|
|||||||
flush()
|
flush()
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readBy is every repository other than the module's own that this build's recipes read source from,
|
||||||
|
// each once and in a fixed order, so two builds of one commit report the same thing the same way.
|
||||||
|
func readBy(manifest catalogue.Manifest) []catalogue.ArtifactContext {
|
||||||
|
if manifest.Build == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []catalogue.ArtifactContext
|
||||||
|
for _, a := range manifest.Build.Artifacts {
|
||||||
|
if a.Context == nil || a.Context.Repository == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := a.Context.Repository + "#" + a.Context.Ref
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
out = append(out, *a.Context)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Repository != out[j].Repository {
|
||||||
|
return out[i].Repository < out[j].Repository
|
||||||
|
}
|
||||||
|
return out[i].Ref < out[j].Ref
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 := strings.TrimSpace(a.Binary); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if from := strings.Trim(a.From, "./"); from != "" {
|
||||||
|
return filepath.Base(from)
|
||||||
|
}
|
||||||
|
return a.Name
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A context on a seat is cloned from the base the mesh sent, joined to the repository's path; a
|
||||||
|
// context by URL is itself; a seat the mesh sent no base for is refused by name (novox/hq ADR 0155).
|
||||||
|
func TestAContextOnASeatIsClonedFromTheBaseTheMeshSent(t *testing.T) {
|
||||||
|
seats := map[string]string{"git": "http://forge.example.tld:3000"}
|
||||||
|
got, err := contextURL(catalogue.ArtifactContext{Seat: "git", Repository: "org/controller"}, seats)
|
||||||
|
if err != nil || got != "http://forge.example.tld:3000/org/controller.git" {
|
||||||
|
t.Fatalf("got %q, %v", got, err)
|
||||||
|
}
|
||||||
|
got, err = contextURL(catalogue.ArtifactContext{Repository: "https://elsewhere.example/x.git"}, seats)
|
||||||
|
if err != nil || got != "https://elsewhere.example/x.git" {
|
||||||
|
t.Fatalf("a URL context was changed: %q, %v", got, err)
|
||||||
|
}
|
||||||
|
_, err = contextURL(catalogue.ArtifactContext{Seat: "git", Repository: "org/controller"}, nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "git seat") {
|
||||||
|
t.Fatalf("a seat with no base was not refused by name: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Nothing could compile the mesh's own components, which is why nothing delivers the host
|
||||||
|
// (novox/hq 04-ISSUES/142, and ADR 0141's own insight naming it). The toolchain list was a closed
|
||||||
|
// set of typescript and python, and two things in the path beyond it assumed TypeScript.
|
||||||
|
|
||||||
|
func TestTheMeshCanCompileGo(t *testing.T) {
|
||||||
|
chain, err := ToolchainFor("go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Named, not pinned: the mesh answers with the copy it holds, so moving compiler is a build
|
||||||
|
// rather than an edit to this source (ADR 0044, 0142).
|
||||||
|
if chain.Base != "mesh-tools-go" || chain.Artifact != "build" {
|
||||||
|
t.Fatalf("the go toolchain is based on %s/%s", chain.Base, chain.Artifact)
|
||||||
|
}
|
||||||
|
joined := strings.Join(chain.Compile, " ")
|
||||||
|
// Static, because what a machine holds is a file and not a container: a binary needing a libc
|
||||||
|
// it did not bring is a delivery that works until a machine differs.
|
||||||
|
if !strings.Contains(joined, "CGO_ENABLED=0") {
|
||||||
|
t.Fatalf("the go toolchain does not build statically: %q", joined)
|
||||||
|
}
|
||||||
|
// Reproducible: a version comes from where a component sits, not from its linker (ADR 0142),
|
||||||
|
// so two builds of one commit should produce the same bytes.
|
||||||
|
if !strings.Contains(joined, "-trimpath") {
|
||||||
|
t.Fatalf("the go toolchain leaves build paths in the binary: %q", joined)
|
||||||
|
}
|
||||||
|
if chain.Unit != UnitPackage {
|
||||||
|
t.Fatalf("the go toolchain compiles %q, wanted a package", chain.Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEveryToolchainSaysWhatItIsPointedAt(t *testing.T) {
|
||||||
|
// The field exists because the compile path used to assume one language. A toolchain that says
|
||||||
|
// nothing would fall through to the entrypoint branch and compile a file list, which for a
|
||||||
|
// compiled language builds a program out of exactly those files and ignores the rest of the
|
||||||
|
// package — a missing symbol rather than a legible refusal.
|
||||||
|
for _, chain := range toolchains {
|
||||||
|
switch chain.Unit {
|
||||||
|
case UnitPackage:
|
||||||
|
case UnitSources:
|
||||||
|
if chain.SourceExt == "" {
|
||||||
|
t.Fatalf("%s compiles a file list and names no source extension", chain.Language)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(chain.SourceExt, ".") {
|
||||||
|
t.Fatalf("%s's source extension %q is not an extension", chain.Language, chain.SourceExt)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatalf("%s says it is pointed at %q, which is neither sources nor a package",
|
||||||
|
chain.Language, chain.Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnEntrypointBecomesASourceInItsOwnLanguage(t *testing.T) {
|
||||||
|
// It used to become a `.ts` whatever the language was.
|
||||||
|
out := Out("build")
|
||||||
|
got := sourcesFor([]string{out + "/tools/index.js"}, out, ".ts")
|
||||||
|
if len(got) != 1 || got[0] != "tools/index.ts" {
|
||||||
|
t.Fatalf("a typescript entrypoint became %v", got)
|
||||||
|
}
|
||||||
|
got = sourcesFor([]string{out + "/tools/index.js"}, out, ".py")
|
||||||
|
if len(got) != 1 || got[0] != "tools/index.py" {
|
||||||
|
t.Fatalf("a python entrypoint became %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,6 +180,20 @@ func (r Registry) MirrorImage(ctx context.Context, from, repository string) (str
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
// **Already held is already mirrored.** A base is named by digest, and a digest this registry
|
||||||
|
// holds under the module's repository is the same bytes whatever upstream would say — so
|
||||||
|
// upstream is not asked. Asked every build, the public hub's anonymous pull limit was reached
|
||||||
|
// on the first merge that rebuilt a whole catalogue (2026-09-28), and every module whose base
|
||||||
|
// lives there failed on a copy it did not need.
|
||||||
|
if strings.HasPrefix(where.reference, "sha256:") {
|
||||||
|
held, err := r.has(ctx, "http://"+r.Address+"/v2/"+repository+"/manifests/"+where.reference, manifestAccept)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("asking %s whether it holds %s: %w", r.Address, from, err)
|
||||||
|
}
|
||||||
|
if held {
|
||||||
|
return r.Address + "/" + repository + "@" + where.reference, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
src := &source{client: r.client()}
|
src := &source{client: r.client()}
|
||||||
digest, err := r.copyManifest(ctx, src, where, where.reference, repository)
|
digest, err := r.copyManifest(ctx, src, where, where.reference, repository)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -103,6 +103,20 @@ func (m *theMeshsRegistry) handler() http.Handler {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
switch {
|
switch {
|
||||||
|
case r.Method == http.MethodHead && strings.Contains(r.URL.Path, "/manifests/"):
|
||||||
|
// **As strictly as a real registry.** A manifest is answered only in a media type the
|
||||||
|
// caller named; a request with no Accept is answered as if nothing were there. The fake
|
||||||
|
// used to answer regardless, which is why it could not catch a check that asked without
|
||||||
|
// one — and the mesh copied every base again (2026-09-28).
|
||||||
|
if !strings.Contains(r.Header.Get("Accept"), "manifest") && !strings.Contains(r.Header.Get("Accept"), "index") {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := m.manifests[r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]]; ok {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
case r.Method == http.MethodHead && strings.Contains(r.URL.Path, "/blobs/"):
|
case r.Method == http.MethodHead && strings.Contains(r.URL.Path, "/blobs/"):
|
||||||
if _, ok := m.blobs[r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]]; ok {
|
if _, ok := m.blobs[r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]]; ok {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@@ -219,3 +233,27 @@ func TestATagBeforeTheDigestIsNotPartOfTheRepository(t *testing.T) {
|
|||||||
t.Fatalf("got %+v", got)
|
t.Fatalf("got %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A base this registry already holds by digest is not asked of upstream at all: the public hub
|
||||||
|
// limits anonymous pulls, and a catalogue rebuilt on one merge asked it once per module.
|
||||||
|
func TestABaseAlreadyHeldIsNotAskedOfUpstream(t *testing.T) {
|
||||||
|
src, indexDigest, _ := anUpstreamRegistry(t)
|
||||||
|
dst := &theMeshsRegistry{blobs: map[string][]byte{}, manifests: map[string][]byte{}}
|
||||||
|
dstServer := httptest.NewServer(dst.handler())
|
||||||
|
defer dstServer.Close()
|
||||||
|
address := strings.TrimPrefix(dstServer.URL, "http://")
|
||||||
|
r := Registry{Address: address, HTTP: src.Client()}
|
||||||
|
host := strings.TrimPrefix(src.URL, "http://")
|
||||||
|
if _, err := r.MirrorImage(context.Background(), host+"/library/thing:latest", "hello-web/server"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Upstream gone: the pinned base is answered from what the mesh holds.
|
||||||
|
src.Close()
|
||||||
|
reference, err := r.MirrorImage(context.Background(), host+"/library/thing@"+indexDigest, "hello-web/server")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a base the registry holds was asked of an upstream that is gone: %v", err)
|
||||||
|
}
|
||||||
|
if reference != address+"/hello-web/server@"+indexDigest {
|
||||||
|
t.Fatalf("pinned as %q", reference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -122,11 +122,23 @@ func (r Registry) PublishArchive(ctx context.Context, repository string, body []
|
|||||||
return final, nil
|
return final, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Registry) has(ctx context.Context, url string) (bool, error) {
|
// has is whether this registry already holds what is at that URL.
|
||||||
|
//
|
||||||
|
// **A manifest HEAD must say what it accepts.** A registry answers a manifest request only in a media
|
||||||
|
// type the caller named, and a bare HEAD — no Accept at all — is answered 404 for a manifest it holds
|
||||||
|
// perfectly well. Measured against the mesh's own registry (2026-09-28): the same digest answered 200
|
||||||
|
// with the manifest media types and 404 without them, so a check written without them concluded the
|
||||||
|
// registry held nothing, copied every base again, and exhausted the public hub's pull limit. A blob
|
||||||
|
// needs no Accept, which is why this went unnoticed: the same helper was right for blobs and wrong
|
||||||
|
// for manifests.
|
||||||
|
func (r Registry) has(ctx context.Context, url string, accept ...string) (bool, error) {
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
|
request, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
for _, media := range accept {
|
||||||
|
request.Header.Add("Accept", media)
|
||||||
|
}
|
||||||
response, err := r.client().Do(request)
|
response, err := r.client().Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("cannot reach the registry at %s: %w", r.Address, err)
|
return false, fmt.Errorf("cannot reach the registry at %s: %w", r.Address, err)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func TestABaseTheMeshHasNotBuiltIsRefused(t *testing.T) {
|
|||||||
On: []catalogue.BuildsOn{{Arg: "RUNTIME_BASE", Module: "mesh-tools", Artifact: "runtime"}},
|
On: []catalogue.BuildsOn{{Arg: "RUNTIME_BASE", Module: "mesh-tools", Artifact: "runtime"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
_, err := standingOn(context.Background(), manifest, map[string]string{}, noMirror)
|
_, _, err := standingOn(context.Background(), manifest, map[string]string{}, noMirror)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("a base nothing has built was accepted; the build would have failed on its first line")
|
t.Fatal("a base nothing has built was accepted; the build would have failed on its first line")
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ func TestABaseTheMeshHoldsBecomesABuildArgument(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
held := map[string]string{"mesh-tools/runtime": "127.0.0.1:5000/mesh-tools/runtime@sha256:" + strings.Repeat("a", 64)}
|
held := map[string]string{"mesh-tools/runtime": "127.0.0.1:5000/mesh-tools/runtime@sha256:" + strings.Repeat("a", 64)}
|
||||||
args, err := standingOn(context.Background(), manifest, held, noMirror)
|
args, _, err := standingOn(context.Background(), manifest, held, noMirror)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("a base this mesh holds was refused: %v", err)
|
t.Fatalf("a base this mesh holds was refused: %v", err)
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ func TestABaseTheMeshHoldsBecomesABuildArgument(t *testing.T) {
|
|||||||
|
|
||||||
// A module naming no base asks for nothing, which is most modules.
|
// A module naming no base asks for nothing, which is most modules.
|
||||||
func TestAModuleNamingNoBaseAddsNoArguments(t *testing.T) {
|
func TestAModuleNamingNoBaseAddsNoArguments(t *testing.T) {
|
||||||
args, err := standingOn(context.Background(), catalogue.Manifest{Module: "hello-web", Build: &catalogue.Build{}}, nil, noMirror)
|
args, _, err := standingOn(context.Background(), catalogue.Manifest{Module: "hello-web", Build: &catalogue.Build{}}, nil, noMirror)
|
||||||
if err != nil || args != nil {
|
if err != nil || args != nil {
|
||||||
t.Fatalf("a module naming no base produced %v, %v", args, err)
|
t.Fatalf("a module naming no base produced %v, %v", args, err)
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ func TestAnIncompleteBaseIsRefused(t *testing.T) {
|
|||||||
Module: "postgres",
|
Module: "postgres",
|
||||||
Build: &catalogue.Build{On: []catalogue.BuildsOn{{Module: "mesh-tools", Artifact: "runtime"}}},
|
Build: &catalogue.Build{On: []catalogue.BuildsOn{{Module: "mesh-tools", Artifact: "runtime"}}},
|
||||||
}
|
}
|
||||||
if _, err := standingOn(context.Background(), manifest, map[string]string{"mesh-tools/runtime": "x"}, noMirror); err == nil {
|
if _, _, err := standingOn(context.Background(), manifest, map[string]string{"mesh-tools/runtime": "x"}, noMirror); err == nil {
|
||||||
t.Fatal("a base with no build argument was accepted; nothing would have read it")
|
t.Fatal("a base with no build argument was accepted; nothing would have read it")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@ func TestADeclaredVendorImageIsCopiedInAndHandedToTheRecipe(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
var asked []string
|
var asked []string
|
||||||
args, err := standingOn(context.Background(), manifest, nil, func(_ context.Context, from, repository string) (string, error) {
|
args, _, err := standingOn(context.Background(), manifest, nil, func(_ context.Context, from, repository string) (string, error) {
|
||||||
asked = append(asked, from+" -> "+repository)
|
asked = append(asked, from+" -> "+repository)
|
||||||
return "127.0.0.1:5000/" + repository + "@sha256:" + strings.Repeat("d", 64), nil
|
return "127.0.0.1:5000/" + repository + "@sha256:" + strings.Repeat("d", 64), nil
|
||||||
})
|
})
|
||||||
@@ -101,7 +101,7 @@ func TestADeclaredVendorImageIsCopiedInAndHandedToTheRecipe(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Unpinned, it is refused: a tag is what somebody else can move.
|
// Unpinned, it is refused: a tag is what somebody else can move.
|
||||||
manifest.Build.On[0].Image = "quay.io/minio/mc:latest"
|
manifest.Build.On[0].Image = "quay.io/minio/mc:latest"
|
||||||
if _, err := standingOn(context.Background(), manifest, nil, noMirror); err == nil || !strings.Contains(err.Error(), "not pinned") {
|
if _, _, err := standingOn(context.Background(), manifest, nil, noMirror); err == nil || !strings.Contains(err.Error(), "not pinned") {
|
||||||
t.Fatalf("an unpinned vendor image was accepted: %v", err)
|
t.Fatalf("an unpinned vendor image was accepted: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,3 +151,52 @@ func TestARecipeIsReadAsInstructions(t *testing.T) {
|
|||||||
t.Fatalf("a heredoc line or a continued stage was read as a base: %v", bases)
|
t.Fatalf("a heredoc line or a continued stage was read as a base: %v", bases)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What a build was handed as its bases is what it stood on — recorded, so a changed base knows what
|
||||||
|
// to rebuild (novox/hq 04-ISSUES/131). A recipe reads the base from an argument, so nothing else
|
||||||
|
// could know.
|
||||||
|
func TestTheBasesABuildWasHandedAreWhatItStoodOn(t *testing.T) {
|
||||||
|
manifest := catalogue.Manifest{
|
||||||
|
Module: "gitea",
|
||||||
|
Build: &catalogue.Build{
|
||||||
|
On: []catalogue.BuildsOn{
|
||||||
|
{Arg: "RUNTIME_BASE", Module: "mesh-tools", Artifact: "runtime"},
|
||||||
|
{Arg: "BUILD_BASE", Module: "mesh-tools", Artifact: "build"},
|
||||||
|
},
|
||||||
|
Artifacts: []catalogue.Artifact{{Name: "runtime", Kind: catalogue.ArtifactImage, From: "Dockerfile"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
held := map[string]string{
|
||||||
|
"mesh-tools/runtime": "127.0.0.1:5000/mesh-tools/runtime@sha256:" + strings.Repeat("a", 64),
|
||||||
|
"mesh-tools/build": "127.0.0.1:5000/mesh-tools/build@sha256:" + strings.Repeat("b", 64),
|
||||||
|
}
|
||||||
|
_, resolved, err := standingOn(context.Background(), manifest, held, noMirror)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := against(t.TempDir(), manifest, resolved)
|
||||||
|
if len(got) != 2 || got[0] != held["mesh-tools/build"] || got[1] != held["mesh-tools/runtime"] {
|
||||||
|
t.Fatalf("the bases the build was handed were not what it stood on: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a build read besides its module's own repository is the second repository its recipes name,
|
||||||
|
// each once: a module that packages source living elsewhere is affected when that source moves.
|
||||||
|
func TestWhatABuildReadIsTheRepositoriesItsRecipesName(t *testing.T) {
|
||||||
|
elsewhere := catalogue.ArtifactContext{Repository: "http://forge.internal:20000/novox/mesh-controller.git", Ref: "main"}
|
||||||
|
manifest := catalogue.Manifest{
|
||||||
|
Module: "builder",
|
||||||
|
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
|
||||||
|
{Name: "server", Kind: catalogue.ArtifactImage, From: "Dockerfile", Context: &elsewhere},
|
||||||
|
{Name: "tools", Kind: catalogue.ArtifactImage, From: "Dockerfile", Context: &elsewhere},
|
||||||
|
{Name: "config", Kind: catalogue.ArtifactArchive, From: "etc"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
read := readBy(manifest)
|
||||||
|
if len(read) != 1 || read[0] != elsewhere {
|
||||||
|
t.Fatalf("the repositories this build read are %+v", read)
|
||||||
|
}
|
||||||
|
if readBy(catalogue.Manifest{Module: "gitea", Build: &catalogue.Build{}}) != nil {
|
||||||
|
t.Fatal("a module whose recipes name no other repository read one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A host built without knowing its system refuses every declaration before applying anything —
|
||||||
|
// safely, totally, and with nothing reporting it. The mesh built one, delivered it, started it, and
|
||||||
|
// it would have refused the first thing it was asked to do (novox/hq 04-ISSUES/161).
|
||||||
|
|
||||||
|
func TestTheGoToolchainStampsTheArtifactsSystem(t *testing.T) {
|
||||||
|
chain, err := ToolchainFor("go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if chain.SystemStamp != "main.builtFor" {
|
||||||
|
t.Fatalf("the go toolchain fills %q", chain.SystemStamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestALanguageWithNoPinnedSystemStampsNothing(t *testing.T) {
|
||||||
|
// Interpreted output is not pinned to a system, and a manifest declaring one for it is already
|
||||||
|
// refused. Nothing to fill.
|
||||||
|
for _, language := range []string{"typescript", "python"} {
|
||||||
|
chain, err := ToolchainFor(language)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if chain.SystemStamp != "" {
|
||||||
|
t.Fatalf("%s fills %q, and its output is not pinned to a system",
|
||||||
|
language, chain.SystemStamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheStampIsTheOneThingTakenFromTheArtifact(t *testing.T) {
|
||||||
|
// The toolchain accepts nothing else from the module — anything it could override it would be
|
||||||
|
// writing a Dockerfile to override. The system is the stated exception, because a compiled
|
||||||
|
// binary is per system and the artifact is what declares one (ADR 0142).
|
||||||
|
chain, err := ToolchainFor("go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
joined := strings.Join(chain.Compile, " ")
|
||||||
|
if strings.Contains(joined, "${") || strings.Contains(joined, "%s") {
|
||||||
|
t.Fatalf("the compile line takes something from the module: %q", joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheLinkerIsToldOnceNotTwice(t *testing.T) {
|
||||||
|
// A repeated flag is not a merged one: the Go command takes the last -ldflags and drops the
|
||||||
|
// first. Passing the toolchain's flags and then the stamp separately produced a binary that knew
|
||||||
|
// its system and had lost -s -w — 12.2MB against 8.5MB, with its debug info (04-ISSUES/161).
|
||||||
|
chain, err := ToolchainFor("go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, arg := range chain.Compile {
|
||||||
|
if arg == "-ldflags" {
|
||||||
|
t.Fatal("the compile line carries -ldflags, so composing one here makes two")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(chain.LinkerFlags) == 0 {
|
||||||
|
t.Fatal("the go toolchain passes no linker flags, so the binary keeps its debug info")
|
||||||
|
}
|
||||||
|
var stripped bool
|
||||||
|
for _, f := range chain.LinkerFlags {
|
||||||
|
if f == "-s" {
|
||||||
|
stripped = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !stripped {
|
||||||
|
t.Fatalf("the go toolchain does not strip: %v", chain.LinkerFlags)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,8 +35,50 @@ type Toolchain struct {
|
|||||||
Compile []string
|
Compile []string
|
||||||
// OutputFlag is how this compiler is told where to put its output.
|
// OutputFlag is how this compiler is told where to put its output.
|
||||||
OutputFlag string
|
OutputFlag string
|
||||||
|
// Unit is what this compiler is pointed at: UnitSources, the entrypoint files the module named,
|
||||||
|
// or UnitPackage, the one directory the artifact is built `from`.
|
||||||
|
//
|
||||||
|
// **The difference is the language and not the module.** A TypeScript bundle is a set of files
|
||||||
|
// compiled into a set of files, so what to compile is the module's entrypoints with their source
|
||||||
|
// extension. A Go bundle is a package compiled into one binary, and there is no per-file
|
||||||
|
// compilation to name — pointing `go build` at a file list builds a program out of exactly those
|
||||||
|
// files and ignores the rest of the package, which fails as a missing symbol rather than as a
|
||||||
|
// wrong instruction.
|
||||||
|
Unit string
|
||||||
|
// SourceExt is the extension an entrypoint has in the repository, for UnitSources. An entrypoint
|
||||||
|
// is named as it will be FOUND, inside the unpacked bundle, so the source is the same path with
|
||||||
|
// the output directory taken off the front and this on the end.
|
||||||
|
SourceExt string
|
||||||
|
// LinkerFlags are passed to the linker as one flag, together with the system stamp below.
|
||||||
|
//
|
||||||
|
// **Separate from Compile because a repeated flag is not a merged one.** They were in the compile
|
||||||
|
// line, and appending the stamp as a second `-ldflags` meant the Go command took the last and
|
||||||
|
// dropped the first — so the binary gained its system and lost `-s -w`, growing by half and
|
||||||
|
// carrying its debug info. The mistake was believing a comment rather than reading the file it
|
||||||
|
// produced (novox/hq 04-ISSUES/161).
|
||||||
|
LinkerFlags []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).
|
||||||
|
//
|
||||||
|
// **The one thing a toolchain takes from the artifact, and 0142 says why**: the target is a
|
||||||
|
// property of the artifact rather than of the recipe, because a compiled binary is per system
|
||||||
|
// and a toolchain that accepted it from the module would be accepting a build instruction. This
|
||||||
|
// is the narrow exception, named here rather than inferred.
|
||||||
|
//
|
||||||
|
// Empty for a language that compiles to nothing pinned. A host built without it refuses every
|
||||||
|
// declaration before applying anything — safely, totally, and with nothing reporting it
|
||||||
|
// (novox/hq 04-ISSUES/161).
|
||||||
|
SystemStamp string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What a toolchain is pointed at.
|
||||||
|
const (
|
||||||
|
// UnitSources is a list of files, derived from the module's entrypoints.
|
||||||
|
UnitSources = "sources"
|
||||||
|
// UnitPackage is the single directory the artifact is built `from`, compiled whole.
|
||||||
|
UnitPackage = "package"
|
||||||
|
)
|
||||||
|
|
||||||
// Out is where one artifact's compiled output lands, inside the module's own directory.
|
// Out is where one artifact's compiled output lands, inside the module's own directory.
|
||||||
//
|
//
|
||||||
// **Per artifact, never per toolchain.** A module is one piece of software and may still be
|
// **Per artifact, never per toolchain.** A module is one piece of software and may still be
|
||||||
@@ -71,6 +113,41 @@ var toolchains = []Toolchain{
|
|||||||
"--target", "ES2022",
|
"--target", "ES2022",
|
||||||
},
|
},
|
||||||
OutputFlag: "--outDir",
|
OutputFlag: "--outDir",
|
||||||
|
Unit: UnitSources,
|
||||||
|
SourceExt: ".ts",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Language: "go",
|
||||||
|
Base: "mesh-tools-go",
|
||||||
|
Artifact: "build",
|
||||||
|
// **The mesh's own components, and not modules.** The warning above this list — that every
|
||||||
|
// language is another implementation of the contracts modules share, so adding one commits
|
||||||
|
// to keeping N implementations in step — does not attach here. Go is how the host, the
|
||||||
|
// control plane and the builder are written, and none of them is a module in that sense:
|
||||||
|
// the host is what APPLIES modules. So there is no SDK obligation, and the reason this
|
||||||
|
// entry did not exist was that nothing needed to compile the mesh itself
|
||||||
|
// (novox/hq ADR 0142, and 04-ISSUES/142 where that is why nothing delivers the host).
|
||||||
|
//
|
||||||
|
// Static, because what a machine ends up holding is a file rather than a container, and a
|
||||||
|
// binary that needs a libc it did not bring is a delivery that works until a machine
|
||||||
|
// differs. Trimmed of its own paths for the same reason a version comes from where it sits
|
||||||
|
// rather than from the linker: two builds of one commit produce the same bytes.
|
||||||
|
Compile: []string{
|
||||||
|
"env", "CGO_ENABLED=0", "GOFLAGS=-trimpath",
|
||||||
|
"go", "build",
|
||||||
|
},
|
||||||
|
// Stripped of symbols and debug info: what a machine holds is a file it runs, not one it
|
||||||
|
// debugs, and the difference measured 12.2MB against 8.5MB.
|
||||||
|
LinkerFlags: []string{"-s", "-w"},
|
||||||
|
OutputFlag: "-o",
|
||||||
|
// Pointed at the package the artifact is built `from`, compiled whole. Go writes the binary
|
||||||
|
// into the output directory, named after the package — so the bundle a machine unpacks is a
|
||||||
|
// directory holding one executable, which is what the delivery mechanism expects
|
||||||
|
// (novox/hq ADR 0141).
|
||||||
|
Unit: UnitPackage,
|
||||||
|
// The mesh's own Go components read the system they were built for from this variable, and
|
||||||
|
// refuse to touch a machine without one.
|
||||||
|
SystemStamp: "main.builtFor",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Language: "python",
|
Language: "python",
|
||||||
@@ -82,6 +159,8 @@ var toolchains = []Toolchain{
|
|||||||
// each actually does.
|
// each actually does.
|
||||||
Compile: []string{"python", "-m", "pip", "install", "--no-compile", "--target"},
|
Compile: []string{"python", "-m", "pip", "install", "--no-compile", "--target"},
|
||||||
OutputFlag: "",
|
OutputFlag: "",
|
||||||
|
Unit: UnitSources,
|
||||||
|
SourceExt: ".py",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,11 @@ func anchorRendering(adopted bool) Rendering {
|
|||||||
Values: map[string]any{ExposeSetting: map[string]any{"5000": FromEverywhere}}}}},
|
Values: map[string]any{ExposeSetting: map[string]any{"5000": FromEverywhere}}}}},
|
||||||
Mesh: []string{"10.42.0.1"},
|
Mesh: []string{"10.42.0.1"},
|
||||||
Foundation: []int{5671},
|
Foundation: []int{5671},
|
||||||
Adopted: adopted,
|
// What the machine reported faces outside, which every rule in the filter is written
|
||||||
|
// around (novox/hq ADR 0140).
|
||||||
|
OutwardLinks: []string{"eth0"},
|
||||||
|
TunnelInterface: "mesh0",
|
||||||
|
Adopted: adopted,
|
||||||
// Genesis takes the foundation's modules.
|
// Genesis takes the foundation's modules.
|
||||||
Taken: map[string]bool{"postgres": true, "lavinmq": true},
|
Taken: map[string]bool{"postgres": true, "lavinmq": true},
|
||||||
}
|
}
|
||||||
@@ -575,11 +579,13 @@ func TestAGivenMachineSideReachesTheFilterTheOpeningAndTheConsumer(t *testing.T)
|
|||||||
}
|
}
|
||||||
r := Resolution{Node: "anchor", Modules: []Manifest{forge}}
|
r := Resolution{Node: "anchor", Modules: []Manifest{forge}}
|
||||||
with := Rendering{
|
with := Rendering{
|
||||||
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, given)},
|
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, given)},
|
||||||
Given: map[string]map[int]int{"forge": given},
|
Given: map[string]map[int]int{"forge": given},
|
||||||
Mesh: []string{"10.77.0.1"},
|
Mesh: []string{"10.77.0.1"},
|
||||||
Adopted: true,
|
Adopted: true,
|
||||||
Taken: map[string]bool{"forge": true},
|
OutwardLinks: []string{"eth0"},
|
||||||
|
TunnelInterface: "mesh0",
|
||||||
|
Taken: map[string]bool{"forge": true},
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the runtime is handed: the machine's own port on the outside, the container's within.
|
// What the runtime is handed: the machine's own port on the outside, the container's within.
|
||||||
@@ -660,9 +666,11 @@ func TestALongFormPortIsOpenedWhereTheManifestPublishesIt(t *testing.T) {
|
|||||||
forge := aForge()
|
forge := aForge()
|
||||||
r := Resolution{Node: "anchor", Modules: []Manifest{forge}}
|
r := Resolution{Node: "anchor", Modules: []Manifest{forge}}
|
||||||
composed, err := r.Compose(Rendering{
|
composed, err := r.Compose(Rendering{
|
||||||
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, nil)},
|
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, nil)},
|
||||||
Mesh: []string{"10.77.0.1"},
|
Mesh: []string{"10.77.0.1"},
|
||||||
Adopted: true,
|
Adopted: true,
|
||||||
|
OutwardLinks: []string{"eth0"},
|
||||||
|
TunnelInterface: "mesh0",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **The word does not come back through a manifest** (novox/hq ADR 0131). A module that wants
|
||||||
|
// messaging wants the mesh's bus, reached through the sdk and named by the `mesh-broker` seat. Naming
|
||||||
|
// the old wire protocol asks for the one server being retired, so both directions are refused at the
|
||||||
|
// parser — this is judged from the manifest alone, no store needed.
|
||||||
|
|
||||||
|
func TestAManifestProvidingAmqpIsRefused(t *testing.T) {
|
||||||
|
raw := []byte(`{"module":"old-broker","version":"1","provides":[{"name":"amqp","scope":"mesh"}]}`)
|
||||||
|
_, err := ParseManifest(raw)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `provides "amqp", which is not a provision`) {
|
||||||
|
t.Fatalf("a module providing amqp was not refused, or not for the reason: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAManifestRequiringAmqpIsRefused(t *testing.T) {
|
||||||
|
raw := []byte(`{"module":"forwarder","version":"1","requires":["amqp"]}`)
|
||||||
|
_, err := ParseManifest(raw)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `requires "amqp", which is not a provision`) {
|
||||||
|
t.Fatalf("a module requiring amqp was not refused, or not for the reason: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the catalogue as checked out beside this repository names it nowhere — the three modules that
|
||||||
|
// did are removed under design 28 task 5.4, not converted.
|
||||||
|
func TestNoCatalogueManifestNamesAmqp(t *testing.T) {
|
||||||
|
modules, err := filepath.Glob("../../../mesh-catalog/modules/*/module.json")
|
||||||
|
if err != nil || len(modules) == 0 {
|
||||||
|
t.Skip("the catalogue is not checked out beside this repository")
|
||||||
|
}
|
||||||
|
for _, path := range modules {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(raw), `"amqp"`) {
|
||||||
|
t.Errorf("%s names amqp, which is not a provision (novox/hq ADR 0131)",
|
||||||
|
filepath.Base(filepath.Dir(path)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A claim written before the rename still holds (novox/hq ADR 0122, ADR 0156): with the store's
|
||||||
|
// aliases loaded, the former name resolves to the seat.
|
||||||
|
func TestTheArtifactStoresFormerNameResolvesToIt(t *testing.T) {
|
||||||
|
was := aliases
|
||||||
|
t.Cleanup(func() { aliases = was })
|
||||||
|
UseAliases(map[string]string{"the-artifact-store": "mesh-artifact-store"})
|
||||||
|
seat, known := SeatNamed("the-artifact-store")
|
||||||
|
if !known || seat.Name != "mesh-artifact-store" || seat.Delivers != "artifact-store" {
|
||||||
|
t.Fatalf("the former name did not resolve: %+v %v", seat, known)
|
||||||
|
}
|
||||||
|
if _, known := SeatNamed("mesh-artifact-store"); !known {
|
||||||
|
t.Fatal("the seat is not in the set under its name")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A second one, on any other machine, is refused — and the refusal names the seat.
|
// A second one, on any other machine, is refused — and the refusal names the seat.
|
||||||
elsewhere := World{Held: []Held{{Claim: "the-artifact-store", Scope: ScopeMesh,
|
elsewhere := World{Held: []Held{{Claim: "mesh-artifact-store", Scope: ScopeMesh,
|
||||||
Node: "anchor", Module: "distribution"}}}
|
Node: "anchor", Module: "distribution"}}}
|
||||||
other := workstation()
|
other := workstation()
|
||||||
other.Name = "laptop"
|
other.Name = "laptop"
|
||||||
@@ -32,7 +32,7 @@ func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) {
|
|||||||
t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " +
|
t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " +
|
||||||
"second time and every consumer elsewhere would refuse to choose")
|
"second time and every consumer elsewhere would refuse to choose")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "the-artifact-store") || !strings.Contains(err.Error(), "one per mesh") {
|
if !strings.Contains(err.Error(), "mesh-artifact-store") || !strings.Contains(err.Error(), "one per mesh") {
|
||||||
t.Fatalf("refused without naming the seat: %v", err)
|
t.Fatalf("refused without naming the seat: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,27 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withOwnNames adds a module's own composed names to what it may name from one binding:
|
||||||
|
// `${bound:<provision>:name}` and `:internal-name`, and for several contributions to one requirement
|
||||||
|
// `:name-<local>` / `:internal-name-<local>`. Set over anything the provider serves under those keys:
|
||||||
|
// what the module is called is the mesh's statement, not the provider's.
|
||||||
|
func withOwnNames(values map[string]string, own map[string]any) {
|
||||||
|
for _, key := range []string{"name", "internal-name"} {
|
||||||
|
if v, ok := own[key].(string); ok {
|
||||||
|
values[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
many, _ := own["names"].(map[string]any)
|
||||||
|
for local, raw := range many {
|
||||||
|
names, _ := raw.(map[string]any)
|
||||||
|
for _, key := range []string{"name", "internal-name"} {
|
||||||
|
if v, ok := names[key].(string); ok {
|
||||||
|
values[key+"-"+local] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// plainly renders a served value as a program would expect to read it.
|
// plainly renders a served value as a program would expect to read it.
|
||||||
func plainly(value any) string {
|
func plainly(value any) string {
|
||||||
switch v := value.(type) {
|
switch v := value.(type) {
|
||||||
|
|||||||
@@ -46,23 +46,9 @@ func TestTheSeatRefusesADifferentBusToo(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The old broker no longer claims the seat: it is an ordinary provider of `amqp`
|
// The old broker is gone from the catalogue (novox/hq ADR 0131, design 28 task 5.4), so it is no
|
||||||
// (novox/hq ADR 0119), so it can sit on the same mesh as the bus without contending for it.
|
// longer a fixture here. That two eligible holders stand beside each other with one on record is
|
||||||
func TestTheAmqpBrokerDoesNotContendForTheSeat(t *testing.T) {
|
// pinned in holdings_test.go against manifests this package owns.
|
||||||
lavinmq := catalogueManifest(t, "lavinmq")
|
|
||||||
for _, c := range lavinmq.Claims {
|
|
||||||
if c.Name == "mesh-broker" {
|
|
||||||
t.Fatal("the amqp broker still claims mesh-broker; it is a provider, not foundation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
busHeld := World{Held: []Held{{Claim: "mesh-broker", Scope: ScopeMesh,
|
|
||||||
Node: "anchor", Module: "nats"}}}
|
|
||||||
other := workstation()
|
|
||||||
other.Name = "laptop"
|
|
||||||
if _, err := Resolve(shelf(lavinmq), []string{"lavinmq"}, other, busHeld); err != nil {
|
|
||||||
t.Fatalf("the amqp broker was refused beside the mesh bus: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **A seat and the interface it delivers are different names, and renaming one must not rename
|
// **A seat and the interface it delivers are different names, and renaming one must not rename
|
||||||
// the other** (novox/hq ADR 0118). This nearly went wrong: the seats were renamed to the `mesh-*`
|
// the other** (novox/hq ADR 0118). This nearly went wrong: the seats were renamed to the `mesh-*`
|
||||||
@@ -73,7 +59,7 @@ func TestRenamingASeatDidNotRenameTheInterfaceItDelivers(t *testing.T) {
|
|||||||
for _, pair := range []struct{ seat, delivers string }{
|
for _, pair := range []struct{ seat, delivers string }{
|
||||||
{"git", "git"},
|
{"git", "git"},
|
||||||
{"npm-package-registry", "npm-package-registry"},
|
{"npm-package-registry", "npm-package-registry"},
|
||||||
{"the-artifact-store", "artifact-store"},
|
{"mesh-artifact-store", "artifact-store"},
|
||||||
{"mesh-store", "postgres-database"},
|
{"mesh-store", "postgres-database"},
|
||||||
{"mesh-broker", "mesh-bus"},
|
{"mesh-broker", "mesh-bus"},
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func reachable() Node {
|
|||||||
func onNetwork(nodes ...string) map[string][]Provider {
|
func onNetwork(nodes ...string) map[string][]Provider {
|
||||||
out := make([]Provider, 0, len(nodes))
|
out := make([]Provider, 0, len(nodes))
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
out = append(out, Provider{Node: n, At: n + ".internal"})
|
out = append(out, Provider{Node: n, At: n + ".internal", Module: "postgres"})
|
||||||
}
|
}
|
||||||
return map[string][]Provider{"postgres-database": out}
|
return map[string][]Provider{"postgres-database": out}
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ func TestSayingWhichOneSettlesIt(t *testing.T) {
|
|||||||
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
||||||
World{
|
World{
|
||||||
Offered: onNetwork("anchor", "archive"),
|
Offered: onNetwork("anchor", "archive"),
|
||||||
Pinned: map[string]string{"postgres-database": "archive"},
|
Pinned: map[string]Chosen{"postgres-database": {Node: "archive", Module: "postgres"}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -115,7 +115,7 @@ func TestBeingPointedAtAMachineThatDoesNotProvideItIsRefused(t *testing.T) {
|
|||||||
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
||||||
World{
|
World{
|
||||||
Offered: onNetwork("anchor", "archive"),
|
Offered: onNetwork("anchor", "archive"),
|
||||||
Pinned: map[string]string{"postgres-database": "somewhere-else"},
|
Pinned: map[string]Chosen{"postgres-database": {Node: "somewhere-else", Module: "postgres"}},
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("a machine was silently given a different database from the one chosen")
|
t.Fatal("a machine was silently given a different database from the one chosen")
|
||||||
@@ -131,12 +131,12 @@ func TestOneProviderDoesNotOverruleAChoice(t *testing.T) {
|
|||||||
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
||||||
World{
|
World{
|
||||||
Offered: onNetwork("anchor"),
|
Offered: onNetwork("anchor"),
|
||||||
Pinned: map[string]string{"postgres-database": "archive"},
|
Pinned: map[string]Chosen{"postgres-database": {Node: "archive", Module: "postgres"}},
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("the only database was used although another was chosen")
|
t.Fatal("the only database was used although another was chosen")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "only anchor provides it") {
|
if !strings.Contains(err.Error(), "only anchor/postgres provides it") {
|
||||||
t.Fatalf("the refusal does not say what is available: %v", err)
|
t.Fatalf("the refusal does not say what is available: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-1
@@ -94,12 +94,43 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
|||||||
m.Module, r["id"], named)
|
m.Module, r["id"], named)
|
||||||
case ArtifactImage, ArtifactUpstream:
|
case ArtifactImage, ArtifactUpstream:
|
||||||
filled["image"] = artifact.Reference
|
filled["image"] = artifact.Reference
|
||||||
|
// An image is not unpacked anywhere, so it has no directory to be named for its
|
||||||
|
// version and `${version}` has nothing to mean. Refused rather than left as literal
|
||||||
|
// text in a path, which is how it would reach a machine and be created as a directory
|
||||||
|
// called `${version}`.
|
||||||
|
for key, value := range filled {
|
||||||
|
if text, isText := value.(string); isText && strings.Contains(text, versionRef) {
|
||||||
|
return Manifest{}, fmt.Errorf(
|
||||||
|
"%s: %v says %s in %q, and %q is an image — an image is not unpacked, so "+
|
||||||
|
"it has no versioned place. %s is for an archive or a bundle",
|
||||||
|
m.Module, r["id"], versionRef, key, named, versionRef)
|
||||||
|
}
|
||||||
|
}
|
||||||
case ArtifactArchive, ArtifactBundle:
|
case ArtifactArchive, ArtifactBundle:
|
||||||
// The same on the wire: both are bytes fetched by digest and unpacked. They differ in
|
// The same on the wire: both are bytes fetched by digest and unpacked. They differ in
|
||||||
// how they were made — one packed as it stood, the other compiled first — and a
|
// how they were made — one packed as it stood, the other compiled first — and a
|
||||||
// machine has no reason to care which.
|
// machine has no reason to care which.
|
||||||
filled["source"] = artifact.Reference
|
filled["source"] = artifact.Reference
|
||||||
filled["digest"] = artifact.Digest
|
filled["digest"] = artifact.Digest
|
||||||
|
// **And `${version}`, so a resource can name a place that is this build's alone**
|
||||||
|
// (novox/hq ADR 0141, 04-ISSUES/142). A component is unpacked into a directory named
|
||||||
|
// for its version so it can read its own version from its path — and until this,
|
||||||
|
// nothing could compose that path: an archive named a fixed one in the manifest and
|
||||||
|
// nothing interpolated the build into it, so nothing could ask for
|
||||||
|
// `…/versions/<version>/` and every machine took a hand-placed fallback.
|
||||||
|
//
|
||||||
|
// The version is the artifact's own digest, short. Not the commit: two builds of one
|
||||||
|
// commit are meant to be the same bytes (the toolchains are `-trimpath` for this), and
|
||||||
|
// a content-addressed version means an unchanged build resolves to the path it already
|
||||||
|
// had — so re-composing a declaration moves nothing, where a commit would move the
|
||||||
|
// path of an identical binary and recreate everything that reads it.
|
||||||
|
for key, value := range filled {
|
||||||
|
text, isText := value.(string)
|
||||||
|
if !isText || !strings.Contains(text, versionRef) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filled[key] = strings.ReplaceAll(text, versionRef, versionOf(artifact.Digest))
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return Manifest{}, fmt.Errorf("%s: %q is a %q, and an artifact is %q, %q, %q or %q",
|
return Manifest{}, fmt.Errorf("%s: %q is a %q, and an artifact is %q, %q, %q or %q",
|
||||||
m.Module, named, artifact.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream,
|
m.Module, named, artifact.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream,
|
||||||
@@ -142,7 +173,14 @@ func (b *Build) problems(module string) []string {
|
|||||||
// is which compiler — because the mesh chooses that, and cannot choose for a module that
|
// is which compiler — because the mesh chooses that, and cannot choose for a module that
|
||||||
// has not said.
|
// has not said.
|
||||||
if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
|
if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
|
||||||
if a.From != "" {
|
// **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
|
||||||
|
// directory compiled whole, and naming a source would be describing its own build. A
|
||||||
|
// repository written in a compiled language holds several commands — the host and its
|
||||||
|
// bootstrap live in one, and the mesh needs the host — and "the module's own directory"
|
||||||
|
// is then not a package at all. So the compiled case may say which package, and says
|
||||||
|
// the module root by saying nothing.
|
||||||
|
if a.From != "" && !compilesToABinary(a.Language) {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s: %q is a bundle and names what it is built from (%q). A bundle is built "+
|
"%s: %q is a bundle and names what it is built from (%q). A bundle is built "+
|
||||||
"from the module's own directory; what it says is the language",
|
"from the module's own directory; what it says is the language",
|
||||||
@@ -153,6 +191,26 @@ func (b *Build) problems(module string) []string {
|
|||||||
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
|
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
|
||||||
"for it", module, a.Name))
|
"for it", module, a.Name))
|
||||||
}
|
}
|
||||||
|
// **A system, for a language that compiles to a binary** (novox/hq ADR 0142). A binary
|
||||||
|
// is pinned to one operating system at link time so a host refuses to touch a machine
|
||||||
|
// it was not built for (novox/hq ADR 0005); an artifact that says nothing would be
|
||||||
|
// compiled for whatever the build machine happened to be, which reads as portable and
|
||||||
|
// is not.
|
||||||
|
if compiled := compilesToABinary(a.Language); compiled && strings.TrimSpace(a.System) == "" {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s: %q is compiled to a binary and says no system, so it would be built for "+
|
||||||
|
"whatever the build machine happens to be. Declare one artifact per "+
|
||||||
|
"system: %s", module, a.Name, spokenSystems()))
|
||||||
|
} else if !compiled && strings.TrimSpace(a.System) != "" {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s: %q names the system %q and is written in %q, which compiles to code that "+
|
||||||
|
"runs anywhere — a system that decides nothing reads as though it did",
|
||||||
|
module, a.Name, a.System, a.Language))
|
||||||
|
} else if compiled && !knownSystem(a.System) {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s: %q is built for %q, and a system is %s",
|
||||||
|
module, a.Name, a.System, spokenSystems()))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if a.From == "" {
|
if a.From == "" {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
@@ -193,3 +251,67 @@ func oneOrOther(n int) string {
|
|||||||
}
|
}
|
||||||
return "them"
|
return "them"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Systems the mesh builds binaries for, which is the set a host may be pinned to (novox/hq ADR 0005).
|
||||||
|
//
|
||||||
|
// **A closed list, and the host's own, not the compiler's.** These are not the values a Go toolchain
|
||||||
|
// would call an operating system — the difference between two of them is a C library, not a kernel.
|
||||||
|
// They are what a machine reports itself to be and what a host is linked to refuse, so the list that
|
||||||
|
// matters is the one the host understands.
|
||||||
|
var systems = []string{"alpine", "android", "arch"}
|
||||||
|
|
||||||
|
// knownSystem is whether the mesh builds for it.
|
||||||
|
func knownSystem(system string) bool {
|
||||||
|
want := strings.ToLower(strings.TrimSpace(system))
|
||||||
|
for _, s := range systems {
|
||||||
|
if s == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// spokenSystems is the list as a refusal says it, so a reader is one edit from right.
|
||||||
|
func spokenSystems() string {
|
||||||
|
return strings.Join(systems, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// compilesToABinary is whether this language's bundle is a binary for one operating system rather
|
||||||
|
// than code that runs wherever its interpreter does.
|
||||||
|
//
|
||||||
|
// **Asked of the language, not of the artifact.** A module says what it is written in; what that
|
||||||
|
// implies is the mesh's to know, exactly as the compiler is (novox/hq ADR 0142). Asking the artifact
|
||||||
|
// would let two artifacts in one language disagree about whether they are portable.
|
||||||
|
func compilesToABinary(language string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(language)) {
|
||||||
|
case "go":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// versionRef is how a resource names the version of the artifact it uses: ${version}.
|
||||||
|
//
|
||||||
|
// No artifact name in it, because the resource already says which artifact it is for — a second
|
||||||
|
// name would be a second thing to keep in step with the first.
|
||||||
|
const versionRef = "${version}"
|
||||||
|
|
||||||
|
// versionOf is an artifact's version as a path names it: its digest, short.
|
||||||
|
//
|
||||||
|
// **Content-addressed on purpose.** The alternative is the commit a build came from, and two builds
|
||||||
|
// of one commit are meant to produce the same bytes — every toolchain here is `-trimpath` for that
|
||||||
|
// reason. A commit-named path would move for an identical binary, and everything reading that path
|
||||||
|
// would be recreated for a change that is not one. A digest-named path moves exactly when the bytes
|
||||||
|
// do.
|
||||||
|
//
|
||||||
|
// Twelve hex characters: enough that two of this mesh's builds will not collide, short enough to
|
||||||
|
// read in a path and in a journal line. The `sha256:` prefix goes, because a directory name carrying
|
||||||
|
// a colon is a directory name people quote wrong.
|
||||||
|
func versionOf(digest string) string {
|
||||||
|
hex := strings.TrimPrefix(strings.TrimSpace(digest), "sha256:")
|
||||||
|
if len(hex) > 12 {
|
||||||
|
return hex[:12]
|
||||||
|
}
|
||||||
|
return hex
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bundleFor is a manifest whose one artifact is a bundle in the given language and system.
|
||||||
|
func bundleFor(language, system string) Manifest {
|
||||||
|
return Manifest{Module: "a-component", Build: &Build{Artifacts: []Artifact{
|
||||||
|
{Name: "binary", Kind: ArtifactBundle, Language: language, System: system},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func problemsOf(t *testing.T, m Manifest) string {
|
||||||
|
t.Helper()
|
||||||
|
return strings.Join(m.Build.problems(m.Module), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// **A language that compiles to a binary must say which system.**
|
||||||
|
//
|
||||||
|
// A binary is pinned to one operating system at link time, so a host refuses to touch a machine it
|
||||||
|
// was not built for. An artifact that says nothing would be compiled for whatever the build machine
|
||||||
|
// happened to be — which reads as portable and is not, and is the fault this check exists for.
|
||||||
|
func TestABinaryMustSayWhichSystemItIsFor(t *testing.T) {
|
||||||
|
got := problemsOf(t, bundleFor("go", ""))
|
||||||
|
if !strings.Contains(got, "says no system") {
|
||||||
|
t.Fatalf("a compiled bundle with no system was accepted:\n%s", got)
|
||||||
|
}
|
||||||
|
// And the refusal names what it could have said, so a reader is one edit from right.
|
||||||
|
for _, system := range []string{"alpine", "android", "arch"} {
|
||||||
|
if !strings.Contains(got, system) {
|
||||||
|
t.Fatalf("the refusal does not name %q as a choice:\n%s", system, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABinaryThatNamesASystemIsAccepted(t *testing.T) {
|
||||||
|
if got := problemsOf(t, bundleFor("go", "arch")); got != "" {
|
||||||
|
t.Fatalf("a compiled bundle naming a system was refused:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A system the mesh does not build for is refused where it is written. These are the host's own
|
||||||
|
// names, not a compiler's: the difference between two of them is a C library rather than a kernel,
|
||||||
|
// so a value that looks like an operating system to a toolchain is still wrong here.
|
||||||
|
func TestASystemTheMeshDoesNotBuildForIsRefused(t *testing.T) {
|
||||||
|
for _, wrong := range []string{"linux", "debian", "darwin"} {
|
||||||
|
got := problemsOf(t, bundleFor("go", wrong))
|
||||||
|
if !strings.Contains(got, "and a system is") {
|
||||||
|
t.Fatalf("%q was accepted as a system:\n%s", wrong, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **And a language that runs anywhere must not name one.** A system that decides nothing reads as
|
||||||
|
// though it did, which is the same fault as a restriction that restricts nothing (novox/hq ADR 0045).
|
||||||
|
func TestAPortableBundleMayNotNameASystem(t *testing.T) {
|
||||||
|
got := problemsOf(t, bundleFor("typescript", "arch"))
|
||||||
|
if !strings.Contains(got, "runs anywhere") {
|
||||||
|
t.Fatalf("a portable bundle was allowed to name a system:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPortableBundleNamingNoSystemIsAccepted(t *testing.T) {
|
||||||
|
if got := problemsOf(t, bundleFor("typescript", "")); got != "" {
|
||||||
|
t.Fatalf("an ordinary bundle was refused:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One component, one artifact per system: the shape the mesh's own binaries are declared in, and the
|
||||||
|
// reason the target is the artifact's rather than the recipe's.
|
||||||
|
func TestOneArtifactPerSystemIsAccepted(t *testing.T) {
|
||||||
|
m := Manifest{Module: "the-host", Build: &Build{Artifacts: []Artifact{
|
||||||
|
{Name: "arch", Kind: ArtifactBundle, Language: "go", System: "arch"},
|
||||||
|
{Name: "alpine", Kind: ArtifactBundle, Language: "go", System: "alpine"},
|
||||||
|
{Name: "android", Kind: ArtifactBundle, Language: "go", System: "android"},
|
||||||
|
}}}
|
||||||
|
if got := problemsOf(t, m); got != "" {
|
||||||
|
t.Fatalf("one artifact per system was refused:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEveryCatalogueManifestParses runs the real catalogue through the real gate.
|
||||||
|
//
|
||||||
|
// Not a fixture: the point is whether the manifests as written are accepted by the control plane that
|
||||||
|
// will read them, and a copy of one manifest proves nothing about the other seventy-one.
|
||||||
|
// catalogueRoot is the catalogue these checks run over: MESH_CATALOGUE when set, else the checkout
|
||||||
|
// beside this one, the way the main layout has it. A check that only ran when somebody remembered a
|
||||||
|
// variable was a check nobody ran (novox/hq issue 134, 2026-09-30); it skips only when there is no
|
||||||
|
// catalogue to be found at all.
|
||||||
|
func catalogueRoot(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
if root := os.Getenv("MESH_CATALOGUE"); root != "" {
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
sibling := filepath.Join("..", "..", "..", "mesh-catalog")
|
||||||
|
if _, err := os.Stat(filepath.Join(sibling, "modules")); err != nil {
|
||||||
|
t.Skip("no catalogue beside this checkout and MESH_CATALOGUE unset")
|
||||||
|
}
|
||||||
|
return sibling
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEveryCatalogueManifestParses(t *testing.T) {
|
||||||
|
root := catalogueRoot(t)
|
||||||
|
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||||
|
if err != nil || len(found) == 0 {
|
||||||
|
t.Fatalf("no manifests under %s: %v", root, err)
|
||||||
|
}
|
||||||
|
named, routed := 0, 0
|
||||||
|
for _, p := range found {
|
||||||
|
raw, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", p, err)
|
||||||
|
}
|
||||||
|
m, err := ParseManifest(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: %v", filepath.Base(filepath.Dir(p)), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, l := range m.Listens {
|
||||||
|
if l.Name != "" {
|
||||||
|
named++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for port := range RoutedPorts(m) {
|
||||||
|
_ = port
|
||||||
|
routed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("%d manifests, %d named endpoints, %d routed endpoints resolved", len(found), named, routed)
|
||||||
|
if named == 0 {
|
||||||
|
t.Fatal("no endpoint in the catalogue is named, so this proved nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoCatalogueManifestNamesAnInstallation is ADR 0112's check, run over the real catalogue: no
|
||||||
|
// definition names a domain or a public address the mesh acts on, and every value that must for now
|
||||||
|
// carries its reason (novox/hq ADR 0155, issue 134). The list it prints is the one that shrinks.
|
||||||
|
func TestNoCatalogueManifestNamesAnInstallation(t *testing.T) {
|
||||||
|
root := catalogueRoot(t)
|
||||||
|
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||||
|
if err != nil || len(found) == 0 {
|
||||||
|
t.Fatalf("no manifests under %s: %v", root, err)
|
||||||
|
}
|
||||||
|
var named []string
|
||||||
|
for _, p := range found {
|
||||||
|
raw, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", p, err)
|
||||||
|
}
|
||||||
|
m, err := ParseManifest(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: %v", p, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
named = append(named, InstallationProblems(m)...)
|
||||||
|
}
|
||||||
|
if len(named) > 0 {
|
||||||
|
t.Fatalf("%d value(s) name an installation:\n %s", len(named), strings.Join(named, "\n "))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **A machine trusts the mesh's authority because a module put its root there** (novox/hq ADR
|
||||||
|
// 0147, issue 129). The module carries a shell script and a unit, and both are worthless unless
|
||||||
|
// the mesh fills in where the authority is — which is the one thing about it the manifest cannot
|
||||||
|
// state, because the authority's address is a fact about the mesh and not about the module.
|
||||||
|
//
|
||||||
|
// So what is checked here is the rendering, not the parsing: the script the machine will run
|
||||||
|
// names the authority it was bound to, and the unit runs that script both ways. The verification
|
||||||
|
// itself — a plain client trusting an internal name on a machine holding this, and failing on one
|
||||||
|
// that does not — is the lab's, and cannot be had here.
|
||||||
|
func TestCaTrustRendersTheAuthorityItWasBoundTo(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../../mesh-catalog/modules/ca-trust/module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("the catalogue is not beside this checkout: %v", err)
|
||||||
|
}
|
||||||
|
m, err := ParseManifest(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the trust module does not parse:\n%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := Resolution{
|
||||||
|
Node: "workstation",
|
||||||
|
Modules: []Manifest{m},
|
||||||
|
Needs: []Needed{{
|
||||||
|
Name: "internal-acme-ca", From: "anchor", At: "anchor.internal", For: "ca-trust",
|
||||||
|
Serves: map[string]any{
|
||||||
|
"port": float64(9000), "path": "/acme/acme/directory", "roots": "/roots.pem",
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
out, err := r.Declaration(Rendering{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the trust module could not be composed for a machine: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
script := fileNamed(out, "ca-trust.anchor")
|
||||||
|
if script == nil {
|
||||||
|
t.Fatalf("nothing writes the script the unit runs: %v", out)
|
||||||
|
}
|
||||||
|
body, _ := script["content"].(string)
|
||||||
|
if !strings.Contains(body, "https://anchor.internal:9000/roots.pem") {
|
||||||
|
t.Errorf("the script does not fetch from the authority it was bound to:\n%s", body)
|
||||||
|
}
|
||||||
|
if script["mode"] != "0755" {
|
||||||
|
t.Errorf("the script is written %v, which systemd cannot execute", script["mode"])
|
||||||
|
}
|
||||||
|
|
||||||
|
unit := fileNamed(out, "ca-trust.unit")
|
||||||
|
if unit == nil {
|
||||||
|
t.Fatalf("no unit: %v", out)
|
||||||
|
}
|
||||||
|
text, _ := unit["content"].(string)
|
||||||
|
// Both halves. A unit that only installs the anchor leaves a machine trusting an authority
|
||||||
|
// nobody assigned it to any more, which is the half issue 129 asked for by name.
|
||||||
|
for _, want := range []string{
|
||||||
|
"ExecStart=" + script["path"].(string) + " install",
|
||||||
|
"ExecStop=" + script["path"].(string) + " remove",
|
||||||
|
"RemainAfterExit=yes",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("the unit does not say %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Chosen is the provider somebody named for a provision: the module, and the node it runs on. Both,
|
||||||
|
// always (novox/hq #258) — a provision comes from a module, and the same module on two machines is
|
||||||
|
// two answers, so neither half alone says which. Module is empty only on a record made before this
|
||||||
|
// was asked, and such a record is honoured exactly as long as it is unambiguous.
|
||||||
|
type Chosen struct {
|
||||||
|
Node string
|
||||||
|
Module string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Chosen) String() string {
|
||||||
|
if c.Module == "" {
|
||||||
|
return c.Node
|
||||||
|
}
|
||||||
|
return c.Node + "/" + c.Module
|
||||||
|
}
|
||||||
|
|
||||||
|
// matches is whether this provider is the one chosen.
|
||||||
|
func (c Chosen) matches(p Provider) bool {
|
||||||
|
return p.Node == c.Node && (c.Module == "" || p.Module == c.Module)
|
||||||
|
}
|
||||||
|
|
||||||
|
// among is every offered provider the choice names — one, when the choice is whole.
|
||||||
|
func (c Chosen) among(where []Provider) []Provider {
|
||||||
|
var out []Provider
|
||||||
|
for _, p := range where {
|
||||||
|
if c.matches(p) {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// nameOf is how a refusal names a provider: the node and the module on it.
|
||||||
|
func nameOf(p Provider) string {
|
||||||
|
return Chosen{Node: p.Node, Module: p.Module}.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// providerNames is every provider named, sorted, for a refusal to list.
|
||||||
|
func providerNames(where []Provider) []string {
|
||||||
|
out := make([]string, 0, len(where))
|
||||||
|
for _, p := range where {
|
||||||
|
out = append(out, nameOf(p))
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// providersHere is which modules in this node's own set offer a provision, sorted.
|
||||||
|
func providersHere(catalogue map[string]Manifest, here func(string) bool, want string) []string {
|
||||||
|
var out []string
|
||||||
|
for name, m := range catalogue {
|
||||||
|
if !here(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, o := range m.Offers() {
|
||||||
|
if o == want {
|
||||||
|
out = append(out, name)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// servedByOne is what one provider beside the consumer says a consumer needs to know, or nothing.
|
||||||
|
//
|
||||||
|
// Serving is *whether* a need is created at all when the provider is on this same machine (novox/hq
|
||||||
|
// 04-ISSUES/038's sibling): a need never created is a binding the consumer never gets. The manifest
|
||||||
|
// alone answers that; the values are settled later, with the node's settings.
|
||||||
|
func servedByOne(m Manifest, want string) map[string]any {
|
||||||
|
if _, ok := m.Serves[want]; ok {
|
||||||
|
return ServedOn(m, want, nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sharedByOne is the own secret that provider names as its credential (ADR 0158), or "" when it
|
||||||
|
// gives each consumer its own.
|
||||||
|
func sharedByOne(m Manifest, want string) string {
|
||||||
|
if own, shared := m.SharedCredentialOf(want); shared {
|
||||||
|
return own
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func oneOf(list []string, s string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -197,6 +197,27 @@ func TestARouteCanBeSetPerMesh(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestASettingReachesAContributionOnlyWhereItDeclaresTheKey(t *testing.T) {
|
||||||
|
// novox/hq 04-ISSUES/173: the mail module's site name, set so its environment file could read
|
||||||
|
// it, arrived in every route it contributed. A setting overrides a key the contribution
|
||||||
|
// declares and adds none — the provider reads the contribution as a contract.
|
||||||
|
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), World{})
|
||||||
|
|
||||||
|
out, err := got.Declaration(Rendering{Settings: SettingsBy{
|
||||||
|
"board": {{From: "the mesh", Values: map[string]any{"host": "dashboard", "sitename": "Board"}}},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
given := received(t, out)
|
||||||
|
if given[0].Values["host"] != "dashboard" {
|
||||||
|
t.Fatalf("the setting did not override the route's host: %v", given[0].Values)
|
||||||
|
}
|
||||||
|
if _, leaked := given[0].Values["sitename"]; leaked {
|
||||||
|
t.Fatalf("a setting the route never declared reached the proxy: %v", given[0].Values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReceivingWhatYouDoNotProvideIsRefused(t *testing.T) {
|
func TestReceivingWhatYouDoNotProvideIsRefused(t *testing.T) {
|
||||||
// It would create a file nobody ever writes to, on a machine where nothing asked for it.
|
// It would create a file nobody ever writes to, on a machine where nothing asked for it.
|
||||||
_, err := ParseManifest([]byte(`{"module":"traefik","version":"1",
|
_, err := ParseManifest([]byte(`{"module":"traefik","version":"1",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user