Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d90c6ab93a | ||
|
|
6b7d2ec49b | ||
|
|
4ed1057df3 | ||
|
|
1f4c67a01b | ||
|
|
5474ea2d41 | ||
|
|
b7912172af | ||
|
|
b36babcd7d | ||
|
|
49cf0aa562 | ||
|
|
5a4f73f761 | ||
|
|
6af891e358 | ||
|
|
568f55fd2e | ||
|
|
35314175f2 | ||
|
|
ec2e6255a9 | ||
|
|
f3f34a170e | ||
|
|
e2622fd031 | ||
|
|
3ee32970ef | ||
|
|
11b654499b | ||
|
|
d69e19103c | ||
|
|
10f948e970 | ||
|
|
f421d4588c | ||
|
|
74b0dab34c | ||
|
|
41b20b2782 | ||
|
|
912e9f4e85 | ||
|
|
babd7b2f47 | ||
|
|
892dfd1d08 | ||
|
|
cfac579392 | ||
|
|
fe0d295490 | ||
|
|
d8a0238e02 | ||
|
|
dcf710a8d5 | ||
|
|
a2003ab616 | ||
|
|
1363a2fe27 | ||
|
|
f19a2254ac | ||
|
|
7d46e48b26 | ||
|
|
78915f9f7a | ||
|
|
17b8f14fe1 | ||
|
|
42c394acc2 | ||
|
|
b9ad7a2948 | ||
|
|
cde22ff627 | ||
|
|
79993fb498 | ||
|
|
aec55b7072 | ||
|
|
c7884f5a72 | ||
|
|
580c4d66a7 | ||
|
|
63bfc5fda5 | ||
|
|
02e3482eb5 | ||
|
|
294e83dab1 | ||
|
|
b5438bb331 | ||
|
|
c23be73d4d | ||
|
|
d2d171f2d2 | ||
|
|
73fa64ea68 | ||
|
|
1a13dbeb17 | ||
|
|
e11caecdad | ||
|
|
7bb9e55d0b | ||
|
|
00037608ae | ||
|
|
cea59428b1 | ||
|
|
5c832f2d19 | ||
|
|
cdebb7d1a5 | ||
|
|
6a803ea5b3 | ||
|
|
9745c1ab31 | ||
|
|
c0c3c3fed4 | ||
|
|
c1449fffe9 | ||
|
|
f873c97db5 | ||
|
|
b0b3d87fe2 | ||
|
|
ba189e6943 | ||
|
|
cadf74a176 | ||
|
|
74efe8e2e7 | ||
|
|
68af9eff44 | ||
|
|
518eeb7941 | ||
|
|
bf2da878a0 | ||
|
|
50cf253a43 | ||
|
|
980a0dee93 | ||
|
|
ac9c2d57be | ||
|
|
cf2bb3b87d | ||
|
|
6784efae75 | ||
|
|
d86baebe9a |
+8
-2
@@ -1,5 +1,11 @@
|
|||||||
ARG GO_BASE=golang:1.25-alpine
|
# The Go it builds with, pinned here because genesis builds this file with no arguments (novox/hq
|
||||||
# The control plane's image.
|
# issue 223) — the Makefile passes the same digest. A tag older than go.mod asks for is how
|
||||||
|
# `make image` broke once before (issue 146).
|
||||||
|
ARG GO_BASE=golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c
|
||||||
|
# The control plane's image — for genesis and the lab only. The mesh runs the controller as a Go
|
||||||
|
# bundle the host starts as a process (module.json; novox/hq issue 213), and builds no image of it.
|
||||||
|
# Genesis builds this file and raises it as the container the process replaces on the first push
|
||||||
|
# (mesh-host internal/bootstrap, novox/hq issue 223).
|
||||||
#
|
#
|
||||||
# novox/hq ADR 0006: this image is pinned by digest in the bundle the host carries, fetched on a
|
# novox/hq ADR 0006: this image is pinned by digest in the bundle the host carries, fetched on a
|
||||||
# machine where no mesh exists yet, and run before there is anything to check it against. So it
|
# machine where no mesh exists yet, and run before there is anything to check it against. So it
|
||||||
|
|||||||
@@ -27,17 +27,21 @@ build:
|
|||||||
IMAGE ?= mesh-controller:$(VERSION)
|
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.
|
# The Go base the image is built on.
|
||||||
#
|
#
|
||||||
# **`make image` was broken and stayed broken**, because the Dockerfile's fallback base was a Go
|
# **`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 >=
|
# 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
|
# 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,
|
# building the image by hand hit it and had to find the digest themselves (novox/hq 04-ISSUES/146,
|
||||||
# what it cost).
|
# what it cost).
|
||||||
GO_BASE ?= $(shell python3 -c "import json;print(next(o['image'] for o in json.load(open('module.json'))['build']['on'] if o['arg']=='GO_BASE'))" 2>/dev/null)
|
#
|
||||||
|
# **Pinned here since the manifest stopped building an image** (novox/hq issue 213): the mesh builds
|
||||||
|
# the controller as a Go bundle with its own toolchain, and only `make image` — genesis and the lab —
|
||||||
|
# still needs a Go base. The digest is the one the manifest declared until then.
|
||||||
|
GO_BASE ?= golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c
|
||||||
|
|
||||||
image:
|
image:
|
||||||
@test -n "$(GO_BASE)" || { echo "module.json declares no GO_BASE; pass GO_BASE=<image> or fix the manifest"; exit 1; }
|
@test -n "$(GO_BASE)" || { echo "no GO_BASE; pass GO_BASE=<image>"; exit 1; }
|
||||||
docker build --build-arg GO_BASE=$(GO_BASE) --build-arg VERSION=$(VERSION) -t $(IMAGE) -t $(DEV_TAG) .
|
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'
|
||||||
@@ -48,7 +52,7 @@ BUILDER_IMAGE ?= mesh-builder:$(VERSION)
|
|||||||
BUILDER_DEV_TAG ?= mesh-builder:development
|
BUILDER_DEV_TAG ?= mesh-builder:development
|
||||||
|
|
||||||
builder-image:
|
builder-image:
|
||||||
@test -n "$(GO_BASE)" || { echo "module.json declares no GO_BASE; pass GO_BASE=<image> or fix the manifest"; exit 1; }
|
@test -n "$(GO_BASE)" || { echo "no GO_BASE; pass GO_BASE=<image>"; exit 1; }
|
||||||
docker build --build-arg GO_BASE=$(GO_BASE) -f cmd/mesh-builder/Dockerfile -t $(BUILDER_IMAGE) -t $(BUILDER_DEV_TAG) .
|
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'
|
||||||
|
|||||||
@@ -193,6 +193,13 @@ passes every check that only looks at the message.
|
|||||||
|
|
||||||
## The image
|
## The image
|
||||||
|
|
||||||
|
**The mesh no longer runs the controller from it** (novox/hq issue 213). The module declares a Go
|
||||||
|
bundle, `controller`, which the host on the controller's machine unpacks and runs as the process
|
||||||
|
`mesh-controller` under the account of the same name (ADR 0188 §1, §3). The image stays for what
|
||||||
|
still runs a container of the controller: genesis, which raises the first controller from it and
|
||||||
|
installs the module from its manifest (mesh-host `internal/bootstrap`), and the lab. Neither is the
|
||||||
|
mesh's own build any more — `make image` builds it.
|
||||||
|
|
||||||
`FROM scratch`, holding one statically linked binary and nothing else — no shell, no package
|
`FROM scratch`, holding one statically linked binary and nothing else — no shell, no package
|
||||||
manager, no libc, no CA certificates.
|
manager, no libc, no CA certificates.
|
||||||
|
|
||||||
|
|||||||
+140
-15
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -39,7 +40,10 @@ import (
|
|||||||
//
|
//
|
||||||
// It costs a resolution per machine. Assignment is a person typing a command, and being told which
|
// It costs a resolution per machine. Assignment is a person typing a command, and being told which
|
||||||
// machines this just blocked is worth more than the milliseconds.
|
// machines this just blocked is worth more than the milliseconds.
|
||||||
func assign(ctx context.Context, open *stores, node, module string) (string, error) {
|
func assign(ctx context.Context, open *stores, node string, modules ...string) (string, error) {
|
||||||
|
if len(modules) == 0 {
|
||||||
|
return "", fmt.Errorf("assign %s names no module", node)
|
||||||
|
}
|
||||||
// Held while it is recorded, so it cannot land between a converge's preview and its flip and
|
// Held while it is recorded, so it cannot land between a converge's preview and its flip and
|
||||||
// be taken without ever having been previewed (novox/hq ADR 0100).
|
// be taken without ever having been previewed (novox/hq ADR 0100).
|
||||||
ctx, release, err := holdNodes(ctx, open, []string{node})
|
ctx, release, err := holdNodes(ctx, open, []string{node})
|
||||||
@@ -47,6 +51,16 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer release()
|
defer release()
|
||||||
|
// **The one assignment refused for what the node lacks** (novox/hq ADR 0207). Everything else
|
||||||
|
// an assignment leaves unresolved is kept, because assignment is not an ordering; a module whose
|
||||||
|
// resources are applied through a seat nothing on the node holds is refused, because that order
|
||||||
|
// — the service manager, the package manager and the runtime before anything that installs,
|
||||||
|
// runs or contains — is the mesh's to keep. Several modules in one act are judged together, so
|
||||||
|
// holders that depend on each other go on in one command.
|
||||||
|
shelf, before, err := seatDependenciesOnAssign(ctx, open, node, modules)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
// **Before the new assignment can unsettle a seat somebody holds only by being alone**
|
// **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
|
// (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.
|
// assignment resolves against a record rather than against a coincidence.
|
||||||
@@ -54,65 +68,176 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
var lines []string
|
||||||
|
var added []string
|
||||||
|
for _, module := range modules {
|
||||||
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 strings.Join(lines, "\n"), err
|
||||||
}
|
}
|
||||||
if !fresh {
|
if !fresh {
|
||||||
// Nothing changed, and saying "is assigned" would read as an action. One node runs one
|
// Nothing changed, and saying "is assigned" would read as an action. One node runs one
|
||||||
// of each — the module's name is the assignment's identity (novox/hq ADR 0115).
|
// of each — the module's name is the assignment's identity (novox/hq ADR 0115).
|
||||||
return fmt.Sprintf("%s already runs %s — one node runs one of each (ADR 0115); nothing changed",
|
lines = append(lines, fmt.Sprintf(
|
||||||
node, module), nil
|
"%s already runs %s — one node runs one of each (ADR 0115); nothing changed", node, module))
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
said := fmt.Sprintf("%s is assigned %s", node, module)
|
added = append(added, module)
|
||||||
|
lines = append(lines, fmt.Sprintf("%s is assigned %s", node, module))
|
||||||
|
}
|
||||||
|
if len(added) == 0 {
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
|
}
|
||||||
|
answer := strings.Join(lines, "\n")
|
||||||
for _, line := range settled {
|
for _, line := range settled {
|
||||||
said += "\n " + line
|
answer += "\n " + line
|
||||||
|
}
|
||||||
|
// What this act changed about this node's unmet seat dependencies, and nothing else (novox/hq
|
||||||
|
// ADR 0207): a dependency of a module just assigned, or one this assignment met. The rest of the
|
||||||
|
// node's list, and every other node's, is `status`'s.
|
||||||
|
for _, line := range unheldChange(shelf, node, before, append(append([]string(nil), before...), added...)) {
|
||||||
|
answer += "\n " + line
|
||||||
}
|
}
|
||||||
// Its bus credential, in the same act (novox/hq issue 203): an assignment pushed before its
|
// Its bus credential, in the same act (novox/hq issue 203): an assignment pushed before its
|
||||||
// credential exists delivers a process that cannot authenticate and crash-loops until somebody
|
// credential exists delivers a process that cannot authenticate and crash-loops until somebody
|
||||||
// runs a second verb and a second push. Issued here when the module speaks on the bus and has
|
// runs a second verb and a second push. Issued here when the module speaks on the bus and has
|
||||||
// no credential yet; kept when it has one, so re-assigning rotates nothing.
|
// no credential yet; kept when it has one, so re-assigning rotates nothing.
|
||||||
|
for _, module := range added {
|
||||||
if line := issueOnAssign(ctx, open, node, module); line != "" {
|
if line := issueOnAssign(ctx, open, node, module); line != "" {
|
||||||
said += "\n " + line
|
answer += "\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
|
||||||
// worth reporting: this machine's refusal is rarely the only consequence.
|
// worth reporting: this machine's refusal is rarely the only consequence.
|
||||||
return said + blockedElsewhere(ctx, open, node), err
|
return answer + blockedElsewhere(ctx, open, node), err
|
||||||
}
|
}
|
||||||
// Kept, and cannot be hosted here. Said at once rather than discovered at push: a module whose
|
// Kept, and cannot be hosted here. Said at once rather than discovered at push: a module whose
|
||||||
// capability the machine lacks is on the wrong machine, and the assignment records what a person
|
// capability the machine lacks is on the wrong machine, and the assignment records what a person
|
||||||
// meant while this line says it will not run until it moves. The rest of the node still pushes.
|
// meant while this line says it will not run until it moves. The rest of the node still pushes.
|
||||||
|
isAdded := map[string]bool{}
|
||||||
|
for _, m := range added {
|
||||||
|
isAdded[m] = true
|
||||||
|
}
|
||||||
for _, u := range plan.Unhostable {
|
for _, u := range plan.Unhostable {
|
||||||
if u.Module != module {
|
if !isAdded[u.Module] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, c := range u.Missing {
|
for _, c := range u.Missing {
|
||||||
said += "\n but " + catalogue.WrongMachine(u.Module, c, node)
|
answer += "\n but " + catalogue.WrongMachine(u.Module, c, node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return said + fmt.Sprintf("\n run `push %s` to send it", node) +
|
return answer + fmt.Sprintf("\n run `push %s` to send it", node) +
|
||||||
blockedElsewhere(ctx, open, node), nil
|
blockedElsewhere(ctx, open, node), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// unassign takes a module off a node. What it leaves behind is the host's business: a directory
|
// seatDependenciesOnAssign is the refusal ADR 0207 makes at assignment, or nothing, with the
|
||||||
|
// catalogue and the node's assignments it was judged against. Modules already assigned are not new
|
||||||
|
// and are not judged again.
|
||||||
|
func seatDependenciesOnAssign(ctx context.Context, open *stores, node string, modules []string) (
|
||||||
|
map[string]catalogue.Manifest, []string, error) {
|
||||||
|
shelf, err := open.inventory.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
assigned, err := open.inventory.Assigned(ctx, node)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
already := map[string]bool{}
|
||||||
|
for _, a := range assigned {
|
||||||
|
already[a] = true
|
||||||
|
}
|
||||||
|
var adding []string
|
||||||
|
for _, m := range modules {
|
||||||
|
if !already[m] {
|
||||||
|
adding = append(adding, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The lines AssignRefusal says beside an assignment it lets through are said by unheldChange
|
||||||
|
// with everything else this act changed, so they are not said twice.
|
||||||
|
if _, err := catalogue.AssignRefusal(shelf, node, assigned, adding); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
// Two modules declaring one package, path or unit is refused before anything is recorded
|
||||||
|
// (novox/hq ADR 0210, 04-ISSUES/235): kept, the node would not resolve until one came off again.
|
||||||
|
if err := catalogue.CollisionRefusal(shelf, node, assigned, adding); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return shelf, assigned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unassign takes modules off a node. What they leave behind is the host's business: a directory
|
||||||
// holding anything the mesh did not put there is kept (novox/hq ADR 0030).
|
// holding anything the mesh did not put there is kept (novox/hq ADR 0030).
|
||||||
//
|
//
|
||||||
// It reports the rest of the mesh for the same reason assign does, and more sharply: taking a
|
// It reports the rest of the mesh for the same reason assign does, and more sharply: taking a
|
||||||
// module off one machine is the ordinary way to stop providing something to another, and nothing
|
// module off one machine is the ordinary way to stop providing something to another, and nothing
|
||||||
// about the command's own output would ever have said so.
|
// about the command's own output would ever have said so.
|
||||||
func unassign(ctx context.Context, open *stores, node, module string) (string, error) {
|
//
|
||||||
|
// **Refused when it takes away the last holder of a seat a module left on the node depends on**
|
||||||
|
// (novox/hq ADR 0207) — the other side of refusing that module's assignment without one. Several
|
||||||
|
// modules in one act are judged together, so a holder and its dependents come off in one command.
|
||||||
|
func unassign(ctx context.Context, open *stores, node string, modules ...string) (string, error) {
|
||||||
|
if len(modules) == 0 {
|
||||||
|
return "", fmt.Errorf("unassign %s names no module", node)
|
||||||
|
}
|
||||||
ctx, release, err := holdNodes(ctx, open, []string{node})
|
ctx, release, err := holdNodes(ctx, open, []string{node})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer release()
|
defer release()
|
||||||
|
shelf, err := open.inventory.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
assigned, err := open.inventory.Assigned(ctx, node)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Every one checked before any is taken off, so a refusal leaves the node as it was.
|
||||||
|
runs := map[string]bool{}
|
||||||
|
for _, a := range assigned {
|
||||||
|
runs[a] = true
|
||||||
|
}
|
||||||
|
for _, module := range modules {
|
||||||
|
if !runs[module] {
|
||||||
|
return "", fmt.Errorf("%s is not assigned to %s", module, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := catalogue.UnassignRefusal(shelf, node, assigned, modules); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, module := range modules {
|
||||||
if err := open.inventory.Unassign(ctx, node, module); err != nil {
|
if err := open.inventory.Unassign(ctx, node, module); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s no longer runs %s — run `push %s` to make it so",
|
}
|
||||||
node, module, node) + blockedElsewhere(ctx, open, node), nil
|
answer := fmt.Sprintf("%s no longer runs %s — run `push %s` to make it so",
|
||||||
|
node, strings.Join(modules, ", "), node)
|
||||||
|
var left []string
|
||||||
|
for _, a := range assigned {
|
||||||
|
if !slices.Contains(modules, a) {
|
||||||
|
left = append(left, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, line := range unheldChange(shelf, node, assigned, left) {
|
||||||
|
answer += "\n " + line
|
||||||
|
}
|
||||||
|
return answer + blockedElsewhere(ctx, open, node), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitModules is a surface's one `module` field as the modules it names: several, comma-separated,
|
||||||
|
// are one act (novox/hq ADR 0207), so the holders that depend on each other go on together from the
|
||||||
|
// command API and the controller seat's verbs as they do from the command line.
|
||||||
|
func splitModules(field string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, m := range strings.Split(field, ",") {
|
||||||
|
if m = strings.TrimSpace(m); m != "" {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// blockedElsewhere is every OTHER machine that cannot be worked out as things now stand.
|
// blockedElsewhere is every OTHER machine that cannot be worked out as things now stand.
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ func TestTheRegistryTrustAndEveryImageFollowThePortTheNodeGaveTheStore(t *testin
|
|||||||
//
|
//
|
||||||
// Composed from the control plane's own manifest against a real inventory: the store's module is
|
// Composed from the control plane's own manifest against a real inventory: the store's module is
|
||||||
// given 6852 on this node the way genesis or an operator gives it, and the control plane's
|
// given 6852 on this node the way genesis or an operator gives it, and the control plane's
|
||||||
// container is told so beside the sealed connection genesis wrote.
|
// process is told so beside the sealed connection genesis wrote.
|
||||||
func TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker(t *testing.T) {
|
func TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker(t *testing.T) {
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -157,8 +157,8 @@ func TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
control, err := withSeatPorts(m).Resolve([]catalogue.Built{{Name: "server", Kind: catalogue.ArtifactImage,
|
control, err := withSeatPorts(m).Resolve([]catalogue.Built{{Name: "controller", Kind: catalogue.ArtifactBundle,
|
||||||
Reference: "registry.example/control@" + aDigest}})
|
Reference: "https://registry.example/mesh-controller/controller.tar.gz", Digest: aDigest}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -200,12 +200,12 @@ func TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker(t *testing.T)
|
|||||||
|
|
||||||
var env map[string]any
|
var env map[string]any
|
||||||
for _, r := range composed(t, open, "anchor").Resources {
|
for _, r := range composed(t, open, "anchor").Resources {
|
||||||
if r["id"] == "mesh-controller.server" {
|
if r["id"] == "mesh-controller.controller" {
|
||||||
env, _ = r["env"].(map[string]any)
|
env, _ = r["env"].(map[string]any)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if env == nil {
|
if env == nil {
|
||||||
t.Fatal("the control plane's container is not in its own node's declaration")
|
t.Fatal("the control plane's process is not in its own node's declaration")
|
||||||
}
|
}
|
||||||
for key, want := range map[string]string{
|
for key, want := range map[string]string{
|
||||||
"MESH_STORE_INVENTORY_PORT": "6852",
|
"MESH_STORE_INVENTORY_PORT": "6852",
|
||||||
@@ -238,7 +238,7 @@ func withSeatPorts(m catalogue.Manifest) catalogue.Manifest {
|
|||||||
out := m
|
out := m
|
||||||
out.Resources = nil
|
out.Resources = nil
|
||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
if r["type"] != "container" {
|
if r["type"] != "container" && r["type"] != "process" {
|
||||||
out.Resources = append(out.Resources, r)
|
out.Resources = append(out.Resources, r)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,10 +95,10 @@ func commands(who Authenticator) http.Handler {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.HandleFunc("POST /assign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
mux.HandleFunc("POST /assign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||||
return assign(ctx, open, in.Node, in.Module)
|
return assign(ctx, open, in.Node, splitModules(in.Module)...)
|
||||||
}))
|
}))
|
||||||
mux.HandleFunc("POST /unassign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
mux.HandleFunc("POST /unassign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||||
return unassign(ctx, open, in.Node, in.Module)
|
return unassign(ctx, open, in.Node, splitModules(in.Module)...)
|
||||||
}))
|
}))
|
||||||
// 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) {
|
||||||
|
|||||||
@@ -148,6 +148,11 @@ func buildFrom(result link.BuildResult) inventory.Build {
|
|||||||
// rebuild the graph rather than a list of names.
|
// rebuild the graph rather than a list of names.
|
||||||
Path: result.Path,
|
Path: result.Path,
|
||||||
}
|
}
|
||||||
|
// When it was asked, which is what orders it against another build of the same module
|
||||||
|
// (novox/hq 04-ISSUES/219) — not when it was heard.
|
||||||
|
if asked, ok := link.BuildAskedAt(result.ID); ok {
|
||||||
|
kept.Asked = asked
|
||||||
|
}
|
||||||
for _, ref := range result.Against {
|
for _, ref := range result.Against {
|
||||||
kept.Against = append(kept.Against, catalogue.Recorded(ref))
|
kept.Against = append(kept.Against, catalogue.Recorded(ref))
|
||||||
}
|
}
|
||||||
@@ -409,7 +414,7 @@ func buildOne(ctx context.Context, source buildSource, path, ref string, wait ti
|
|||||||
// Correlated by something the control plane makes, not by the module's name: two builds of one
|
// Correlated by something the control plane makes, not by the module's name: two builds of one
|
||||||
// module can be in flight, and the second answer is not the first one's.
|
// module can be in flight, and the second answer is not the first one's.
|
||||||
request := link.BuildRequest{
|
request := link.BuildRequest{
|
||||||
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()),
|
ID: link.NewBuildID(time.Now()),
|
||||||
Repository: repository,
|
Repository: repository,
|
||||||
Path: path,
|
Path: path,
|
||||||
Ref: ref,
|
Ref: ref,
|
||||||
@@ -526,17 +531,37 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
|||||||
BuiltFrom: result.Commit, Head: result.Commit,
|
BuiltFrom: result.Commit, Head: result.Commit,
|
||||||
// What it stood on, so registration can judge a built manifest's base (to-be 38 WP2.4).
|
// What it stood on, so registration can judge a built manifest's base (to-be 38 WP2.4).
|
||||||
Against: kept.Against,
|
Against: kept.Against,
|
||||||
|
// When it was asked, so an older request heard later does not replace a newer one
|
||||||
|
// (novox/hq 04-ISSUES/219).
|
||||||
|
Asked: kept.Asked,
|
||||||
}
|
}
|
||||||
if result.Source != nil && result.Source.Seat != "" {
|
if result.Source != nil && result.Source.Seat != "" {
|
||||||
recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat
|
recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat
|
||||||
}
|
}
|
||||||
|
// **A build at a commit does not change the branch a module follows** (novox/hq 04-ISSUES/215):
|
||||||
|
// the commit is built and recorded as what it was built from, and the module keeps following
|
||||||
|
// what it followed before — the repository's default branch for one new to the catalogue.
|
||||||
|
if followedBranch(result.Ref) == "" && result.Ref != "" {
|
||||||
|
recorded.Ref = ""
|
||||||
|
if was, err := inv.SourceOf(ctx, manifest.Module); err == nil {
|
||||||
|
recorded.Ref = followedBranch(was.Ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := namesNoInstallation(manifest); err != nil {
|
if err := namesNoInstallation(manifest); err != nil {
|
||||||
return manifest, kept, fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w",
|
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)
|
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 {
|
||||||
|
if errors.Is(err, inventory.ErrSuperseded) {
|
||||||
|
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w",
|
||||||
|
result.On, manifest.Module, short(result.Commit), err)
|
||||||
|
}
|
||||||
return manifest, kept, err
|
return manifest, kept, err
|
||||||
}
|
}
|
||||||
|
// The keep set just moved, and new bytes just landed (novox/hq ADR 0189). Asked here rather
|
||||||
|
// than on a timer of its own: this is the only moment either is true. Never fatal — the build
|
||||||
|
// worked and the module is registered.
|
||||||
|
collect(ctx, inv)
|
||||||
return manifest, kept, nil
|
return manifest, kept, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,7 +589,7 @@ func buildAndShow(ctx context.Context, source buildSource, path, ref string, wai
|
|||||||
defer ask.Close()
|
defer ask.Close()
|
||||||
|
|
||||||
result, err := ask.Submit(ctx, link.BuildRequest{
|
result, err := ask.Submit(ctx, link.BuildRequest{
|
||||||
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()),
|
ID: link.NewBuildID(time.Now()),
|
||||||
Repository: repository, Path: path, Ref: ref,
|
Repository: repository, Path: path, Ref: ref,
|
||||||
Held: heldBy(ctx), Seats: seatBases(ctx),
|
Held: heldBy(ctx), Seats: seatBases(ctx),
|
||||||
}, wait)
|
}, wait)
|
||||||
@@ -625,6 +650,11 @@ type answers struct {
|
|||||||
// public name on the machine went dark. The holds were correct; they were recorded only in the
|
// 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.
|
// machine's own state file, and the one visible symptom was a count that did not add up.
|
||||||
untaken map[string]map[string]int
|
untaken map[string]map[string]int
|
||||||
|
// unheld is every module on a machine whose resources are applied through a seat nothing on
|
||||||
|
// that machine holds (novox/hq ADR 0207), with the modules that could hold it. Reported, not
|
||||||
|
// refused, until the switch — and while there is any, the mesh is not all well: the order the
|
||||||
|
// machines' modules are built in is the mesh's to keep, and this is where it says it is not kept.
|
||||||
|
unheld []catalogue.Unheld
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,3 +66,87 @@ func TestABuildHeardIsRecordedAndRegistered(t *testing.T) {
|
|||||||
t.Fatalf("a failure is said in the builder's words: %v", err)
|
t.Fatalf("a failure is said in the builder's words: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// novox/hq 04-ISSUES/215: a build asked at a commit is recorded as built from that commit, and the
|
||||||
|
// module keeps following the branch it followed — a new one, the default branch.
|
||||||
|
func TestABuildAtACommitKeepsTheBranchTheModuleFollows(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
manifest, _ := json.Marshal(map[string]any{"module": "unifi", "version": "1"})
|
||||||
|
result := func(id, ref, commit string) link.BuildResult {
|
||||||
|
return link.BuildResult{ID: id, Repository: "http://forge.internal:20000/novox/mesh-catalog.git",
|
||||||
|
Path: "modules/unifi", Ref: ref, On: "anchor", Commit: commit, Manifest: manifest,
|
||||||
|
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
|
||||||
|
}
|
||||||
|
if _, _, err := takeIn(ctx, open.inventory, result("b-1", "main", "1111111aaaa")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := takeIn(ctx, open.inventory, result("b-2", "9c97a8a", "9c97a8a1d2c3")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
src, err := open.inventory.SourceOf(ctx, "unifi")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if src.Ref != "main" || src.BuiltFrom != "9c97a8a1d2c3" {
|
||||||
|
t.Errorf("after a build at a commit the module follows %q, built from %q; want main, 9c97a8a1d2c3", src.Ref, src.BuiltFrom)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One new to the catalogue, first built at a commit, follows the default branch.
|
||||||
|
other, _ := json.Marshal(map[string]any{"module": "letta", "version": "1"})
|
||||||
|
r := result("b-3", "deadbeef", "deadbeefcafe")
|
||||||
|
r.Manifest, r.Path = other, "modules/letta"
|
||||||
|
if _, _, err := takeIn(ctx, open.inventory, r); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if src, _ := open.inventory.SourceOf(ctx, "letta"); src.Ref != "" {
|
||||||
|
t.Errorf("a module first built at a commit follows %q, want the default branch", src.Ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq 04-ISSUES/219: an older request heard after a newer one is recorded and not registered,
|
||||||
|
// so a push sends what the newer request built.
|
||||||
|
func TestAnOlderBuildHeardLaterDoesNotReplaceTheNewer(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
older := time.Date(2026, 10, 3, 21, 33, 45, 0, time.UTC)
|
||||||
|
newer := time.Date(2026, 10, 3, 21, 51, 57, 0, time.UTC)
|
||||||
|
result := func(asked time.Time, image string) link.BuildResult {
|
||||||
|
manifest, _ := json.Marshal(map[string]any{"module": "postgres", "version": image})
|
||||||
|
return link.BuildResult{ID: link.NewBuildID(asked), Repository: "http://forge.internal:20000/novox/mesh-catalog.git",
|
||||||
|
Path: "modules/postgres", Ref: "main", On: "anchor", Commit: "efff5415", Manifest: manifest,
|
||||||
|
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
|
||||||
|
}
|
||||||
|
if _, _, err := takeIn(ctx, open.inventory, result(newer, "4bcd5f73")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _, err := takeIn(ctx, open.inventory, result(older, "0ab07fa9"))
|
||||||
|
if !errors.Is(err, inventory.ErrSuperseded) {
|
||||||
|
t.Fatalf("the older request's outcome was taken in as current: %v", err)
|
||||||
|
}
|
||||||
|
shelf, err := open.inventory.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := shelf["postgres"].Version; got != "4bcd5f73" {
|
||||||
|
t.Errorf("postgres is %q; want the newer request's 4bcd5f73", got)
|
||||||
|
}
|
||||||
|
if builds, _ := open.inventory.Builds(ctx, "postgres", 5); len(builds) != 2 {
|
||||||
|
t.Errorf("the late build was not recorded: %v", builds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABuildIDSaysWhenItWasAsked(t *testing.T) {
|
||||||
|
at := time.Date(2026, 10, 3, 21, 51, 57, 392539762, time.UTC)
|
||||||
|
if got, ok := link.BuildAskedAt(link.NewBuildID(at)); !ok || !got.Equal(at) {
|
||||||
|
t.Errorf("read back %v %v; want %v", got, ok, at)
|
||||||
|
}
|
||||||
|
if got, ok := link.BuildAskedAt("build-1791064317392539762"); !ok || got.Format(time.TimeOnly) != "21:51:57" {
|
||||||
|
t.Errorf("the incident's id reads as %v %v", got, ok)
|
||||||
|
}
|
||||||
|
for _, id := range []string{"b-1", "build-2", "build-", "build-x", ""} {
|
||||||
|
if _, ok := link.BuildAskedAt(id); ok {
|
||||||
|
t.Errorf("%q read as a request time", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
)
|
)
|
||||||
@@ -90,6 +91,17 @@ func moduleCheck(paths []string, out io.Writer) error {
|
|||||||
if len(m.Invokes) > 0 {
|
if len(m.Invokes) > 0 {
|
||||||
fmt.Fprintf(out, ", invokes %s", joinInvokes(m.Invokes))
|
fmt.Fprintf(out, ", invokes %s", joinInvokes(m.Invokes))
|
||||||
}
|
}
|
||||||
|
// The state it keeps and reads (novox/hq ADR 0201), so a reviewer sees what lands on the bus.
|
||||||
|
if len(m.State) > 0 {
|
||||||
|
kept := make([]string, 0, len(m.State))
|
||||||
|
for _, s := range m.State {
|
||||||
|
kept = append(kept, s.Name)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, ", keeps state %s", strings.Join(kept, ", "))
|
||||||
|
}
|
||||||
|
if len(m.Reads) > 0 {
|
||||||
|
fmt.Fprintf(out, ", reads %s", strings.Join(m.Reads, ", "))
|
||||||
|
}
|
||||||
fmt.Fprintln(out)
|
fmt.Fprintln(out)
|
||||||
}
|
}
|
||||||
if failed > 0 {
|
if failed > 0 {
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/artifacts"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Letting the artifact store go of what the mesh no longer keeps (novox/hq ADR 0189, issue 108).
|
||||||
|
//
|
||||||
|
// **Run where the records change.** A build is the moment new bytes landed in the store and the
|
||||||
|
// moment the keep set moved, so it is the moment to say what may go — and it needs no timer of
|
||||||
|
// its own. Reclaiming the bytes is the store's own nightly step; this only decides.
|
||||||
|
//
|
||||||
|
// Never fatal to a build. The build succeeded, the module is registered, and a store that could
|
||||||
|
// not be reached is a thing to say rather than a reason to undo any of that. The next build asks
|
||||||
|
// again, and the references it could not collect are still uncollected, so nothing is lost by
|
||||||
|
// having failed.
|
||||||
|
|
||||||
|
// collect asks the store to let go of everything the mesh made and no longer keeps, and records
|
||||||
|
// what it let go of. Says what it did and what it could not; returns nothing, because nothing
|
||||||
|
// upstream should branch on it.
|
||||||
|
func collect(ctx context.Context, inv *inventory.Inventory) {
|
||||||
|
references, err := inv.ToCollect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "could not work out what the artifact store may let go of: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(references) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shelf, err := inv.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "could not read the catalogue to find the artifact store: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// As the mesh reaches it from the network. Empty means the store is not on the network — on a
|
||||||
|
// mesh being raised it is not yet, and there the store holds one build of anything and has
|
||||||
|
// nothing to collect.
|
||||||
|
address, err := artifactStoreAddress(ctx, inv, shelf, "")
|
||||||
|
if err != nil || address == "" {
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "could not find the artifact store to collect from: %v\n", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Bounded, because this runs inside somebody's build.** The first sweep of a mesh that has
|
||||||
|
// never collected has the whole history to get through, and a person waiting on `build` should
|
||||||
|
// not pay for it. Two bounds, and what is left over is simply offered again next time —
|
||||||
|
// builds are frequent, and the point is that the store stops growing, not that it empties
|
||||||
|
// tonight.
|
||||||
|
within, stop := context.WithTimeout(ctx, sweepBudget)
|
||||||
|
defer stop()
|
||||||
|
store := artifacts.Store{Address: address}
|
||||||
|
|
||||||
|
var done []string
|
||||||
|
var left, skipped int
|
||||||
|
for i, reference := range references {
|
||||||
|
if i >= mostPerSweep || within.Err() != nil {
|
||||||
|
left = len(references) - i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
err := store.LetGo(within, reference)
|
||||||
|
if err == nil || errors.Is(err, artifacts.Gone) {
|
||||||
|
// Gone is the outcome wanted, already true. Recorded so the next sweep does not ask
|
||||||
|
// again for ever.
|
||||||
|
done = append(done, reference)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if errors.Is(err, artifacts.ErrNotOurs) {
|
||||||
|
// **A fact about this record, so this record is skipped** (novox/hq issue 226). Not
|
||||||
|
// marked collected — the mesh did not remove it and should not claim to — and not a
|
||||||
|
// reason to stop, because the store was never asked. One of these at the front of
|
||||||
|
// the oldest-first order ended every sweep until this.
|
||||||
|
skipped++
|
||||||
|
if skipped == 1 {
|
||||||
|
fmt.Fprintf(os.Stderr,
|
||||||
|
"the sweep will not address %s and went on: %v\n", reference, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// **Stopped at the first refusal by the STORE, not pushed through.** A store that refuses
|
||||||
|
// one refuses all of them — deletion disabled, the store down, the network gone — so
|
||||||
|
// going on would be a hundred identical failures and a hundred identical log lines in
|
||||||
|
// front of whoever was building something.
|
||||||
|
fmt.Fprintf(os.Stderr, "the artifact store kept %s, so nothing more was asked of it: %v\n",
|
||||||
|
reference, err)
|
||||||
|
left = len(references) - i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(done) > 0 {
|
||||||
|
// Recorded outside `within`: the deletions happened, and losing the record of them because
|
||||||
|
// the sweep ran out of budget would mean asking about them again for ever.
|
||||||
|
if err := inv.MarkCollected(ctx, done); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "the store let go of %d artifact(s) and the record of it did not keep: %v\n",
|
||||||
|
len(done), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "the artifact store let go of %d artifact(s) the mesh no longer keeps\n",
|
||||||
|
len(done))
|
||||||
|
}
|
||||||
|
if left > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "%d more to collect; the next build asks again\n", left)
|
||||||
|
}
|
||||||
|
if skipped > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "%d artifact(s) the sweep will not address were skipped\n", skipped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mostPerSweep is how many artifacts one sweep will ask about. Enough that a mesh building
|
||||||
|
// several times a day converges within days of this landing; small enough that no single build
|
||||||
|
// waits on the whole backlog.
|
||||||
|
const mostPerSweep = 200
|
||||||
|
|
||||||
|
// sweepBudget is the longest a sweep will keep a build waiting.
|
||||||
|
const sweepBudget = 60 * time.Second
|
||||||
@@ -8,6 +8,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -178,8 +179,8 @@ func usage() {
|
|||||||
seat <name> --to <node>/<module> hand a seat to that assignment as one act; never empty in between (ADR 0131)
|
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 modules on a node, judged together (ADR 0207)
|
||||||
unassign <node> <module> take it off
|
unassign <node> <module>... take them off
|
||||||
take <node> <module> preview a module's cutover on an adopted node: what runs beside
|
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
|
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
|
||||||
@@ -253,25 +254,34 @@ func parseAround(set *flag.FlagSet, args []string) ([]string, error) {
|
|||||||
// became of a build nobody was watching.
|
// 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 {
|
||||||
manifest, _, err := takeIn(ctx, b.inv, result)
|
manifest, _, err := takeIn(ctx, b.inv, result)
|
||||||
|
// When it was asked, so a plan takes as its outcome only a build asked for it or after it
|
||||||
|
// (novox/hq 04-ISSUES/219). Zero when the id does not say.
|
||||||
|
asked, _ := link.BuildAskedAt(result.ID)
|
||||||
switch {
|
switch {
|
||||||
case err != nil && result.Failed != "":
|
case err != nil && result.Failed != "":
|
||||||
fmt.Printf("%s: %v\n", result.ID, err)
|
fmt.Printf("%s: %v\n", result.ID, err)
|
||||||
if result.Module != "" {
|
if result.Module != "" {
|
||||||
planBuilt(ctx, b.open, result.Module, result.Commit, result.Failed)
|
planBuilt(ctx, b.open, result.Module, result.Commit, result.Failed, asked)
|
||||||
} else {
|
} else {
|
||||||
planFailedBuild(ctx, b.open, result)
|
planFailedBuild(ctx, b.open, result)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
case errors.Is(err, inventory.ErrSuperseded):
|
||||||
|
// Not a failure: the module is already at what a later request built. A plan that asked
|
||||||
|
// before that later request is answered by it; one that asked after it ignores this.
|
||||||
|
fmt.Printf("%s: %v\n", result.ID, err)
|
||||||
|
planBuilt(ctx, b.open, manifest.Module, result.Commit, "", asked)
|
||||||
|
return nil
|
||||||
case err != nil:
|
case err != nil:
|
||||||
fmt.Printf("%s: heard and recorded, and not registered: %v\n", result.ID, err)
|
fmt.Printf("%s: heard and recorded, and not registered: %v\n", result.ID, err)
|
||||||
if manifest.Module != "" {
|
if manifest.Module != "" {
|
||||||
planBuilt(ctx, b.open, manifest.Module, result.Commit, err.Error())
|
planBuilt(ctx, b.open, manifest.Module, result.Commit, err.Error(), asked)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fmt.Printf("%s: %s %s registered, built on %s from %s\n",
|
fmt.Printf("%s: %s %s registered, built on %s from %s\n",
|
||||||
result.ID, manifest.Module, manifest.Version, result.On, short(result.Commit))
|
result.ID, manifest.Module, manifest.Version, result.On, short(result.Commit))
|
||||||
saysWhenThePolicyActs(ctx, b.inv, manifest.Module)
|
saysWhenThePolicyActs(ctx, b.inv, manifest.Module)
|
||||||
planBuilt(ctx, b.open, manifest.Module, result.Commit, "")
|
planBuilt(ctx, b.open, manifest.Module, result.Commit, "", asked)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -334,8 +334,10 @@ func moduleCommand(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func assignCommand(ctx context.Context, verb string, args []string) error {
|
func assignCommand(ctx context.Context, verb string, args []string) error {
|
||||||
if len(args) != 2 {
|
// Several modules in one act (novox/hq ADR 0207): holders that depend on each other — the
|
||||||
return fmt.Errorf("%s <node> <module>", verb)
|
// service manager and the package manager — can only go on, or come off, together.
|
||||||
|
if len(args) < 2 {
|
||||||
|
return fmt.Errorf("%s <node> <module> [<module>…]", verb)
|
||||||
}
|
}
|
||||||
open, err := openStores(ctx)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -349,7 +351,7 @@ func assignCommand(ctx context.Context, verb string, args []string) error {
|
|||||||
if verb == "unassign" {
|
if verb == "unassign" {
|
||||||
act = unassign
|
act = unassign
|
||||||
}
|
}
|
||||||
said, err := act(ctx, open, args[0], args[1])
|
said, err := act(ctx, open, args[0], args[1:]...)
|
||||||
if said != "" {
|
if said != "" {
|
||||||
fmt.Println(said)
|
fmt.Println(said)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,11 +254,10 @@ func theResolver(t *testing.T) catalogue.Manifest {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// The resolver is handed every machine on the private network as a wildcard, the same set and the
|
// The resolver is handed every machine on the private network as a wildcard, and is handed it again
|
||||||
// same source as the hosts file, and is handed it again when a machine leaves — through the
|
// when a machine leaves — through the module's own manifest asking for the fact, with no module of the
|
||||||
// module's own manifest asking for the fact, with no module of the mesh's own in between (hal
|
// mesh's own in between (hal dnsmasq-app conversion, novox/hq 08-connectivity). It is the mesh's one
|
||||||
// dnsmasq-app conversion, novox/hq 08-connectivity). The runtime on that machine is pointed at the
|
// resolver (ADR 0194), and the container runtime is given no resolver of its own (ADR 0196).
|
||||||
// machine's own address, where the resolver answers for its containers.
|
|
||||||
func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *testing.T) {
|
func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *testing.T) {
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -266,6 +265,12 @@ func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *t
|
|||||||
if _, err := assign(ctx, open, "anchor", "dnsmasq"); err != nil {
|
if _, err := assign(ctx, open, "anchor", "dnsmasq"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
// Its bus credential, as assigning issues it where the bus is reachable (novox/hq issue 203):
|
||||||
|
// no bus is known to this test, so it is minted here, or composing refuses the placeholder.
|
||||||
|
if _, err := open.inventory.MintBusPassword(ctx, inventory.BusUser{
|
||||||
|
Username: "anchor.dnsmasq", Kind: inventory.BusModule, Node: "anchor", Module: "dnsmasq"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
zones := func() string {
|
zones := func() string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, r := range composed(t, open, "anchor").Resources {
|
for _, r := range composed(t, open, "anchor").Resources {
|
||||||
@@ -287,11 +292,12 @@ func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *t
|
|||||||
t.Errorf("the resolver's machines lack %q:\n%s", want, first)
|
t.Errorf("the resolver's machines lack %q:\n%s", want, first)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The container runtime is given no resolver of its own (novox/hq ADR 0196): it copies its
|
||||||
|
// machine's, which name the mesh's resolver first. A `dns` key would be a second account of where a
|
||||||
|
// container asks, read only when the runtime starts.
|
||||||
for _, r := range composed(t, open, "anchor").Resources {
|
for _, r := range composed(t, open, "anchor").Resources {
|
||||||
if r["id"] == "dnsmasq.runtime-dns" {
|
if r["id"] == "dnsmasq.runtime-dns" {
|
||||||
if !strings.Contains(r["content"].(string), `"10.77.0.1"`) || r["into"] != "json" {
|
t.Errorf("the resolver still writes the runtime's own dns: %v", r)
|
||||||
t.Errorf("the runtime is not pointed at this machine's own address, written into its file: %v", r)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -211,3 +211,27 @@ func TestWhatAHandedOverModuleRecordsAboutItsSource(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// novox/hq 04-ISSUES/215: a module once built at a commit still follows its branch — a merge into it
|
||||||
|
// matches the module, and a plan re-asks the branch, not the old commit.
|
||||||
|
func TestAModuleBuiltAtACommitStillFollowsItsBranch(t *testing.T) {
|
||||||
|
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main"}
|
||||||
|
pinned := inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Ref: "9c97a8a"}
|
||||||
|
if !sourceIs(pinned, m) {
|
||||||
|
t.Error("a module whose record names a commit is left out of a merge into its branch")
|
||||||
|
}
|
||||||
|
full := inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Ref: "9c97a8a1d2c3b4a5f60718293a4b5c6d7e8f9012"}
|
||||||
|
if !sourceIs(full, m) {
|
||||||
|
t.Error("a full commit hash is read as a branch")
|
||||||
|
}
|
||||||
|
if got := followedBranch("9c97a8a"); got != "" {
|
||||||
|
t.Errorf("a plan would re-ask the old commit %q", got)
|
||||||
|
}
|
||||||
|
if got := followedBranch("release"); got != "release" {
|
||||||
|
t.Errorf("a branch is not followed as named: %q", got)
|
||||||
|
}
|
||||||
|
// A module that follows another branch is still not this merge's.
|
||||||
|
if sourceIs(inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Ref: "release"}, m) {
|
||||||
|
t.Error("a module following another branch was matched")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+163
-1
@@ -8,8 +8,10 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"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"
|
||||||
@@ -127,6 +129,7 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
|||||||
// a mesh-wide gatherer may pass over — see notResolvable.
|
// a mesh-wide gatherer may pass over — see notResolvable.
|
||||||
return catalogue.Resolution{}, nil, notResolvable{err}
|
return catalogue.Resolution{}, nil, notResolvable{err}
|
||||||
}
|
}
|
||||||
|
logUnheld(nodeName, resolved.Unheld)
|
||||||
|
|
||||||
// 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
|
||||||
// password a provider is told to create is the one its consumer was given — and sealed to
|
// password a provider is told to create is the one its consumer was given — and sealed to
|
||||||
@@ -657,6 +660,13 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
machines[name] = at
|
machines[name] = at
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// And every zone a module in the mesh answers itself (novox/hq ADR 0199), for the mesh's resolver
|
||||||
|
// to forward.
|
||||||
|
zones, err := zonesInTheMesh(ctx, open)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
|
}
|
||||||
|
|
||||||
// **The bus is never public** (novox/hq ADR 0169). It was a foundation port — widened from the
|
// **The bus is never public** (novox/hq ADR 0169). It was a foundation port — widened from the
|
||||||
// broker's own `from: mesh` to from-anywhere on the broker's host, so a machine could enrol
|
// broker's own `from: mesh` to from-anywhere on the broker's host, so a machine could enrol
|
||||||
// before it had an address on the private network. A machine joins through the tunnel now, and
|
// before it had an address on the private network. A machine joins through the tunnel now, and
|
||||||
@@ -726,7 +736,7 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
BusMembership: memberships[node],
|
BusMembership: memberships[node],
|
||||||
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports,
|
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, Zones: zones,
|
||||||
Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation,
|
Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation,
|
||||||
Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks,
|
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,
|
||||||
@@ -734,6 +744,71 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
}, record, nil
|
}, record, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR
|
||||||
|
// 0199): the zone settled from that node's settings, the node's private address, the port the
|
||||||
|
// answering listen is published on there.
|
||||||
|
//
|
||||||
|
// Read across every machine's resolution, as the roster once read routed names: a node whose set does
|
||||||
|
// not compose declares nothing and is passed over, so one broken machine does not cost the rest their
|
||||||
|
// zones; a store that cannot be read is raised, naming the machine, because returning the zones
|
||||||
|
// without it would withdraw them from the resolver as if the operator had (novox/hq 04-ISSUES/152).
|
||||||
|
// What the mesh refuses about the zones together — one declared twice, one shadowing the mesh's
|
||||||
|
// suffix or a node's public domain — is refused here, by name.
|
||||||
|
func zonesInTheMesh(ctx context.Context, open *stores) ([]catalogue.ZoneAt, error) {
|
||||||
|
inv := open.inventory
|
||||||
|
places, err := inv.Overlays(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("where the machines are cannot be read: %w", err)
|
||||||
|
}
|
||||||
|
address := map[string]string{}
|
||||||
|
for _, p := range places {
|
||||||
|
if strings.TrimSpace(p.Address) != "" {
|
||||||
|
address[p.Name] = p.Address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodes, err := inv.Nodes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("which machines the mesh has cannot be read: %w", err)
|
||||||
|
}
|
||||||
|
var zones []catalogue.ZoneAt
|
||||||
|
var public []string
|
||||||
|
for _, n := range nodes {
|
||||||
|
plan, _, err := planFor(ctx, open, n.Name)
|
||||||
|
switch {
|
||||||
|
case unresolvable(err):
|
||||||
|
continue
|
||||||
|
case err != nil:
|
||||||
|
return nil, fmt.Errorf("the zones %s answers cannot be read: %w", n.Name, err)
|
||||||
|
}
|
||||||
|
if plan.PublicDomain != "" {
|
||||||
|
public = append(public, plan.PublicDomain)
|
||||||
|
}
|
||||||
|
for _, m := range plan.Modules {
|
||||||
|
if m.Zone == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
at := address[n.Name]
|
||||||
|
if at == "" {
|
||||||
|
// Not on the private network yet: nothing could reach its answerer.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
published, layers, err := portsGivenOn(ctx, inv, n.Name, m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the zone %s declares on %s cannot be read: %w", m.Module, n.Name, err)
|
||||||
|
}
|
||||||
|
z, err := catalogue.ZoneOn(m, layers, published, n.Name, at)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
zones = append(zones, *z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if problems := catalogue.ZonesProblems(zones, overlay.Suffix(), public); len(problems) > 0 {
|
||||||
|
return nil, fmt.Errorf("the mesh's zones cannot be forwarded:\n - %s", strings.Join(problems, "\n - "))
|
||||||
|
}
|
||||||
|
return zones, nil
|
||||||
|
}
|
||||||
|
|
||||||
// certificateFor is what the mesh certifies about one machine's internal name.
|
// certificateFor is what the mesh certifies about one machine's internal name.
|
||||||
//
|
//
|
||||||
// It reaches across two contexts and reads neither one's store from the other: `inventory` knows
|
// It reaches across two contexts and reads neither one's store from the other: `inventory` knows
|
||||||
@@ -1325,3 +1400,90 @@ func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.C
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unheldLogged is what was last logged about each node's unmet seat dependencies, so the log says
|
||||||
|
// each change once (novox/hq ADR 0207), on stderr so `status --json` stays a document.
|
||||||
|
//
|
||||||
|
// **The serving controller's log only.** planFor runs for every node on every push, assignment and
|
||||||
|
// status — `blockedElsewhere` alone resolves the whole mesh — and a one-shot command starts with an
|
||||||
|
// empty memory, so every node's report was "a change" and a push printed the whole mesh's list,
|
||||||
|
// burying the line about the node it acted on. A command says what concerns its own act instead
|
||||||
|
// (unheldChange, reportUnheldPushed); the full list is `status`'s.
|
||||||
|
var (
|
||||||
|
unheldLogged = map[string]string{}
|
||||||
|
unheldLoggedMu sync.Mutex
|
||||||
|
logUnheldChanges bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// logUnheld logs a node's unmet seat dependencies when they differ from what was last logged for
|
||||||
|
// it, including when they become none — in the serving controller, and nowhere else.
|
||||||
|
func logUnheld(node string, unheld []catalogue.Unheld) {
|
||||||
|
if !logUnheldChanges {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lines := make([]string, 0, len(unheld))
|
||||||
|
for _, u := range unheld {
|
||||||
|
lines = append(lines, u.String())
|
||||||
|
}
|
||||||
|
now := strings.Join(lines, "\n")
|
||||||
|
unheldLoggedMu.Lock()
|
||||||
|
before, seen := unheldLogged[node]
|
||||||
|
unheldLogged[node] = now
|
||||||
|
unheldLoggedMu.Unlock()
|
||||||
|
if (seen && before == now) || (!seen && now == "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if now == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: every seat its modules depend on is held (novox/hq ADR 0207)\n", node)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: %d unmet seat dependenc(ies), reported and not refused (novox/hq ADR 0207):\n %s\n",
|
||||||
|
node, len(lines), strings.Join(lines, "\n "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// unheldChange is what an act on one node changed about its unmet seat dependencies, judged over
|
||||||
|
// its assignments before and after (novox/hq ADR 0207): each dependency now unmet that was not —
|
||||||
|
// which includes every one of a module just assigned — and each now met that was not. Nothing about
|
||||||
|
// any other node, and nothing that was already true before the act.
|
||||||
|
func unheldChange(shelf map[string]catalogue.Manifest, node string, before, after []string) []string {
|
||||||
|
judge := func(names []string) map[string]catalogue.Unheld {
|
||||||
|
var set []catalogue.Manifest
|
||||||
|
for _, n := range names {
|
||||||
|
if m, known := shelf[n]; known {
|
||||||
|
set = append(set, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := map[string]catalogue.Unheld{}
|
||||||
|
for _, u := range catalogue.UnheldDependencies(shelf, node, set, nil) {
|
||||||
|
out[u.Module+" "+u.Seat] = u
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
was, now := judge(before), judge(after)
|
||||||
|
var lines []string
|
||||||
|
for _, k := range sortedNames(now) {
|
||||||
|
if _, already := was[k]; !already {
|
||||||
|
lines = append(lines, "but "+now[k].String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range sortedNames(was) {
|
||||||
|
if _, still := now[k]; still {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u := was[k]
|
||||||
|
if !slices.Contains(after, u.Module) {
|
||||||
|
continue // went with its module, which says nothing about the seat
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("and %s on %s now has %s held", u.Module, node, u.Seat))
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedNames[V any](m map[string]V) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq issue 213: for the moment a machine hands its controller over, the container and the
|
||||||
|
// process both run the plan timer on one store. Only the one holding the plans moves them; the other
|
||||||
|
// leaves them alone, and moves them once they are let go.
|
||||||
|
func TestAControllerLeavesThePlansToTheOneHoldingThem(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
// Every tier done: the next step is the plan's last, and needs nothing but the store.
|
||||||
|
plan := inventory.Plan{ID: "plan-213", Repository: "r", Commit: "abc", Created: now, Updated: now,
|
||||||
|
State: inventory.PlanRolling, Tier: 1, Tiers: [][]string{{"app"}},
|
||||||
|
Modules: map[string]*inventory.PlanModule{"app": {State: "built"}}}
|
||||||
|
if err := open.inventory.SavePlan(ctx, plan); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other controller: its own connections to the same store, holding the plans.
|
||||||
|
other, err := inventory.Open(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(other.Close)
|
||||||
|
release, err := other.HoldPlans(ctx, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(release) // before the close above: a pool waits for a connection still held
|
||||||
|
|
||||||
|
advancePlans(ctx, open)
|
||||||
|
if p, err := open.inventory.PlanByID(ctx, "plan-213"); err != nil || !p.Open() {
|
||||||
|
t.Fatalf("a controller moved a plan another held: %+v %v", p, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
release()
|
||||||
|
advancePlans(ctx, open)
|
||||||
|
if p, err := open.inventory.PlanByID(ctx, "plan-213"); err != nil || p.State != inventory.PlanDone {
|
||||||
|
t.Fatalf("the plan did not move once it was let go: %+v %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -62,6 +63,9 @@ func connectLink(ctx context.Context, inv *inventory.Inventory, enroller link.En
|
|||||||
}
|
}
|
||||||
|
|
||||||
func serve(ctx context.Context) error {
|
func serve(ctx context.Context) error {
|
||||||
|
// The one process whose log is read over time, so the one that says each change to a node's
|
||||||
|
// unmet seat dependencies once (novox/hq ADR 0207).
|
||||||
|
logUnheldChanges = true
|
||||||
open, err := openStores(ctx)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -360,6 +364,9 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Each machine's unmet seat dependencies (novox/hq ADR 0207), said after the sends: in full
|
||||||
|
// for a machine named, as a count for each of many — the full list is `status`'s.
|
||||||
|
unheld := map[string][]catalogue.Unheld{}
|
||||||
sending, refusals := composeEach(asked, allotting(held, inv), func(node string) (sendable, error) {
|
sending, refusals := composeEach(asked, allotting(held, inv), func(node string) (sendable, error) {
|
||||||
plan, settings, err := planFor(held, open, node)
|
plan, settings, err := planFor(held, open, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -369,6 +376,7 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
// healthy modules beside it are still resolved and sent. Reported so it is not silently
|
// healthy modules beside it are still resolved and sent. Reported so it is not silently
|
||||||
// dropped — the remedy is to move it, and until then the rest of the node converges.
|
// dropped — the remedy is to move it, and until then the rest of the node converges.
|
||||||
reportUnhostable(node, plan)
|
reportUnhostable(node, plan)
|
||||||
|
unheld[node] = plan.Unheld
|
||||||
// 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.
|
||||||
@@ -402,6 +410,7 @@ 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))
|
||||||
|
reportUnheldPushed(os.Stdout, len(args) == 1, asked, unheld)
|
||||||
// And each machine's memberships, as every other send does (ADR 0160): a push is the one most
|
// 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.
|
// operators run, and on 2026-10-01 it was the one path that issued none.
|
||||||
if err := issueMemberships(ctx, open, server, sending); err != nil {
|
if err := issueMemberships(ctx, open, server, sending); err != nil {
|
||||||
@@ -747,6 +756,16 @@ func issueMemberships(ctx context.Context, open *stores, server *link.Server, se
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// **Every declared state's bucket, before the memberships that name it** (novox/hq ADR 0201). The
|
||||||
|
// raise at start asserts them too, but a module registered and assigned since would otherwise have
|
||||||
|
// its bucket only after the control plane next restarts — found the first time a module declared
|
||||||
|
// state: its bundle asked for a bucket that did not exist. Idempotent and cheap; a failure is said
|
||||||
|
// and the push stands, as a membership's is.
|
||||||
|
if buckets, err := open.inventory.DeclaredBuckets(ctx); err != nil {
|
||||||
|
fmt.Printf(" the modules' state could not be read, so no bucket was asserted: %v\n", err)
|
||||||
|
} else if _, err := broker.RaiseBuckets(broker.OnConn(bus.Conn), buckets); err != nil {
|
||||||
|
fmt.Printf(" the modules' state could not be asserted on the bus: %v — the next push tries again\n", err)
|
||||||
|
}
|
||||||
// The declarations are sent and recorded by now; a membership that cannot be issued is said
|
// 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
|
// 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.
|
// the push stands, the first failure is named once, and the next push tries again.
|
||||||
@@ -898,6 +917,21 @@ func raiseTheBus(ctx context.Context, inv *inventory.Inventory, address string)
|
|||||||
if err := broker.RaiseSeats(js, inventory.MeshSeats(), holders); err != nil {
|
if err := broker.RaiseSeats(js, inventory.MeshSeats(), holders); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Every module's state (novox/hq ADR 0201), from the catalogue: a bucket exists from
|
||||||
|
// registration, so a module reading one may watch it before its owner runs anywhere. One that
|
||||||
|
// nothing declares any more is said and kept — what it holds is data.
|
||||||
|
buckets, err := inv.DeclaredBuckets(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
undeclared, err := broker.RaiseBuckets(js, buckets)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(undeclared) > 0 {
|
||||||
|
fmt.Printf("the bus holds state nothing declares any more, kept because it is data: %s — "+
|
||||||
|
"removing it is a person's act\n", strings.Join(undeclared, ", "))
|
||||||
|
}
|
||||||
// And how every module hears what it consumes. Derived from the same records the user list is
|
// 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.
|
// 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
|
// Done on every raise, not only when a credential is issued: every module moved onto this bus
|
||||||
@@ -911,18 +945,14 @@ func raiseTheBus(ctx context.Context, inv *inventory.Inventory, address string)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
hearing := 0
|
hearing := 0
|
||||||
for _, p := range users {
|
for _, c := range broker.ConsumersOf(users) {
|
||||||
consumer, needed := broker.ConsumerFor(p)
|
if err := js.EnsureConsumer(c.Consumer); err != nil {
|
||||||
if !needed {
|
return fmt.Errorf("how %s on %s hears what it consumes: %w", c.Module, c.Node, err)
|
||||||
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++
|
hearing++
|
||||||
}
|
}
|
||||||
fmt.Printf("the bus at %s has its streams, %d machine(s) can hear a declaration, and %d module(s) "+
|
fmt.Printf("the bus at %s has its streams, %d machine(s) can hear a declaration, %d module(s) "+
|
||||||
"can hear what they consume\n", broker.BareAddress(address), len(names), hearing)
|
"can hear what they consume, and %d bucket(s) of state\n", broker.BareAddress(address), len(names), hearing, len(buckets))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -997,3 +1027,24 @@ func recordSent(ctx context.Context, inv *inventory.Inventory, node string, body
|
|||||||
}
|
}
|
||||||
return digest, nil
|
return digest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reportUnheldPushed says what a push's machines lack of the seats their modules depend on
|
||||||
|
// (novox/hq ADR 0207): every line for a machine the push named, since that is the machine somebody
|
||||||
|
// is looking at, and one line per machine otherwise — a list per machine across the mesh is the
|
||||||
|
// hundred lines that buried the one that mattered. Nothing for a machine that lacks nothing.
|
||||||
|
func reportUnheldPushed(w io.Writer, named bool, asked []string, unheld map[string][]catalogue.Unheld) {
|
||||||
|
for _, node := range asked {
|
||||||
|
lines := unheld[node]
|
||||||
|
if len(lines) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if named {
|
||||||
|
fmt.Fprintf(w, "\n%s has %d unmet seat dependenc(ies) (novox/hq ADR 0207):\n", node, len(lines))
|
||||||
|
for _, u := range lines {
|
||||||
|
fmt.Fprintf(w, " %s\n", u)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s: %d unmet seat dependenc(ies) — see `status`\n", node, len(lines))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
@@ -73,6 +74,10 @@ type meshStatus struct {
|
|||||||
// alone. A document without this called a machine well while a predecessor's chain refused
|
// alone. A document without this called a machine well while a predecessor's chain refused
|
||||||
// what the mesh declared open.
|
// what the mesh declared open.
|
||||||
Filtered []machineFiltered `json:"filtered,omitempty"`
|
Filtered []machineFiltered `json:"filtered,omitempty"`
|
||||||
|
// Unheld is every module on a machine whose resources are applied through a seat nothing on
|
||||||
|
// that machine holds, with the modules that could hold it (novox/hq ADR 0207). Absent when every
|
||||||
|
// dependency is met. Reported, not refused, until the switch.
|
||||||
|
Unheld []catalogue.Unheld `json:"unheld,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// machineFiltered is one rule set on a converged machine that the mesh did not write and that
|
// machineFiltered is one rule set on a converged machine that the mesh did not write and that
|
||||||
@@ -204,6 +209,7 @@ func statusAsJSON(asked answers) ([]byte, error) {
|
|||||||
out.Filtered = append(out.Filtered, machineFiltered{Node: name, Where: x.Where, Owner: x.Owner, Refuses: x.Refuses})
|
out.Filtered = append(out.Filtered, machineFiltered{Node: name, Where: x.Where, Owner: x.Owner, Refuses: x.Refuses})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
out.Unheld = asked.unheld
|
||||||
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]})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -272,7 +273,8 @@ func askTier(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan) e
|
|||||||
}
|
}
|
||||||
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
||||||
fmt.Printf(" tier %d: ", p.Tier)
|
fmt.Printf(" tier %d: ", p.Tier)
|
||||||
if err := buildOne(ctx, source, e.Source.Path, e.Source.Ref, 0); err != nil {
|
// The branch it follows, never a commit a build once named (novox/hq 04-ISSUES/215).
|
||||||
|
if err := buildOne(ctx, source, e.Source.Path, followedBranch(e.Source.Ref), 0); err != nil {
|
||||||
state.State = "failed"
|
state.State = "failed"
|
||||||
state.Why = err.Error()
|
state.Why = err.Error()
|
||||||
p.State = inventory.PlanFailed
|
p.State = inventory.PlanFailed
|
||||||
@@ -287,8 +289,23 @@ func askTier(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan) e
|
|||||||
|
|
||||||
// planBuilt marks a module built (or failed) in every open plan whose current tier holds it, and
|
// 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.
|
// advances what that completes. Called from the daemon's take-in of every outcome.
|
||||||
func planBuilt(ctx context.Context, open *stores, module, commit, failed string) {
|
//
|
||||||
|
// **Only a build asked at or after the plan's ask is its outcome** (novox/hq 04-ISSUES/219). Two
|
||||||
|
// plans a few minutes apart both ask for a module; the earlier plan's build, finishing late, is not
|
||||||
|
// the later plan's answer — it stood on the bases from before the later plan's merge, and taking it
|
||||||
|
// would send machines, and the next tier, what the later merge replaced. asked is zero when the
|
||||||
|
// build's request time is not known, and such an outcome is taken as before.
|
||||||
|
func planBuilt(ctx context.Context, open *stores, module, commit, failed string, asked time.Time) {
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
|
// One controller works the plans at a time (novox/hq issue 213); an outcome waits its turn rather
|
||||||
|
// than write over what the holder is about to save. Not taken, it is still in the build records,
|
||||||
|
// which the holder settles the plan from (issue 214).
|
||||||
|
release, err := inv.HoldPlans(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("plans: %s's outcome is left to the build records: %v\n", module, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
plans, err := inv.OpenPlans(ctx)
|
plans, err := inv.OpenPlans(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("plans: cannot read them: %v\n", err)
|
fmt.Printf("plans: cannot read them: %v\n", err)
|
||||||
@@ -314,6 +331,9 @@ func planBuilt(ctx context.Context, open *stores, module, commit, failed string)
|
|||||||
state = &inventory.PlanModule{}
|
state = &inventory.PlanModule{}
|
||||||
p.Modules[module] = state
|
p.Modules[module] = state
|
||||||
}
|
}
|
||||||
|
if askedBefore(asked, state.AskedAt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if failed != "" {
|
if failed != "" {
|
||||||
state.State = "failed"
|
state.State = "failed"
|
||||||
state.Why = failed
|
state.Why = failed
|
||||||
@@ -332,13 +352,30 @@ func planBuilt(ctx context.Context, open *stores, module, commit, failed string)
|
|||||||
fmt.Printf("%s: %s; the tiers after it are not asked\n", p.ID, p.Note)
|
fmt.Printf("%s: %s; the tiers after it are not asked\n", p.ID, p.Note)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
advancePlans(ctx, open)
|
advanceHeld(ctx, open)
|
||||||
}
|
}
|
||||||
|
|
||||||
// advancePlans moves every open plan as far as the facts allow: a tier whose modules are all built
|
// 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
|
// 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.
|
// after every outcome and on a timer, so a plan waiting on a machine's report moves when it comes.
|
||||||
|
//
|
||||||
|
// **One controller at a time** (novox/hq issue 213). A plan is read, changed and saved whole; two
|
||||||
|
// controllers — the old and the new while a machine hands its controller over — would each ask a
|
||||||
|
// tier the other had just asked. Taken without waiting: whoever holds the plans is moving them.
|
||||||
func advancePlans(ctx context.Context, open *stores) {
|
func advancePlans(ctx context.Context, open *stores) {
|
||||||
|
release, err := open.inventory.HoldPlans(ctx, false)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, inventory.ErrPlansBusy) {
|
||||||
|
fmt.Printf("plans: cannot hold them: %v\n", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
advanceHeld(ctx, open)
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceHeld is advancePlans for a caller already holding the plans.
|
||||||
|
func advanceHeld(ctx context.Context, open *stores) {
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
plans, err := inv.OpenPlans(ctx)
|
plans, err := inv.OpenPlans(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -399,6 +436,24 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
|||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
// **Asked: settle from the build records first** (novox/hq 04-ISSUES/214). An outcome is taken
|
||||||
|
// in by whichever controller hears it, and a merge to the controller's own repository replaces
|
||||||
|
// the controller in its first tier: the build that produced the new one is recorded, and the
|
||||||
|
// plan never hears it. The record is the fact; a build recorded after the ask is that tier's
|
||||||
|
// outcome, whoever was listening.
|
||||||
|
recorded := map[string][]inventory.Build{}
|
||||||
|
for _, m := range tier {
|
||||||
|
if s := p.Modules[m]; s != nil && s.State == "asked" {
|
||||||
|
builds, err := inv.Builds(ctx, m, 5)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
recorded[m] = builds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if settleFromRecords(p, tier, recorded) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
// Asked: wait for every build.
|
// Asked: wait for every build.
|
||||||
var latest time.Time
|
var latest time.Time
|
||||||
for _, m := range tier {
|
for _, m := range tier {
|
||||||
@@ -530,7 +585,8 @@ func planFailedBuild(ctx context.Context, open *stores, result link.BuildResult)
|
|||||||
}
|
}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if repositoryMatches(e.Source.Repository, result.Repository) && e.Source.Path == result.Path {
|
if repositoryMatches(e.Source.Repository, result.Repository) && e.Source.Path == result.Path {
|
||||||
planBuilt(ctx, open, e.Manifest.Module, result.Commit, result.Failed)
|
asked, _ := link.BuildAskedAt(result.ID)
|
||||||
|
planBuilt(ctx, open, e.Manifest.Module, result.Commit, result.Failed, asked)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -647,6 +703,19 @@ func plansCommand(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
p.State = inventory.PlanFailed
|
p.State = inventory.PlanFailed
|
||||||
p.Note = "stopped by hand at tier " + fmt.Sprint(p.Tier)
|
p.Note = "stopped by hand at tier " + fmt.Sprint(p.Tier)
|
||||||
|
release, err := inv.HoldPlans(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
if p, err = inv.PlanByID(ctx, positionals[1]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !p.Open() {
|
||||||
|
return fmt.Errorf("%s is already %s", p.ID, p.State)
|
||||||
|
}
|
||||||
|
p.State = inventory.PlanFailed
|
||||||
|
p.Note = "stopped by hand at tier " + fmt.Sprint(p.Tier)
|
||||||
if err := inv.SavePlan(ctx, p); err != nil {
|
if err := inv.SavePlan(ctx, p); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -755,3 +824,52 @@ func splitList(s string) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// settleFromRecords marks every module of the tier still `asked` built — or failed — from a build
|
||||||
|
// recorded after it was asked, and says whether it changed anything (novox/hq 04-ISSUES/214).
|
||||||
|
// Newest first, as Builds answers: the first record after the ask is the outcome of that ask.
|
||||||
|
func settleFromRecords(p *inventory.Plan, tier []string, recorded map[string][]inventory.Build) bool {
|
||||||
|
changed := false
|
||||||
|
for _, m := range tier {
|
||||||
|
s := p.Modules[m]
|
||||||
|
if s == nil || s.State != "asked" || s.AskedAt == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var outcome *inventory.Build
|
||||||
|
for i := range recorded[m] {
|
||||||
|
b := recorded[m][i]
|
||||||
|
if b.At.Before(*s.AskedAt) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Recorded after the ask and asked before it: an earlier ask's late outcome, not this
|
||||||
|
// one's (novox/hq 04-ISSUES/219).
|
||||||
|
if askedBefore(b.Asked, s.AskedAt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outcome = &b
|
||||||
|
}
|
||||||
|
if outcome == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
at := outcome.At
|
||||||
|
if outcome.Worked() {
|
||||||
|
s.State = "built"
|
||||||
|
s.BuiltAt = &at
|
||||||
|
s.Commit = outcome.Commit
|
||||||
|
} else {
|
||||||
|
s.State = "failed"
|
||||||
|
s.Why = outcome.Failed
|
||||||
|
p.State = inventory.PlanFailed
|
||||||
|
p.Note = fmt.Sprintf("%s failed to build in tier %d", m, p.Tier)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: %s settled from the build records as %s (%s)\n", p.ID, m, s.State, outcome.ID)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// askedBefore is whether a build asked at asked was asked before a plan asked for its module — and
|
||||||
|
// so is not that plan's outcome (novox/hq 04-ISSUES/219). False when either time is not known.
|
||||||
|
func askedBefore(asked time.Time, planAsked *time.Time) bool {
|
||||||
|
return !asked.IsZero() && planAsked != nil && asked.Before(*planAsked)
|
||||||
|
}
|
||||||
|
|||||||
@@ -126,3 +126,58 @@ func TestABundleIsPlannedAfterTheToolchainItIsCompiledIn(t *testing.T) {
|
|||||||
t.Fatalf("the toolchain, then the bundle: %v", p.Tiers)
|
t.Fatalf("the toolchain, then the bundle: %v", p.Tiers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// novox/hq 04-ISSUES/214: a plan whose build outcome was recorded while no controller followed it —
|
||||||
|
// the controller rebuilding itself — settles from the build records instead of waiting for ever.
|
||||||
|
func TestAPlanSettlesAnAskedBuildFromTheRecords(t *testing.T) {
|
||||||
|
asked := time.Date(2026, 10, 3, 19, 20, 0, 0, time.UTC)
|
||||||
|
p := inventory.Plan{ID: "plan-1", Tiers: [][]string{{"mesh-controller", "builder"}, {"route-proxy"}},
|
||||||
|
Modules: map[string]*inventory.PlanModule{
|
||||||
|
"mesh-controller": {State: "asked", AskedAt: &asked},
|
||||||
|
"builder": {State: "asked", AskedAt: &asked},
|
||||||
|
}}
|
||||||
|
records := map[string][]inventory.Build{
|
||||||
|
// Newest first, as Builds answers: the build after the ask is the outcome.
|
||||||
|
"mesh-controller": {
|
||||||
|
{ID: "build-2", Commit: "2ebbb799", At: asked.Add(4 * time.Minute)},
|
||||||
|
{ID: "build-1", Commit: "06ea2168", At: asked.Add(-10 * time.Minute)},
|
||||||
|
},
|
||||||
|
// Only a build from before the ask: not this ask's outcome.
|
||||||
|
"builder": {{ID: "build-0", Commit: "06ea2168", At: asked.Add(-time.Hour)}},
|
||||||
|
}
|
||||||
|
if !settleFromRecords(&p, p.Tiers[0], records) {
|
||||||
|
t.Fatal("nothing settled, though the controller's build is recorded after the ask")
|
||||||
|
}
|
||||||
|
if s := p.Modules["mesh-controller"]; s.State != "built" || s.Commit != "2ebbb799" || s.BuiltAt == nil {
|
||||||
|
t.Errorf("the controller's ask is %+v, want built from 2ebbb799", s)
|
||||||
|
}
|
||||||
|
if s := p.Modules["builder"]; s.State != "asked" {
|
||||||
|
t.Errorf("an ask with no record after it was settled: %+v", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq 04-ISSUES/219: a build recorded after the ask but asked before it — an earlier
|
||||||
|
// plan's late outcome — is not this ask's, built or failed.
|
||||||
|
r := inventory.Plan{ID: "plan-3", Tiers: [][]string{{"postgres"}},
|
||||||
|
Modules: map[string]*inventory.PlanModule{"postgres": {State: "asked", AskedAt: &asked}}}
|
||||||
|
late := map[string][]inventory.Build{"postgres": {
|
||||||
|
{ID: "build-old", Commit: "efff5415", Asked: asked.Add(-18 * time.Minute), At: asked.Add(12 * time.Minute)},
|
||||||
|
}}
|
||||||
|
if settleFromRecords(&r, r.Tiers[0], late) || r.Modules["postgres"].State != "asked" {
|
||||||
|
t.Errorf("an earlier ask's late outcome settled this ask: %+v", r.Modules["postgres"])
|
||||||
|
}
|
||||||
|
// Newest heard first: the earlier ask's late outcome, then this ask's own, heard before it.
|
||||||
|
late["postgres"] = append(late["postgres"], inventory.Build{ID: "build-mine", Commit: "4bcd5f73",
|
||||||
|
Asked: asked.Add(time.Second), At: asked.Add(5 * time.Minute)})
|
||||||
|
if !settleFromRecords(&r, r.Tiers[0], late) || r.Modules["postgres"].State != "built" ||
|
||||||
|
r.Modules["postgres"].Commit != "4bcd5f73" {
|
||||||
|
t.Errorf("this ask's own outcome, heard before the earlier ask's, did not settle it: %+v", r.Modules["postgres"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failure recorded after the ask fails the plan, as hearing it would have.
|
||||||
|
q := inventory.Plan{ID: "plan-2", Tiers: [][]string{{"x"}},
|
||||||
|
Modules: map[string]*inventory.PlanModule{"x": {State: "asked", AskedAt: &asked}}}
|
||||||
|
settleFromRecords(&q, q.Tiers[0], map[string][]inventory.Build{"x": {{ID: "b", Failed: "no", At: asked.Add(time.Minute)}}})
|
||||||
|
if q.State != inventory.PlanFailed || q.Modules["x"].State != "failed" {
|
||||||
|
t.Errorf("a recorded failure did not fail the plan: %+v %+v", q, q.Modules["x"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defends novox/hq ADR 0207 at the controller's acts: `assign` refuses a module whose resources a
|
||||||
|
// seat nothing on the node holds applies, `unassign` refuses taking the last holder from under its
|
||||||
|
// dependents, and `status` reports what composition does not yet refuse.
|
||||||
|
|
||||||
|
func serviceManagerHolder() catalogue.Manifest {
|
||||||
|
return catalogue.Manifest{Module: "systemd", Version: "1",
|
||||||
|
Claims: []catalogue.Claim{{Name: catalogue.ServiceManagerSeat, Scope: catalogue.ScopeNode,
|
||||||
|
Serves: []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal"}}},
|
||||||
|
Resources: []map[string]any{{"id": "systemd", "type": "package", "package": "systemd"}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func packageManagerHolder() catalogue.Manifest {
|
||||||
|
return catalogue.Manifest{Module: "pacman", Version: "1",
|
||||||
|
Claims: []catalogue.Claim{{Name: catalogue.PackageManagerSeat, Scope: catalogue.ScopeNode}},
|
||||||
|
Resources: []map[string]any{{"id": "refresh", "type": "service", "unit": "pacman-refresh.timer"}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func aDaemon() catalogue.Manifest {
|
||||||
|
return catalogue.Manifest{Module: "sshd", Version: "1",
|
||||||
|
Resources: []map[string]any{{"id": "sshd", "type": "service", "unit": "sshd.service"}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAssignmentWithoutItsHolderIsRefusedAndNotKept(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
register(t, open, serviceManagerHolder())
|
||||||
|
register(t, open, packageManagerHolder())
|
||||||
|
register(t, open, aDaemon())
|
||||||
|
|
||||||
|
_, err := assign(ctx, open, "laptop", "sshd")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("sshd went onto a machine nothing holds the service manager of")
|
||||||
|
}
|
||||||
|
for _, want := range []string{catalogue.ServiceManagerSeat, "systemd", "ADR 0207"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("the refusal does not say %q:\n%v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assigned, err := open.inventory.Assigned(ctx, "laptop")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if contains(assigned, "sshd") {
|
||||||
|
t.Fatalf("a refused assignment was kept: %v", assigned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The holders depend on each other, so neither goes on alone — and both go on in one act.
|
||||||
|
if _, err := assign(ctx, open, "laptop", "systemd"); err == nil {
|
||||||
|
t.Fatal("systemd went on alone though its package needs a package manager")
|
||||||
|
}
|
||||||
|
if said, err := assign(ctx, open, "laptop", "systemd", "pacman"); err != nil {
|
||||||
|
t.Fatalf("the two holders assigned together were refused: %v\n%s", err, said)
|
||||||
|
}
|
||||||
|
if said, err := assign(ctx, open, "laptop", "sshd"); err != nil {
|
||||||
|
t.Fatalf("sshd beside its holder was refused: %v\n%s", err, said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheControllerSeatsAssignTakesSeveralModulesAsOneAct(t *testing.T) {
|
||||||
|
argv, err := argvFor("assign", map[string]any{"node": "laptop", "module": "systemd, pacman"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Join(argv, " ") != "assign laptop systemd pacman" {
|
||||||
|
t.Errorf("the seat's assign became %v", argv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnassigningTheLastHolderUnderItsDependentsIsRefused(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
register(t, open, serviceManagerHolder())
|
||||||
|
register(t, open, packageManagerHolder())
|
||||||
|
register(t, open, aDaemon())
|
||||||
|
if _, err := assign(ctx, open, "laptop", "systemd", "pacman", "sshd"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := unassign(ctx, open, "laptop", "systemd")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("the service manager came off a machine still running services")
|
||||||
|
}
|
||||||
|
for _, want := range []string{catalogue.ServiceManagerSeat, "sshd", "pacman"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("the refusal does not name %q:\n%v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assigned, _ := open.inventory.Assigned(ctx, "laptop")
|
||||||
|
if !contains(assigned, "systemd") {
|
||||||
|
t.Fatalf("a refused unassignment took the module off anyway: %v", assigned)
|
||||||
|
}
|
||||||
|
if _, err := unassign(ctx, open, "laptop", "sshd"); err != nil {
|
||||||
|
t.Fatalf("a dependent could not come off: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusReportsAnUnheldDependencyWithoutRefusingTheMachine(t *testing.T) {
|
||||||
|
// The mesh as it ran before the switch (novox/hq ADR 0207 §4).
|
||||||
|
defer catalogue.EnforcingSeatDependencies(false)()
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
register(t, open, serviceManagerHolder())
|
||||||
|
register(t, open, aDaemon())
|
||||||
|
// Assigned straight into the store: a machine whose modules predate the rule, which is every
|
||||||
|
// machine on the day it ships.
|
||||||
|
if _, err := open.inventory.Assign(ctx, "laptop", "sshd"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
asked, err := theThreeQuestions(ctx, open)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, refused := asked.refused["laptop"]; refused {
|
||||||
|
t.Fatalf("an unmet dependency refused the machine before the switch: %s", asked.refused["laptop"])
|
||||||
|
}
|
||||||
|
if asked.well() {
|
||||||
|
t.Error("a mesh with an unheld dependency reads as all well")
|
||||||
|
}
|
||||||
|
got := printed(t, func() error { return printStatus(asked) })
|
||||||
|
for _, want := range []string{"unheld", "laptop", "sshd", catalogue.ServiceManagerSeat, "systemd"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("status does not say %q:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body, err := statusAsJSON(asked)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var doc struct {
|
||||||
|
Unheld []catalogue.Unheld `json:"unheld"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &doc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(doc.Unheld) != 1 || doc.Unheld[0].Module != "sshd" || doc.Unheld[0].Seat != catalogue.ServiceManagerSeat {
|
||||||
|
t.Errorf("the document's unheld is %+v", doc.Unheld)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,7 +113,9 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
|||||||
if err := need("node", "module"); err != nil {
|
if err := need("node", "module"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return []string{verb, str("node"), str("module")}, nil
|
// Several modules comma-separated, judged as one act (novox/hq ADR 0207): the holders of
|
||||||
|
// the seats that apply resources depend on each other and go on together.
|
||||||
|
return append([]string{verb, str("node")}, splitModules(str("module"))...), nil
|
||||||
case "pin":
|
case "pin":
|
||||||
if err := need("node", "provision", "from", "module"); err != nil {
|
if err := need("node", "provision", "from", "module"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -282,6 +282,22 @@ func printStatus(asked answers) error {
|
|||||||
fmt.Printf("\n `take <node> <module>` compares what runs against what it declares, and runs it\n\n")
|
fmt.Printf("\n `take <node> <module>` compares what runs against what it declares, and runs it\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(asked.unheld) > 0 {
|
||||||
|
// **Reported, and not refused yet** (novox/hq ADR 0207 §4). Each machine still resolves and
|
||||||
|
// is sent what it would be; this says which of its modules depend on a seat nothing there
|
||||||
|
// holds, until every machine has its holders and the switch makes it a refusal.
|
||||||
|
fmt.Printf("%d module dependenc(ies) on a seat nothing on the machine holds (unheld, ADR 0207):\n",
|
||||||
|
len(asked.unheld))
|
||||||
|
for _, u := range asked.unheld {
|
||||||
|
holders := "no module in the catalogue claims it yet"
|
||||||
|
if len(u.Holders) > 0 {
|
||||||
|
holders = "could be held by " + strings.Join(u.Holders, ", ")
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-12s %-24s %-24s %s\n", u.Node, u.Module, u.Seat, holders)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n `assign <node> <holder>` meets it; reported until every machine has its holders, then refused\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".
|
||||||
@@ -386,6 +402,20 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return answers{}, err
|
return answers{}, err
|
||||||
}
|
}
|
||||||
|
// And which machines run a module whose resources a seat nothing there holds applies (novox/hq
|
||||||
|
// ADR 0207). Each machine resolved again rather than threaded through whoResolves, whose answer
|
||||||
|
// the private network is built from and should say nothing else; a machine that does not
|
||||||
|
// resolve is already in refused, and is passed over here.
|
||||||
|
for _, n := range out.nodes {
|
||||||
|
plan, _, err := planFor(ctx, open, n.Name)
|
||||||
|
if err != nil {
|
||||||
|
if unresolvable(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return answers{}, err
|
||||||
|
}
|
||||||
|
out.unheld = append(out.unheld, plan.Unheld...)
|
||||||
|
}
|
||||||
out.plans, err = inv.RecentPlans(ctx, 5)
|
out.plans, err = inv.RecentPlans(ctx, 5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return answers{}, err
|
return answers{}, err
|
||||||
@@ -496,7 +526,7 @@ func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inven
|
|||||||
func (a answers) well() bool {
|
func (a answers) well() bool {
|
||||||
return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 &&
|
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 &&
|
len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0 &&
|
||||||
len(a.filtered) == 0
|
len(a.filtered) == 0 && len(a.unheld) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostSplit is which machines report which host version, for every version more than one machine
|
// hostSplit is which machines report which host version, for every version more than one machine
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An act says what it changed about the node it acted on, and nothing about the rest of the mesh
|
||||||
|
// (novox/hq ADR 0207): after the seat dependencies shipped, every `push <node>` and `assign` printed
|
||||||
|
// every node's unmet dependencies, a hundred lines around the one about the module just assigned.
|
||||||
|
|
||||||
|
func aContainer(name string) catalogue.Manifest {
|
||||||
|
return catalogue.Manifest{Module: name, Version: "1",
|
||||||
|
Resources: []map[string]any{{"id": name, "type": "container", "image": name}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAssignmentSaysOnlyWhatItChangedOnItsOwnNode(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
register(t, open, aContainer("web"))
|
||||||
|
register(t, open, aContainer("db"))
|
||||||
|
// anchor already lacks a runtime for db: true, and not this act's to say.
|
||||||
|
if _, err := open.inventory.Assign(ctx, "anchor", "db"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := open.inventory.Assign(ctx, "laptop", "db"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
said, err := assign(ctx, open, "laptop", "web")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v\n%s", err, said)
|
||||||
|
}
|
||||||
|
if !strings.Contains(said, "web on laptop depends on "+catalogue.ContainerRuntimeSeat) {
|
||||||
|
t.Errorf("the assignment does not say what the module it assigned depends on:\n%s", said)
|
||||||
|
}
|
||||||
|
for _, not := range []string{"db on laptop", "db on anchor", "anchor:"} {
|
||||||
|
if strings.Contains(said, not) {
|
||||||
|
t.Errorf("the assignment says %q, which it did not change:\n%s", not, said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAssignmentThatMeetsADependencySaysSo(t *testing.T) {
|
||||||
|
shelf := map[string]catalogue.Manifest{
|
||||||
|
"web": aContainer("web"),
|
||||||
|
"docker": {Module: "docker", Claims: []catalogue.Claim{{Name: catalogue.ContainerRuntimeSeat}},
|
||||||
|
Resources: []map[string]any{{"id": "d", "type": "container", "image": "dind"}}},
|
||||||
|
}
|
||||||
|
lines := unheldChange(shelf, "laptop", []string{"web"}, []string{"web", "docker"})
|
||||||
|
if len(lines) != 1 || !strings.Contains(lines[0], "web on laptop now has "+catalogue.ContainerRuntimeSeat+" held") {
|
||||||
|
t.Errorf("meeting a dependency said %v", lines)
|
||||||
|
}
|
||||||
|
// Taking the dependent off says nothing: the dependency went with its module.
|
||||||
|
if lines := unheldChange(shelf, "laptop", []string{"web"}, nil); len(lines) != 0 {
|
||||||
|
t.Errorf("unassigning the dependent said %v", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPushSaysANamedNodesDependenciesAndCountsTheRest(t *testing.T) {
|
||||||
|
unheld := map[string][]catalogue.Unheld{
|
||||||
|
"anchor": {{Node: "anchor", Module: "db", Seat: catalogue.ContainerRuntimeSeat}},
|
||||||
|
"laptop": {{Node: "laptop", Module: "web", Seat: catalogue.ContainerRuntimeSeat},
|
||||||
|
{Node: "laptop", Module: "sshd", Seat: catalogue.ServiceManagerSeat}},
|
||||||
|
}
|
||||||
|
var named bytes.Buffer
|
||||||
|
reportUnheldPushed(&named, true, []string{"laptop"}, unheld)
|
||||||
|
got := named.String()
|
||||||
|
if !strings.Contains(got, "laptop has 2 unmet") || !strings.Contains(got, "web on laptop") ||
|
||||||
|
!strings.Contains(got, "sshd on laptop") || strings.Contains(got, "anchor") {
|
||||||
|
t.Errorf("a named push said:\n%s", got)
|
||||||
|
}
|
||||||
|
var all bytes.Buffer
|
||||||
|
reportUnheldPushed(&all, false, []string{"anchor", "laptop", "quiet"}, unheld)
|
||||||
|
want := "anchor: 1 unmet seat dependenc(ies) — see `status`\nlaptop: 2 unmet seat dependenc(ies) — see `status`\n"
|
||||||
|
if all.String() != want {
|
||||||
|
t.Errorf("a push to every node said:\n%s\nwant\n%s", all.String(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlyTheServingControllerLogsEachChange(t *testing.T) {
|
||||||
|
read := func(f func()) string {
|
||||||
|
old := os.Stderr
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stderr = w
|
||||||
|
f()
|
||||||
|
_ = w.Close()
|
||||||
|
os.Stderr = old
|
||||||
|
var b bytes.Buffer
|
||||||
|
_, _ = b.ReadFrom(r)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
u := []catalogue.Unheld{{Node: "n1", Module: "web", Seat: catalogue.ContainerRuntimeSeat}}
|
||||||
|
if got := read(func() { logUnheld("n1", u) }); got != "" {
|
||||||
|
t.Errorf("a command logged:\n%s", got)
|
||||||
|
}
|
||||||
|
logUnheldChanges = true
|
||||||
|
defer func() { logUnheldChanges = false }()
|
||||||
|
if got := read(func() { logUnheld("n1", u) }); !strings.Contains(got, "web on n1") {
|
||||||
|
t.Errorf("the serving controller did not log a change:\n%s", got)
|
||||||
|
}
|
||||||
|
if got := read(func() { logUnheld("n1", u) }); got != "" {
|
||||||
|
t.Errorf("an unchanged report was logged again:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -283,6 +284,14 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
|||||||
if isHistory(m.MergedAt, lastLookAt(entries, m)) {
|
if isHistory(m.MergedAt, lastLookAt(entries, m)) {
|
||||||
packaging = nil
|
packaging = nil
|
||||||
}
|
}
|
||||||
|
// Said, never silent (novox/hq 04-ISSUES/215): a module built from this repository that follows
|
||||||
|
// another branch is not part of this merge, and whoever is waiting for its change should read why.
|
||||||
|
for _, e := range entries {
|
||||||
|
if sameRepository(e.Source.Repository, m) && !sourceIs(e.Source, m) {
|
||||||
|
fmt.Printf(" %s is built from %s/%s and follows %s, not %s; this merge leaves it out\n",
|
||||||
|
e.Manifest.Module, m.Owner, m.Repo, e.Source.Ref, m.Base)
|
||||||
|
}
|
||||||
|
}
|
||||||
touched := whatTheMergeTouched(from, entries, m)
|
touched := whatTheMergeTouched(from, entries, m)
|
||||||
for _, e := range touched {
|
for _, e := range touched {
|
||||||
if err := inv.SourceMoved(ctx, e.Manifest.Module, m.Commit); err != nil {
|
if err := inv.SourceMoved(ctx, e.Manifest.Module, m.Commit); err != nil {
|
||||||
@@ -306,6 +315,13 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
|||||||
for _, e := range moved {
|
for _, e := range moved {
|
||||||
movedNames = append(movedNames, e.Manifest.Module)
|
movedNames = append(movedNames, e.Manifest.Module)
|
||||||
}
|
}
|
||||||
|
// Written and its first tier asked as one act on the plans (novox/hq issue 213): a timer on
|
||||||
|
// another controller reading it between the two would ask the tier again.
|
||||||
|
release, err := inv.HoldPlans(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
return notNow(err)
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
plan := planOfMerge(m, movedNames, edges)
|
plan := planOfMerge(m, movedNames, edges)
|
||||||
if hasCycle(plan.Tiers, edges) {
|
if hasCycle(plan.Tiers, edges) {
|
||||||
fmt.Printf(" the last tier depends on itself: %s — built together, in no order\n",
|
fmt.Printf(" the last tier depends on itself: %s — built together, in no order\n",
|
||||||
@@ -345,7 +361,24 @@ func sourceIs(s inventory.Source, m link.SourceMoved) bool {
|
|||||||
if !sameRepository(s.Repository, m) {
|
if !sameRepository(s.Repository, m) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return s.Ref == "" || s.Ref == m.Base
|
ref := followedBranch(s.Ref)
|
||||||
|
return ref == "" || ref == m.Base
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitRef is a ref that names a commit rather than a branch: what `build --ref <commit>` asks for.
|
||||||
|
var commitRef = regexp.MustCompile(`^[0-9a-f]{7,40}$`)
|
||||||
|
|
||||||
|
// followedBranch is the branch a recorded ref means a module follows (novox/hq 04-ISSUES/215). **A
|
||||||
|
// commit is never a branch to follow.** A build asked at a commit — to try one, or to pin it during a
|
||||||
|
// fix — recorded that commit as the module's ref; every merge after it then failed to match the
|
||||||
|
// module, its plan left it out without saying so, and every plan that rebuilt it asked for that same
|
||||||
|
// old commit again. A commit recorded so is read as the repository's default branch, which is what
|
||||||
|
// the module followed before it; a branch is followed as named.
|
||||||
|
func followedBranch(ref string) string {
|
||||||
|
if commitRef.MatchString(strings.TrimSpace(ref)) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ref
|
||||||
}
|
}
|
||||||
|
|
||||||
// sameRepository is whether a recorded repository is the one a merge names, in either spelling it
|
// sameRepository is whether a recorded repository is the one a merge names, in either spelling it
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Package artifacts speaks to the mesh's artifact store over its own door.
|
||||||
|
//
|
||||||
|
// Only what the mesh needs that nothing else does: letting go of something it put there
|
||||||
|
// (novox/hq ADR 0189, issue 108). Pushing is the builder's, through the container runtime; reading
|
||||||
|
// is every machine's, through its runtime. This is the one operation that belongs to the thing
|
||||||
|
// holding the records, because it is the only one that is a decision rather than a transfer.
|
||||||
|
package artifacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is the artifact store at an address, as this machine reaches it.
|
||||||
|
type Store struct {
|
||||||
|
// Address is `host:port` — the store as the caller reaches it now, composed and never
|
||||||
|
// recorded (novox/hq 04-ISSUES/102).
|
||||||
|
Address string
|
||||||
|
// HTTP is the client used; nil is a client with a modest timeout.
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gone is the answer when the store does not hold it: the outcome wanted, already true.
|
||||||
|
var Gone = errors.New("the store does not hold it")
|
||||||
|
|
||||||
|
// ErrNotOurs is a reference this sweep will not address: not the mesh's own, or naming nothing
|
||||||
|
// the store holds by digest.
|
||||||
|
//
|
||||||
|
// **A fact about the record, not about the store** (novox/hq issue 226). The two deserve opposite
|
||||||
|
// responses — skip one and go on, abandon the sweep for the other — and collapsing them into "an
|
||||||
|
// error" is how a cautious loop became one that did nothing while reporting the right number.
|
||||||
|
var ErrNotOurs = errors.New("not a reference into the mesh's artifact store")
|
||||||
|
|
||||||
|
// LetGo asks the store to drop one artifact the mesh recorded making.
|
||||||
|
//
|
||||||
|
// Takes a reference as the mesh records it — `artifact-store://<module>/<artifact>@sha256:…` for
|
||||||
|
// an image, `…/blobs/sha256:…` for an archive — because that is the identity every record uses,
|
||||||
|
// and composes the address here at the moment of use.
|
||||||
|
//
|
||||||
|
// Returns Gone when the store answers that it does not have it. That is not a failure: the sweep
|
||||||
|
// wants the artifact absent, and it is. It is distinguished from success only so a caller can say
|
||||||
|
// which of the two happened.
|
||||||
|
func (s Store) LetGo(ctx context.Context, reference string) error {
|
||||||
|
// **Strict, and deliberately** (novox/hq issue 226). Only a reference the mesh keeps in its
|
||||||
|
// own vocabulary is addressed here. `Recorded` would read `docker.io/library/registry@sha256:…`
|
||||||
|
// as the mesh's too — it cannot tell one registry host from another — so normalising belongs
|
||||||
|
// where the provenance is known, which is the sweep reading its own build records, not here
|
||||||
|
// where the only job is to refuse anything that is not plainly ours.
|
||||||
|
path, kept := catalogue.InArtifactStore(reference)
|
||||||
|
if !kept {
|
||||||
|
// Nothing the mesh put in its own store. Refused rather than attempted: composing a
|
||||||
|
// delete for a reference of unknown shape is how a sweep reaches something that is not
|
||||||
|
// the mesh's. Distinguished from a store that refuses, so a sweep skips this and goes on.
|
||||||
|
return fmt.Errorf("%w: %s", ErrNotOurs, reference)
|
||||||
|
}
|
||||||
|
if s.Address == "" {
|
||||||
|
return fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference)
|
||||||
|
}
|
||||||
|
repository, kind, digest, err := split(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
url := "http://" + s.Address + "/v2/" + repository + "/" + kind + "/" + digest
|
||||||
|
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
client := s.HTTP
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
switch response.StatusCode {
|
||||||
|
case http.StatusAccepted, http.StatusOK, http.StatusNoContent:
|
||||||
|
return nil
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return Gone
|
||||||
|
case http.StatusMethodNotAllowed:
|
||||||
|
// The registry was started without deletion enabled. Said plainly, because the remedy is
|
||||||
|
// a setting on the store's module and not anything about this artifact.
|
||||||
|
return fmt.Errorf(
|
||||||
|
"the artifact store refuses deletion: its server was started without it enabled "+
|
||||||
|
"(REGISTRY_STORAGE_DELETE_ENABLED), so nothing can be collected until the store "+
|
||||||
|
"module is applied again (novox/hq ADR 0189). Asking about %s", reference)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("the artifact store answered %s for %s", response.Status, reference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// split reads a recorded path into the repository, which endpoint names the thing, and the digest.
|
||||||
|
//
|
||||||
|
// Two shapes, which are the two the mesh records: `<repository>@sha256:<hex>` is a manifest, and
|
||||||
|
// `<repository>/blobs/sha256:<hex>` is a blob.
|
||||||
|
func split(path string) (repository, kind, digest string, err error) {
|
||||||
|
if before, after, ok := strings.Cut(path, "@sha256:"); ok {
|
||||||
|
return before, "manifests", "sha256:" + after, nil
|
||||||
|
}
|
||||||
|
if before, after, ok := strings.Cut(path, "/blobs/sha256:"); ok {
|
||||||
|
return before, "blobs", "sha256:" + after, nil
|
||||||
|
}
|
||||||
|
return "", "", "", fmt.Errorf("%w: %q names nothing the store holds by digest", ErrNotOurs, path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package artifacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Asking the store to let go of what the mesh no longer keeps (novox/hq ADR 0189, issue 108).
|
||||||
|
//
|
||||||
|
// A fake store records what it was asked to delete, so what is asserted is the mesh's decision
|
||||||
|
// and the shape of the request — not the registry's behaviour, which is the registry's to test.
|
||||||
|
|
||||||
|
func fakeStore(t *testing.T, answer int) (Store, *[]string) {
|
||||||
|
t.Helper()
|
||||||
|
var asked []string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodDelete {
|
||||||
|
t.Errorf("the store was asked %s %s; collecting is a delete", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
asked = append(asked, r.URL.Path)
|
||||||
|
w.WriteHeader(answer)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
return Store{Address: strings.TrimPrefix(server.URL, "http://")}, &asked
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnImageAndAnArchiveAreAskedForAtTheirOwnEndpoints(t *testing.T) {
|
||||||
|
// The two shapes the mesh records: a manifest by digest, and a blob by digest. They are
|
||||||
|
// different endpoints, and asking at the wrong one answers 404 — which this would then
|
||||||
|
// record as collected, leaving the bytes on disk for ever while the record says otherwise.
|
||||||
|
store, asked := fakeStore(t, http.StatusAccepted)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
image := catalogue.ArtifactStoreScheme + "web/app@sha256:abc123"
|
||||||
|
archive := catalogue.ArtifactStoreScheme + "web/config/blobs/sha256:def456"
|
||||||
|
if err := store.LetGo(ctx, image); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.LetGo(ctx, archive); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"/v2/web/app/manifests/sha256:abc123", "/v2/web/config/blobs/sha256:def456"}
|
||||||
|
if len(*asked) != 2 || (*asked)[0] != want[0] || (*asked)[1] != want[1] {
|
||||||
|
t.Fatalf("the store was asked %v; want %v", *asked, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAStoreThatDoesNotHaveItAnswersGone(t *testing.T) {
|
||||||
|
// The outcome wanted, already true. Told apart from success only so the sweep can say which
|
||||||
|
// happened; both are recorded, because retrying for ever is the thing to avoid.
|
||||||
|
store, _ := fakeStore(t, http.StatusNotFound)
|
||||||
|
err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@sha256:abc123")
|
||||||
|
if !errors.Is(err, Gone) {
|
||||||
|
t.Fatalf("a store that does not hold it answered %v, want Gone", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAStoreWithDeletionOffSaysSoAndNamesTheRemedy(t *testing.T) {
|
||||||
|
// The registry answers 405 when it was started without deletion enabled. The remedy is a
|
||||||
|
// setting on the store's module, and saying "405" would send somebody to the wrong place.
|
||||||
|
store, _ := fakeStore(t, http.StatusMethodNotAllowed)
|
||||||
|
err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@sha256:abc123")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a store that refuses deletion was read as success")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "REGISTRY_STORAGE_DELETE_ENABLED") {
|
||||||
|
t.Fatalf("the refusal does not name the remedy: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAReferenceThatIsNotTheMeshsOwnIsNeverAsked(t *testing.T) {
|
||||||
|
// The whole safety of the sweep is that it names only what the mesh recorded putting there.
|
||||||
|
// A reference of another shape — a vendor's image, a package version — is refused rather
|
||||||
|
// than composed into a delete somewhere that is not the mesh's store.
|
||||||
|
store, asked := fakeStore(t, http.StatusAccepted)
|
||||||
|
for _, reference := range []string{
|
||||||
|
"docker.io/library/registry@sha256:abc123",
|
||||||
|
"registry@sha256:abc123",
|
||||||
|
"1.4.2",
|
||||||
|
} {
|
||||||
|
if err := store.LetGo(context.Background(), reference); err == nil {
|
||||||
|
t.Errorf("%s was asked about; it is not a reference into the mesh's store", reference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(*asked) != 0 {
|
||||||
|
t.Fatalf("the store was asked about %v", *asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reference this sweep will not address says so as ErrNotOurs, which is a fact about the
|
||||||
|
// record and not about the store (novox/hq issue 226).
|
||||||
|
//
|
||||||
|
// The sweep skips one and abandons itself for the other, so they cannot be the same error. The
|
||||||
|
// first live run met a reference recorded with the store's old address, read the refusal as "the
|
||||||
|
// store refuses everything", and collected none of the 1681 it had found.
|
||||||
|
func TestAReferenceThisSweepWillNotAddressIsToldApartFromAStoreRefusing(t *testing.T) {
|
||||||
|
store, asked := fakeStore(t, http.StatusAccepted)
|
||||||
|
for _, reference := range []string{
|
||||||
|
"docker.io/library/registry@sha256:abc123",
|
||||||
|
"127.0.0.1:5100/mesh-tools/build@sha256:abc123",
|
||||||
|
"1.4.2",
|
||||||
|
} {
|
||||||
|
err := store.LetGo(context.Background(), reference)
|
||||||
|
if !errors.Is(err, ErrNotOurs) {
|
||||||
|
t.Errorf("%s answered %v; a sweep must be able to skip it and go on", reference, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(*asked) != 0 {
|
||||||
|
t.Fatalf("the store was asked about %v", *asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,6 +144,44 @@ func ConsumerFor(p Principal) (Consumer, bool) {
|
|||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ModuleConsumer is one module's durable consumer, with the module and node it is for.
|
||||||
|
type ModuleConsumer struct {
|
||||||
|
Node, Module string
|
||||||
|
Consumer Consumer
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsumersOf is every module's durable consumer the composed users imply: each module user's, and
|
||||||
|
// each module a runtime carries — a carried module with no account of its own is no user (novox/hq
|
||||||
|
// issue 195), and its consumer is still the controller's to make, since the runtime reads it on the
|
||||||
|
// module's behalf (ADR 0198). One per module and node, whichever of the two named it first.
|
||||||
|
func ConsumersOf(users []Principal) []ModuleConsumer {
|
||||||
|
var out []ModuleConsumer
|
||||||
|
seen := map[string]bool{}
|
||||||
|
add := func(p Principal) {
|
||||||
|
c, needed := ConsumerFor(p)
|
||||||
|
if !needed || seen[p.Node+"/"+p.Module] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[p.Node+"/"+p.Module] = true
|
||||||
|
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: c})
|
||||||
|
}
|
||||||
|
for _, p := range users {
|
||||||
|
if p.Kind == KindModule {
|
||||||
|
add(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range users {
|
||||||
|
if p.Kind != KindNodeTools {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, d := range p.Carries {
|
||||||
|
add(Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
|
||||||
|
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// HolderConsumerFor is the worker a seat's holders share on that seat's work queue.
|
// HolderConsumerFor is the worker a seat's holders share on that seat's work queue.
|
||||||
//
|
//
|
||||||
// **One worker for every holder, and each holder pulls one ask when it is idle** (novox/hq ADR
|
// **One worker for every holder, and each holder pulls one ask when it is idle** (novox/hq ADR
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package broker
|
package broker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The JetStream side of the controller: the one place the mesh's streams and consumers are
|
// The JetStream side of the controller: the one place the mesh's streams and consumers are
|
||||||
@@ -84,6 +86,13 @@ func pinnedTo(path string) (*tls.Config, error) {
|
|||||||
return PinnedToFingerprint(want), nil
|
return PinnedToFingerprint(want), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnConn is the JetStream handle over a connection the caller already holds — the control plane's
|
||||||
|
// link — for asserting what the bus holds without dialling a second time.
|
||||||
|
func OnConn(conn *nats.Conn) *JetStream {
|
||||||
|
js, _ := conn.JetStream()
|
||||||
|
return &JetStream{conn: conn, js: js}
|
||||||
|
}
|
||||||
|
|
||||||
// DialPinned is Dial with the server's certificate pinned by a fingerprint the caller already holds
|
// 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.
|
// — 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) {
|
func DialPinned(url, fingerprint string, opts ...nats.Option) (*JetStream, error) {
|
||||||
@@ -316,3 +325,50 @@ func retentionOf(r Retention) nats.RetentionPolicy {
|
|||||||
return nats.LimitsPolicy
|
return nats.LimitsPolicy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsureBucket creates a module's bucket if it is absent and brings its options to match if it is
|
||||||
|
// present (novox/hq ADR 0201).
|
||||||
|
//
|
||||||
|
// **An update, never a delete and recreate**, for the reason a stream is updated: recreating
|
||||||
|
// discards what the bucket holds, and what a module's state holds is data. The mesh's caps are
|
||||||
|
// asserted with the owner's options, so a bucket made by hand converges to them.
|
||||||
|
func (j *JetStream) EnsureBucket(b Bucket) error {
|
||||||
|
history := b.History
|
||||||
|
if history == 0 {
|
||||||
|
history = 1
|
||||||
|
}
|
||||||
|
js, err := jetstream.New(j.conn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||||
|
Bucket: b.Bucket(),
|
||||||
|
Description: b.Why(),
|
||||||
|
History: uint8(history),
|
||||||
|
TTL: time.Duration(b.TTLSeconds) * time.Second,
|
||||||
|
MaxValueSize: StateMaxValueBytes,
|
||||||
|
MaxBytes: StateMaxBytes,
|
||||||
|
Storage: jetstream.FileStorage,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("asserting bucket %s: %w", b.Bucket(), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BucketNames is every key-value bucket on the server, the mesh's and anybody else's.
|
||||||
|
func (j *JetStream) BucketNames() ([]string, error) {
|
||||||
|
js, err := jetstream.New(j.conn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
lister := js.KeyValueStoreNames(ctx)
|
||||||
|
var out []string
|
||||||
|
for name := range lister.Name() {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out, lister.Error()
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ type Membership struct {
|
|||||||
// module that must tell the mesh from the world, the route proxy serving an internal name, reads
|
// module that must tell the mesh from the world, the route proxy serving an internal name, reads
|
||||||
// it here rather than keeping a definition of its own.
|
// it here rather than keeping a definition of its own.
|
||||||
Mesh []string `json:"mesh,omitempty"`
|
Mesh []string `json:"mesh,omitempty"`
|
||||||
|
// State is every bucket this module's code may reach, by the name it uses for each, and whether
|
||||||
|
// it may write it (novox/hq ADR 0201): the runtime answers a bundle's state verbs from this list
|
||||||
|
// and refuses, with the reason, what is not on it — the bus enforces only the union over every
|
||||||
|
// module on the machine.
|
||||||
|
State []StateIssued `json:"state,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Served is one address a tool is answered on.
|
// Served is one address a tool is answered on.
|
||||||
@@ -103,6 +108,7 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
|
|||||||
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
|
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
m.State = stateIssuedFor(d)
|
||||||
if len(d.Invokes) > 0 {
|
if len(d.Invokes) > 0 {
|
||||||
m.Reaches = map[string][]string{}
|
m.Reaches = map[string][]string{}
|
||||||
for _, t := range d.Invokes {
|
for _, t := range d.Invokes {
|
||||||
|
|||||||
+35
-2
@@ -103,6 +103,12 @@ type Principal struct {
|
|||||||
// permission and nothing beside it.
|
// permission and nothing beside it.
|
||||||
Invokes []string
|
Invokes []string
|
||||||
|
|
||||||
|
// State is the local names of the state this principal's module keeps, and Reads the state of
|
||||||
|
// others it reads as `<module>.<name>` (novox/hq ADR 0201): a bucket each, kept by the owner's
|
||||||
|
// instances and read by whoever declares it.
|
||||||
|
State []string
|
||||||
|
Reads []string
|
||||||
|
|
||||||
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
||||||
// and never appears here: this file is written to a node's disk and read by a server, and a
|
// and never appears here: this file is written to a node's disk and read by a server, and a
|
||||||
// secret that can be read from a configuration file is a secret with a wider blast radius
|
// secret that can be read from a configuration file is a secret with a wider blast radius
|
||||||
@@ -421,6 +427,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. Its state, and the state of others it reads (novox/hq ADR 0201): every one read and
|
||||||
|
// watched, its own written too.
|
||||||
|
pub = append(pub, stateGrants(p.Module, p.State, p.Reads)...)
|
||||||
|
|
||||||
case KindNodeTools:
|
case KindNodeTools:
|
||||||
// **One process serves what every module on the machine would have served for itself**
|
// **One process serves what every module on the machine would have served for itself**
|
||||||
// (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that
|
// (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that
|
||||||
@@ -469,8 +479,31 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
// request once, so the runtime announces everything it carries under its own name.
|
// request once, so the runtime announces everything it carries under its own name.
|
||||||
sub = append(sub, announcing(append([]string{RuntimeModule}, serves...)...)...)
|
sub = append(sub, announcing(append([]string{RuntimeModule}, serves...)...)...)
|
||||||
pub = append(pub, discovering()...)
|
pub = append(pub, discovering()...)
|
||||||
// Nothing about consumers: it consumes nothing. A module's reactions to events are its
|
// **And it consumes for the modules it carries** (novox/hq ADR 0198, which changes ADR 0175's
|
||||||
// own long-lived process, which ADR 0175 leaves where it is; what moves here is tools.
|
// "it consumes nothing"): a module's long-running code is a bundle this runtime launches, and
|
||||||
|
// the runtime is its bus — it reads the module's own durable consumer and acknowledges what
|
||||||
|
// the module's code took. Exactly the grants the module's own principal has for that consumer,
|
||||||
|
// on its name and no other's: asking about it, pulling from it, acknowledging it. The
|
||||||
|
// consumer is still the controller's to make, from the module's own principal.
|
||||||
|
for _, d := range p.Carries {
|
||||||
|
own := Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
|
||||||
|
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches}
|
||||||
|
if _, consumes := ConsumerFor(own); !consumes {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stream, durable := consumerStream(own), consumerDurable(own)
|
||||||
|
pub = append(pub,
|
||||||
|
"$JS.API.CONSUMER.INFO."+stream+"."+durable,
|
||||||
|
"$JS.API.CONSUMER.MSG.NEXT."+stream+"."+durable,
|
||||||
|
"$JS.ACK."+stream+"."+durable+".>")
|
||||||
|
}
|
||||||
|
// **And it keeps and reads state for the modules it carries** (novox/hq ADR 0201): the union
|
||||||
|
// of what each may do with a bucket — an owner's write, a reader's read. That one module's code
|
||||||
|
// does not write another's bucket through it is the runtime's to keep, from the membership
|
||||||
|
// each assignment is issued, as it keeps each module's events under that module's own name.
|
||||||
|
for _, d := range p.Carries {
|
||||||
|
pub = append(pub, stateGrants(d.Module, stateNames(d.State), d.Reads)...)
|
||||||
|
}
|
||||||
sub = unique(sub)
|
sub = unique(sub)
|
||||||
pub = unique(pub)
|
pub = unique(pub)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -378,7 +378,7 @@ func TestAModulePullsItsOwnConsumerAndNoOthers(t *testing.T) {
|
|||||||
// their tools (novox/hq ADR 0175): every carried module's tool namespace, every held seat's verbs
|
// their tools (novox/hq ADR 0175): every carried module's tool namespace, every held seat's verbs
|
||||||
// on this node, every module's membership on this node, and a call to anything. Nothing it
|
// on this node, every module's membership on this node, and a call to anything. Nothing it
|
||||||
// consumes, because it reacts to nothing.
|
// consumes, because it reacts to nothing.
|
||||||
func TestTheRuntimeServesTheUnionAndConsumesNothing(t *testing.T) {
|
func TestTheRuntimeServesTheUnionAndConsumesForItsModules(t *testing.T) {
|
||||||
filter := Seat{Name: "node-packet-filter", Scope: "node", Serves: []string{"rules", "reload"}}
|
filter := Seat{Name: "node-packet-filter", Scope: "node", Serves: []string{"rules", "reload"}}
|
||||||
p := Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, Carries: []Declared{
|
p := Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, Carries: []Declared{
|
||||||
{Module: "nftables", Holds: []Seat{filter}, Serves: []string{"firewall_rules"}},
|
{Module: "nftables", Holds: []Seat{filter}, Serves: []string{"firewall_rules"}},
|
||||||
@@ -408,22 +408,33 @@ func TestTheRuntimeServesTheUnionAndConsumesNothing(t *testing.T) {
|
|||||||
t.Errorf("the runtime may not publish %s: %v", want, perms.Publish)
|
t.Errorf("the runtime may not publish %s: %v", want, perms.Publish)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Nothing of what a carried module consumes, and no consumer of its own to ack.
|
// It reads the consumer of every carried module that consumes — that module's, by its name, as
|
||||||
for _, s := range perms.Subscribe {
|
// the module's own principal could (novox/hq ADR 0198) — and of no module that consumes nothing.
|
||||||
if strings.Contains(s, ".event.") || strings.HasPrefix(s, "_DELIVER.") {
|
for _, want := range []string{
|
||||||
t.Errorf("the runtime was granted a delivery it has no consumer for: %s", s)
|
"$JS.API.CONSUMER.INFO.EVENTS.anchor_zsh",
|
||||||
|
"$JS.API.CONSUMER.MSG.NEXT.EVENTS.anchor_zsh",
|
||||||
|
"$JS.ACK.EVENTS.anchor_zsh.>",
|
||||||
|
} {
|
||||||
|
if !contains(perms.Publish, want) {
|
||||||
|
t.Errorf("the runtime may not read zsh's consumer: %s missing from %v", want, perms.Publish)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, s := range perms.Publish {
|
for _, s := range perms.Publish {
|
||||||
if strings.HasPrefix(s, "$JS.ACK.") || strings.Contains(s, "CONSUMER") {
|
if (strings.HasPrefix(s, "$JS.ACK.") || strings.Contains(s, "CONSUMER")) && !strings.Contains(s, "anchor_zsh") {
|
||||||
t.Errorf("the runtime was granted a consumer's subject and has no consumer: %s", s)
|
t.Errorf("the runtime was granted a consumer no carried module of it consumes on: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// It pulls; nothing is pushed to it, and it subscribes no event subject directly.
|
||||||
|
for _, s := range perms.Subscribe {
|
||||||
|
if strings.Contains(s, ".event.") || strings.HasPrefix(s, "_DELIVER.") {
|
||||||
|
t.Errorf("the runtime was granted a delivery: %s", s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !perms.AllowResponses {
|
if !perms.AllowResponses {
|
||||||
t.Error("the runtime answers what it is asked, and may not reply")
|
t.Error("the runtime answers what it is asked, and may not reply")
|
||||||
}
|
}
|
||||||
if _, needed := ConsumerFor(p); needed {
|
if _, needed := ConsumerFor(p); needed {
|
||||||
t.Error("a consumer would be made for the runtime, which consumes nothing")
|
t.Error("a consumer would be made for the runtime itself; it reads its modules' consumers, never one of its own")
|
||||||
}
|
}
|
||||||
// Each subject once in each list: the file is read as the mesh's authority model. One subject may
|
// Each subject once in each list: the file is read as the mesh's authority model. One subject may
|
||||||
// stand in both — the runtime answers discovery on `$SRV.INFO` and, as the console, asks it
|
// stand in both — the runtime answers discovery on `$SRV.INFO` and, as the console, asks it
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package broker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module's state on the bus (novox/hq ADR 0201, design 32 §4, design 25 §3).
|
||||||
|
//
|
||||||
|
// A module names the state it keeps (`state`) and the state of others it reads (`reads`), and each
|
||||||
|
// is a key-value bucket: the server's own last-per-subject stream with direct reads, delete markers
|
||||||
|
// and watches, which is the state relationship the mesh already uses for declarations, opened to
|
||||||
|
// modules. The controller creates every bucket from the catalogue — from registration, like a
|
||||||
|
// seat's stream, so a reader may watch before the owner runs anywhere — and no module can.
|
||||||
|
//
|
||||||
|
// Pure, like everything else in this package that decides what the bus holds; jetstream.go is the
|
||||||
|
// part that asks a server.
|
||||||
|
|
||||||
|
// The mesh's caps on a bucket, the same for every module: a value is a piece of state, not a file,
|
||||||
|
// and a bucket that grew without bound would be one module filling the bus's disk for everyone.
|
||||||
|
const (
|
||||||
|
StateMaxValueBytes = 256 * 1024
|
||||||
|
StateMaxBytes = 64 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Bucket is one module's declared state as the bus holds it.
|
||||||
|
type Bucket struct {
|
||||||
|
Module string
|
||||||
|
Name string
|
||||||
|
// History is how many values a key keeps; zero is one.
|
||||||
|
History int
|
||||||
|
// TTLSeconds is how long a value lives; zero is until replaced or deleted.
|
||||||
|
TTLSeconds int
|
||||||
|
}
|
||||||
|
|
||||||
|
// BucketName is the bucket a module's state lives in: the module and the local name joined by an
|
||||||
|
// underscore, which neither may contain, so two modules can never derive one bucket.
|
||||||
|
func BucketName(module, name string) string { return module + "_" + name }
|
||||||
|
|
||||||
|
// Bucket is this bucket's name on the bus.
|
||||||
|
func (b Bucket) Bucket() string { return BucketName(b.Module, b.Name) }
|
||||||
|
|
||||||
|
// Why is carried into the server's description of the bucket, so somebody reading the server's
|
||||||
|
// own state finds whose it is and why it is kept.
|
||||||
|
func (b Bucket) Why() string {
|
||||||
|
return fmt.Sprintf("%s's state %q (novox/hq ADR 0201): its current value per key, written by %s, "+
|
||||||
|
"read by whatever declares it reads it; kept when %s is unassigned, because it is data",
|
||||||
|
b.Module, b.Name, b.Module, b.Module)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bucketOfRead is the bucket a read names, `<module>.<name>`, or false when it names none.
|
||||||
|
func bucketOfRead(read string) (string, bool) {
|
||||||
|
at := strings.LastIndex(read, ".")
|
||||||
|
if at <= 0 || at == len(read)-1 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
module, name := read[:at], read[at+1:]
|
||||||
|
if !safeSubject.MatchString(module) || !safeSubject.MatchString(name) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return BucketName(module, name), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// stateGrants is what a principal publishes to reach the state its modules keep and read: for every
|
||||||
|
// bucket, binding to it, reading a key directly, and an ordered consumer for listing and watching,
|
||||||
|
// created and deleted on the bucket's own stream, with its flow control answered; for a bucket an
|
||||||
|
// owner keeps, writing under the bucket's own subjects too.
|
||||||
|
//
|
||||||
|
// **Measured against a running server, 2026-10-04** (novox/hq research 024), and each one is there
|
||||||
|
// because leaving it out failed: without STREAM.INFO nothing binds; without DIRECT.GET nothing is
|
||||||
|
// read; without CONSUMER.CREATE no key is listed and nothing is watched; without CONSUMER.DELETE a
|
||||||
|
// watch cannot be stopped and lingers on the server. A write outside these is refused by the server
|
||||||
|
// — and reaches the writer as a timeout, not a refusal, which is why the runtime refuses first.
|
||||||
|
func stateGrants(module string, keeps []string, reads []string) []string {
|
||||||
|
var out []string
|
||||||
|
read := func(bucket string) {
|
||||||
|
stream := "KV_" + bucket
|
||||||
|
out = append(out,
|
||||||
|
"$JS.API.STREAM.INFO."+stream,
|
||||||
|
"$JS.API.DIRECT.GET."+stream+".>",
|
||||||
|
"$JS.API.CONSUMER.CREATE."+stream+".>",
|
||||||
|
"$JS.API.CONSUMER.DELETE."+stream+".>",
|
||||||
|
"$JS.FC."+stream+".>")
|
||||||
|
}
|
||||||
|
for _, name := range keeps {
|
||||||
|
if !safeSubject.MatchString(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bucket := BucketName(module, name)
|
||||||
|
read(bucket)
|
||||||
|
out = append(out, "$KV."+bucket+".>")
|
||||||
|
}
|
||||||
|
for _, r := range reads {
|
||||||
|
if bucket, ok := bucketOfRead(r); ok {
|
||||||
|
read(bucket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateIssued is one bucket an assignment may reach, by the name its module uses for it: its own
|
||||||
|
// state by the local name, another's as `<module>.<name>` (novox/hq ADR 0201).
|
||||||
|
type StateIssued struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Bucket string `json:"bucket"`
|
||||||
|
Writes bool `json:"writes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// stateIssuedFor is every bucket a module's code may reach, as its membership lists them.
|
||||||
|
func stateIssuedFor(d Declared) []StateIssued {
|
||||||
|
var out []StateIssued
|
||||||
|
for _, b := range d.State {
|
||||||
|
out = append(out, StateIssued{Name: b.Name, Bucket: BucketName(d.Module, b.Name), Writes: true})
|
||||||
|
}
|
||||||
|
for _, r := range d.Reads {
|
||||||
|
if bucket, ok := bucketOfRead(r); ok {
|
||||||
|
out = append(out, StateIssued{Name: r, Bucket: bucket})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// stateNames is the local names of a module's own buckets.
|
||||||
|
func stateNames(buckets []Bucket) []string {
|
||||||
|
out := make([]string, 0, len(buckets))
|
||||||
|
for _, b := range buckets {
|
||||||
|
out = append(out, b.Name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// A BucketAsserter is the part of a JetStream connection bucket assertion needs.
|
||||||
|
type BucketAsserter interface {
|
||||||
|
// EnsureBucket creates the bucket if absent and brings its options to match if present, never
|
||||||
|
// discarding what it holds.
|
||||||
|
EnsureBucket(b Bucket) error
|
||||||
|
// BucketNames is every key-value bucket on the server.
|
||||||
|
BucketNames() ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RaiseBuckets asserts every declared bucket and answers the buckets on the server that nothing
|
||||||
|
// declares any more.
|
||||||
|
//
|
||||||
|
// **Those are reported, never removed** (novox/hq ADR 0201, ADR 0030): what a module stored is
|
||||||
|
// data, and a manifest edited, a module renamed or a catalogue entry dropped is an ordinary day's
|
||||||
|
// work that must not take data with it. Removing one is a person's act.
|
||||||
|
func RaiseBuckets(a BucketAsserter, buckets []Bucket) (undeclared []string, err error) {
|
||||||
|
sorted := append([]Bucket(nil), buckets...)
|
||||||
|
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Bucket() < sorted[j].Bucket() })
|
||||||
|
declared := map[string]bool{}
|
||||||
|
for _, b := range sorted {
|
||||||
|
if err := a.EnsureBucket(b); err != nil {
|
||||||
|
return nil, fmt.Errorf("asserting %s's state %q: %w", b.Module, b.Name, err)
|
||||||
|
}
|
||||||
|
declared[b.Bucket()] = true
|
||||||
|
}
|
||||||
|
names, err := a.BucketNames()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing the bus's state: %w", err)
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
if !declared[n] {
|
||||||
|
undeclared = append(undeclared, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(undeclared)
|
||||||
|
return undeclared, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package broker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The grants measured against a running server (novox/hq research 024): an owner reads and writes
|
||||||
|
// its bucket, a reader only reads, and neither reaches any other bucket.
|
||||||
|
func TestAnOwnerWritesItsStateAndAReaderOnlyReads(t *testing.T) {
|
||||||
|
owner, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "claude-code",
|
||||||
|
State: []string{"servers"}, PasswordHash: "x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, s := range []string{
|
||||||
|
"$KV.claude-code_servers.>",
|
||||||
|
"$JS.API.STREAM.INFO.KV_claude-code_servers",
|
||||||
|
"$JS.API.DIRECT.GET.KV_claude-code_servers.>",
|
||||||
|
"$JS.API.CONSUMER.CREATE.KV_claude-code_servers.>",
|
||||||
|
"$JS.API.CONSUMER.DELETE.KV_claude-code_servers.>",
|
||||||
|
"$JS.FC.KV_claude-code_servers.>",
|
||||||
|
} {
|
||||||
|
has(t, owner.Publish, s)
|
||||||
|
}
|
||||||
|
hasNot(t, owner.Publish, "$KV.>")
|
||||||
|
hasNot(t, owner.Publish, "$JS.API.>")
|
||||||
|
|
||||||
|
reader, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "console",
|
||||||
|
Reads: []string{"claude-code.servers"}, PasswordHash: "x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
has(t, reader.Publish, "$JS.API.DIRECT.GET.KV_claude-code_servers.>")
|
||||||
|
has(t, reader.Publish, "$JS.API.CONSUMER.CREATE.KV_claude-code_servers.>")
|
||||||
|
hasNot(t, reader.Publish, "$KV.claude-code_servers.>")
|
||||||
|
for _, s := range reader.Subscribe {
|
||||||
|
if s == "$KV.claude-code_servers.>" {
|
||||||
|
t.Fatalf("a reader subscribes the bucket's subjects directly: %v", reader.Subscribe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One runtime carries every module on its machine, so its grant is the union: the owner's write
|
||||||
|
// where an owner is carried, a read where only a reader is.
|
||||||
|
func TestTheRuntimeKeepsAndReadsStateForItsModules(t *testing.T) {
|
||||||
|
perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "one", Module: RuntimeModule,
|
||||||
|
Carries: []Declared{
|
||||||
|
{Module: "claude-code", State: []Bucket{{Module: "claude-code", Name: "servers"}},
|
||||||
|
Reads: []string{"licence-manager.bindings"}},
|
||||||
|
{Module: "audit"},
|
||||||
|
}, PasswordHash: "x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
has(t, perms.Publish, "$KV.claude-code_servers.>")
|
||||||
|
has(t, perms.Publish, "$JS.API.DIRECT.GET.KV_licence-manager_bindings.>")
|
||||||
|
hasNot(t, perms.Publish, "$KV.licence-manager_bindings.>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module with no state is granted nothing of any bucket — the composition of every module that
|
||||||
|
// existed before this is unchanged.
|
||||||
|
func TestAModuleWithNoStateReachesNoBucket(t *testing.T) {
|
||||||
|
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "billing",
|
||||||
|
Emits: []string{"order.placed"}, PasswordHash: "x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, s := range perms.Publish {
|
||||||
|
if strings.HasPrefix(s, "$KV.") || strings.HasPrefix(s, "$JS.FC.") || strings.Contains(s, ".KV_") {
|
||||||
|
t.Fatalf("granted %q without declaring state", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A read that names no bucket grants nothing rather than something that happens to parse.
|
||||||
|
func TestAReadThatNamesNoBucketGrantsNothing(t *testing.T) {
|
||||||
|
if got := stateGrants("a", nil, []string{"nodot", "x.", ".y", "a.b>"}); len(got) != 0 {
|
||||||
|
t.Fatalf("granted %v for reads that name no bucket", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The membership lists every bucket the module's code may reach, by the name the module uses for
|
||||||
|
// it, and whether it may write it — the list the runtime refuses from.
|
||||||
|
func TestAMembershipListsTheStateItsModuleMayReach(t *testing.T) {
|
||||||
|
m := MembershipFor("one", Declared{Module: "claude-code",
|
||||||
|
State: []Bucket{{Module: "claude-code", Name: "servers"}},
|
||||||
|
Reads: []string{"licence-manager.bindings"}}, Placements{})
|
||||||
|
want := []StateIssued{
|
||||||
|
{Name: "servers", Bucket: "claude-code_servers", Writes: true},
|
||||||
|
{Name: "licence-manager.bindings", Bucket: "licence-manager_bindings"},
|
||||||
|
}
|
||||||
|
if !slices.Equal(m.State, want) {
|
||||||
|
t.Fatalf("issued %+v, want %+v", m.State, want)
|
||||||
|
}
|
||||||
|
if none := MembershipFor("one", Declared{Module: "audit"}, Placements{}); none.State != nil {
|
||||||
|
t.Fatalf("a module with no state was issued %+v", none.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type buckets struct {
|
||||||
|
ensured []string
|
||||||
|
on []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *buckets) EnsureBucket(x Bucket) error {
|
||||||
|
b.ensured = append(b.ensured, x.Bucket())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (b *buckets) BucketNames() ([]string, error) { return b.on, nil }
|
||||||
|
|
||||||
|
// Every declared bucket is asserted; one on the server that nothing declares is said, not removed.
|
||||||
|
func TestRaisingStateReportsWhatNothingDeclares(t *testing.T) {
|
||||||
|
b := &buckets{on: []string{"claude-code_servers", "gone_old", "ours_by_hand"}}
|
||||||
|
undeclared, err := RaiseBuckets(b, []Bucket{{Module: "claude-code", Name: "servers"}, {Module: "a", Name: "b"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(b.ensured, []string{"a_b", "claude-code_servers"}) {
|
||||||
|
t.Fatalf("asserted %v", b.ensured)
|
||||||
|
}
|
||||||
|
if !slices.Equal(undeclared, []string{"gone_old", "ours_by_hand"}) {
|
||||||
|
t.Fatalf("reported %v", undeclared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Against a real server: a bucket is created with the owner's options and the mesh's caps,
|
||||||
|
// asserting it again changes nothing and keeps what it holds, and a changed option is brought to
|
||||||
|
// match in place.
|
||||||
|
func TestABucketIsAssertedInPlace(t *testing.T) {
|
||||||
|
js := aLiveBus(t)
|
||||||
|
b := Bucket{Module: "statetest", Name: "servers"}
|
||||||
|
if _, err := RaiseBuckets(js, []Bucket{b}); err != nil {
|
||||||
|
t.Fatalf("a real server refused a module's bucket: %v", err)
|
||||||
|
}
|
||||||
|
kv, err := js.Context().KeyValue(b.Bucket())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := kv.Put("all.one", []byte(`{"kept":true}`)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b.History = 3
|
||||||
|
if _, err := RaiseBuckets(js, []Bucket{b}); err != nil {
|
||||||
|
t.Fatalf("asserting the bucket again failed, so a restart would: %v", err)
|
||||||
|
}
|
||||||
|
got, err := kv.Get("all.one")
|
||||||
|
if err != nil || string(got.Value()) != `{"kept":true}` {
|
||||||
|
t.Fatalf("asserting again lost what the bucket held: %v %v", got, err)
|
||||||
|
}
|
||||||
|
status, err := kv.Status()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status.History() != 3 {
|
||||||
|
t.Fatalf("history is %d, the owner declared 3", status.History())
|
||||||
|
}
|
||||||
|
if s, ok := status.(*nats.KeyValueBucketStatus); ok {
|
||||||
|
if c := s.StreamInfo().Config; c.MaxMsgSize != StateMaxValueBytes || c.MaxBytes != StateMaxBytes {
|
||||||
|
t.Fatalf("the mesh's caps are not on the bucket: value %d, bucket %d", c.MaxMsgSize, c.MaxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Against a real server: the handle over a connection the control plane already holds asserts a
|
||||||
|
// bucket as Dial's does — what a push uses, so a module registered since the last start has its
|
||||||
|
// bucket before its membership names it.
|
||||||
|
func TestABucketIsAssertedOverAHeldConnection(t *testing.T) {
|
||||||
|
js := aLiveBus(t)
|
||||||
|
held := OnConn(js.Conn())
|
||||||
|
if _, err := RaiseBuckets(held, []Bucket{{Module: "statetest", Name: "held"}}); err != nil {
|
||||||
|
t.Fatalf("asserting over a held connection failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := js.Context().KeyValue("statetest_held"); err != nil {
|
||||||
|
t.Fatalf("the bucket is not there: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,14 @@ type Declared struct {
|
|||||||
Watches []Seat
|
Watches []Seat
|
||||||
// Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152).
|
// Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152).
|
||||||
Invokes []string
|
Invokes []string
|
||||||
|
// State is the state it keeps, each a bucket its instances write (novox/hq ADR 0201).
|
||||||
|
State []Bucket
|
||||||
|
// Reads are other modules' state it reads, each `<module>.<name>` (novox/hq ADR 0201).
|
||||||
|
Reads []string
|
||||||
|
// NoAccount says the module declares no own secret named broker, so no account could ever be
|
||||||
|
// delivered to it and nothing can connect as it (novox/hq issue 195). Said in the negative so a
|
||||||
|
// record that does not say is composed as it always was.
|
||||||
|
NoAccount bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -77,10 +85,20 @@ func Users(r Records) ([]Principal, error) {
|
|||||||
if runtimeHere && d.Module == RuntimeModule {
|
if runtimeHere && d.Module == RuntimeModule {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// **A module with nowhere to read an account is no user** (novox/hq issue 195). `module
|
||||||
|
// issue` refuses it one (issue 078: an account nothing reads is an orphan), so its user
|
||||||
|
// could only ever be left out of the file for want of a password — and every such module
|
||||||
|
// was named, on every status and plan, as a credential the mesh had not minted. Where the
|
||||||
|
// runtime is, it speaks for the module; where it is not, the module cannot speak at all,
|
||||||
|
// and a user would not change that.
|
||||||
|
if d.NoAccount {
|
||||||
|
continue
|
||||||
|
}
|
||||||
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, Invokes: d.Invokes,
|
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
|
||||||
|
State: stateNames(d.State), Reads: d.Reads,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if runtimeHere {
|
if runtimeHere {
|
||||||
|
|||||||
@@ -296,3 +296,57 @@ func TestTheRuntimeModuleBecomesTheMachinesRuntimePrincipal(t *testing.T) {
|
|||||||
t.Error("telegram lost its own principal when the runtime arrived on its node")
|
t.Error("telegram lost its own principal when the runtime arrived on its node")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A module that declares nowhere to read an account is no user: it could never be issued one, so it
|
||||||
|
// was only ever named as a credential the mesh had not minted (novox/hq issue 195). Where the runtime
|
||||||
|
// is, the runtime still carries it — its grants are the runtime's.
|
||||||
|
func TestAModuleThatCannotReadAnAccountIsNoUser(t *testing.T) {
|
||||||
|
r := someRecords()
|
||||||
|
r.Assigned["one"] = append(r.Assigned["one"],
|
||||||
|
Declared{Module: "packet-filter", NoAccount: true, Emits: []string{"rule.changed"}},
|
||||||
|
Declared{Module: RuntimeModule})
|
||||||
|
users, err := Users(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
if u.Username() == "one.packet-filter" {
|
||||||
|
t.Fatalf("packet-filter declares no broker secret and was composed as a user: %v", namesOf(t, r))
|
||||||
|
}
|
||||||
|
if u.Kind == KindNodeTools {
|
||||||
|
carried := false
|
||||||
|
for _, d := range u.Carries {
|
||||||
|
carried = carried || d.Module == "packet-filter"
|
||||||
|
}
|
||||||
|
if !carried {
|
||||||
|
t.Error("the runtime stopped carrying a module that has no account of its own")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if names := strings.Join(namesOf(t, r), ","); !strings.Contains(names, "one.telegram") {
|
||||||
|
t.Errorf("a module that does read an account lost its user: %s", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A carried module with no account still has its consumer made: the runtime reads it on the module's
|
||||||
|
// behalf (ADR 0198), and the consumer was derived from the module's own user until issue 195 took
|
||||||
|
// that user away.
|
||||||
|
func TestACarriedModuleWithNoAccountStillHasItsConsumer(t *testing.T) {
|
||||||
|
r := someRecords()
|
||||||
|
r.Assigned["one"] = append(r.Assigned["one"],
|
||||||
|
Declared{Module: "listener", NoAccount: true, Consumes: []string{"shop.order.placed"}},
|
||||||
|
Declared{Module: RuntimeModule})
|
||||||
|
r.Assigned["two"] = append(r.Assigned["two"],
|
||||||
|
Declared{Module: "auditor", Consumes: []string{"shop.order.placed"}})
|
||||||
|
users, err := Users(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := map[string]int{}
|
||||||
|
for _, c := range ConsumersOf(users) {
|
||||||
|
got[c.Node+"/"+c.Module]++
|
||||||
|
}
|
||||||
|
if got["one/listener"] != 1 || got["two/auditor"] != 1 || len(got) != 2 {
|
||||||
|
t.Fatalf("consumers: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+106
-3
@@ -215,7 +215,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
|
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, seatBases, say)
|
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, npmrc, 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
|
||||||
@@ -443,7 +443,7 @@ 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, seats map[string]string,
|
held map[string]string, npmrc string, registry Npmrc, seats map[string]string,
|
||||||
say func(step, format string, args ...any)) (catalogue.Built, error) {
|
say func(step, format string, args ...any)) (catalogue.Built, error) {
|
||||||
|
|
||||||
switch a.Kind {
|
switch a.Kind {
|
||||||
@@ -582,6 +582,11 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
"holds no copy of it. Build %s first",
|
"holds no copy of it. Build %s first",
|
||||||
module, a.Name, chain.Language, chain.Base, chain.Artifact, chain.Base)
|
module, a.Name, chain.Language, chain.Base, chain.Artifact, chain.Base)
|
||||||
}
|
}
|
||||||
|
// The module's own packages first, where the compiler and the bundler resolve them from
|
||||||
|
// (dependencies.go); nothing at all for a module whose package.json names only the SDK.
|
||||||
|
if err := installOwn(ctx, run, tree, chain, base, registry, say); err != nil {
|
||||||
|
return catalogue.Built{}, fmt.Errorf("%s: %s: %w", module, a.Name, err)
|
||||||
|
}
|
||||||
say("bundle", "compiling %s in %s's toolchain", a.Language, chain.Base)
|
say("bundle", "compiling %s in %s's toolchain", a.Language, chain.Base)
|
||||||
compiled, err := compile(ctx, run, tree, chain, base, a)
|
compiled, err := compile(ctx, run, tree, chain, base, a)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -593,6 +598,12 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Built{}, fmt.Errorf("%s: writing %s's launchers failed: %w", module, a.Name, err)
|
return catalogue.Built{}, fmt.Errorf("%s: writing %s's launchers failed: %w", module, a.Name, err)
|
||||||
}
|
}
|
||||||
|
if chain.Bundler != "" {
|
||||||
|
say("bundle", "bundling each entrypoint into one file")
|
||||||
|
if compiled, err = bundled(ctx, run, tree, chain, base, a, launchers); err != nil {
|
||||||
|
return catalogue.Built{}, fmt.Errorf("%s: bundling %s failed: %w", module, a.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
say("bundle", "compiled, packing")
|
say("bundle", "compiled, packing")
|
||||||
body, err := pack(compiled)
|
body, err := pack(compiled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -974,7 +985,7 @@ func compile(ctx context.Context, run Runner, tree string, chain Toolchain,
|
|||||||
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if chain.Dependencies != "" {
|
if chain.Dependencies != "" && chain.Bundler == "" {
|
||||||
// **What the bundle runs with, from the image it was compiled in** (Toolchain.Dependencies).
|
// **What the bundle runs with, from the image it was compiled in** (Toolchain.Dependencies).
|
||||||
// A second run in the same image rather than a shell wrapped around the compiler: the
|
// A second run in the same image rather than a shell wrapped around the compiler: the
|
||||||
// compile line stays a plain command a reader can run by hand, and the copy is one more
|
// compile line stays a plain command a reader can run by hand, and the copy is one more
|
||||||
@@ -1224,3 +1235,95 @@ func writeLaunchers(root string, chain Toolchain, a catalogue.Artifact) (map[str
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bundledSuffix is where a bundle's one-file output is written, beside what the compiler wrote.
|
||||||
|
const bundledSuffix = ".bundled"
|
||||||
|
|
||||||
|
// bundled makes every entrypoint and every launcher of a compiled bundle ONE file, in the toolchain
|
||||||
|
// image's bundler, and answers the directory to pack (novox/hq ADR 0193).
|
||||||
|
//
|
||||||
|
// **What a launched bundle runs is what it imports, and nothing else.** Every served bundle is its
|
||||||
|
// own process, so it carries its own copy of the SDK and its own dependencies inlined — the
|
||||||
|
// toolchain's whole node_modules no longer travels in every bundle. An entrypoint a process runs by
|
||||||
|
// name (`node daemon/index.js`) is bundled in place under its own name; a launcher keeps its name
|
||||||
|
// and its first line, and stays executable. A package the bundler cannot inline is named by the
|
||||||
|
// artifact (`external`), kept as an import, and only then is the toolchain's runtime directory
|
||||||
|
// copied beside the files. CommonJS inlined into an ES module still finds `require`.
|
||||||
|
func bundled(ctx context.Context, run Runner, tree string, chain Toolchain, base string,
|
||||||
|
a catalogue.Artifact, launchers map[string]string) (string, error) {
|
||||||
|
const within = "/app/modules/module"
|
||||||
|
out, final := Out(a.Name), Out(a.Name)+bundledSuffix
|
||||||
|
if err := os.RemoveAll(filepath.Join(tree, final)); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(tree, final), 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
common := []string{"--bundle", "--platform=node", "--format=esm", "--target=node22",
|
||||||
|
"--outbase=" + out, "--outdir=" + final, "--log-level=warning",
|
||||||
|
"--banner:js=import { createRequire as __meshRequire } from 'node:module'; const require = __meshRequire(import.meta.url);"}
|
||||||
|
for _, x := range a.External {
|
||||||
|
common = append(common, "--external:"+x)
|
||||||
|
}
|
||||||
|
var plain []string
|
||||||
|
for _, e := range a.Entrypoints {
|
||||||
|
if strings.HasSuffix(e, ".js") {
|
||||||
|
plain = append(plain, out+"/"+e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var launch []string
|
||||||
|
for _, l := range sortedValues(launchers) {
|
||||||
|
launch = append(launch, out+"/"+l)
|
||||||
|
}
|
||||||
|
// Refused by name in an image that predates the bundler, as the dependencies copy is: a bundle
|
||||||
|
// packed without it would carry nothing it imports. Run as itself: npm installs esbuild's native
|
||||||
|
// binary in place of its script, which `node` cannot run.
|
||||||
|
guard := `test -x "$0" || { echo "the toolchain image carries no bundler at $0: it predates one-file bundles, rebuild mesh-tools first" >&2; exit 1; }; exec "$0" "$@"`
|
||||||
|
step := func(entries []string, extra ...string) error {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
invocation := []string{"run", "--rm", "--volume", tree + ":" + within, "--workdir", within, base,
|
||||||
|
"sh", "-c", guard, chain.Bundler}
|
||||||
|
invocation = append(invocation, entries...)
|
||||||
|
invocation = append(invocation, common...)
|
||||||
|
invocation = append(invocation, extra...)
|
||||||
|
_, err := run(ctx, tree, "docker", invocation...)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := step(plain); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := step(launch, "--out-extension:.js=.mjs"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Plain `.js` output is an ES module; said once, as the runtime directory used to say it.
|
||||||
|
if err := os.WriteFile(filepath.Join(tree, final, "package.json"), []byte(`{"type":"module","private":true}`+"\n"), 0o644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, l := range launchers {
|
||||||
|
path := filepath.Join(tree, final, filepath.FromSlash(l))
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
if err := os.Chmod(path, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(a.External) > 0 && chain.Dependencies != "" {
|
||||||
|
copying := []string{"run", "--rm", "--volume", tree + ":" + within, "--workdir", within, base,
|
||||||
|
"sh", "-c", `cp -a "$0/node_modules" "$1/"`, chain.Dependencies, final}
|
||||||
|
if _, err := run(ctx, tree, "docker", copying...); err != nil {
|
||||||
|
return "", fmt.Errorf("copying the packages %s keeps external: %w", a.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Join(tree, final), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedValues(m map[string]string) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for _, v := range m {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,25 +83,49 @@ func TestABundleIsCompiledAndPackedWithNoDockerfile(t *testing.T) {
|
|||||||
t.Fatalf("the bundle was not pinned: %v", got.Manifest.Resources[0])
|
t.Fatalf("the bundle was not pinned: %v", got.Manifest.Resources[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
// **And what it runs with, from the image it was compiled in** (novox/hq to-be 38 WP3). A
|
// **One file per entrypoint and launcher, in the toolchain's bundler** (novox/hq ADR 0193). A
|
||||||
// second run in the same toolchain image copies the toolchain's runtime directory — the
|
// second run in the same toolchain image bundles each into the artifact's bundled output, the SDK
|
||||||
// `"type": "module"` package.json and the pruned node_modules — into the output's root, and
|
// inlined, refusing by name in an image that predates the bundler; and the toolchain's
|
||||||
// refuses by name when the image carries none rather than packing a bundle that starts nowhere.
|
// node_modules is no longer copied into a bundle that keeps nothing external.
|
||||||
var copied string
|
var bundling []string
|
||||||
for _, line := range r.ran {
|
for _, line := range r.ran {
|
||||||
if strings.HasPrefix(line, "docker run") && strings.Contains(line, "/app/runtime") {
|
if strings.HasPrefix(line, "docker run") && strings.Contains(line, "esbuild") {
|
||||||
copied = line
|
bundling = append(bundling, line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if copied == "" {
|
if len(bundling) != 2 {
|
||||||
t.Fatalf("the bundle's dependencies were not copied in after the compile:\n%s", strings.Join(r.ran, "\n"))
|
t.Fatalf("want one bundling run for the entrypoints and one for the launchers:\n%s", strings.Join(r.ran, "\n"))
|
||||||
}
|
}
|
||||||
if !strings.Contains(copied, "mesh-tools/build@sha256:") || !strings.Contains(copied, "predates") ||
|
for _, want := range []string{"mesh-tools/build@sha256:", "predates one-file bundles", "--bundle", "--format=esm",
|
||||||
!strings.Contains(copied, Out("code")) {
|
"--platform=node", "--outdir=" + Out("code") + ".bundled", Out("code") + "/index.js"} {
|
||||||
t.Fatalf("the copy does not run in the same toolchain, refuse an older image by name, or land in the artifact's output: %s", copied)
|
if !strings.Contains(bundling[0], want) {
|
||||||
|
t.Errorf("the entrypoints' bundling lacks %q: %s", want, bundling[0])
|
||||||
}
|
}
|
||||||
if strings.Index(strings.Join(r.ran, "\n"), "--outDir") > strings.Index(strings.Join(r.ran, "\n"), "/app/runtime") {
|
}
|
||||||
t.Fatal("the dependencies were copied before the compile wrote its output")
|
if !strings.Contains(bundling[1], Out("code")+"/index.serve.mjs") || !strings.Contains(bundling[1], "--out-extension:.js=.mjs") {
|
||||||
|
t.Errorf("the launcher is not bundled under its own name: %s", bundling[1])
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.Join(r.ran, "\n"), "/app/runtime") {
|
||||||
|
t.Errorf("the toolchain's node_modules was copied into a bundle that keeps nothing external:\n%s", strings.Join(r.ran, "\n"))
|
||||||
|
}
|
||||||
|
if strings.Index(strings.Join(r.ran, "\n"), "--outDir") > strings.Index(strings.Join(r.ran, "\n"), "esbuild") {
|
||||||
|
t.Fatal("the bundler ran before the compile wrote its output")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bundle naming packages it keeps external is bundled with them as imports, and carries the
|
||||||
|
// toolchain's node_modules for them — the one case it still does.
|
||||||
|
func TestABundleKeepingAPackageExternalCarriesTheToolchainsModules(t *testing.T) {
|
||||||
|
manifest := strings.Replace(aBundle, `"entrypoints":["index.js"]`, `"entrypoints":["index.js"],"external":["sharp"]`, 1)
|
||||||
|
r, workspace := aRepository(t, manifest, map[string]string{"index.ts": "console.log(1)"})
|
||||||
|
held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)}
|
||||||
|
if _, err := Build(context.Background(), compiling{r}.run, r,
|
||||||
|
"https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, GitCredential{}, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
all := strings.Join(r.ran, "\n")
|
||||||
|
if !strings.Contains(all, "--external:sharp") || !strings.Contains(all, "/app/runtime") {
|
||||||
|
t.Errorf("an external package was not kept as an import with the toolchain's modules beside it:\n%s", all)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module's own packages, installed before its bundle is compiled, so the bundler inlines them.
|
||||||
|
//
|
||||||
|
// **A bundle could only import what the toolchain happened to carry.** The compiler and the bundler
|
||||||
|
// resolve an import by walking up from the module's source: the module's own directory first, then
|
||||||
|
// the toolchain image's node_modules. Nothing ever put anything in the first, so a module needing a
|
||||||
|
// database driver (`pg`, `mongodb`, `mssql`) could not be a bundle at all, and kept a container whose
|
||||||
|
// recipe installed it by hand (novox/hq ADR 0198 §4: "the backend's own driver inside the bundle").
|
||||||
|
// Now the module's `package.json` says what it depends on, as any Node package does, and the build
|
||||||
|
// installs exactly that into the module's own directory before compiling.
|
||||||
|
//
|
||||||
|
// **The SDK the toolchain carries is the one a bundle is built with, whatever the module says**
|
||||||
|
// (novox/hq issue 212: the toolchain is rebuilt on every SDK release and every bundle after it). A
|
||||||
|
// module's `package.json` names `@novox/mesh-sdk` with a range — it has to, to type-check on a
|
||||||
|
// workstation — and installing that range would shadow the toolchain's copy for this module alone:
|
||||||
|
// one module compiled against an older SDK than its neighbours, chosen by a caret nobody re-reads.
|
||||||
|
// So the SDK is taken out of what is installed (and never fetched), and any copy something else
|
||||||
|
// pulls in is removed afterwards; every import of it resolves past the module's node_modules to the
|
||||||
|
// toolchain's. A module therefore cannot pin a different SDK, by design: the toolchain is the pin.
|
||||||
|
//
|
||||||
|
// **Correctness before speed.** Every build installs afresh into a fresh clone, from the lockfile
|
||||||
|
// when the module has one (`npm ci`, exact) and from its ranges otherwise; nothing installed is kept
|
||||||
|
// between builds. What is shared is npm's own download cache, a named volume, which is
|
||||||
|
// content-addressed and verified by integrity on every read — it saves the network, never the
|
||||||
|
// install. Install scripts do not run: the build node runs nobody's postinstall, and what a script
|
||||||
|
// would build natively could not be inlined into one file anyway.
|
||||||
|
|
||||||
|
// sdkPackage is the package a TypeScript bundle's launcher serves through, and the one package a
|
||||||
|
// module's own dependencies never supply (above).
|
||||||
|
const sdkPackage = "@novox/mesh-sdk"
|
||||||
|
|
||||||
|
// npmCache is the named volume npm's download cache lives in across builds on one build node.
|
||||||
|
const npmCache = "mesh-builder-npm-cache"
|
||||||
|
|
||||||
|
// ownDependencies is what a module's package.json depends on beyond the SDK, sorted; nothing when
|
||||||
|
// the module has no package.json or depends on nothing else — which builds exactly as before.
|
||||||
|
func ownDependencies(tree string) ([]string, error) {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(tree, "package.json"))
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var p struct {
|
||||||
|
Dependencies map[string]string `json:"dependencies"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil {
|
||||||
|
return nil, fmt.Errorf("the module's package.json is not JSON: %w", err)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for name := range p.Dependencies {
|
||||||
|
if name != sdkPackage {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// installSteps is the script run inside the toolchain image, from the module's own directory ($0).
|
||||||
|
// It works in a scratch copy so the module's package.json and lockfile are never rewritten, takes
|
||||||
|
// the SDK out of what is installed, installs production dependencies only, removes any copy of the
|
||||||
|
// SDK something pulled in, and puts the result at the module's node_modules.
|
||||||
|
const installSteps = `set -e
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
cp "$0/package.json" "$work/"
|
||||||
|
if [ -f "$0/package-lock.json" ]; then cp "$0/package-lock.json" "$work/"; fi
|
||||||
|
cd "$work"
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs"), sdk = process.argv[1];
|
||||||
|
const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
|
||||||
|
for (const k of ["dependencies", "peerDependencies", "optionalDependencies"]) if (p[k]) delete p[k][sdk];
|
||||||
|
delete p.devDependencies; delete p.scripts;
|
||||||
|
fs.writeFileSync("package.json", JSON.stringify(p));
|
||||||
|
' "$1"
|
||||||
|
shift
|
||||||
|
if [ -f package-lock.json ]; then
|
||||||
|
npm ci --omit=dev --omit=peer --ignore-scripts --no-audit --no-fund "$@"
|
||||||
|
else
|
||||||
|
npm install --omit=dev --omit=peer --ignore-scripts --no-audit --no-fund --no-package-lock "$@"
|
||||||
|
fi
|
||||||
|
find node_modules -depth -type d -path "*/node_modules/@novox/mesh-sdk" -exec rm -rf {} +
|
||||||
|
rm -rf "$0/node_modules"
|
||||||
|
cp -a node_modules "$0/node_modules"
|
||||||
|
`
|
||||||
|
|
||||||
|
// installOwn installs a TypeScript module's own production dependencies into its directory, in the
|
||||||
|
// toolchain image, before the compile — or does nothing at all for a module that has none.
|
||||||
|
func installOwn(ctx context.Context, run Runner, tree string, chain Toolchain, base string,
|
||||||
|
registry Npmrc, say func(step, format string, args ...any)) error {
|
||||||
|
if chain.Language != "typescript" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
deps, err := ownDependencies(tree)
|
||||||
|
if err != nil || len(deps) == 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
scoped := strings.TrimSpace(registry.Scope)
|
||||||
|
if !registry.Enabled() {
|
||||||
|
// **No registry, no scoped package.** Without the mesh's registry a scoped name resolves on
|
||||||
|
// the public one, where anybody may have published it: a dependency that installs is not
|
||||||
|
// the dependency the module meant.
|
||||||
|
for _, d := range deps {
|
||||||
|
if strings.HasPrefix(d, "@novox/") {
|
||||||
|
return fmt.Errorf("the module depends on %s, and this build knows no package registry "+
|
||||||
|
"for its scope; it would resolve from the public registry, which is not where the "+
|
||||||
|
"mesh publishes it", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const within = "/app/modules/module"
|
||||||
|
invocation := []string{"run", "--rm",
|
||||||
|
"--volume", tree + ":" + within,
|
||||||
|
"--volume", npmCache + ":/root/.npm",
|
||||||
|
"--workdir", within}
|
||||||
|
var flags []string
|
||||||
|
if registry.Enabled() {
|
||||||
|
// The registry is reached where the binding says it is, which may be this machine's own
|
||||||
|
// loopback — the reason an image build that resolves packages runs on the host network too.
|
||||||
|
invocation = append(invocation, "--network", "host")
|
||||||
|
reg := strings.TrimSpace(registry.Registry)
|
||||||
|
if !strings.HasSuffix(reg, "/") {
|
||||||
|
reg += "/"
|
||||||
|
}
|
||||||
|
flags = append(flags, "--"+scoped+":registry="+reg)
|
||||||
|
}
|
||||||
|
invocation = append(invocation, base, "sh", "-c", installSteps, within, sdkPackage)
|
||||||
|
invocation = append(invocation, flags...)
|
||||||
|
say("bundle", "installing the module's own packages: %s", strings.Join(deps, ", "))
|
||||||
|
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
||||||
|
return fmt.Errorf("installing the module's own packages (%s): %w", strings.Join(deps, ", "), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module's own packages (dependencies.go): installed into its own directory, in the toolchain,
|
||||||
|
// before the compile, so the bundler inlines them — the SDK always the toolchain's.
|
||||||
|
|
||||||
|
func buildWithPackageJSON(t *testing.T, pkg string, extra map[string]string, registry Npmrc) (*recorded, error) {
|
||||||
|
t.Helper()
|
||||||
|
files := map[string]string{"index.ts": "console.log(1)"}
|
||||||
|
if pkg != "" {
|
||||||
|
files["package.json"] = pkg
|
||||||
|
}
|
||||||
|
for k, v := range extra {
|
||||||
|
files[k] = v
|
||||||
|
}
|
||||||
|
r, workspace := aRepository(t, aBundle, files)
|
||||||
|
held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)}
|
||||||
|
_, err := Build(context.Background(), compiling{r}.run, r,
|
||||||
|
"https://forge.invalid/greeter.git", "", "", workspace, held, registry, GitCredential{}, nil)
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func installs(r *recorded) []string {
|
||||||
|
var out []string
|
||||||
|
for _, line := range r.ran {
|
||||||
|
if strings.HasPrefix(line, "docker run") && strings.Contains(line, "npm ci") {
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileIndex(r *recorded) int {
|
||||||
|
for i, line := range r.ran {
|
||||||
|
if strings.Contains(line, "--outDir") {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAModulesOwnPackagesAreInstalledInTheToolchainBeforeTheCompile(t *testing.T) {
|
||||||
|
r, err := buildWithPackageJSON(t, `{"type":"module","dependencies":{"@novox/mesh-sdk":"^0.1.0","pg":"^8"},"devDependencies":{"typescript":"^5"}}`,
|
||||||
|
nil, Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := installs(r)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("want one install of the module's own packages:\n%s", strings.Join(r.ran, "\n"))
|
||||||
|
}
|
||||||
|
line := got[0]
|
||||||
|
for _, want := range []string{
|
||||||
|
"mesh-tools/build@sha256:", // in the toolchain image
|
||||||
|
":/app/modules/module", // into the module's own directory
|
||||||
|
"--workdir /app/modules/module", //
|
||||||
|
npmCache + ":/root/.npm", // npm's verified download cache, and only that
|
||||||
|
"--omit=dev", "--ignore-scripts", // production packages, no build-node scripts
|
||||||
|
"npm ci", "npm install", "--no-package-lock", // the lockfile when there is one, else the ranges
|
||||||
|
"--@novox:registry=https://forge.invalid/api/packages/novox/npm/", // the scope from the mesh's registry
|
||||||
|
"--network host",
|
||||||
|
"@novox/mesh-sdk", // named, to be taken out of what is installed
|
||||||
|
} {
|
||||||
|
if !strings.Contains(line, want) {
|
||||||
|
t.Errorf("the install lacks %q:\n%s", want, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The SDK is the toolchain's: never installed from the module's range, and any copy removed.
|
||||||
|
if !strings.Contains(line, `delete p[k][sdk]`) || !strings.Contains(line, `-path "*/node_modules/@novox/mesh-sdk" -exec rm -rf`) {
|
||||||
|
t.Errorf("the module's own SDK range could shadow the toolchain's SDK:\n%s", line)
|
||||||
|
}
|
||||||
|
if i, c := strings.Index(strings.Join(r.ran, "\n"), "npm ci"), compileIndex(r); c < 0 ||
|
||||||
|
i > strings.Index(strings.Join(r.ran, "\n"), "--outDir") {
|
||||||
|
t.Fatalf("the install did not run before the compile:\n%s", strings.Join(r.ran, "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **A module with nothing beyond the SDK builds exactly as before**: the same commands, no install.
|
||||||
|
func TestAModuleDependingOnlyOnTheSDKBuildsExactlyAsBefore(t *testing.T) {
|
||||||
|
without, err := buildWithPackageJSON(t, "", nil, Npmrc{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, pkg := range []string{
|
||||||
|
`{"type":"module","dependencies":{"@novox/mesh-sdk":"^0.1.0"},"devDependencies":{"typescript":"^5"}}`,
|
||||||
|
`{"type":"module"}`,
|
||||||
|
} {
|
||||||
|
with, err := buildWithPackageJSON(t, pkg, map[string]string{"package-lock.json": "{}"}, Npmrc{Scope: "@novox", Registry: "https://forge.invalid/npm/"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.Join(with.ran, "\n"), "npm ") {
|
||||||
|
t.Fatalf("a module depending on nothing but the SDK ran npm:\n%s", strings.Join(with.ran, "\n"))
|
||||||
|
}
|
||||||
|
if len(with.ran) != len(without.ran) {
|
||||||
|
t.Fatalf("a module depending only on the SDK built differently from one with no package.json:\n%s\n---\n%s",
|
||||||
|
strings.Join(with.ran, "\n"), strings.Join(without.ran, "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without the mesh's registry a scoped package would resolve on the public one: refused by name.
|
||||||
|
func TestAScopedPackageWithNoRegistryIsRefused(t *testing.T) {
|
||||||
|
r, err := buildWithPackageJSON(t, `{"dependencies":{"@novox/mesh-sdk":"^0.1.0","@novox/other":"^1"}}`, nil, Npmrc{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "@novox/other") {
|
||||||
|
t.Fatalf("a scoped package was installed with no registry for its scope: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.Join(r.ran, "\n"), "--outDir") {
|
||||||
|
t.Fatal("the compile ran after the refusal")
|
||||||
|
}
|
||||||
|
// A public package installs without one, from the public registry and nothing else.
|
||||||
|
r, err = buildWithPackageJSON(t, `{"dependencies":{"mssql":"^11"}}`, nil, Npmrc{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := installs(r); len(got) != 1 || strings.Contains(got[0], ":registry=") || strings.Contains(got[0], "--network host") {
|
||||||
|
t.Fatalf("a public package's install: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUnreadablePackageJSONIsRefusedByName(t *testing.T) {
|
||||||
|
_, err := buildWithPackageJSON(t, `{"dependencies":`, nil, Npmrc{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "package.json") {
|
||||||
|
t.Fatalf("a broken package.json was not refused by name: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -200,3 +200,23 @@ func TestWhatABuildReadIsTheRepositoriesItsRecipesName(t *testing.T) {
|
|||||||
t.Fatal("a module whose recipes name no other repository read one")
|
t.Fatal("a module whose recipes name no other repository read one")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// novox/hq 04-ISSUES/212: a toolchain stands on the SDK's published package, and is built with the
|
||||||
|
// exact version the mesh published — an argument that changes when the SDK does, so a rebuild after
|
||||||
|
// a release never reuses an install of the version before it.
|
||||||
|
func TestAPackageTheMeshPublishedIsPassedByItsExactVersion(t *testing.T) {
|
||||||
|
manifest := catalogue.Manifest{
|
||||||
|
Module: "mesh-tools",
|
||||||
|
Build: &catalogue.Build{
|
||||||
|
On: []catalogue.BuildsOn{{Arg: "MESH_SDK", Module: "mesh-sdk", Artifact: "lib"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
held := map[string]string{"mesh-sdk/lib": "@novox/mesh-sdk@0.1.6"}
|
||||||
|
args, resolved, err := standingOn(context.Background(), manifest, held, noMirror)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fmt.Sprint(args) != "[--build-arg MESH_SDK=@novox/mesh-sdk@0.1.6]" || fmt.Sprint(resolved) != "[@novox/mesh-sdk@0.1.6]" {
|
||||||
|
t.Errorf("the package was passed as %v, recorded as %v", args, resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,13 +67,23 @@ type Toolchain struct {
|
|||||||
// `package.json` saying `"type": "module"` — Node reads a bare `.js` as CommonJS otherwise, so a
|
// `package.json` saying `"type": "module"` — Node reads a bare `.js` as CommonJS otherwise, so a
|
||||||
// bundle with its dependencies and without that line still fails to start — and the pruned,
|
// bundle with its dependencies and without that line still fails to start — and the pruned,
|
||||||
// production-only node_modules the runtime itself ships with: the SDK's and the runtime's
|
// production-only node_modules the runtime itself ships with: the SDK's and the runtime's
|
||||||
// dependencies, and nothing module-specific yet (novox/hq ADR 0188 §5: a skeleton; a module's
|
// dependencies, and nothing module-specific (a module's own npm dependencies are installed into
|
||||||
// own npm dependencies are a later step). Empty for a language whose bundle carries its own —
|
// its own directory before the compile and inlined by the bundler: dependencies.go). Empty for a
|
||||||
|
// language whose bundle carries its own —
|
||||||
// a Go binary is static, a Python bundle is installed with its dependencies.
|
// a Go binary is static, a Python bundle is installed with its dependencies.
|
||||||
//
|
//
|
||||||
// A toolchain image without the directory fails the build by name rather than packing a bundle
|
// A toolchain image without the directory fails the build by name rather than packing a bundle
|
||||||
// that starts nowhere: the image predates this and must be rebuilt first.
|
// that starts nowhere: the image predates this and must be rebuilt first.
|
||||||
|
//
|
||||||
|
// *Since the bundler (below):* copied only for a bundle that names packages it keeps external,
|
||||||
|
// which cannot be inlined; a bundle with none carries no node_modules at all.
|
||||||
Dependencies string
|
Dependencies string
|
||||||
|
// Bundler is the bundler inside the toolchain image that makes each compiled entrypoint and each
|
||||||
|
// launcher ONE self-contained file (novox/hq ADR 0193): every served bundle is its own process
|
||||||
|
// now, so each carries its own copy of what it imports — the SDK included — and nothing else.
|
||||||
|
// A bundle shrinks from the toolchain's whole node_modules to the code it runs. Empty for a
|
||||||
|
// language whose build is already one file.
|
||||||
|
Bundler string
|
||||||
// SystemStamp is the variable this language's linker fills with the artifact's declared system,
|
// 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).
|
// for a language whose binaries are pinned to one at link time (novox/hq ADR 0005).
|
||||||
//
|
//
|
||||||
@@ -139,6 +149,7 @@ var toolchains = []Toolchain{
|
|||||||
Unit: UnitSources,
|
Unit: UnitSources,
|
||||||
SourceExt: ".ts",
|
SourceExt: ".ts",
|
||||||
Dependencies: "/app/runtime",
|
Dependencies: "/app/runtime",
|
||||||
|
Bundler: "/app/node_modules/esbuild/bin/esbuild",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Language: "go",
|
Language: "go",
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq issue 213 (ADR 0188 §1, §3): a module's own Go service is a bundle the host runs as a
|
||||||
|
// process, not an image. Each test holds one thing that had to change in the composer for the
|
||||||
|
// controller to be declared that way.
|
||||||
|
|
||||||
|
const aServiceDigest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||||
|
|
||||||
|
// aServiceModule is the controller's shape in miniature: it answers tools of its own, its code is a
|
||||||
|
// Go bundle a process runs as an account it declares, its secrets belong to that account, it
|
||||||
|
// prepares its state, and its process replaces the container it used to run as.
|
||||||
|
func aServiceModule(t *testing.T) Manifest {
|
||||||
|
t.Helper()
|
||||||
|
raw := `{
|
||||||
|
"module": "svc", "version": "1", "tools": ["status"], "prepares": true,
|
||||||
|
"own-secrets": {"store": "${dir:state}/store"},
|
||||||
|
"secrets-owner": "svc",
|
||||||
|
"resources": [
|
||||||
|
{"id": "state", "type": "directory", "mode": "0700", "place": "mesh", "owner": "svc"},
|
||||||
|
{"id": "service", "type": "process", "name": "svc", "artifact": "code",
|
||||||
|
"run": ["./svc", "serve"], "user": "svc", "replaces": ["server"],
|
||||||
|
"env": {"SVC_STORE_FILE": "${dir:state}/store", "SVC_STORE_PORT": "${seat:mesh-store:5432}"}},
|
||||||
|
{"id": "account", "type": "user", "name": "svc", "shell": "/usr/bin/nologin", "home": "/var/lib/svc"}
|
||||||
|
],
|
||||||
|
"build": {"artifacts": [{"name": "code", "kind": "bundle", "language": "go", "system": "arch",
|
||||||
|
"from": "cmd/svc", "binary": "svc"}]}
|
||||||
|
}`
|
||||||
|
m, err := ParseManifest([]byte(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the service's manifest is refused: %v", err)
|
||||||
|
}
|
||||||
|
resolved, err := m.Resolve([]Built{{Name: "code", Kind: ArtifactBundle,
|
||||||
|
Reference: ArtifactStoreScheme + "svc/code@" + aServiceDigest, Digest: aServiceDigest}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func composeTheService(t *testing.T, with Rendering) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
with.Needed = map[string]map[string]string{"svc": {"store": "sealed-store"}}
|
||||||
|
with.ArtifactStore = "anchor.internal:5100"
|
||||||
|
out, err := Resolution{Node: "anchor", Modules: []Manifest{aServiceModule(t)}}.Declaration(with)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the service does not compose: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(out []map[string]any, id string) int {
|
||||||
|
for i, r := range out {
|
||||||
|
if r["id"] == id {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module that declares tools has every bundle served by the node's runtime unless it says
|
||||||
|
// otherwise — and the controller declares the verbs it answers as tools. Its service bundle is run
|
||||||
|
// by its own process; launched a second time by the runtime it would be a second controller
|
||||||
|
// pretending to be an MCP server.
|
||||||
|
func TestABundleItsOwnProcessRunsIsNotServedByTheRuntime(t *testing.T) {
|
||||||
|
m := aServiceModule(t)
|
||||||
|
if len(m.Bundles) != 1 {
|
||||||
|
t.Fatalf("the service's bundle was not kept: %+v", m.Bundles)
|
||||||
|
}
|
||||||
|
if loads := m.Bundles[0].Loads; len(loads) != 0 {
|
||||||
|
t.Fatalf("the runtime would launch the service's own bundle as tools: %v", loads)
|
||||||
|
}
|
||||||
|
// And a bundle no resource runs still is served, as a module declaring tools always had it.
|
||||||
|
tools := Manifest{Module: "t", Version: "1", Tools: []string{"x"},
|
||||||
|
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "go",
|
||||||
|
System: "arch", From: "cmd/t"}}}}
|
||||||
|
resolved, err := tools.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
||||||
|
Reference: ArtifactStoreScheme + "t/tools@" + aServiceDigest, Digest: aServiceDigest}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if loads := resolved.Bundles[0].Loads; len(loads) != 1 || loads[0] != "t" {
|
||||||
|
t.Fatalf("a tools bundle nothing runs is no longer served: %v", loads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The account is created before anything is given to it. Its secrets are mesh-computed and so
|
||||||
|
// placed before the module's own resources; given to a user the machine did not have yet, they were
|
||||||
|
// refused on the first apply and the process started without them.
|
||||||
|
func TestAModulesAccountComesBeforeWhatBelongsToIt(t *testing.T) {
|
||||||
|
out := composeTheService(t, Rendering{})
|
||||||
|
account, secret := indexOf(out, "svc.account"), indexOf(out, "svc."+NeedID("store"))
|
||||||
|
if account < 0 || secret < 0 {
|
||||||
|
t.Fatalf("the account or the secret is missing: %v", out)
|
||||||
|
}
|
||||||
|
if account > secret {
|
||||||
|
t.Fatalf("the secret owned by svc is written before svc exists: account at %d, secret at %d",
|
||||||
|
account, secret)
|
||||||
|
}
|
||||||
|
if owner := out[secret]["owner"]; owner != "svc" {
|
||||||
|
t.Errorf("the secret belongs to %v, not the account its process runs as", owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The process is the module's program; its preparation is the same program asked to prepare, as a
|
||||||
|
// step before it — with the same account and environment, and handing nothing over.
|
||||||
|
func TestAProcessIsPreparedByItsOwnProgram(t *testing.T) {
|
||||||
|
out := composeTheService(t, Rendering{})
|
||||||
|
step, process := indexOf(out, "svc.service-prepare"), indexOf(out, "svc.service")
|
||||||
|
if step < 0 || process < 0 || step > process {
|
||||||
|
t.Fatalf("the preparation is not a step before the process (%d, %d): %v", step, process, out)
|
||||||
|
}
|
||||||
|
s := out[step]
|
||||||
|
if s["type"] != "process" || s["run-once"] != true || s["name"] != "svc-prepare" {
|
||||||
|
t.Errorf("the preparation is not a run-once process: %v", s)
|
||||||
|
}
|
||||||
|
if run, _ := json.Marshal(s["run"]); string(run) != `["./svc","prepare"]` {
|
||||||
|
t.Errorf("the preparation runs %s", run)
|
||||||
|
}
|
||||||
|
if s["user"] != "svc" || s["source"] != out[process]["source"] {
|
||||||
|
t.Errorf("the preparation does not run the same bundle as the same account: %v", s)
|
||||||
|
}
|
||||||
|
if env, _ := s["env"].(map[string]any); env["SVC_STORE_FILE"] == nil {
|
||||||
|
t.Errorf("the preparation is not given the process's environment: %v", s["env"])
|
||||||
|
}
|
||||||
|
if _, has := s["replaces"]; has {
|
||||||
|
t.Errorf("the preparation would hand over what the process replaces: %v", s)
|
||||||
|
}
|
||||||
|
if _, has := s["args"]; has {
|
||||||
|
t.Errorf("the preparation carries a container's args: %v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the process replaces is named as the host recorded it, `<module>.<id>`; unprefixed, the host
|
||||||
|
// matches nothing and removes the container first, as before.
|
||||||
|
func TestWhatAProcessReplacesIsNamedAsTheHostRecordedIt(t *testing.T) {
|
||||||
|
out := composeTheService(t, Rendering{})
|
||||||
|
p := out[indexOf(out, "svc.service")]
|
||||||
|
if got, _ := json.Marshal(p["replaces"]); string(got) != `["svc.server"]` {
|
||||||
|
t.Fatalf("the process replaces %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhatReplacesMayNameIsRefusedNearItsAuthor(t *testing.T) {
|
||||||
|
for what, resource := range map[string]string{
|
||||||
|
"a container": `{"id":"c","type":"container","name":"c","image":"x@` + aServiceDigest + `","replaces":["old"]}`,
|
||||||
|
"a step": `{"id":"p","type":"process","name":"p","run":["./p"],"run-once":true,"replaces":["old"]}`,
|
||||||
|
"something declared": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["p"]}`,
|
||||||
|
"another module's": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["other.old"]}`,
|
||||||
|
"not a list": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":"old"}`,
|
||||||
|
} {
|
||||||
|
raw := `{"module":"m","version":"1","resources":[` + resource + `]}`
|
||||||
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "replace") {
|
||||||
|
t.Errorf("replaces on %s was accepted: %v", what, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok := `{"module":"m","version":"1","resources":[{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["old"]}]}`
|
||||||
|
if _, err := ParseManifest([]byte(ok)); err != nil {
|
||||||
|
t.Errorf("a process replacing what its module no longer declares was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,9 @@ import (
|
|||||||
// formats and gains no fields.
|
// formats and gains no fields.
|
||||||
//
|
//
|
||||||
// **It stays name-agnostic** ([ADR 0027]). The mesh does not learn what a `postgres-database` is:
|
// **It stays name-agnostic** ([ADR 0027]). The mesh does not learn what a `postgres-database` is:
|
||||||
// `at`, `as` and `from` are facts about any provision at all, and everything else comes from what
|
// `at`, `as`, `from` and `address` (the providing machine's private address, novox/hq ADR 0194) are
|
||||||
// the provider said it serves — whose keys are agreed by the requirement's name, not by this file.
|
// facts about any provision at all, and everything else comes from what the provider said it
|
||||||
|
// serves — whose keys are agreed by the requirement's name, not by this file.
|
||||||
|
|
||||||
// bound is where a module says a value from one of its bindings belongs:
|
// bound is where a module says a value from one of its bindings belongs:
|
||||||
// ${bound:<provision>.<key>}.
|
// ${bound:<provision>.<key>}.
|
||||||
@@ -46,7 +47,7 @@ func boundUsed(content string) [][2]string {
|
|||||||
// Three facts the mesh states about any provision, plus whatever the provider said it serves. A
|
// Three facts the mesh states about any provision, plus whatever the provider said it serves. A
|
||||||
// module may not reach a binding it does not have — the same boundary as a secret, for the same
|
// module may not reach a binding it does not have — the same boundary as a secret, for the same
|
||||||
// reason.
|
// reason.
|
||||||
func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]string {
|
func knownFor(m Manifest, needs []Needed, node string) (map[string]map[string]string, error) {
|
||||||
out := map[string]map[string]string{}
|
out := map[string]map[string]string{}
|
||||||
for _, want := range m.Wants() {
|
for _, want := range m.Wants() {
|
||||||
for i := range needs {
|
for i := range needs {
|
||||||
@@ -54,12 +55,20 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str
|
|||||||
if n.Name != want || n.For != m.Module {
|
if n.Name != want || n.For != m.Module {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
as := ConsumerIdentity(node, IdentitySource(m.Slug, m.Module))
|
||||||
values := map[string]string{
|
values := map[string]string{
|
||||||
"at": n.At,
|
"at": n.At,
|
||||||
"from": n.From,
|
"from": n.From,
|
||||||
"as": ConsumerIdentity(node, IdentitySource(m.Slug, m.Module)),
|
"as": as,
|
||||||
}
|
}
|
||||||
for key, value := range n.Serves {
|
// What the provider derives for this consumer rather than for all of them
|
||||||
|
// (novox/hq ADR 0201). Filled here, the one place a provision and the module
|
||||||
|
// requiring it are both in hand.
|
||||||
|
served, err := ServedTo(n.Serves, as)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s requires %s: %w", m.Module, want, err)
|
||||||
|
}
|
||||||
|
for key, value := range served {
|
||||||
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
|
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
|
||||||
// which is what a float would write and what a connection string would refuse.
|
// which is what a float would write and what a connection string would refuse.
|
||||||
values[key] = plainly(value)
|
values[key] = plainly(value)
|
||||||
@@ -67,7 +76,7 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str
|
|||||||
out[want] = values
|
out[want] = values
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// withOwnNames adds a module's own composed names to what it may name from one binding:
|
// withOwnNames adds a module's own composed names to what it may name from one binding:
|
||||||
|
|||||||
@@ -232,3 +232,34 @@ func TestTwoModulesOnOneNodeAreTwoIdentities(t *testing.T) {
|
|||||||
t.Fatal("one module on two machines shares an identity")
|
t.Fatal("one module on two machines shares an identity")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A machine's resolver configuration must name its resolver by address — it cannot resolve the name
|
||||||
|
// of the thing it resolves names with (novox/hq ADR 0194). So a binding offers the providing
|
||||||
|
// machine's private address beside its name, and only when the machine has one.
|
||||||
|
func TestABindingOffersTheProvidersAddress(t *testing.T) {
|
||||||
|
consumer := func() Resolution {
|
||||||
|
return Resolution{
|
||||||
|
Node: "workstation",
|
||||||
|
Modules: []Manifest{{
|
||||||
|
Module: "resolv-conf",
|
||||||
|
Requires: []string{"wildcard-resolution"},
|
||||||
|
Resources: []map[string]any{{
|
||||||
|
"id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644",
|
||||||
|
"content": "nameserver ${bound:wildcard-resolution:address}\n",
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
Needs: []Needed{{Name: "wildcard-resolution", From: "anchor", At: "anchor.internal", For: "resolv-conf"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, err := consumer().Declaration(Rendering{Machines: map[string]string{"anchor.internal": "10.77.0.1"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := fileNamed(out, "resolv-conf.resolv")["content"]; got != "nameserver 10.77.0.1\n" {
|
||||||
|
t.Fatalf("the resolver is not named by its address: %q", got)
|
||||||
|
}
|
||||||
|
// A machine with no address yet: refused, never written with a blank where the address belongs.
|
||||||
|
if _, err := consumer().Declaration(Rendering{}); err == nil {
|
||||||
|
t.Fatal("a file naming an address the mesh does not have was composed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
|||||||
// build compare equal.
|
// build compare equal.
|
||||||
out.Bundles = nil
|
out.Bundles = nil
|
||||||
if m.Build != nil {
|
if m.Build != nil {
|
||||||
|
run := runByAResource(m)
|
||||||
for _, a := range m.Build.Artifacts {
|
for _, a := range m.Build.Artifacts {
|
||||||
if a.Kind != ArtifactBundle {
|
if a.Kind != ArtifactBundle {
|
||||||
continue
|
continue
|
||||||
@@ -84,8 +85,13 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
|||||||
made := by[a.Name]
|
made := by[a.Name]
|
||||||
// What the runtime loads: what the artifact said, else every entrypoint of a module
|
// What the runtime loads: what the artifact said, else every entrypoint of a module
|
||||||
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
|
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
|
||||||
|
//
|
||||||
|
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
|
||||||
|
// A process the host runs is the module's service, not its tools: the controller declares
|
||||||
|
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
|
||||||
|
// have been launched a second time by the node's runtime, as an MCP child it is not.
|
||||||
loads := append([]string(nil), a.Loads...)
|
loads := append([]string(nil), a.Loads...)
|
||||||
if a.Loads == nil && len(m.Tools) > 0 {
|
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
|
||||||
loads = append([]string(nil), a.Entrypoints...)
|
loads = append([]string(nil), a.Entrypoints...)
|
||||||
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
|
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
|
||||||
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
|
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
|
||||||
@@ -214,6 +220,11 @@ func (b *Build) problems(module string) []string {
|
|||||||
// A bundle's source is the module's own directory by definition, and what it needs to say
|
// A bundle's source is the module's own directory by definition, and what it needs to say
|
||||||
// is which compiler — because the mesh chooses that, and cannot choose for a module that
|
// is which compiler — because the mesh chooses that, and cannot choose for a module that
|
||||||
// has not said.
|
// has not said.
|
||||||
|
if len(a.External) > 0 && (a.Kind != ArtifactBundle || a.Language != "typescript") {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s: %q names packages it keeps external, and only a TypeScript bundle is bundled into "+
|
||||||
|
"one file with some kept out (novox/hq ADR 0193)", module, a.Name))
|
||||||
|
}
|
||||||
if len(a.Env) > 0 && a.Kind != ArtifactBundle {
|
if len(a.Env) > 0 && a.Kind != ArtifactBundle {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s: %q is a %q and says what it is given (env). Only a bundle the node's runtime "+
|
"%s: %q is a %q and says what it is given (env). Only a bundle the node's runtime "+
|
||||||
@@ -447,3 +458,40 @@ func BinaryOf(a Artifact) string {
|
|||||||
}
|
}
|
||||||
return a.Name
|
return a.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runByAResource is the artifacts one of a module's own resources names — a process that runs it,
|
||||||
|
// a step, an archive that unpacks it — by name.
|
||||||
|
func runByAResource(m Manifest) map[string]bool {
|
||||||
|
named := map[string]bool{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if a, ok := r["artifact"].(string); ok && a != "" {
|
||||||
|
named[a] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return named
|
||||||
|
}
|
||||||
|
|
||||||
|
// undeliveredBundles says which of a module's bundles nothing would ever put on a machine (novox/hq
|
||||||
|
// 04-ISSUES/216). A bundle reaches a machine three ways: the node's runtime serves it (it says
|
||||||
|
// `loads`, or its module declares `tools`), a resource names it (a process, a step, an archive), or
|
||||||
|
// it is the runtime itself. One reached by none of them was built, recorded and pushed as success,
|
||||||
|
// and was simply absent — seven modules' tools went missing that way on 2026-10-03. Refused here,
|
||||||
|
// naming the field that would deliver it.
|
||||||
|
func undeliveredBundles(m Manifest) []string {
|
||||||
|
if m.Build == nil || m.Module == RuntimeModule {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
named := runByAResource(m)
|
||||||
|
var problems []string
|
||||||
|
for _, a := range m.Build.Artifacts {
|
||||||
|
if a.Kind != ArtifactBundle || named[a.Name] || len(a.Loads) > 0 || len(m.Tools) > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s: the bundle %q would be built and never reach a machine: nothing loads it, runs it or "+
|
||||||
|
"unpacks it. A tools bundle says `loads` (the entrypoints the node's runtime serves) or its "+
|
||||||
|
"module lists its `tools`; a daemon or a step is a resource naming it (novox/hq 04-ISSUES/216)",
|
||||||
|
m.Module, a.Name))
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What a provider derives for one consumer, said once in the provider's definition and delivered
|
||||||
|
// to both ends (novox/hq ADR 0201, issue 124).
|
||||||
|
//
|
||||||
|
// A `serves` block is otherwise literal: the same values for every consumer. Where the provider
|
||||||
|
// *names the resource* — a bucket, a database, a vhost — the name is derived from who is asking,
|
||||||
|
// and before this the mesh had no channel for it. The provider recomputed it in its own code and
|
||||||
|
// every consumer transcribed it into its own definition by hand, which is a copy of somebody
|
||||||
|
// else's rule kept in agreement by nobody. One of three transcriptions was wrong for months.
|
||||||
|
//
|
||||||
|
// **The mesh learns no protocol here; it spells its own name in an alphabet it already knows.**
|
||||||
|
// The only fact a served value may name is the identity the mesh itself minted for the consumer,
|
||||||
|
// in one of two alphabets: as it was minted, and as a DNS label. Everything a provider wants
|
||||||
|
// around it — a prefix, a suffix, a separator — it writes around the placeholder, because a
|
||||||
|
// served value is a string.
|
||||||
|
|
||||||
|
// consumerFact is `${consumer:<fact>}` or `${consumer:<fact>:<alphabet>}`.
|
||||||
|
var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`)
|
||||||
|
|
||||||
|
// consumerFacts are what a served value may name about the consumer it is being derived for.
|
||||||
|
// One entry, deliberately: the identity is the one thing about a consumer the mesh itself chose,
|
||||||
|
// so it is the one thing the mesh can hand to a provider without either end guessing.
|
||||||
|
var consumerFacts = []string{"as"}
|
||||||
|
|
||||||
|
// consumerAlphabets are the ways the mesh will write that identity. `dns` is the mesh's own
|
||||||
|
// identifier with its separator written `-` instead of `_` — the whole of the difference between
|
||||||
|
// the alphabet the mesh mints in and the one buckets, vhosts and hostnames accept.
|
||||||
|
var consumerAlphabets = []string{"dns"}
|
||||||
|
|
||||||
|
// ServedTo fills a provider's served values for one consumer.
|
||||||
|
//
|
||||||
|
// `as` is the identity the mesh minted for that consumer — the same string it is told to present
|
||||||
|
// as a login. Values with no placeholder are returned exactly as they were, and a block with no
|
||||||
|
// placeholder at all is returned unchanged, so this costs nothing for the providers that derive
|
||||||
|
// nothing.
|
||||||
|
//
|
||||||
|
// Only strings carry placeholders. A number, a boolean or a nested object is a value the provider
|
||||||
|
// stated outright, and is left alone.
|
||||||
|
func ServedTo(serves map[string]any, as string) (map[string]any, error) {
|
||||||
|
if len(serves) == 0 {
|
||||||
|
return serves, nil
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
for _, key := range sortedAnyKeys(serves) {
|
||||||
|
text, ok := serves[key].(string)
|
||||||
|
if !ok || !strings.Contains(text, "${consumer:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filled, err := consumerInto(text, as)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the value served as %q: %w", key, err)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
// Copied only once something actually changes: the caller's map is the manifest's,
|
||||||
|
// and a provider that derives nothing must not have it rewritten underneath it.
|
||||||
|
out = make(map[string]any, len(serves))
|
||||||
|
for k, v := range serves {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[key] = filled
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
return serves, nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumerInto replaces every `${consumer:…}` in one value.
|
||||||
|
//
|
||||||
|
// **A fact or an alphabet the mesh does not have is refused, not left standing.** Written through,
|
||||||
|
// the literal `${consumer:as}` would reach a configuration file and be read as a bucket name,
|
||||||
|
// failing somewhere that names neither the module nor the mesh — the same reasoning `${bound:…}`
|
||||||
|
// is refused by (boundInto).
|
||||||
|
func consumerInto(value, as string) (string, error) {
|
||||||
|
var failed error
|
||||||
|
out := consumerFact.ReplaceAllStringFunc(value, func(match string) string {
|
||||||
|
parts := consumerFact.FindStringSubmatch(match)
|
||||||
|
fact, alphabet := parts[1], parts[2]
|
||||||
|
if fact != "as" {
|
||||||
|
if failed == nil {
|
||||||
|
failed = fmt.Errorf(
|
||||||
|
"says %s, and the mesh states %s about a consumer", match, orNothing(consumerFacts))
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
switch alphabet {
|
||||||
|
case "":
|
||||||
|
return as
|
||||||
|
case "dns":
|
||||||
|
return asDNSLabel(as)
|
||||||
|
default:
|
||||||
|
if failed == nil {
|
||||||
|
failed = fmt.Errorf(
|
||||||
|
"says %s, and the mesh writes an identity as %s", match, orNothing(consumerAlphabets))
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if failed != nil {
|
||||||
|
return "", failed
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// asDNSLabel writes a minted identity as a DNS label.
|
||||||
|
//
|
||||||
|
// The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and
|
||||||
|
// already short enough for the tightest backend they reach (CheckIdentity, twenty characters). So
|
||||||
|
// this is the separator and nothing else — no lower-casing of what is already lower case, no
|
||||||
|
// truncation to a limit the identity is already inside, no padding of a name that is already long
|
||||||
|
// enough. Each of those would be the mesh guessing at a rule it has not been given.
|
||||||
|
func asDNSLabel(as string) string {
|
||||||
|
return strings.ReplaceAll(as, "_", "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckServes refuses a `serves` block that names a consumer fact or an alphabet the mesh does not
|
||||||
|
// have, when the definition is parsed rather than when a consumer is resolved.
|
||||||
|
//
|
||||||
|
// A provision nobody consumes yet still has its rule read: a definition that would be refused the
|
||||||
|
// first time somebody required it is a definition that is wrong now.
|
||||||
|
func CheckServes(m Manifest) []string {
|
||||||
|
var problems []string
|
||||||
|
for _, provision := range sortedServes(m.Serves) {
|
||||||
|
for _, key := range sortedAnyKeys(m.Serves[provision]) {
|
||||||
|
text, ok := m.Serves[provision][key].(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A probe identity, because what is checked is the shape of the statement and not
|
||||||
|
// what any consumer is called.
|
||||||
|
if _, err := consumerInto(text, "mesh_node_module"); err != nil {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedServes(serves map[string]map[string]any) []string {
|
||||||
|
out := make([]string, 0, len(serves))
|
||||||
|
for k := range serves {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedAnyKeys(values map[string]any) []string {
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for k := range values {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// derivedFor is what the provider on this machine derives for one consumer of one provision
|
||||||
|
// (novox/hq ADR 0201).
|
||||||
|
//
|
||||||
|
// Settled first, then derived: an operator may set a prefix on what the provider serves and the
|
||||||
|
// mesh still fills the consumer's half of it ([ADR 0174]). Only the keys that actually name the
|
||||||
|
// consumer are returned — the rest of a `serves` block is the same for every consumer and is
|
||||||
|
// already in the provider's own definition, so repeating it here would be a second copy to go
|
||||||
|
// stale.
|
||||||
|
//
|
||||||
|
// The first module in the resolved order that says it serves the provision answers, which is the
|
||||||
|
// choice servedOnThisMachine makes for the consumer's half. Nothing serving it on this machine is
|
||||||
|
// not an error: a contribution can reach a machine whose provider is a record or an adapter, and
|
||||||
|
// then there is nothing derived to tell.
|
||||||
|
func (r Resolution) derivedFor(provision, as, consumer, local string, settings SettingsBy) (map[string]any, error) {
|
||||||
|
for _, m := range r.Modules {
|
||||||
|
serves, said := m.Serves[provision]
|
||||||
|
if !said {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var names map[string]any
|
||||||
|
for key, value := range serves {
|
||||||
|
if text, ok := value.(string); ok && strings.Contains(text, "${consumer:") {
|
||||||
|
if names == nil {
|
||||||
|
names = map[string]any{}
|
||||||
|
}
|
||||||
|
names[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if names == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// **A consumer that keeps several holders of this provision is refused** — this is issue
|
||||||
|
// 124's own failure one case to the side, and it would be just as quiet.
|
||||||
|
//
|
||||||
|
// Each holder gets its own login, `…_<local>` (ADR 0094), and a provider derives from the
|
||||||
|
// login, so it would make one resource per holder. The consumer's side has no such
|
||||||
|
// dimension: one binding file per provision, one `${bound:<provision>:<key>}`, both
|
||||||
|
// derived from the un-suffixed identity. So the provider would create the holder's
|
||||||
|
// resource and the consumer would be configured against a name nothing made — it would
|
||||||
|
// authenticate successfully and be refused on every object, which reads like a credential
|
||||||
|
// fault and is not one.
|
||||||
|
//
|
||||||
|
// Lifting this means giving the consumer's side a local dimension. That is a decision,
|
||||||
|
// not an omission, and until it is taken the mesh says so rather than guessing.
|
||||||
|
if local != "" {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%s keeps several holders of %s (this one is %q), and %s derives %s for each "+
|
||||||
|
"consumer from the login the mesh minted. Each holder has its own login, and a "+
|
||||||
|
"consumer is told one value per requirement — so the two ends would name "+
|
||||||
|
"different things and nothing would compare them (novox/hq ADR 0201)",
|
||||||
|
consumer, local, provision, m.Module, orNothing(sortedAnyKeys(names)))
|
||||||
|
}
|
||||||
|
settled, err := Settle(names, settings[m.Module])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s serving %s: %w", m.Module, provision, err)
|
||||||
|
}
|
||||||
|
derived, err := ServedTo(settled, as)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s serving %s to %s: %w", m.Module, provision, as, err)
|
||||||
|
}
|
||||||
|
return derived, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// notTranscribed refuses a consumer's file that writes out the value its provider derives for it,
|
||||||
|
// instead of asking for it (novox/hq ADR 0201, issue 124).
|
||||||
|
//
|
||||||
|
// **What would have caught the one wrong instance.** The object store's three consumers each wrote
|
||||||
|
// their bucket into their own configuration by hand. One of them named a predecessor's bucket, and
|
||||||
|
// nothing compared it to what the provider would actually create: the module would have
|
||||||
|
// authenticated successfully and been refused on every object, which reads like a credential fault
|
||||||
|
// and is not one. It looked authoritative for months.
|
||||||
|
//
|
||||||
|
// The test is exact and costs one string search: a definition whose file already contains the
|
||||||
|
// value the mesh is about to derive for it has written down somebody else's rule. It cannot be a
|
||||||
|
// coincidence — a derived value carries the identity the mesh minted for this very consumer on
|
||||||
|
// this very machine, which nothing else would spell out — and it cannot be checked afterwards,
|
||||||
|
// because after substitution every consumer's file contains it legitimately.
|
||||||
|
//
|
||||||
|
// Only values that actually name the consumer are judged. A provider that serves a constant under
|
||||||
|
// the same key serves the same constant to everyone, and a consumer repeating it is redundant
|
||||||
|
// rather than wrong.
|
||||||
|
func notTranscribed(resource map[string]any, known map[string]map[string]string, module string) error {
|
||||||
|
if fmt.Sprint(resource["type"]) != "file" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content, ok := resource["content"].(string)
|
||||||
|
if !ok || content == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, provision := range sortedKnown(known) {
|
||||||
|
values := known[provision]
|
||||||
|
identity := values["as"]
|
||||||
|
if identity == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, key := range sortedStringKeys(values) {
|
||||||
|
if key == "as" {
|
||||||
|
// The login is not derived from itself, and a consumer that must present it in a
|
||||||
|
// connection string legitimately has it from `${bound:…}` — which is what it will
|
||||||
|
// be after substitution, so this would judge the substitution, not the module.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value := values[key]
|
||||||
|
if value == "" || !namesTheConsumer(value, identity) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, value) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%s writes %q into %v, and that is exactly what %s derives for it — a definition "+
|
||||||
|
"keeping its own copy of somebody else's naming rule is one that can disagree "+
|
||||||
|
"with it, silently. Say ${bound:%s:%s} and be told",
|
||||||
|
module, value, resource["id"], provision, provision, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// namesTheConsumer is whether a derived value was built from this consumer's identity — in the
|
||||||
|
// alphabet it was minted in, or as a DNS label. A value that does not contain it was not derived
|
||||||
|
// from it, whatever else it may be.
|
||||||
|
func namesTheConsumer(value, identity string) bool {
|
||||||
|
return strings.Contains(value, identity) || strings.Contains(value, asDNSLabel(identity))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKnown(known map[string]map[string]string) []string {
|
||||||
|
out := make([]string, 0, len(known))
|
||||||
|
for k := range known {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedStringKeys(values map[string]string) []string {
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for k := range values {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defends novox/hq ADR 0210 §3: a contribution is a dependency on the seat that receives it.
|
||||||
|
|
||||||
|
func TestAContributionDependsOnTheSeatThatPlacesIt(t *testing.T) {
|
||||||
|
env := mod("theme", nil, nil, nil)
|
||||||
|
env.Environment = &Environment{Variables: map[string]string{"GTK_THEME": "Adwaita:dark"}}
|
||||||
|
path := mod("toolchain", nil, nil, nil)
|
||||||
|
path.Environment = &Environment{Path: []PathEntry{{Entry: "/opt/x/bin"}}}
|
||||||
|
shell := mod("prompt", nil, nil, nil)
|
||||||
|
shell.Shell = []ShellCode{{For: "zsh", Slot: "first", Code: "true"}}
|
||||||
|
session := mod("wallpaper", nil, nil, nil)
|
||||||
|
session.Shell = []ShellCode{{For: "xinitrc", Slot: "normal", Code: "true"}}
|
||||||
|
resources := mod("bar", nil, nil, nil)
|
||||||
|
resources.Shell = []ShellCode{{For: "xresources", Slot: "normal", Code: "x: y"}}
|
||||||
|
empty := mod("nothing", nil, nil, nil)
|
||||||
|
empty.Environment = &Environment{}
|
||||||
|
|
||||||
|
cases := map[string]struct {
|
||||||
|
m Manifest
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
"a variable": {env, []string{EnvironmentSeat}},
|
||||||
|
"a path entry": {path, []string{EnvironmentSeat}},
|
||||||
|
"shell code": {shell, []string{LoginShellSeat}},
|
||||||
|
"the session's start": {session, []string{DisplayServerSeat}},
|
||||||
|
"the session's X resources": {resources, []string{DisplayServerSeat}},
|
||||||
|
"an empty environment": {empty, nil},
|
||||||
|
}
|
||||||
|
for name, c := range cases {
|
||||||
|
got := DependsOn(c.m)
|
||||||
|
if len(got) == 0 {
|
||||||
|
got = nil
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, c.want) {
|
||||||
|
t.Errorf("%s depends on %v, want %v", name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAContributionIsMetByAHolderOnTheNodeAndRefusedWithout(t *testing.T) {
|
||||||
|
holder := mod("node-env", nil, nil, nil, Claim{Name: EnvironmentSeat})
|
||||||
|
contributor := mod("theme", nil, nil, nil)
|
||||||
|
contributor.Environment = &Environment{Variables: map[string]string{"GTK_THEME": "Adwaita:dark"}}
|
||||||
|
catalogue := map[string]Manifest{"node-env": holder, "theme": contributor}
|
||||||
|
|
||||||
|
if _, err := AssignRefusal(catalogue, "laptop", []string{"node-env"}, []string{"theme"}); err != nil {
|
||||||
|
t.Fatalf("a contributor beside the holder is refused: %v", err)
|
||||||
|
}
|
||||||
|
_, err := AssignRefusal(catalogue, "laptop", nil, []string{"theme"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a contributor on a node without the holder was accepted, and its contribution would be written nowhere")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), EnvironmentSeat) || !strings.Contains(err.Error(), "node-env") {
|
||||||
|
t.Errorf("the refusal names neither the seat nor its holder: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := AssignRefusal(catalogue, "laptop", nil, []string{"theme", "node-env"}); err != nil {
|
||||||
|
t.Errorf("the contributor and the holder assigned together are refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAHolderMeetsItsOwnContribution(t *testing.T) {
|
||||||
|
// The display server's module contributes nothing to its own seat today, but the zsh module's
|
||||||
|
// environment contributions do go to another seat: a claim meets only the seat it names.
|
||||||
|
zsh := mod("zsh", nil, nil, nil, Claim{Name: LoginShellSeat})
|
||||||
|
zsh.Shell = []ShellCode{{For: "zsh", Slot: "normal", Code: "true"}}
|
||||||
|
zsh.Environment = &Environment{Variables: map[string]string{"EDITOR": "vim"}}
|
||||||
|
catalogue := map[string]Manifest{"zsh": zsh,
|
||||||
|
"node-env": mod("node-env", nil, nil, nil, Claim{Name: EnvironmentSeat})}
|
||||||
|
unheld := UnheldDependencies(catalogue, "laptop", []Manifest{zsh}, nil)
|
||||||
|
if len(unheld) != 1 || unheld[0].Seat != EnvironmentSeat {
|
||||||
|
t.Errorf("zsh alone: unheld %v, want only %s (its shell code is its own seat's)", unheld, EnvironmentSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTwoModulesDeclaringOnePackageAreRefusedBeforeAnythingIsRecorded(t *testing.T) {
|
||||||
|
pacman := withResources(mod("pacman", nil, nil, nil, Claim{Name: PackageManagerSeat}),
|
||||||
|
res("package", "pacman-contrib"))
|
||||||
|
bar := withResources(mod("bar", nil, nil, nil), res("package", "bar"), res("package", "pacman-contrib"))
|
||||||
|
other := withResources(mod("other", nil, nil, nil), res("package", "other"))
|
||||||
|
catalogue := map[string]Manifest{"pacman": pacman, "bar": bar, "other": other}
|
||||||
|
|
||||||
|
err := CollisionRefusal(catalogue, "laptop", []string{"pacman"}, []string{"bar"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "pacman-contrib") || !strings.Contains(err.Error(), "bar") {
|
||||||
|
t.Fatalf("the second owner of a package was not refused by name: %v", err)
|
||||||
|
}
|
||||||
|
if err := CollisionRefusal(catalogue, "laptop", []string{"pacman"}, []string{"other"}); err != nil {
|
||||||
|
t.Errorf("a module declaring nothing shared is refused: %v", err)
|
||||||
|
}
|
||||||
|
// A collision already on the node is status's, not a reason to refuse an unrelated assignment.
|
||||||
|
if err := CollisionRefusal(catalogue, "laptop", []string{"pacman", "bar"}, []string{"other"}); err != nil {
|
||||||
|
t.Errorf("an unrelated assignment is refused for a collision already there: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodeForAPowerMomentDependsOnThePowerSeat(t *testing.T) {
|
||||||
|
for _, moment := range powerMoments {
|
||||||
|
m := mod("laptop", nil, nil, nil)
|
||||||
|
m.Shell = []ShellCode{{For: moment, Slot: "normal", Code: "true"}}
|
||||||
|
if got := DependsOn(m); !reflect.DeepEqual(got, []string{PowerSeat}) {
|
||||||
|
t.Errorf("code for %s depends on %v, want %s", moment, got, PowerSeat)
|
||||||
|
}
|
||||||
|
if p := m.shellProblems(); len(p) != 0 {
|
||||||
|
t.Errorf("code for %s is refused: %v", moment, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m := mod("laptop", nil, nil, nil)
|
||||||
|
m.Shell = []ShellCode{{For: "after-lunch", Slot: "normal", Code: "true"}}
|
||||||
|
if p := m.shellProblems(); len(p) == 0 {
|
||||||
|
t.Error("code for a moment that does not exist was accepted")
|
||||||
|
}
|
||||||
|
if s, ok := SeatNamed(PowerSeat); !ok || s.Scope != ScopeNode {
|
||||||
|
t.Errorf("%s is not a node seat of the mesh's own: %+v %v", PowerSeat, s, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq issue 213: the controller is a Go program and was the one piece of the mesh's own Go
|
||||||
|
// code still shipped as an image (ADR 0188 §1). Its own manifest, composed for the machine that runs
|
||||||
|
// it, is a Go bundle run by the host as a process — and no container.
|
||||||
|
func TestTheControllerIsAProcessAndNoContainer(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m, err := ParseManifest(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the controller's own manifest does not parse:\n%v", err)
|
||||||
|
}
|
||||||
|
if m.Build == nil || len(m.Build.Artifacts) != 1 {
|
||||||
|
t.Fatalf("the controller builds %+v; it is one bundle", m.Build)
|
||||||
|
}
|
||||||
|
a := m.Build.Artifacts[0]
|
||||||
|
if a.Kind != ArtifactBundle || a.Language != "go" || a.System == "" || BinaryOf(a) != "mesh-controller" {
|
||||||
|
t.Fatalf("the controller's artifact is %+v, not a Go bundle naming its system and binary", a)
|
||||||
|
}
|
||||||
|
for _, c := range m.Capabilities {
|
||||||
|
if c == "container-runtime" {
|
||||||
|
t.Error("the controller still requires a container runtime on its machine")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
digest := "sha256:" + strings.Repeat("c", 64)
|
||||||
|
control, err := m.Resolve([]Built{{Name: a.Name, Kind: ArtifactBundle,
|
||||||
|
Reference: ArtifactStoreScheme + "mesh-controller/" + a.Name + "@" + digest, Digest: digest}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The node's runtime does not launch it: it serves its seat's verbs itself.
|
||||||
|
if loads := control.Bundles[0].Loads; len(loads) != 0 {
|
||||||
|
t.Errorf("the node's runtime would launch the controller as a tools bundle: %v", loads)
|
||||||
|
}
|
||||||
|
|
||||||
|
needed := map[string]map[string]string{"mesh-controller": {}}
|
||||||
|
for name := range m.OwnSecrets {
|
||||||
|
needed["mesh-controller"][name] = "sealed-" + name
|
||||||
|
}
|
||||||
|
out, err := Resolution{Node: "anchor", Modules: []Manifest{control}}.Declaration(Rendering{
|
||||||
|
Needed: needed, ArtifactStore: "anchor.internal:5100",
|
||||||
|
Seats: map[string]map[int]int{"mesh-store": {5432: 6852}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the controller does not compose: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var process, step map[string]any
|
||||||
|
account, firstSecret := -1, -1
|
||||||
|
for i, r := range out {
|
||||||
|
switch {
|
||||||
|
case r["type"] == "container":
|
||||||
|
t.Errorf("the controller's declaration still runs a container: %v", r)
|
||||||
|
case r["id"] == "mesh-controller.controller":
|
||||||
|
process = r
|
||||||
|
case r["id"] == "mesh-controller.controller-prepare":
|
||||||
|
step = r
|
||||||
|
if process != nil {
|
||||||
|
t.Error("the controller's preparation is placed after the process it prepares for")
|
||||||
|
}
|
||||||
|
case r["type"] == "user" && r["name"] == "mesh-controller":
|
||||||
|
account = i
|
||||||
|
case strings.HasPrefix(fmt.Sprint(r["id"]), "mesh-controller.needs-") && firstSecret < 0:
|
||||||
|
firstSecret = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if process == nil {
|
||||||
|
t.Fatalf("the controller's process is not in its declaration: %v", out)
|
||||||
|
}
|
||||||
|
if run, _ := json.Marshal(process["run"]); string(run) != `["./mesh-controller","serve"]` {
|
||||||
|
t.Errorf("the controller is run as %s, not its own bundle's binary", run)
|
||||||
|
}
|
||||||
|
if process["source"] != "anchor.internal:5100/mesh-controller/"+a.Name+"@"+digest || process["digest"] != digest {
|
||||||
|
t.Errorf("the controller's bundle is fetched from %v (%v)", process["source"], process["digest"])
|
||||||
|
}
|
||||||
|
// The user: an account the host declares, which owns what the process reads.
|
||||||
|
if process["user"] != "mesh-controller" || account < 0 {
|
||||||
|
t.Errorf("the controller runs as %v, and the account declared is at %d", process["user"], account)
|
||||||
|
}
|
||||||
|
if firstSecret >= 0 && account > firstSecret {
|
||||||
|
t.Error("the controller's secrets are written before the account they belong to exists")
|
||||||
|
}
|
||||||
|
for _, r := range out {
|
||||||
|
if strings.HasPrefix(fmt.Sprint(r["id"]), "mesh-controller.needs-") && r["owner"] != "mesh-controller" {
|
||||||
|
t.Errorf("%v belongs to %v, which the controller's process cannot read", r["id"], r["owner"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dir := fileNamed(out, "mesh-controller.mesh-state"); dir == nil || dir["owner"] != "mesh-controller" {
|
||||||
|
t.Errorf("the controller's state directory is not its account's to enter: %v", dir)
|
||||||
|
}
|
||||||
|
// Each mount became a path the process reads: nothing it is told is a path inside a container.
|
||||||
|
state := fmt.Sprint(fileNamed(out, "mesh-controller.mesh-state")["path"])
|
||||||
|
env, _ := process["env"].(map[string]any)
|
||||||
|
for key, value := range env {
|
||||||
|
v := fmt.Sprint(value)
|
||||||
|
if strings.HasPrefix(v, "/run/secrets") || strings.HasPrefix(v, "/broker-tls") {
|
||||||
|
t.Errorf("%s=%s is a path inside the container the controller no longer runs in", key, v)
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(key, "_FILE") && !strings.HasPrefix(v, state+"/") {
|
||||||
|
t.Errorf("%s=%s is not one of the files the mesh places for it", key, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if env["MESH_BROKER_CERTIFICATE"] != "/var/lib/mesh-broker-tls/tls.crt" {
|
||||||
|
t.Errorf("the controller reads the broker's certificate from %v", env["MESH_BROKER_CERTIFICATE"])
|
||||||
|
}
|
||||||
|
if env["MESH_STORE_INVENTORY_PORT"] != "6852" {
|
||||||
|
t.Errorf("the controller is told the store is on %v; the node put it on 6852", env["MESH_STORE_INVENTORY_PORT"])
|
||||||
|
}
|
||||||
|
// The handover: the container it ran as goes only once this is running.
|
||||||
|
if got, _ := json.Marshal(process["replaces"]); string(got) != `["mesh-controller.server"]` {
|
||||||
|
t.Errorf("the controller's process replaces %s, not the container it ran as", got)
|
||||||
|
}
|
||||||
|
// And its state is prepared first, by the same program as the same account.
|
||||||
|
if step == nil || step["run-once"] != true || step["user"] != "mesh-controller" {
|
||||||
|
t.Fatalf("the controller's preparation is %v", step)
|
||||||
|
}
|
||||||
|
if run, _ := json.Marshal(step["run"]); string(run) != `["./mesh-controller","prepare"]` {
|
||||||
|
t.Errorf("the controller's preparation runs %s", run)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -155,6 +155,10 @@ type Rendering struct {
|
|||||||
// standing beside the machines and looking as real as they do.
|
// standing beside the machines and looking as real as they do.
|
||||||
Machines map[string]string
|
Machines map[string]string
|
||||||
|
|
||||||
|
// Zones is every zone a module in the mesh answers itself, where it is answered (novox/hq ADR
|
||||||
|
// 0199): the mesh's resolver forwards each one there.
|
||||||
|
Zones []ZoneAt
|
||||||
|
|
||||||
Settings SettingsBy
|
Settings SettingsBy
|
||||||
Generators map[string]Generator
|
Generators map[string]Generator
|
||||||
// Grants are the credentials this node must create, for the provisions it offers. Passed in
|
// Grants are the credentials this node must create, for the provisions it offers. Passed in
|
||||||
@@ -415,6 +419,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
filtering := AsNftables(rules, with.Mesh, r.PublicDomain != "", with.Foundation,
|
filtering := AsNftables(rules, with.Mesh, r.PublicDomain != "", with.Foundation,
|
||||||
with.OutwardLinks, with.TunnelInterface)
|
with.OutwardLinks, with.TunnelInterface)
|
||||||
|
|
||||||
|
// **A variable two modules set is refused whether or not anything places it** (novox/hq ADR
|
||||||
|
// 0203 §5): the account has one environment, and a machine whose holder arrives later should not
|
||||||
|
// be the moment two modules are found to disagree about it.
|
||||||
|
if err := variablesSetOnce(r.Modules); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
var out []map[string]any
|
var out []map[string]any
|
||||||
for _, m := range r.Modules {
|
for _, m := range r.Modules {
|
||||||
if with.Adopted && m.Filtering != nil {
|
if with.Adopted && m.Filtering != nil {
|
||||||
@@ -603,14 +614,14 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
// and nothing would say so.
|
// and nothing would say so.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
first = append(first, map[string]any{
|
first = append(first, ownedBy(r.provisionsAs(m), map[string]any{
|
||||||
// One file per holder — the consumer's module with its local name after it
|
// One file per holder — the consumer's module with its local name after it
|
||||||
// where it keeps several (ADR 0094); the lab found two files with one id.
|
// where it keeps several (ADR 0094); the lab found two files with one id.
|
||||||
"id": GrantID(to, g.Consumer+"."+holderAs(g.From, g.Local)),
|
"id": GrantID(to, g.Consumer+"."+holderAs(g.From, g.Local)),
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": grantPath(m.Grants[to], g.Consumer, holderAs(g.From, g.Local)),
|
"path": grantPath(m.Grants[to], g.Consumer, holderAs(g.From, g.Local)),
|
||||||
"sealed": g.Sealed,
|
"sealed": g.Sealed,
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, to := range sortedKeys(m.Binds) {
|
for _, to := range sortedKeys(m.Binds) {
|
||||||
@@ -645,7 +656,16 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)), own)
|
as := ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module))
|
||||||
|
// What the provider derives for THIS consumer, filled here where the consumer is
|
||||||
|
// known (novox/hq ADR 0201). The same fill knownFor does below, so the binding file
|
||||||
|
// and the module's `${bound:…}` substitutions cannot say different things.
|
||||||
|
told := *found
|
||||||
|
told.Serves, err = ServedTo(told.Serves, as)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s is told about %s: %w", m.Module, to, err)
|
||||||
|
}
|
||||||
|
file, err := boundFile(told, m.Binds[to], as, own)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -693,7 +713,15 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
|
|
||||||
// Now, and not before: a module whose resources are computed replaces them wholesale, and
|
// Now, and not before: a module whose resources are computed replaces them wholesale, and
|
||||||
// merging earlier would throw away the files it still needs.
|
// merging earlier would throw away the files it still needs.
|
||||||
resources = append(append([]map[string]any{}, first...), resources...)
|
//
|
||||||
|
// **Except the module's own accounts, which go before even those** (novox/hq issue 213). What
|
||||||
|
// the mesh computes may belong to one: a module whose code runs as an account it declares has
|
||||||
|
// its secrets written owned by that account, and a file given to a user the machine does not
|
||||||
|
// have yet fails — so on the first apply the secrets were refused, the process started without
|
||||||
|
// them, and the second apply healed it, which is the fault the paragraph above describes.
|
||||||
|
// An account depends on nothing the mesh computes.
|
||||||
|
accounts, rest := accountsFirst(resources)
|
||||||
|
resources = append(append(accounts, first...), rest...)
|
||||||
|
|
||||||
// No container is given the mesh's names (novox/hq ADR 0148). It used to be: every
|
// No container is given the mesh's names (novox/hq ADR 0148). It used to be: every
|
||||||
// container got the whole roster as `--add-host` entries at creation, and a name that
|
// container got the whole roster as `--add-host` entries at creation, and a name that
|
||||||
@@ -711,7 +739,10 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// And what its bindings say, for the half of a connection that is not secret.
|
// And what its bindings say, for the half of a connection that is not secret.
|
||||||
known := knownFor(m, r.Needs, r.Node)
|
known, err := knownFor(m, r.Needs, r.Node)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
// A requirement answered on this same machine is not in r.Needs — its binding file is
|
// A requirement answered on this same machine is not in r.Needs — its binding file is
|
||||||
// written from `here` (above) — and so `${bound:…}` could not name it, though the file
|
// written from `here` (above) — and so `${bound:…}` could not name it, though the file
|
||||||
// beside it said the same facts. Filled from the same answer, so the two cannot disagree.
|
// beside it said the same facts. Filled from the same answer, so the two cannot disagree.
|
||||||
@@ -728,10 +759,24 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
}
|
}
|
||||||
local := *answered
|
local := *answered
|
||||||
local.For = m.Module
|
local.For = m.Module
|
||||||
for provision, values := range knownFor(m, []Needed{local}, r.Node) {
|
here, err := knownFor(m, []Needed{local}, r.Node)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for provision, values := range here {
|
||||||
known[provision] = values
|
known[provision] = values
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// And the providing machine's private address, beside its name (novox/hq ADR 0194). A name is
|
||||||
|
// what nearly every consumer wants; the one that cannot use it is a machine's resolver
|
||||||
|
// configuration, which must reach the resolver before it can resolve anything — the resolver's
|
||||||
|
// own name included. Absent when the machine has no address yet, so a file naming it is refused
|
||||||
|
// rather than written with a blank where an address belongs.
|
||||||
|
for _, values := range known {
|
||||||
|
if address := with.Machines[values["at"]]; address != "" {
|
||||||
|
values["address"] = address
|
||||||
|
}
|
||||||
|
}
|
||||||
// And what the module is called through each requirement it contributes to (novox/hq
|
// And what the module is called through each requirement it contributes to (novox/hq
|
||||||
// 04-ISSUES/122) — the same composition its binding file carries.
|
// 04-ISSUES/122) — the same composition its binding file carries.
|
||||||
for provision, values := range known {
|
for provision, values := range known {
|
||||||
@@ -753,6 +798,17 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
// And the machine underneath, which no binding of its own can tell it.
|
// And the machine underneath, which no binding of its own can tell it.
|
||||||
thisMachine := machineFacts(r, with.Names, with.MeshRange)
|
thisMachine := machineFacts(r, with.Names, with.MeshRange)
|
||||||
|
|
||||||
|
// **A definition that already holds the answer transcribed it** (novox/hq ADR 0201).
|
||||||
|
// Judged over what the module itself declares, and before anything is substituted: the
|
||||||
|
// mesh's own generated files — the binding, the contributions — legitimately carry the
|
||||||
|
// derived value, and after substitution so does every consumer's file, so this is the one
|
||||||
|
// moment the two can be told apart.
|
||||||
|
for _, own := range m.Resources {
|
||||||
|
if err := notTranscribed(own, known, m.Module); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Which of this module's files carry a secret, for the rule that a container may not read
|
// Which of this module's files carry a secret, for the rule that a container may not read
|
||||||
// one of them as its environment without saying so (ADR 0086, issue 041).
|
// one of them as its environment without saying so (ADR 0086, issue 041).
|
||||||
secretFiles := secretFilesOf(resources)
|
secretFiles := secretFilesOf(resources)
|
||||||
@@ -854,6 +910,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
publishedOn(copied, m.Module, with)
|
publishedOn(copied, m.Module, with)
|
||||||
|
// The account's environment and every module's shell code, where this module holds the
|
||||||
|
// seat that places them (novox/hq ADR 0203, ADR 0204). Gathered from every module on
|
||||||
|
// the node, as the jails are, and **last of every placeholder pass**: shell code is a
|
||||||
|
// shell's own syntax, full of `${…}` no pass above should ever be shown.
|
||||||
|
if err := contributionsInto(copied, m, r.Modules, thisMachine); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
copied["id"] = m.Module + "." + fmt.Sprint(resource["id"])
|
copied["id"] = m.Module + "." + fmt.Sprint(resource["id"])
|
||||||
// A service saying what it reflects names resources within its own module, so those
|
// A service saying what it reflects names resources within its own module, so those
|
||||||
// are prefixed too or they would point at nothing.
|
// are prefixed too or they would point at nothing.
|
||||||
@@ -872,6 +935,21 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil {
|
if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil {
|
||||||
copied["reload-on"] = renamed
|
copied["reload-on"] = renamed
|
||||||
}
|
}
|
||||||
|
// And which of its module's containers a scheduled step holds still (novox/hq ADR 0189).
|
||||||
|
// **The loudest of the three when it is missed.** An unprefixed `restart-on` matches
|
||||||
|
// nothing and a service quietly never restarts; an unprefixed `while-stopped` names a
|
||||||
|
// container the declaration does not contain, and the host refuses the whole
|
||||||
|
// declaration — so the machine takes nothing at all, for every push, until this is
|
||||||
|
// right. That is what it did on the control node (2026-10-04).
|
||||||
|
if renamed := reflectsRenamed(m.Module, resource[WhileStopped]); renamed != nil {
|
||||||
|
copied[WhileStopped] = renamed
|
||||||
|
}
|
||||||
|
// And what a process replaces (novox/hq issue 213): a resource of this module's that it
|
||||||
|
// no longer declares, named as the host recorded it, or the host hands nothing over and
|
||||||
|
// removes it first.
|
||||||
|
if renamed := reflectsRenamed(m.Module, resource["replaces"]); renamed != nil {
|
||||||
|
copied["replaces"] = renamed
|
||||||
|
}
|
||||||
// **What reads one of this module's own secrets is restarted when it changes** (novox/hq
|
// **What reads one of this module's own secrets is restarted when it changes** (novox/hq
|
||||||
// issue 203, issue 206). A credential is re-issued by the mesh, and a container that
|
// issue 203, issue 206). A credential is re-issued by the mesh, and a container that
|
||||||
// mounted the old file keeps the old one open: the build machine ran for an hour on a
|
// mounted the old file keeps the old one open: the build machine ran for an hour on a
|
||||||
@@ -901,7 +979,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
// plane's; making a name resolve is the module's software. Emitted as ordinary files under
|
// plane's; making a name resolve is the module's software. Emitted as ordinary files under
|
||||||
// this module's name, so they are applied, reported and removed exactly as anything else
|
// this module's name, so they are applied, reported and removed exactly as anything else
|
||||||
// it declares.
|
// it declares.
|
||||||
given, err := FactsInto(m, r, with.Names, with.Machines, with.Accounts, with.Suffix)
|
given, err := FactsWithZonesInto(m, r, with.Names, with.Machines, with.Accounts, with.Suffix, with.Zones)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1161,6 +1239,41 @@ type Contribution struct {
|
|||||||
// requirement's name — everything providing `reverse-proxy` understands the same shape, which
|
// requirement's name — everything providing `reverse-proxy` understands the same shape, which
|
||||||
// is what makes swapping one for another cost nothing.
|
// is what makes swapping one for another cost nothing.
|
||||||
Values map[string]any `json:"values"`
|
Values map[string]any `json:"values"`
|
||||||
|
// Derived is what this provider's own definition said it derives for this consumer, already
|
||||||
|
// derived (novox/hq ADR 0201).
|
||||||
|
//
|
||||||
|
// **The provider is told, rather than recomputing it.** A served value may name the consumer's
|
||||||
|
// identity — a bucket named for who is asking, a database prefixed with it — and before this
|
||||||
|
// the rule lived twice: once in the provisioner's code, once transcribed into every consumer's
|
||||||
|
// definition. The mesh fills the provider's own statement here and delivers the same filled
|
||||||
|
// value to the consumer, so the two cannot disagree: there is no second computation to
|
||||||
|
// disagree with.
|
||||||
|
//
|
||||||
|
// Only the keys that are per-consumer. The rest of what the provider serves is the same for
|
||||||
|
// everyone and is in its own definition, where it already is.
|
||||||
|
Derived map[string]any `json:"derived,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// provisionsAs is the account that reads what the mesh writes for this provider: the one secret
|
||||||
|
// per consumer it must open to set that consumer's password (novox/hq issue 225).
|
||||||
|
//
|
||||||
|
// **A root-owned 0600 file is one that process cannot read**, which is the same sentence already
|
||||||
|
// written above for a module's own secrets — and the grant secret is the other kind of secret
|
||||||
|
// the mesh writes for a module, so it is the same rule.
|
||||||
|
//
|
||||||
|
// Which account depends on where the module's code runs. A module whose code is a bundle is run
|
||||||
|
// by the node's tool runtime, as the node's account ([ADR 0198](0198)); one still in a container
|
||||||
|
// is whatever it declares as its secrets owner. Nothing names these paths, so the rule that
|
||||||
|
// claims a bundle's other files by the words that name them (givenTo) cannot reach them: the
|
||||||
|
// harness composes a grant secret's path from the contributions file, not from a word.
|
||||||
|
//
|
||||||
|
// Empty is root, which is what it was and what a module with no bundle and no declared owner
|
||||||
|
// still wants.
|
||||||
|
func (r Resolution) provisionsAs(m Manifest) string {
|
||||||
|
if len(m.Bundles) > 0 && r.Account != "" {
|
||||||
|
return r.Account
|
||||||
|
}
|
||||||
|
return m.SecretsOwner
|
||||||
}
|
}
|
||||||
|
|
||||||
// grantPath is where one consumer's sealed credential lands on the providing machine.
|
// grantPath is where one consumer's sealed credential lands on the providing machine.
|
||||||
@@ -1252,12 +1365,17 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
|||||||
// told about it and withdraws the login on its next pass.
|
// told about it and withdraws the login on its next pass.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
as := holderAs(ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)), g.Local)
|
||||||
|
derived, err := r.derivedFor(g.Provision, as, g.From, g.Local, settings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
out[g.Provision] = append(out[g.Provision], Contribution{
|
out[g.Provision] = append(out[g.Provision], Contribution{
|
||||||
From: g.From, Node: g.Consumer, At: g.At, Values: g.Values,
|
From: g.From, Node: g.Consumer, At: g.At, Values: g.Values, Derived: derived,
|
||||||
// One holder per local name: the identity the consumer is known by, and the local name
|
// One holder per local name: the identity the consumer is known by, and the local name
|
||||||
// after it where the module keeps several (ADR 0094). Not a login any backend checks —
|
// after it where the module keeps several (ADR 0094). Not a login any backend checks —
|
||||||
// a secret is not a login — so the identity limit does not apply to the suffix.
|
// a secret is not a login — so the identity limit does not apply to the suffix.
|
||||||
As: holderAs(ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)), g.Local),
|
As: as,
|
||||||
Secret: grantPath(directories[g.Provision], g.Consumer, holderAs(g.From, g.Local)),
|
Secret: grantPath(directories[g.Provision], g.Consumer, holderAs(g.From, g.Local)),
|
||||||
})
|
})
|
||||||
if granted[g.Provision] == nil {
|
if granted[g.Provision] == nil {
|
||||||
@@ -2010,12 +2128,18 @@ func preparationTarget(m Manifest) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
if fmt.Sprint(r["type"]) != "container" || !ownArtifact(r, m.Module) {
|
// A container, or a process the host runs from a bundle the module built (novox/hq issue
|
||||||
|
// 213): the same program in the same context, hosted as a unit rather than a container.
|
||||||
|
kind := fmt.Sprint(r["type"])
|
||||||
|
if (kind != "container" && kind != "process") || !ownArtifact(r, m.Module) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if once, _ := r["run-once"].(bool); once {
|
if once, _ := r["run-once"].(bool); once {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if r["schedule"] != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
return fmt.Sprint(r["id"])
|
return fmt.Sprint(r["id"])
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -2029,7 +2153,10 @@ func ownArtifact(resource map[string]any, module string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
image, _ := resource["image"].(string)
|
image, _ := resource["image"].(string)
|
||||||
return strings.HasPrefix(image, ArtifactStoreScheme+module+"/")
|
// A process or an archive carries what was built as its source (novox/hq issue 213).
|
||||||
|
source, _ := resource["source"].(string)
|
||||||
|
return strings.HasPrefix(image, ArtifactStoreScheme+module+"/") ||
|
||||||
|
strings.HasPrefix(source, ArtifactStoreScheme+module+"/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepared is the module's own resource as the step that prepares its state: the same image, the same
|
// prepared is the module's own resource as the step that prepares its state: the same image, the same
|
||||||
@@ -2051,7 +2178,19 @@ func prepared(from map[string]any) map[string]any {
|
|||||||
step["id"] = fmt.Sprint(from["id"]) + "-prepare"
|
step["id"] = fmt.Sprint(from["id"]) + "-prepare"
|
||||||
step["name"] = fmt.Sprint(from["name"]) + "-prepare"
|
step["name"] = fmt.Sprint(from["name"]) + "-prepare"
|
||||||
step["run-once"] = true
|
step["run-once"] = true
|
||||||
|
if fmt.Sprint(from["type"]) == "process" {
|
||||||
|
// A process says its whole command: the program, then its arguments. The step is the same
|
||||||
|
// program asked to prepare (novox/hq issue 213). It replaces nothing — what the process
|
||||||
|
// replaces is handed over to the process, never to the step that runs before it — and a
|
||||||
|
// step is not restarted, it runs again when what it reads changed, which `restart-on` says.
|
||||||
|
run := stringsIn(from["run"])
|
||||||
|
if len(run) > 0 {
|
||||||
|
step["run"] = []any{run[0], PreparationArgument}
|
||||||
|
}
|
||||||
|
delete(step, "replaces")
|
||||||
|
} else {
|
||||||
step["args"] = []any{PreparationArgument}
|
step["args"] = []any{PreparationArgument}
|
||||||
|
}
|
||||||
delete(step, "ports")
|
delete(step, "ports")
|
||||||
delete(step, "ip")
|
delete(step, "ip")
|
||||||
delete(step, "schedule")
|
delete(step, "schedule")
|
||||||
@@ -2196,3 +2335,16 @@ func withRestartOn(have any, add []string) []any {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// accountsFirst splits a module's resources into its accounts and everything else, each in the order
|
||||||
|
// written.
|
||||||
|
func accountsFirst(resources []map[string]any) (accounts, rest []map[string]any) {
|
||||||
|
for _, r := range resources {
|
||||||
|
if fmt.Sprint(r["type"]) == "user" {
|
||||||
|
accounts = append(accounts, r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest = append(rest, r)
|
||||||
|
}
|
||||||
|
return accounts, rest
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What a provider derives for each consumer, said once and delivered to both ends
|
||||||
|
// (novox/hq ADR 0201, issue 124).
|
||||||
|
//
|
||||||
|
// The failure these are written against: the object store's provisioner derived each consumer's
|
||||||
|
// bucket from the login the mesh minted, in its own code, and the mesh had no channel to tell the
|
||||||
|
// consumer which bucket that was — so all three consumers wrote the answer into their own
|
||||||
|
// definitions by hand. Two were right. One named a predecessor's bucket and would have
|
||||||
|
// authenticated successfully and been refused on every object. Each of them also named the
|
||||||
|
// machine the module happens to run on, which a definition may not do.
|
||||||
|
|
||||||
|
// store is an object store in the shape minio has: it serves a region and a port to everyone, and
|
||||||
|
// a bucket named for whoever is asking.
|
||||||
|
func store() Manifest {
|
||||||
|
return Manifest{
|
||||||
|
Module: "store", Version: "1",
|
||||||
|
Provides: FromAnywhere("s3-bucket"),
|
||||||
|
Listens: []Listening{{Port: 9000, Protocol: "tcp", From: FromMesh}},
|
||||||
|
Serves: map[string]map[string]any{"s3-bucket": {
|
||||||
|
"region": "eu-west",
|
||||||
|
"bucket": "${consumer:as:dns}",
|
||||||
|
}},
|
||||||
|
Receives: map[string]string{"s3-bucket": "/var/lib/store/grants/mesh.json"},
|
||||||
|
Grants: map[string]string{"s3-bucket": "/var/lib/store/grants"},
|
||||||
|
Resources: []map[string]any{{
|
||||||
|
"id": "server", "type": "container", "name": "store", "ports": []any{"9000"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// files is a consumer that writes the bucket into its own configuration — which is the thing it
|
||||||
|
// could not do before, and had to transcribe.
|
||||||
|
func files() Manifest {
|
||||||
|
return Manifest{
|
||||||
|
Module: "files", Version: "1", Slug: "files",
|
||||||
|
Requires: []string{"s3-bucket"},
|
||||||
|
Binds: map[string]string{"s3-bucket": "/var/lib/files/store.json"},
|
||||||
|
Secrets: map[string]string{"s3-bucket": "/var/lib/files/store.secret"},
|
||||||
|
Resources: []map[string]any{{
|
||||||
|
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
||||||
|
"content": "BUCKET=${bound:s3-bucket:bucket}\nREGION=${bound:s3-bucket:region}\n",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pics is a second consumer of the same provider on the same machine: two derivations, neither
|
||||||
|
// the other's.
|
||||||
|
func pics() Manifest {
|
||||||
|
return Manifest{
|
||||||
|
Module: "pics", Version: "1", Slug: "pics",
|
||||||
|
Requires: []string{"s3-bucket"},
|
||||||
|
Binds: map[string]string{"s3-bucket": "/var/lib/pics/store.json"},
|
||||||
|
Secrets: map[string]string{"s3-bucket": "/var/lib/pics/store.secret"},
|
||||||
|
Resources: []map[string]any{{
|
||||||
|
"id": "env", "type": "file", "path": "/var/lib/pics/env", "mode": "0600",
|
||||||
|
"content": "BUCKET=${bound:s3-bucket:bucket}\n",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three places the derived value lands must agree, because agreeing is the whole point: the
|
||||||
|
// consumer's own file, the binding it reads as JSON, and the provider's contributions entry.
|
||||||
|
func TestADerivedValueReachesBothEndsAndAgrees(t *testing.T) {
|
||||||
|
r, err := Resolve(shelf(store(), files()), []string{"store", "files"}, reachable(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := r.Declaration(Rendering{Grants: []Grant{{
|
||||||
|
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||||
|
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mesh minted this identity for the consumer; the bucket is that identity as a DNS label.
|
||||||
|
// Derived here with the mesh's own function, so the test cannot agree with a wrong rule.
|
||||||
|
as := ConsumerIdentity("workstation", IdentitySource("files", "files"))
|
||||||
|
want := strings.ReplaceAll(as, "_", "-")
|
||||||
|
if want == as || !strings.Contains(as, "_") {
|
||||||
|
t.Fatalf("the mesh's identity %q has no separator to rewrite; this test proves nothing", as)
|
||||||
|
}
|
||||||
|
|
||||||
|
env := fileNamed(out, "files.env")
|
||||||
|
if env == nil {
|
||||||
|
t.Fatalf("the consumer was given no file: %v", out)
|
||||||
|
}
|
||||||
|
if got := env["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") {
|
||||||
|
t.Errorf("the consumer's own file was not told the bucket:\n%s\nwant BUCKET=%s", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding := fileNamed(out, "files.bound-s3-bucket")
|
||||||
|
if binding == nil {
|
||||||
|
t.Fatalf("the consumer was given no binding: %v", out)
|
||||||
|
}
|
||||||
|
var said struct {
|
||||||
|
Serves map[string]any `json:"serves"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if said.Serves["bucket"] != want {
|
||||||
|
t.Errorf("the binding says the bucket is %q, want %q", said.Serves["bucket"], want)
|
||||||
|
}
|
||||||
|
// And what is the same for everybody is still the same for everybody.
|
||||||
|
if said.Serves["region"] != "eu-west" {
|
||||||
|
t.Errorf("the binding lost what the provider serves to all: %v", said.Serves)
|
||||||
|
}
|
||||||
|
|
||||||
|
given := storeGrants(t, out)
|
||||||
|
if len(given) != 1 {
|
||||||
|
t.Fatalf("the provider was told about %d consumer(s): %v", len(given), given)
|
||||||
|
}
|
||||||
|
if given[0].Derived["bucket"] != want {
|
||||||
|
t.Errorf("the provider was told the bucket is %v, and the consumer was told %q — "+
|
||||||
|
"the two ends disagree, which is the whole failure", given[0].Derived["bucket"], want)
|
||||||
|
}
|
||||||
|
// Only the per-consumer half. The region is the same for everyone and is already in the
|
||||||
|
// provider's own definition; repeating it here would be a copy to go stale.
|
||||||
|
if _, carried := given[0].Derived["region"]; carried {
|
||||||
|
t.Errorf("the provider was handed back what it already says for everyone: %v", given[0].Derived)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two consumers of one provider on one machine get two buckets, and neither gets the other's.
|
||||||
|
func TestTwoConsumersOfOneProviderGetTheirOwnDerivation(t *testing.T) {
|
||||||
|
r, err := Resolve(shelf(store(), files(), pics()),
|
||||||
|
[]string{"store", "files", "pics"}, reachable(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := r.Declaration(Rendering{Grants: []Grant{
|
||||||
|
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||||
|
Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
||||||
|
{Provision: "s3-bucket", Consumer: "workstation", From: "pics", Slug: "pics",
|
||||||
|
Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
forFiles := strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("files", "files")), "_", "-")
|
||||||
|
forPics := strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("pics", "pics")), "_", "-")
|
||||||
|
if forFiles == forPics {
|
||||||
|
t.Fatal("the two consumers were given the same identity; this test proves nothing")
|
||||||
|
}
|
||||||
|
if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+forFiles+"\n") {
|
||||||
|
t.Errorf("files was not given its own bucket:\n%s", got)
|
||||||
|
}
|
||||||
|
if got := fileNamed(out, "pics.env")["content"].(string); !strings.Contains(got, "BUCKET="+forPics+"\n") {
|
||||||
|
t.Errorf("pics was not given its own bucket:\n%s", got)
|
||||||
|
}
|
||||||
|
var buckets []any
|
||||||
|
for _, g := range storeGrants(t, out) {
|
||||||
|
buckets = append(buckets, g.Derived["bucket"])
|
||||||
|
}
|
||||||
|
if len(buckets) != 2 || buckets[0] == buckets[1] {
|
||||||
|
t.Errorf("the provider was told %v; it must be told one bucket per consumer", buckets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An operator may still set what the provider serves, and the mesh still derives the rest: the
|
||||||
|
// setting is laid on first, then the consumer's half is filled.
|
||||||
|
func TestASettingComposesWithADerivedValue(t *testing.T) {
|
||||||
|
r, err := Resolve(shelf(store(), files()), []string{"store", "files"}, reachable(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := r.Declaration(Rendering{
|
||||||
|
Settings: SettingsBy{"store": {{From: "the operator",
|
||||||
|
Values: map[string]any{"bucket": "team-${consumer:as:dns}"}}}},
|
||||||
|
Grants: []Grant{{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||||
|
Values: map[string]any{}, Sealed: "c2VhbGVk"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := "team-" + strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("files", "files")), "_", "-")
|
||||||
|
if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") {
|
||||||
|
t.Errorf("the operator's prefix did not survive the derivation:\n%s\nwant BUCKET=%s", got, want)
|
||||||
|
}
|
||||||
|
if given := storeGrants(t, out); given[0].Derived["bucket"] != want {
|
||||||
|
t.Errorf("the provider was told %v, the consumer %q", given[0].Derived["bucket"], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fact or an alphabet the mesh does not have is refused where the definition is, not where a
|
||||||
|
// consumer happens to be resolved — and the refusal says what may be said instead.
|
||||||
|
func TestAServedValueNamingSomethingTheMeshDoesNotHaveIsRefused(t *testing.T) {
|
||||||
|
for _, c := range []struct{ value, says string }{
|
||||||
|
{"${consumer:node}", "as"},
|
||||||
|
{"${consumer:as:punycode}", "dns"},
|
||||||
|
} {
|
||||||
|
m := store()
|
||||||
|
m.Serves["s3-bucket"]["bucket"] = c.value
|
||||||
|
raw, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = ParseManifest(raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("%s was accepted", c.value)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), c.value) {
|
||||||
|
t.Errorf("the refusal of %s does not quote it: %v", c.value, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), c.says) {
|
||||||
|
t.Errorf("the refusal of %s does not say what may be said (%q): %v", c.value, c.says, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `dns` is checked against an identity the mesh actually mints, not an invented string.
|
||||||
|
func TestTheDNSAlphabetIsTheMintedIdentityWithItsSeparatorRewritten(t *testing.T) {
|
||||||
|
as := ConsumerIdentity("anchor", IdentitySource("ncloud", "nextcloud"))
|
||||||
|
if err := CheckIdentity("anchor", IdentitySource("ncloud", "nextcloud")); err != nil {
|
||||||
|
t.Fatalf("the mesh would not mint this identity at all: %v", err)
|
||||||
|
}
|
||||||
|
label := asDNSLabel(as)
|
||||||
|
if strings.Contains(label, "_") {
|
||||||
|
t.Errorf("%q is not a DNS label", label)
|
||||||
|
}
|
||||||
|
if strings.ReplaceAll(label, "-", "_") != as {
|
||||||
|
t.Errorf("%q is not %q with its separator rewritten", label, as)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The check that would have caught the one wrong instance: a consumer that writes the derived
|
||||||
|
// value into its own definition instead of asking for it is refused, whether it transcribed the
|
||||||
|
// right answer or a predecessor's.
|
||||||
|
func TestAConsumerThatTranscribesWhatItsProviderDerivesIsRefused(t *testing.T) {
|
||||||
|
as := ConsumerIdentity("workstation", IdentitySource("files", "files"))
|
||||||
|
transcribed := strings.ReplaceAll(as, "_", "-")
|
||||||
|
|
||||||
|
m := files()
|
||||||
|
m.Resources = []map[string]any{{
|
||||||
|
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
||||||
|
// Exactly what the provider will create — correct today, and a copy of a rule that is
|
||||||
|
// not this module's.
|
||||||
|
"content": "BUCKET=" + transcribed + "\n",
|
||||||
|
}}
|
||||||
|
r, err := Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = r.Declaration(Rendering{Grants: []Grant{{
|
||||||
|
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||||
|
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
||||||
|
}}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a definition holding its own copy of the provider's naming rule was accepted")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "${bound:s3-bucket:bucket}") {
|
||||||
|
t.Errorf("the refusal does not say what to write instead: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a constant the provider serves to everyone is not a transcription: repeating it is
|
||||||
|
// redundant, not wrong, and refusing it would be the mesh policing style.
|
||||||
|
m.Resources = []map[string]any{{
|
||||||
|
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
||||||
|
"content": "REGION=eu-west\n",
|
||||||
|
}}
|
||||||
|
r, err = Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := r.Declaration(Rendering{Grants: []Grant{{
|
||||||
|
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||||
|
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
||||||
|
}}}); err != nil {
|
||||||
|
t.Errorf("a value the provider serves to everyone was judged a transcription: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeGrants(t *testing.T, out []map[string]any) []Contribution {
|
||||||
|
t.Helper()
|
||||||
|
for _, r := range out {
|
||||||
|
if r["path"] != "/var/lib/store/grants/mesh.json" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Given []Contribution `json:"given"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return parsed.Given
|
||||||
|
}
|
||||||
|
t.Fatalf("the provider was given no contributions file: %v", out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A consumer that keeps SEVERAL holders of one provision is refused, rather than told one thing
|
||||||
|
// while its provider is told another.
|
||||||
|
//
|
||||||
|
// **This is issue 124's own failure, one case to the side.** The mesh gives each holder its own
|
||||||
|
// login — `mesh_node_mod_<local>` (ADR 0094) — and the provider derives from the login, so it
|
||||||
|
// would make one resource per holder. The consumer's side has no such dimension: there is one
|
||||||
|
// binding file per provision and one `${bound:<provision>:<key>}`, both derived from the
|
||||||
|
// un-suffixed identity. So the provider would create `…-mod-cold` and the consumer would be
|
||||||
|
// configured against `…-mod`: it would authenticate successfully and be refused on every object,
|
||||||
|
// which is exactly the fault this whole record exists to end.
|
||||||
|
//
|
||||||
|
// Refused, loudly, at the one place that can see both halves. Lifting it means giving the
|
||||||
|
// consumer's side a local dimension, which is a decision and not an omission.
|
||||||
|
func TestAConsumerWithSeveralHoldersOfADerivingProviderIsRefused(t *testing.T) {
|
||||||
|
m := files()
|
||||||
|
// Two holders of the one provision, the shape ADR 0094 gives a module that keeps several.
|
||||||
|
m.Secrets = nil
|
||||||
|
m.SecretsMany = map[string]map[string]string{"s3-bucket": {
|
||||||
|
"hot": "/var/lib/files/hot.secret",
|
||||||
|
"cold": "/var/lib/files/cold.secret",
|
||||||
|
}}
|
||||||
|
m.Resources = []map[string]any{{
|
||||||
|
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
||||||
|
"content": "BUCKET=${bound:s3-bucket:bucket}\n",
|
||||||
|
}}
|
||||||
|
r, err := Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = r.Declaration(Rendering{Grants: []Grant{
|
||||||
|
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||||
|
Local: "hot", Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
||||||
|
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||||
|
Local: "cold", Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
||||||
|
}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a consumer with several holders of a deriving provider was accepted; " +
|
||||||
|
"its two ends would have disagreed in silence")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"files", "s3-bucket", "bucket"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("the refusal does not name %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The account's environment and the login shell's code, composed from the modules a node runs
|
||||||
|
// (novox/hq ADR 0203, ADR 0204).
|
||||||
|
//
|
||||||
|
// **The same shape as the jails.** Every module may contribute — a toolchain its directory on PATH,
|
||||||
|
// a version manager a variable naming its home, a prompt the code that loads it — naming no node, no
|
||||||
|
// path and no file of the shell's (ADR 0112). The one module holding the matching seat places the
|
||||||
|
// result with a placeholder in its own file, and the controller fills it from every module on the
|
||||||
|
// node. A node not running a module has none of its contribution, and unassigning one takes its
|
||||||
|
// lines away at the next composition.
|
||||||
|
//
|
||||||
|
// **Two kinds of contribution, kept apart on purpose.** The environment is facts, which the
|
||||||
|
// controller writes in two standard formats — POSIX assignment and the service manager's
|
||||||
|
// environment.d — so a terminal, a script, the login shell's `execute` and a graphical session all
|
||||||
|
// read the same values (ADR 0203). Shell code is not a fact: it is text in one shell's syntax, which
|
||||||
|
// the controller sorts into a slot and pastes without reading, as it pastes a jail's stanza (ADR
|
||||||
|
// 0204).
|
||||||
|
|
||||||
|
// EnvironmentSeat and LoginShellSeat are the seats whose holders may place what the modules
|
||||||
|
// contributed: the account's environment, and the login shell's code.
|
||||||
|
const (
|
||||||
|
EnvironmentSeat = "node-environment"
|
||||||
|
LoginShellSeat = "node-login-shell"
|
||||||
|
// PowerSeat is the seat whose holder places code for the power moments (novox/hq ADR 0211).
|
||||||
|
PowerSeat = "node-power"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Where an environment entry on PATH goes: before the account's existing PATH, or after it.
|
||||||
|
const (
|
||||||
|
PathAtStart = "start"
|
||||||
|
PathAtEnd = "end"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Environment is what one module adds to the account's environment (novox/hq ADR 0203).
|
||||||
|
type Environment struct {
|
||||||
|
// Variables are names and literal values. A value may name the machine's own facts with
|
||||||
|
// ${machine:…}, resolved before anything is written, and nothing else that expands.
|
||||||
|
Variables map[string]string `json:"variables,omitempty"`
|
||||||
|
// Path is entries on the account's PATH, each at its start or its end, in the order declared.
|
||||||
|
Path []PathEntry `json:"path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathEntry is one directory a module puts on the account's PATH.
|
||||||
|
type PathEntry struct {
|
||||||
|
Entry string `json:"entry"`
|
||||||
|
At string `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShellCode is one piece of code a module adds to a shell's startup (novox/hq ADR 0204).
|
||||||
|
type ShellCode struct {
|
||||||
|
// For is the shell the code is written in.
|
||||||
|
For string `json:"for"`
|
||||||
|
// Slot is where it runs among the other modules' code: first, normal or last. Named rather
|
||||||
|
// than numbered, because every contributor would guess a number and a collision says nothing.
|
||||||
|
Slot string `json:"slot"`
|
||||||
|
// Code is never interpreted — it is the shell's syntax, and only the shell reads it.
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shells and slots a contribution may name (novox/hq ADR 0204). Closed, so a typo is a refusal
|
||||||
|
// at the check rather than code that silently lands in no placeholder.
|
||||||
|
var (
|
||||||
|
knownShells = []string{"zsh", "bash", "fish"}
|
||||||
|
knownSlots = []string{"first", "normal", "last"}
|
||||||
|
// sessionFiles are the two files of the graphical session's start that read no directory, so a
|
||||||
|
// contribution to them is a slot rather than a drop-in (novox/hq ADR 0208 §4): `xinitrc` is POSIX
|
||||||
|
// code the session's start runs, `xresources` X resources merged at its start. Placed by the
|
||||||
|
// display server's holder, as a shell's slots are placed by the login shell's.
|
||||||
|
sessionFiles = []string{"xinitrc", "xresources"}
|
||||||
|
// powerMoments are the moments of a machine's power a module may run code at (novox/hq ADR
|
||||||
|
// 0211 §3): POSIX code run as root by node-power's holder, in module order, each piece bounded.
|
||||||
|
powerMoments = []string{"after-boot", "before-sleep", "after-wake", "before-shutdown", "on-mains", "on-battery"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// contributionTargets is every name a contribution's `for` may take.
|
||||||
|
func contributionTargets() []string {
|
||||||
|
out := append(append([]string(nil), knownShells...), sessionFiles...)
|
||||||
|
return append(out, powerMoments...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// placerOf is the seat whose holder places a contribution for this target (novox/hq ADR 0204,
|
||||||
|
// ADR 0208 §4).
|
||||||
|
func placerOf(target string) string {
|
||||||
|
if oneOf(sessionFiles, target) {
|
||||||
|
return DisplayServerSeat
|
||||||
|
}
|
||||||
|
if oneOf(powerMoments, target) {
|
||||||
|
return PowerSeat
|
||||||
|
}
|
||||||
|
return LoginShellSeat
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two renderings of the environment a holder may place (novox/hq ADR 0203, decision 3).
|
||||||
|
const (
|
||||||
|
EnvironmentPOSIX = "posix"
|
||||||
|
EnvironmentSystemd = "systemd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ofEnvironment and ofShell are where a holder places what was contributed: ${environment:posix},
|
||||||
|
// ${environment:systemd} and ${shell:<shell>:<slot>}. Loose inside the braces on purpose, so a
|
||||||
|
// misspelt key is found and refused rather than left in a file as a literal nobody reads.
|
||||||
|
var (
|
||||||
|
ofEnvironment = regexp.MustCompile(`\$\{environment:([^}]*)\}`)
|
||||||
|
ofShell = regexp.MustCompile(`\$\{shell:([^}]*)\}`)
|
||||||
|
// ofContributed is either kind of contributed text, matched together so both fill in one pass.
|
||||||
|
ofContributed = regexp.MustCompile(`\$\{(shell|contribution):([^}]*)\}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// variableName is a POSIX shell variable name, which is also what environment.d accepts.
|
||||||
|
var variableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
|
|
||||||
|
// environmentProblems is what is wrong with this module's environment contribution, from the
|
||||||
|
// manifest alone.
|
||||||
|
func (m Manifest) environmentProblems() []string {
|
||||||
|
if m.Environment == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var problems []string
|
||||||
|
for _, n := range sortedKeys(m.Environment.Variables) {
|
||||||
|
switch {
|
||||||
|
case !variableName.MatchString(n):
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s sets the variable %q, which is not a name a shell accepts: a letter or an "+
|
||||||
|
"underscore, then letters, digits and underscores", m.Module, n))
|
||||||
|
continue
|
||||||
|
case n == "PATH":
|
||||||
|
// PATH is the one variable every module shares, so no module may set it whole: a second
|
||||||
|
// setter would replace the first's entries, and the account's own PATH with them.
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s sets PATH as a variable; a module adds an entry under environment.path, at the "+
|
||||||
|
"start or the end, and PATH is composed from every module's (novox/hq ADR 0203)", m.Module))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if why := literalProblem(m.Environment.Variables[n]); why != "" {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s sets %s to %q, which %s — %s", m.Module, n, m.Environment.Variables[n], why, literalRule))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for i, p := range m.Environment.Path {
|
||||||
|
switch {
|
||||||
|
case p.Entry == "":
|
||||||
|
problems = append(problems, fmt.Sprintf("%s's PATH entry %d names no directory", m.Module, i+1))
|
||||||
|
case strings.Contains(ofMachine.ReplaceAllString(p.Entry, ""), ":"):
|
||||||
|
// A colon is PATH's own separator, so an entry holding one is two entries, and the
|
||||||
|
// check that it is already present would look for the wrong thing.
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s puts %q on PATH, which holds a colon, PATH's own separator", m.Module, p.Entry))
|
||||||
|
case seen[p.Entry]:
|
||||||
|
problems = append(problems, fmt.Sprintf("%s puts %q on PATH twice", m.Module, p.Entry))
|
||||||
|
default:
|
||||||
|
if why := literalProblem(p.Entry); why != "" {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s puts %q on PATH, which %s — %s", m.Module, p.Entry, why, literalRule))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen[p.Entry] = true
|
||||||
|
if p.At != PathAtStart && p.At != PathAtEnd {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s puts %q on PATH at %q; an entry goes at %q or %q of the account's PATH",
|
||||||
|
m.Module, p.Entry, p.At, PathAtStart, PathAtEnd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
// literalRule is why a value must be literal, said with every refusal of one.
|
||||||
|
const literalRule = "a value is literal, so a POSIX shell and the service manager read it alike, and " +
|
||||||
|
"names the machine only through the mesh's own ${machine:…} facts (novox/hq ADR 0203)"
|
||||||
|
|
||||||
|
// literalProblem is why a value cannot be written, unquoted by either reader, as the same string in
|
||||||
|
// both formats — or nothing. A `$` would expand differently in each; a quote or a backslash is
|
||||||
|
// quoting in one and a character in the other; a line break ends the line in both.
|
||||||
|
func literalProblem(v string) string {
|
||||||
|
switch {
|
||||||
|
case strings.ContainsAny(v, `'"`):
|
||||||
|
return "holds a quote"
|
||||||
|
case strings.Contains(v, `\`):
|
||||||
|
return "holds a backslash"
|
||||||
|
case strings.ContainsAny(v, "\n\r"):
|
||||||
|
return "holds a line break"
|
||||||
|
case strings.ContainsRune(v, 0):
|
||||||
|
return "holds a NUL"
|
||||||
|
case strings.Contains(ofMachine.ReplaceAllString(v, ""), "$"):
|
||||||
|
return "holds a $ that is not one of the machine's ${machine:…} facts"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellProblems is what is wrong with this module's shell code, from the manifest alone. The code
|
||||||
|
// itself is not judged: it is the shell's syntax, which the controller does not read.
|
||||||
|
func (m Manifest) shellProblems() []string {
|
||||||
|
var problems []string
|
||||||
|
for i, c := range m.Shell {
|
||||||
|
if !oneOf(contributionTargets(), c.For) {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s's shell code %d is for %q; the shells are %s, the session's files %s, and the power "+
|
||||||
|
"moments %s", m.Module, i+1, c.For, strings.Join(knownShells, ", "),
|
||||||
|
strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", ")))
|
||||||
|
}
|
||||||
|
if !oneOf(knownSlots, c.Slot) {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s's shell code %d goes in the slot %q; the slots are %s", m.Module, i+1, c.Slot,
|
||||||
|
strings.Join(knownSlots, ", ")))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.Code) == "" {
|
||||||
|
problems = append(problems, fmt.Sprintf("%s's shell code %d has no code", m.Module, i+1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
// contributionPlaceholderProblems is every place this module's resources name the environment or
|
||||||
|
// the shell's code and may not — judged from the manifest, so the catalogue check refuses it before
|
||||||
|
// a mesh does, and again at composition in the same words.
|
||||||
|
func (m Manifest) contributionPlaceholderProblems() []string {
|
||||||
|
var problems []string
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
problems = append(problems, placeholderProblems(m, r)...)
|
||||||
|
problems = append(problems, seatPlaceholderProblems(m, r)...)
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeholderProblems is what is wrong with one resource's ${environment:…} and ${shell:…}.
|
||||||
|
//
|
||||||
|
// **The seat authorises it, not the placeholder** (novox/hq ADR 0203 §5, ADR 0204 §3), as the seat
|
||||||
|
// authorises the bus's user list: a module that does not hold the account's environment writing it
|
||||||
|
// would be a second writer of a file there is one of, and a module that does not hold the login
|
||||||
|
// shell writing every module's shell code would be a second shell.
|
||||||
|
func placeholderProblems(m Manifest, r map[string]any) []string {
|
||||||
|
var problems []string
|
||||||
|
for _, field := range sortedKeys(r) {
|
||||||
|
s, ok := r[field].(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
env := ofEnvironment.FindAllStringSubmatch(s, -1)
|
||||||
|
code := ofShell.FindAllStringSubmatch(s, -1)
|
||||||
|
if len(env)+len(code) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if field != "content" {
|
||||||
|
// Placed only where a file's bytes are, which is where every one of them is meant to go:
|
||||||
|
// a path or an owner holding several lines of shell is nothing the host could act on.
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s's resource %v names %s in its %s; the environment and the shell's code are placed "+
|
||||||
|
"only in a file's content", m.Module, r["id"], placeholderOf(env, code), field))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range env {
|
||||||
|
if e[1] != EnvironmentPOSIX && e[1] != EnvironmentSystemd {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s's resource %v names %s; the environment is ${environment:%s} or ${environment:%s}",
|
||||||
|
m.Module, r["id"], e[0], EnvironmentPOSIX, EnvironmentSystemd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(env) > 0 && !m.ClaimsSeat(EnvironmentSeat) {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s's resource %v names %s and %s does not claim %s; the account's environment is "+
|
||||||
|
"written by that seat's holder alone (novox/hq ADR 0203)",
|
||||||
|
m.Module, r["id"], env[0][0], m.Module, EnvironmentSeat))
|
||||||
|
}
|
||||||
|
// Each placeholder judged by its own target: a shell's code is the login shell's holder's to
|
||||||
|
// place (ADR 0204), the session's files the display server's (ADR 0208 §4) — and a holder of
|
||||||
|
// one placing the other's would be a second writer of a file there is one of.
|
||||||
|
refusedFor := map[string]bool{}
|
||||||
|
for _, c := range code {
|
||||||
|
target, slot, two := strings.Cut(c[1], ":")
|
||||||
|
if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s's resource %v names %s; shell code is ${shell:<shell>:<slot>}, the shell one of "+
|
||||||
|
"%s, the session's file one of %s or the power moment one of %s, and the slot one of %s",
|
||||||
|
m.Module, r["id"], c[0], strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "),
|
||||||
|
strings.Join(powerMoments, ", "), strings.Join(knownSlots, ", ")))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seat := placerOf(target)
|
||||||
|
if m.ClaimsSeat(seat) || refusedFor[seat] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
refusedFor[seat] = true
|
||||||
|
if seat == PowerSeat {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s's resource %v names %s and %s does not claim %s; every module's code for a power "+
|
||||||
|
"moment is placed by the power seat's holder alone (novox/hq ADR 0211)",
|
||||||
|
m.Module, r["id"], c[0], m.Module, seat))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seat == DisplayServerSeat {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s's resource %v names %s and %s does not claim %s; every module's %s is placed by "+
|
||||||
|
"the display server's holder alone (novox/hq ADR 0208)",
|
||||||
|
m.Module, r["id"], c[0], m.Module, seat, target))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s's resource %v names %s and %s does not claim %s; every module's shell code is "+
|
||||||
|
"placed by the login shell's holder alone (novox/hq ADR 0204)",
|
||||||
|
m.Module, r["id"], c[0], m.Module, seat))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeholderOf(env, code [][]string) string {
|
||||||
|
if len(env) > 0 {
|
||||||
|
return env[0][0]
|
||||||
|
}
|
||||||
|
return code[0][0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// contributedEnvironment is one node's environment, gathered and in the order it is written.
|
||||||
|
type contributedEnvironment struct {
|
||||||
|
// variables is by module in name order, each module's sorted by name.
|
||||||
|
variables []setBy
|
||||||
|
// start and end are PATH's entries in their final order, each once.
|
||||||
|
start, end []placedOn
|
||||||
|
}
|
||||||
|
|
||||||
|
type setBy struct {
|
||||||
|
module string
|
||||||
|
names []string
|
||||||
|
values map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
type placedOn struct {
|
||||||
|
module, entry string
|
||||||
|
}
|
||||||
|
|
||||||
|
// inModuleOrder is the modules sorted by name — the order contributions are written in (novox/hq
|
||||||
|
// ADR 0203, ADR 0204), so the same set composes byte for byte whatever order they were assigned in.
|
||||||
|
func inModuleOrder(modules []Manifest) []Manifest {
|
||||||
|
out := append([]Manifest(nil), modules...)
|
||||||
|
sort.SliceStable(out, func(a, b int) bool { return out[a].Module < out[b].Module })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// variablesSetOnce refuses a variable two modules on one node both set (novox/hq ADR 0203 §5),
|
||||||
|
// naming both. Neither is chosen: whichever was written last would win in one reader and not
|
||||||
|
// necessarily in the other, and the module that lost would not be told.
|
||||||
|
func variablesSetOnce(modules []Manifest) error {
|
||||||
|
setter := map[string]string{}
|
||||||
|
for _, m := range inModuleOrder(modules) {
|
||||||
|
if m.Environment == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, n := range sortedKeys(m.Environment.Variables) {
|
||||||
|
if first, taken := setter[n]; taken {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%s and %s both set %s on this machine; the account has one environment, so one "+
|
||||||
|
"of them must stop setting it (novox/hq ADR 0203)", first, m.Module, n)
|
||||||
|
}
|
||||||
|
setter[n] = m.Module
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// environmentOn gathers every module's environment on a node, with the machine's facts in place.
|
||||||
|
//
|
||||||
|
// A PATH entry two modules both add is written once, where the first puts it: two toolchains
|
||||||
|
// sharing ~/.local/bin is ordinary, and nothing about it is in conflict.
|
||||||
|
func environmentOn(modules []Manifest, facts map[string]string) (contributedEnvironment, error) {
|
||||||
|
var env contributedEnvironment
|
||||||
|
if err := variablesSetOnce(modules); err != nil {
|
||||||
|
return env, err
|
||||||
|
}
|
||||||
|
placed := map[string]bool{}
|
||||||
|
for _, m := range inModuleOrder(modules) {
|
||||||
|
if m.Environment == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(m.Environment.Variables) > 0 {
|
||||||
|
set := setBy{module: m.Module, values: map[string]string{}}
|
||||||
|
for _, n := range sortedKeys(m.Environment.Variables) {
|
||||||
|
v, err := factsIn(m.Environment.Variables[n], facts, m.Module, n)
|
||||||
|
if err != nil {
|
||||||
|
return env, err
|
||||||
|
}
|
||||||
|
set.names = append(set.names, n)
|
||||||
|
set.values[n] = v
|
||||||
|
}
|
||||||
|
env.variables = append(env.variables, set)
|
||||||
|
}
|
||||||
|
for _, p := range m.Environment.Path {
|
||||||
|
entry, err := factsIn(p.Entry, facts, m.Module, "a PATH entry")
|
||||||
|
if err != nil {
|
||||||
|
return env, err
|
||||||
|
}
|
||||||
|
if strings.Contains(entry, ":") {
|
||||||
|
return env, fmt.Errorf("%s puts %q on PATH on this machine, which holds a colon, PATH's own separator",
|
||||||
|
m.Module, entry)
|
||||||
|
}
|
||||||
|
if placed[entry] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
placed[entry] = true
|
||||||
|
if p.At == PathAtEnd {
|
||||||
|
env.end = append(env.end, placedOn{m.Module, entry})
|
||||||
|
} else {
|
||||||
|
env.start = append(env.start, placedOn{m.Module, entry})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// factsIn resolves a contributed value's ${machine:…} facts with this machine's — first, before
|
||||||
|
// either format is written, so both say the same thing (novox/hq ADR 0203).
|
||||||
|
func factsIn(v string, facts map[string]string, module, what string) (string, error) {
|
||||||
|
for _, key := range machineUsed(v) {
|
||||||
|
value, has := facts[key]
|
||||||
|
if !has {
|
||||||
|
return "", fmt.Errorf("%s sets %s to a value that says ${machine:%s}, and this machine says %s",
|
||||||
|
module, what, key, orNothing(namesOfFacts(facts)))
|
||||||
|
}
|
||||||
|
v = strings.ReplaceAll(v, fmt.Sprintf("${machine:%s}", key), value)
|
||||||
|
}
|
||||||
|
// Judged again once filled: a fact is the mesh's, and still has to be a literal both readers
|
||||||
|
// take alike.
|
||||||
|
if why := literalProblem(v); why != "" {
|
||||||
|
return "", fmt.Errorf("%s sets %s to %q on this machine, which %s — %s", module, what, v, why, literalRule)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// posix is the environment as lines a POSIX shell sources (novox/hq ADR 0203 §3): every variable
|
||||||
|
// exported, every PATH entry added only when it is missing, so sourcing the file twice — a login
|
||||||
|
// shell that starts another — changes nothing. POSIX sh only, because sh, bash and zsh all read it.
|
||||||
|
//
|
||||||
|
// The start entries are written last-first: each is put in front of PATH, so the last written ends
|
||||||
|
// up first, and the result reads in module order, then the order each module declared.
|
||||||
|
func (e contributedEnvironment) posix() string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, set := range e.variables {
|
||||||
|
fmt.Fprintf(&b, "# %s\n", set.module)
|
||||||
|
for _, n := range set.names {
|
||||||
|
fmt.Fprintf(&b, "export %s='%s'\n", n, set.values[n])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
named := ""
|
||||||
|
for i := len(e.start) - 1; i >= 0; i-- {
|
||||||
|
p := e.start[i]
|
||||||
|
if p.module != named {
|
||||||
|
fmt.Fprintf(&b, "# %s\n", p.module)
|
||||||
|
named = p.module
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH='%s'\"${PATH:+:${PATH}}\" ;; esac\n",
|
||||||
|
p.entry, p.entry)
|
||||||
|
}
|
||||||
|
named = ""
|
||||||
|
for _, p := range e.end {
|
||||||
|
if p.module != named {
|
||||||
|
fmt.Fprintf(&b, "# %s\n", p.module)
|
||||||
|
named = p.module
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH=\"${PATH:+${PATH}:}\"'%s' ;; esac\n",
|
||||||
|
p.entry, p.entry)
|
||||||
|
}
|
||||||
|
if len(e.start)+len(e.end) > 0 {
|
||||||
|
b.WriteString("export PATH\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemd is the same environment as the service manager's environment.d reads it (novox/hq ADR
|
||||||
|
// 0203 §3), for the account's user manager and so for everything a graphical session starts. Read
|
||||||
|
// once per manager start, so it needs no guard against running twice; the account's existing PATH
|
||||||
|
// sits between the start and the end entries.
|
||||||
|
func (e contributedEnvironment) systemd() string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, set := range e.variables {
|
||||||
|
fmt.Fprintf(&b, "# %s\n", set.module)
|
||||||
|
for _, n := range set.names {
|
||||||
|
fmt.Fprintf(&b, "%s=%s\n", n, set.values[n])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(e.start) > 0 {
|
||||||
|
fmt.Fprintf(&b, "# %s\nPATH=%s${PATH:+:$PATH}\n", modulesOf(e.start), entriesOf(e.start))
|
||||||
|
}
|
||||||
|
if len(e.end) > 0 {
|
||||||
|
fmt.Fprintf(&b, "# %s\nPATH=${PATH:+$PATH:}%s\n", modulesOf(e.end), entriesOf(e.end))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// modulesOf names who contributed a line holding several modules' entries, in the order they appear.
|
||||||
|
func modulesOf(entries []placedOn) string {
|
||||||
|
var names []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, p := range entries {
|
||||||
|
if !seen[p.module] {
|
||||||
|
seen[p.module] = true
|
||||||
|
names = append(names, p.module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func entriesOf(entries []placedOn) string {
|
||||||
|
out := make([]string, len(entries))
|
||||||
|
for i, p := range entries {
|
||||||
|
out[i] = p.entry
|
||||||
|
}
|
||||||
|
return strings.Join(out, ":")
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellCode is every module's code for one shell and one slot (novox/hq ADR 0204 §3): in module
|
||||||
|
// order, each module's pieces in the order it declared them, each preceded by a line naming the
|
||||||
|
// module, and empty when nothing is contributed.
|
||||||
|
func shellCode(modules []Manifest, shell, slot string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, m := range inModuleOrder(modules) {
|
||||||
|
named := false
|
||||||
|
for _, c := range m.Shell {
|
||||||
|
if c.For != shell || c.Slot != slot {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !named {
|
||||||
|
fmt.Fprintf(&b, "# %s\n", m.Module)
|
||||||
|
named = true
|
||||||
|
}
|
||||||
|
b.WriteString(c.Code)
|
||||||
|
if !strings.HasSuffix(c.Code, "\n") {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// contributionsInto fills a holder's file with the node's environment and its shell code.
|
||||||
|
//
|
||||||
|
// **Last, after every other placeholder pass, and in one pass each.** Shell code is contributed text
|
||||||
|
// in a shell's own syntax — `${XDG_CACHE_HOME:-$HOME/.cache}`, `${(%):-%n}` — and the rendered
|
||||||
|
// environment holds `${PATH:+…}`: a scanner for the mesh's own placeholders that ran after these
|
||||||
|
// were in place would read the shell's expansions as the mesh's and refuse them, or fill a
|
||||||
|
// `${machine:…}` some module wrote for its shell to see. So nothing runs after them, the environment
|
||||||
|
// is filled before the shell's code is, and each is replaced in a single pass over what the holder
|
||||||
|
// wrote, so a contributed piece is never scanned again.
|
||||||
|
func contributionsInto(resource map[string]any, m Manifest, modules []Manifest, facts map[string]string) error {
|
||||||
|
if problems := append(placeholderProblems(m, resource), seatPlaceholderProblems(m, resource)...); len(problems) > 0 {
|
||||||
|
return fmt.Errorf("%s", problems[0])
|
||||||
|
}
|
||||||
|
content, ok := resource["content"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ofEnvironment.MatchString(content) {
|
||||||
|
env, err := environmentOn(modules, facts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content = ofEnvironment.ReplaceAllStringFunc(content, func(placeholder string) string {
|
||||||
|
if ofEnvironment.FindStringSubmatch(placeholder)[1] == EnvironmentSystemd {
|
||||||
|
return env.systemd()
|
||||||
|
}
|
||||||
|
return env.posix()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Shell code and seat contributions in one pass (novox/hq ADR 0212): both are contributed text
|
||||||
|
// the controller does not read, so neither may be scanned after the other is in place — a
|
||||||
|
// contributed line that happened to spell the other's placeholder would be filled.
|
||||||
|
if ofContributed.MatchString(content) {
|
||||||
|
content = ofContributed.ReplaceAllStringFunc(content, func(placeholder string) string {
|
||||||
|
found := ofContributed.FindStringSubmatch(placeholder)
|
||||||
|
first, second, _ := strings.Cut(found[2], ":")
|
||||||
|
if found[1] == "contribution" {
|
||||||
|
return seatContributions(modules, first, second)
|
||||||
|
}
|
||||||
|
return shellCode(modules, first, second)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
resource["content"] = content
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defends novox/hq ADR 0203 (the account's environment is one module's, and every module
|
||||||
|
// contributes to it) and ADR 0204 (shell code in named slots, placed by the login shell's holder).
|
||||||
|
|
||||||
|
// contributors is a fixed set of contributions, in no particular order: what the renderings are
|
||||||
|
// asserted against byte for byte. go-toolchain and zsh both put ~/.local/bin on PATH, which is the
|
||||||
|
// ordinary case of two modules sharing a directory, and is written once.
|
||||||
|
func contributors() []Manifest {
|
||||||
|
return []Manifest{
|
||||||
|
{Module: "zsh", Environment: &Environment{
|
||||||
|
Variables: map[string]string{"XDG_CONFIG_HOME": "${machine:account-home}/.config", "EDITOR": "vim"},
|
||||||
|
Path: []PathEntry{
|
||||||
|
{Entry: "${machine:account-home}/.local/bin", At: PathAtStart},
|
||||||
|
{Entry: "${machine:account-home}/bin", At: PathAtStart},
|
||||||
|
{Entry: "/opt/scripts", At: PathAtEnd},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
{Module: "go-toolchain", Environment: &Environment{
|
||||||
|
Variables: map[string]string{"GOPATH": "${machine:account-home}/go"},
|
||||||
|
Path: []PathEntry{
|
||||||
|
{Entry: "${machine:account-home}/go/bin", At: PathAtStart},
|
||||||
|
{Entry: "/usr/local/go/bin", At: PathAtStart},
|
||||||
|
{Entry: "${machine:account-home}/.local/bin", At: PathAtStart},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
{Module: "agent", Environment: &Environment{
|
||||||
|
Variables: map[string]string{"DISABLE_AUTOUPDATER": "1"},
|
||||||
|
Path: []PathEntry{{Entry: "/opt/agent/bin", At: PathAtEnd}},
|
||||||
|
}},
|
||||||
|
// A module contributing nothing is in the set and writes nothing.
|
||||||
|
{Module: "postgres"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var operatorFacts = map[string]string{"name": "workstation", "account": "op", "account-home": "/home/op"}
|
||||||
|
|
||||||
|
// The final PATH this set composes, around whatever the account had: the start entries in module
|
||||||
|
// order and then declared order, the account's own, then the end entries.
|
||||||
|
const composedPOSIX = `# agent
|
||||||
|
export DISABLE_AUTOUPDATER='1'
|
||||||
|
# go-toolchain
|
||||||
|
export GOPATH='/home/op/go'
|
||||||
|
# zsh
|
||||||
|
export EDITOR='vim'
|
||||||
|
export XDG_CONFIG_HOME='/home/op/.config'
|
||||||
|
# zsh
|
||||||
|
case ":${PATH}:" in *':/home/op/bin:'*) ;; *) PATH='/home/op/bin'"${PATH:+:${PATH}}" ;; esac
|
||||||
|
# go-toolchain
|
||||||
|
case ":${PATH}:" in *':/home/op/.local/bin:'*) ;; *) PATH='/home/op/.local/bin'"${PATH:+:${PATH}}" ;; esac
|
||||||
|
case ":${PATH}:" in *':/usr/local/go/bin:'*) ;; *) PATH='/usr/local/go/bin'"${PATH:+:${PATH}}" ;; esac
|
||||||
|
case ":${PATH}:" in *':/home/op/go/bin:'*) ;; *) PATH='/home/op/go/bin'"${PATH:+:${PATH}}" ;; esac
|
||||||
|
# agent
|
||||||
|
case ":${PATH}:" in *':/opt/agent/bin:'*) ;; *) PATH="${PATH:+${PATH}:}"'/opt/agent/bin' ;; esac
|
||||||
|
# zsh
|
||||||
|
case ":${PATH}:" in *':/opt/scripts:'*) ;; *) PATH="${PATH:+${PATH}:}"'/opt/scripts' ;; esac
|
||||||
|
export PATH
|
||||||
|
`
|
||||||
|
|
||||||
|
const composedSystemd = `# agent
|
||||||
|
DISABLE_AUTOUPDATER=1
|
||||||
|
# go-toolchain
|
||||||
|
GOPATH=/home/op/go
|
||||||
|
# zsh
|
||||||
|
EDITOR=vim
|
||||||
|
XDG_CONFIG_HOME=/home/op/.config
|
||||||
|
# go-toolchain, zsh
|
||||||
|
PATH=/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin${PATH:+:$PATH}
|
||||||
|
# agent, zsh
|
||||||
|
PATH=${PATH:+$PATH:}/opt/agent/bin:/opt/scripts
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestTheEnvironmentRendersForAPOSIXShellByteForByte(t *testing.T) {
|
||||||
|
env, err := environmentOn(contributors(), operatorFacts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := env.posix(); got != composedPOSIX {
|
||||||
|
t.Fatalf("the POSIX rendering is\n%s\nnot\n%s", got, composedPOSIX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheEnvironmentRendersForTheServiceManagerByteForByte(t *testing.T) {
|
||||||
|
env, err := environmentOn(contributors(), operatorFacts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := env.systemd(); got != composedSystemd {
|
||||||
|
t.Fatalf("the environment.d rendering is\n%s\nnot\n%s", got, composedSystemd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sourcing twice changes nothing (ADR 0203 §3): a login shell that starts another reads the file
|
||||||
|
// again, and a PATH that grew each time would be the symptom. Run by a real `sh`, because the claim
|
||||||
|
// is about what a shell does with the file, not about what the file looks like.
|
||||||
|
func TestThePOSIXEnvironmentSourcedTwiceLeavesPATHAsOnce(t *testing.T) {
|
||||||
|
sh, err := exec.LookPath("sh")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("no sh on this machine")
|
||||||
|
}
|
||||||
|
script := "PATH=/usr/bin:/bin\n" + composedPOSIX + "once=$PATH\n" + composedPOSIX +
|
||||||
|
`[ "$PATH" = "$once" ] || { echo "changed: $once -> $PATH"; exit 1; }` + "\n" +
|
||||||
|
`echo "$PATH"; echo "$GOPATH"`
|
||||||
|
out, err := exec.Command(sh, "-c", script).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sourcing twice: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
|
want := "/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/usr/bin:/bin:/opt/agent/bin:/opt/scripts"
|
||||||
|
if lines[0] != want {
|
||||||
|
t.Fatalf("PATH is %s, not %s", lines[0], want)
|
||||||
|
}
|
||||||
|
if lines[1] != "/home/op/go" {
|
||||||
|
t.Fatalf("GOPATH was not exported: %q", lines[1])
|
||||||
|
}
|
||||||
|
// And an entry the account already has stays where it is, and once.
|
||||||
|
out, err = exec.Command(sh, "-c", "PATH=/opt/scripts:/usr/bin\n"+composedPOSIX+`echo "$PATH"`).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(string(out)); got !=
|
||||||
|
"/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/opt/scripts:/usr/bin:/opt/agent/bin" {
|
||||||
|
t.Fatalf("an entry already on PATH was added again or moved: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The environment.d rendering, read by the service manager's own generator where this machine has
|
||||||
|
// one — the same reader an account's user manager runs, so the PATH it composes is the one asserted.
|
||||||
|
func TestTheServiceManagerReadsTheSystemdRenderingAsMeant(t *testing.T) {
|
||||||
|
generator := "/usr/lib/systemd/user-environment-generators/30-systemd-environment-d-generator"
|
||||||
|
if _, err := os.Stat(generator); err != nil {
|
||||||
|
t.Skip("no environment.d generator on this machine")
|
||||||
|
}
|
||||||
|
config := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(config, "environment.d"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(config, "environment.d", "50-mesh.conf"), []byte(composedSystemd), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd := exec.Command(generator)
|
||||||
|
cmd.Env = []string{"PATH=/usr/bin:/bin", "HOME=" + config, "XDG_CONFIG_HOME=" + config}
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v\n%s", err, out)
|
||||||
|
}
|
||||||
|
want := "PATH=/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/usr/bin:/bin:/opt/agent/bin:/opt/scripts"
|
||||||
|
if !strings.Contains(string(out), want+"\n") || !strings.Contains(string(out), "GOPATH=/home/op/go\n") {
|
||||||
|
t.Fatalf("the service manager read\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing contributed renders nothing, in both formats — not an empty `export PATH`.
|
||||||
|
func TestNoContributionsRenderNothing(t *testing.T) {
|
||||||
|
env, err := environmentOn([]Manifest{{Module: "postgres"}}, operatorFacts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if env.posix() != "" || env.systemd() != "" {
|
||||||
|
t.Fatalf("an empty environment rendered %q and %q", env.posix(), env.systemd())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ${machine:…} fact the machine does not have is refused naming the module, as a file's is.
|
||||||
|
func TestAContributedFactTheMachineLacksIsRefused(t *testing.T) {
|
||||||
|
_, err := environmentOn(contributors(), map[string]string{"name": "server"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "go-toolchain sets GOPATH") ||
|
||||||
|
!strings.Contains(err.Error(), "${machine:account-home}") {
|
||||||
|
t.Fatalf("a missing account home was not refused by name: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR 0203 §5: two modules setting one variable are refused, both named — neither silently wins.
|
||||||
|
func TestAVariableTwoModulesSetIsRefusedNamingBoth(t *testing.T) {
|
||||||
|
modules := append(contributors(), Manifest{Module: "neovim", Environment: &Environment{
|
||||||
|
Variables: map[string]string{"EDITOR": "nvim"}}})
|
||||||
|
_, err := environmentOn(modules, operatorFacts)
|
||||||
|
if err == nil || err.Error() != "neovim and zsh both set EDITOR on this machine; the account has one "+
|
||||||
|
"environment, so one of them must stop setting it (novox/hq ADR 0203)" {
|
||||||
|
t.Fatalf("a variable set twice was not refused naming both: %v", err)
|
||||||
|
}
|
||||||
|
// And at composition, whether or not the node holds the environment.
|
||||||
|
r := Resolution{Node: "workstation", Account: "op", Modules: modules}
|
||||||
|
if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), "neovim and zsh both set EDITOR") {
|
||||||
|
t.Fatalf("composition accepted a variable set twice: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shells contributes code for several shells and slots, in no order.
|
||||||
|
func shells() []Manifest {
|
||||||
|
return []Manifest{
|
||||||
|
{Module: "zsh-syntax-highlighting", Shell: []ShellCode{
|
||||||
|
{For: "zsh", Slot: "last", Code: "source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh"},
|
||||||
|
}},
|
||||||
|
{Module: "powerlevel10k", Shell: []ShellCode{
|
||||||
|
{For: "zsh", Slot: "first", Code: "if [[ -r \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\" ]]; then\n" +
|
||||||
|
" source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\"\nfi\n"},
|
||||||
|
{For: "zsh", Slot: "normal", Code: "source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme"},
|
||||||
|
{For: "zsh", Slot: "normal", Code: "[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh"},
|
||||||
|
}},
|
||||||
|
{Module: "zsh-autosuggestions", Shell: []ShellCode{
|
||||||
|
{For: "zsh", Slot: "normal", Code: "source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh"},
|
||||||
|
{For: "bash", Slot: "normal", Code: "echo not for zsh"},
|
||||||
|
}},
|
||||||
|
{Module: "direnv", Shell: []ShellCode{
|
||||||
|
{For: "fish", Slot: "last", Code: "direnv hook fish | source"},
|
||||||
|
{For: "bash", Slot: "last", Code: "eval \"$(direnv hook bash)\""},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR 0204 §3: a slot holds that shell's code only, in module order, each module's pieces in the
|
||||||
|
// order it declared them under a line naming it; empty when nothing is contributed.
|
||||||
|
func TestShellCodeLandsInItsSlotInModuleOrderForItsShellOnly(t *testing.T) {
|
||||||
|
if got, want := shellCode(shells(), "zsh", "normal"), "# powerlevel10k\n"+
|
||||||
|
"source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n"+
|
||||||
|
"[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh\n"+
|
||||||
|
"# zsh-autosuggestions\n"+
|
||||||
|
"source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n"; got != want {
|
||||||
|
t.Fatalf("zsh's normal slot is\n%s\nnot\n%s", got, want)
|
||||||
|
}
|
||||||
|
if got, want := shellCode(shells(), "zsh", "last"), "# zsh-syntax-highlighting\n"+
|
||||||
|
"source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n"; got != want {
|
||||||
|
t.Fatalf("zsh's last slot is\n%s\nnot\n%s", got, want)
|
||||||
|
}
|
||||||
|
if got, want := shellCode(shells(), "bash", "last"), "# direnv\neval \"$(direnv hook bash)\"\n"; got != want {
|
||||||
|
t.Fatalf("bash's last slot is %q, not %q", got, want)
|
||||||
|
}
|
||||||
|
if got := shellCode(shells(), "fish", "first"); got != "" {
|
||||||
|
t.Fatalf("a slot nobody contributed to holds %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The holder of node-login-shell, as WP3's zsh module writes its block, with its own zsh around the
|
||||||
|
// slots — which holds `${…}` of the shell's own that no mesh pass may touch either.
|
||||||
|
func zshHolder() Manifest {
|
||||||
|
return Manifest{Module: "zsh", Claims: []Claim{{Name: LoginShellSeat, Scope: ScopeNode}},
|
||||||
|
Resources: []map[string]any{
|
||||||
|
{"id": "zshrc", "type": "file", "path": "${machine:account-home}/.zshrc", "content": "" +
|
||||||
|
"${shell:zsh:first}" +
|
||||||
|
"PROMPT='%n@%m ${PWD/#$HOME/~} '\n" +
|
||||||
|
"${shell:zsh:normal}" +
|
||||||
|
"alias ll='ls -l'\n" +
|
||||||
|
"${shell:zsh:last}"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The case the ordering exists for: contributed zsh code full of `${…}` reaches the file byte for
|
||||||
|
// byte, because the shell's code is placed after every other placeholder pass and in one pass — a
|
||||||
|
// scanner for the mesh's placeholders that ran after it would read `${XDG_CACHE_HOME:-…}` and
|
||||||
|
// `${(%):-%n}` as the mesh's, or fill a `${machine:…}` some module wrote for its shell to see.
|
||||||
|
func TestShellCodeReachesTheHoldersFileByteForByte(t *testing.T) {
|
||||||
|
modules := append(shells(), zshHolder(), Manifest{Module: "sly", Shell: []ShellCode{
|
||||||
|
{For: "zsh", Slot: "last", Code: "echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}"},
|
||||||
|
}})
|
||||||
|
r := Resolution{Node: "workstation", Account: "op", Modules: modules}
|
||||||
|
out, err := r.Declaration(Rendering{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var zshrc map[string]any
|
||||||
|
for _, res := range out {
|
||||||
|
if res["id"] == "zsh.zshrc" {
|
||||||
|
zshrc = res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if zshrc == nil {
|
||||||
|
t.Fatalf("the holder's file was not composed: %v", out)
|
||||||
|
}
|
||||||
|
if zshrc["path"] != "/home/op/.zshrc" {
|
||||||
|
t.Fatalf("the holder's own placeholders were not filled first: %v", zshrc["path"])
|
||||||
|
}
|
||||||
|
want := "# powerlevel10k\n" +
|
||||||
|
"if [[ -r \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\" ]]; then\n" +
|
||||||
|
" source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\"\nfi\n" +
|
||||||
|
"PROMPT='%n@%m ${PWD/#$HOME/~} '\n" +
|
||||||
|
"# powerlevel10k\n" +
|
||||||
|
"source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n" +
|
||||||
|
"[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh\n" +
|
||||||
|
"# zsh-autosuggestions\n" +
|
||||||
|
"source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n" +
|
||||||
|
"alias ll='ls -l'\n" +
|
||||||
|
"# sly\n" +
|
||||||
|
"echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}\n" +
|
||||||
|
"# zsh-syntax-highlighting\n" +
|
||||||
|
"source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n"
|
||||||
|
if got := zshrc["content"]; got != want {
|
||||||
|
t.Fatalf("the holder's .zshrc is\n%s\nnot\n%s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The holder of node-environment places both renderings, and they are the same as rendered alone.
|
||||||
|
func TestTheEnvironmentHolderPlacesBothRenderings(t *testing.T) {
|
||||||
|
holder := Manifest{Module: "node-env", Claims: []Claim{{Name: EnvironmentSeat, Scope: ScopeNode}},
|
||||||
|
Resources: []map[string]any{
|
||||||
|
{"id": "posix", "type": "file", "path": "${machine:account-home}/.config/mesh/environment.sh",
|
||||||
|
"content": "# The mesh's environment.\n${environment:posix}"},
|
||||||
|
{"id": "systemd", "type": "file", "path": "${machine:account-home}/.config/environment.d/50-mesh.conf",
|
||||||
|
"content": "${environment:systemd}"},
|
||||||
|
}}
|
||||||
|
r := Resolution{Node: "workstation", Account: "op", Modules: append(contributors(), holder)}
|
||||||
|
out, err := r.Declaration(Rendering{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
by := map[string]any{}
|
||||||
|
for _, res := range out {
|
||||||
|
by[res["id"].(string)] = res["content"]
|
||||||
|
}
|
||||||
|
if by["node-env.posix"] != "# The mesh's environment.\n"+composedPOSIX {
|
||||||
|
t.Fatalf("the POSIX file is\n%v", by["node-env.posix"])
|
||||||
|
}
|
||||||
|
if by["node-env.systemd"] != composedSystemd {
|
||||||
|
t.Fatalf("the environment.d file is\n%v", by["node-env.systemd"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR 0203 §5 and ADR 0204 §3: a placeholder outside the seat's holder is refused — by the parser,
|
||||||
|
// which is what the catalogue check and registration run, and again at composition, in the same words.
|
||||||
|
func TestAPlaceholderOutsideTheHolderIsRefused(t *testing.T) {
|
||||||
|
for _, c := range []struct{ content, want string }{
|
||||||
|
{"${environment:posix}", "toolchain's resource rc names ${environment:posix} and toolchain does not claim node-environment"},
|
||||||
|
{"${shell:zsh:normal}", "toolchain's resource rc names ${shell:zsh:normal} and toolchain does not claim node-login-shell"},
|
||||||
|
} {
|
||||||
|
raw := `{"module":"toolchain","resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}`
|
||||||
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Errorf("the catalogue check accepted %s outside its holder: %v", c.content, err)
|
||||||
|
}
|
||||||
|
m := Manifest{Module: "toolchain", Resources: []map[string]any{
|
||||||
|
{"id": "rc", "type": "file", "path": "/etc/rc", "content": c.content}}}
|
||||||
|
r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{m}}
|
||||||
|
if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Errorf("composition accepted %s outside its holder: %v", c.content, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A key nobody renders is refused, not left in the file as a literal.
|
||||||
|
func TestAnUnknownPlaceholderKeyIsRefused(t *testing.T) {
|
||||||
|
for _, c := range []struct{ content, want string }{
|
||||||
|
{"${environment:foo}", "names ${environment:foo}; the environment is ${environment:posix} or ${environment:systemd}"},
|
||||||
|
{"${shell:zsh:middle}", "names ${shell:zsh:middle}; shell code is ${shell:<shell>:<slot>}"},
|
||||||
|
{"${shell:tcsh:first}", "names ${shell:tcsh:first}; shell code is ${shell:<shell>:<slot>}"},
|
||||||
|
{"${shell:zsh}", "names ${shell:zsh}; shell code is ${shell:<shell>:<slot>}"},
|
||||||
|
} {
|
||||||
|
raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"},{"name":"node-login-shell","scope":"node"}],` +
|
||||||
|
`"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}`
|
||||||
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Errorf("%s was accepted: %v", c.content, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And outside a file's content, where nothing could be placed.
|
||||||
|
raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"}],` +
|
||||||
|
`"resources":[{"id":"rc","type":"file","path":"/etc/${environment:posix}","content":"x"}]}`
|
||||||
|
if _, err := ParseManifest([]byte(raw)); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "names ${environment:posix} in its path; the environment and the shell's code are placed only in a file's content") {
|
||||||
|
t.Errorf("a placeholder in a path was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What ADR 0203 §2 allows a contribution to say, refused at parse when it says anything else.
|
||||||
|
func TestAMalformedEnvironmentIsRefusedAtParse(t *testing.T) {
|
||||||
|
for _, c := range []struct{ environment, want string }{
|
||||||
|
{`{"variables":{"1X":"a"}}`, `tool sets the variable "1X", which is not a name a shell accepts`},
|
||||||
|
{`{"variables":{"MY-VAR":"a"}}`, `tool sets the variable "MY-VAR", which is not a name a shell accepts`},
|
||||||
|
{`{"variables":{"PATH":"/bin"}}`, `tool sets PATH as a variable; a module adds an entry under environment.path`},
|
||||||
|
{`{"variables":{"A":"$HOME/x"}}`, `tool sets A to "$HOME/x", which holds a $ that is not one of the machine's ${machine:…} facts`},
|
||||||
|
{`{"variables":{"A":"${HOME}/x"}}`, `tool sets A to "${HOME}/x", which holds a $`},
|
||||||
|
{`{"variables":{"A":"it's"}}`, `tool sets A to "it's", which holds a quote`},
|
||||||
|
{`{"variables":{"A":"say \"hi\""}}`, `which holds a quote`},
|
||||||
|
{`{"variables":{"A":"a\\b"}}`, `which holds a backslash`},
|
||||||
|
{`{"variables":{"A":"a\nb"}}`, `which holds a line break`},
|
||||||
|
{`{"variables":{"A":"a\u0000b"}}`, `which holds a NUL`},
|
||||||
|
{`{"path":[{"entry":"","at":"start"}]}`, `tool's PATH entry 1 names no directory`},
|
||||||
|
{`{"path":[{"entry":"/a:/b","at":"start"}]}`, `tool puts "/a:/b" on PATH, which holds a colon`},
|
||||||
|
{`{"path":[{"entry":"$HOME/bin","at":"start"}]}`, `tool puts "$HOME/bin" on PATH, which holds a $`},
|
||||||
|
{`{"path":[{"entry":"/a","at":"middle"}]}`, `tool puts "/a" on PATH at "middle"; an entry goes at "start" or "end"`},
|
||||||
|
{`{"path":[{"entry":"/a"}]}`, `tool puts "/a" on PATH at ""`},
|
||||||
|
{`{"path":[{"entry":"/a","at":"start"},{"entry":"/a","at":"end"}]}`, `tool puts "/a" on PATH twice`},
|
||||||
|
{`{"variables":{"A":"x"},"paths":[]}`, `unknown field "paths"`},
|
||||||
|
} {
|
||||||
|
_, err := ParseManifest([]byte(`{"module":"tool","environment":` + c.environment + `}`))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Errorf("%s: want %q, got %v", c.environment, c.want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// What is allowed: a literal, and the machine's own facts.
|
||||||
|
if _, err := ParseManifest([]byte(`{"module":"tool","environment":{` +
|
||||||
|
`"variables":{"GOPATH":"${machine:account-home}/go","DISABLE_X":"1","ANSWER":"a b+c=d"},` +
|
||||||
|
`"path":[{"entry":"${machine:account-home}/go/bin","at":"start"},{"entry":"/opt/x","at":"end"}]}}`)); err != nil {
|
||||||
|
t.Fatalf("a well-formed environment was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMalformedShellCodeIsRefusedAtParse(t *testing.T) {
|
||||||
|
for _, c := range []struct{ shell, want string }{
|
||||||
|
{`[{"for":"tcsh","slot":"normal","code":"x"}]`, `tool's shell code 1 is for "tcsh"; the shells are zsh, bash, fish`},
|
||||||
|
{`[{"for":"zsh","slot":"middle","code":"x"}]`, `tool's shell code 1 goes in the slot "middle"; the slots are first, normal, last`},
|
||||||
|
{`[{"for":"zsh","slot":"last","code":"x"},{"for":"zsh","slot":"last","code":" \n"}]`, `tool's shell code 2 has no code`},
|
||||||
|
{`[{"for":"zsh","slot":"last","code":"x","order":1}]`, `unknown field "order"`},
|
||||||
|
} {
|
||||||
|
_, err := ParseManifest([]byte(`{"module":"tool","shell":` + c.shell + `}`))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Errorf("%s: want %q, got %v", c.shell, c.want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The code itself is never judged: a shell's own `${…}` is not the mesh's.
|
||||||
|
if _, err := ParseManifest([]byte(`{"module":"tool","shell":[{"for":"zsh","slot":"first",` +
|
||||||
|
`"code":"source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\""}]}`)); err != nil {
|
||||||
|
t.Fatalf("shell code was judged as if it were the mesh's: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR 0203 §1 and ADR 0204 §1: both seats are the mesh's own, held once per machine; the login
|
||||||
|
// shell's contract is `execute`, described and with a schema an agent can call.
|
||||||
|
func TestTheSeatTableCarriesTheEnvironmentAndTheLoginShell(t *testing.T) {
|
||||||
|
env, ok := SeatNamed("node-environment")
|
||||||
|
if !ok || env.Scope != ScopeNode || env.Decision != "novox/hq ADR 0203" ||
|
||||||
|
len(env.Serves)+len(env.Accepts)+len(env.Emits) != 0 || env.Delivers != "" {
|
||||||
|
t.Fatalf("node-environment is not a node seat with no protocol: %+v (defined %v)", env, ok)
|
||||||
|
}
|
||||||
|
shell, ok := SeatNamed("node-login-shell")
|
||||||
|
if !ok || shell.Scope != ScopeNode || shell.Decision != "novox/hq ADR 0204" {
|
||||||
|
t.Fatalf("node-login-shell is not a node seat: %+v (defined %v)", shell, ok)
|
||||||
|
}
|
||||||
|
if len(shell.Serves) != 1 || shell.Serves[0].Name != "execute" || shell.Serves[0].Description == "" {
|
||||||
|
t.Fatalf("the login shell serves %+v, not execute alone", shell.Serves)
|
||||||
|
}
|
||||||
|
props, _ := shell.Serves[0].Input["properties"].(map[string]any)
|
||||||
|
required, _ := shell.Serves[0].Input["required"].([]string)
|
||||||
|
if _, has := props["command"]; !has || len(required) != 1 || required[0] != "command" {
|
||||||
|
t.Fatalf("execute does not require a command: %v", shell.Serves[0].Input)
|
||||||
|
}
|
||||||
|
if _, has := props["timeout_seconds"]; !has {
|
||||||
|
t.Fatalf("execute takes no timeout: %v", props)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR 0204 §1: the login shell is the mesh's, so no module declares it — neither under the mesh's
|
||||||
|
// name nor under the name a module gave it before.
|
||||||
|
func TestNoModuleMayDeclareTheLoginShell(t *testing.T) {
|
||||||
|
for _, n := range []string{"login-shell", "node-login-shell", "node-environment"} {
|
||||||
|
raw := `{"module":"zsh","seats":[{"name":"` + n + `","scope":"node","serves":["execute"]}],"tools":["execute"]}`
|
||||||
|
_, err := ParseManifest([]byte(raw))
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("a module declaring %q was accepted", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got := strings.Join(declaredSeatProblems(Manifest{Module: "zsh",
|
||||||
|
DefinesSeats: []SeatDeclaration{{Name: "login-shell", Scope: ScopeNode}}}), "; ")
|
||||||
|
if !strings.Contains(got, `zsh declares a seat named "login-shell"; the login shell is the mesh's own seat node-login-shell`) {
|
||||||
|
t.Fatalf("declaring login-shell was not refused by name: %q", got)
|
||||||
|
}
|
||||||
|
// And a shell module claiming the mesh's seat, serving execute, is what the seat is for.
|
||||||
|
m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],` +
|
||||||
|
`"claims":[{"name":"node-login-shell","scope":"node"}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := CanHold(m, Seat{Name: LoginShellSeat, Scope: ScopeNode, Serves: loginShellVerbs()}); err != nil {
|
||||||
|
t.Fatalf("a shell module claiming the seat cannot hold it: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -170,7 +171,7 @@ func TestTheForgeHoldsTheNpmAndGitSeats(t *testing.T) {
|
|||||||
// **And the forge's own address follows it**, composed from the manifest in the catalogue beside
|
// **And the forge's own address follows it**, composed from the manifest in the catalogue beside
|
||||||
// this checkout (novox/hq 04-ISSUES/088).
|
// this checkout (novox/hq 04-ISSUES/088).
|
||||||
//
|
//
|
||||||
// The forge is reached a third way that neither test above covers: by its own sidecar, over the
|
// The forge is reached a third way that neither test above covers: by its own code, over the
|
||||||
// machine's loopback, told where to go in its environment. The `2999:3000` mapping that lets the
|
// machine's loopback, told where to go in its environment. The `2999:3000` mapping that lets the
|
||||||
// forge go on binding 3000 does nothing for a caller dialling the machine — so a literal there is
|
// forge go on binding 3000 does nothing for a caller dialling the machine — so a literal there is
|
||||||
// wrong on every node whose assignment differs, and wrong for a second reason on a node given the
|
// wrong on every node whose assignment differs, and wrong for a second reason on a node given the
|
||||||
@@ -178,13 +179,13 @@ func TestTheForgeHoldsTheNpmAndGitSeats(t *testing.T) {
|
|||||||
// in an `env` at all is a declaration, not a manifest.
|
// in an `env` at all is a declaration, not a manifest.
|
||||||
func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) {
|
func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) {
|
||||||
forge, err := catalogueManifest(t, "gitea").Resolve([]Built{{
|
forge, err := catalogueManifest(t, "gitea").Resolve([]Built{{
|
||||||
Name: "runtime", Kind: ArtifactImage,
|
Name: "code", Kind: ArtifactBundle,
|
||||||
Reference: "registry.example/gitea-runtime@sha256:" + strings.Repeat("a", 64),
|
Reference: ArtifactStoreScheme + "gitea/code/blobs/" + bundleDigest, Digest: bundleDigest,
|
||||||
}})
|
}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("the forge's manifest does not resolve against its own build: %v", err)
|
t.Fatalf("the forge's manifest does not resolve against its own build: %v", err)
|
||||||
}
|
}
|
||||||
r := Resolution{Node: "anchor", Modules: []Manifest{forge}, Needs: []Needed{
|
r := Resolution{Node: "anchor", Modules: []Manifest{forge, theRuntime(t)}, Needs: []Needed{
|
||||||
{Name: "postgres-database", For: "gitea", From: "anchor", At: "127.0.0.1",
|
{Name: "postgres-database", For: "gitea", From: "anchor", At: "127.0.0.1",
|
||||||
Serves: map[string]any{"port": float64(5432)}, Sealed: "sealed-db"},
|
Serves: map[string]any{"port": float64(5432)}, Sealed: "sealed-db"},
|
||||||
{Name: "route", For: "gitea", From: "anchor"},
|
{Name: "route", For: "gitea", From: "anchor"},
|
||||||
@@ -194,8 +195,8 @@ func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) {
|
|||||||
|
|
||||||
// The number this node was given for the forge — the one the machine it is about to run on
|
// The number this node was given for the forge — the one the machine it is about to run on
|
||||||
// already publishes.
|
// already publishes.
|
||||||
out, err := r.Declaration(Rendering{
|
out, err := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101",
|
||||||
Needed: map[string]map[string]string{"gitea": {"broker": "sealed-broker"}},
|
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-broker"}},
|
||||||
Given: map[string]map[int]int{"gitea": {3000: 2999}},
|
Given: map[string]map[int]int{"gitea": {3000: 2999}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -210,14 +211,19 @@ func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) {
|
|||||||
if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "2999:3000") {
|
if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "2999:3000") {
|
||||||
t.Fatalf("the forge is not published on the port this node gave it: %v", server["ports"])
|
t.Fatalf("the forge is not published on the port this node gave it: %v", server["ports"])
|
||||||
}
|
}
|
||||||
runtime := fileNamed(out, "gitea.runtime")
|
// The forge's own code runs in the node's runtime (novox/hq ADR 0198), given its words there.
|
||||||
|
runtime := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||||
if runtime == nil {
|
if runtime == nil {
|
||||||
t.Fatalf("the forge's sidecar is not in the declaration: %v", out)
|
t.Fatalf("the node's runtime is not in the declaration: %v", ids(out))
|
||||||
}
|
}
|
||||||
env, _ := runtime["env"].(map[string]any)
|
env, _ := runtime["env"].(map[string]string)
|
||||||
if env["MESH_GITEA_URL"] != "http://127.0.0.1:2999" {
|
var given map[string]map[string]string
|
||||||
t.Fatalf("the forge's sidecar dials %v while the machine publishes the forge on 2999 — "+
|
if err := json.Unmarshal([]byte(env[RuntimeToolEnv]), &given); err != nil {
|
||||||
"whatever reads it dials a dead port", env["MESH_GITEA_URL"])
|
t.Fatalf("the runtime's %s is not JSON: %q", RuntimeToolEnv, env[RuntimeToolEnv])
|
||||||
|
}
|
||||||
|
if given["gitea"]["MESH_GITEA_URL"] != "http://127.0.0.1:2999" {
|
||||||
|
t.Fatalf("the forge's code dials %v while the machine publishes the forge on 2999 — "+
|
||||||
|
"whatever reads it dials a dead port", given["gitea"]["MESH_GITEA_URL"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,13 +235,13 @@ func declaredGiteaSsh(t *testing.T, given map[int]int) map[string]any {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
forge := catalogueManifest(t, "gitea")
|
forge := catalogueManifest(t, "gitea")
|
||||||
resolved, err := forge.Resolve([]Built{{
|
resolved, err := forge.Resolve([]Built{{
|
||||||
Name: "runtime", Kind: ArtifactImage,
|
Name: "code", Kind: ArtifactBundle,
|
||||||
Reference: "registry.example/gitea-runtime@sha256:" + strings.Repeat("a", 64),
|
Reference: ArtifactStoreScheme + "gitea/code/blobs/" + bundleDigest, Digest: bundleDigest,
|
||||||
}})
|
}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("the forge's manifest does not resolve against its own build: %v", err)
|
t.Fatalf("the forge's manifest does not resolve against its own build: %v", err)
|
||||||
}
|
}
|
||||||
r := Resolution{Node: "anchor", Modules: []Manifest{resolved}, Needs: []Needed{
|
r := Resolution{Node: "anchor", Modules: []Manifest{resolved, theRuntime(t)}, Needs: []Needed{
|
||||||
{Name: "postgres-database", For: "gitea", From: "anchor", At: "127.0.0.1",
|
{Name: "postgres-database", For: "gitea", From: "anchor", At: "127.0.0.1",
|
||||||
Serves: map[string]any{"port": float64(5432)}, Sealed: "sealed-db"},
|
Serves: map[string]any{"port": float64(5432)}, Sealed: "sealed-db"},
|
||||||
{Name: "route", For: "gitea", From: "anchor"},
|
{Name: "route", For: "gitea", From: "anchor"},
|
||||||
@@ -246,8 +252,8 @@ func declaredGiteaSsh(t *testing.T, given map[int]int) map[string]any {
|
|||||||
for k, v := range given {
|
for k, v := range given {
|
||||||
givenPorts[k] = v
|
givenPorts[k] = v
|
||||||
}
|
}
|
||||||
out, err := r.Declaration(Rendering{
|
out, err := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101",
|
||||||
Needed: map[string]map[string]string{"gitea": {"broker": "sealed-broker"}},
|
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-broker"}},
|
||||||
Ports: map[string]map[int]int{"gitea": givenPorts},
|
Ports: map[string]map[int]int{"gitea": givenPorts},
|
||||||
Given: map[string]map[int]int{"gitea": given},
|
Given: map[string]map[int]int{"gitea": given},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq issue 223: genesis raises the controller as a container built from this repository's own
|
||||||
|
// Dockerfile, with no build arguments — the manifest no longer builds an image, so nothing passes a
|
||||||
|
// base in. The Dockerfile's own default must therefore be a Go that builds this module, pinned by
|
||||||
|
// digest, and the replacement the manifest's process names must be the container genesis raises.
|
||||||
|
func TestGenesisCanBuildTheControllersImageAsItStands(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../Dockerfile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(`(?m)^ARG GO_BASE=golang@sha256:[0-9a-f]{64}$`).Match(raw) {
|
||||||
|
t.Fatal("the Dockerfile's default Go base is not pinned by digest; genesis builds it with no arguments")
|
||||||
|
}
|
||||||
|
makefile, err := os.ReadFile("../../Makefile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pin := regexp.MustCompile(`golang@sha256:[0-9a-f]{64}`)
|
||||||
|
if string(pin.Find(raw)) != string(pin.Find(makefile)) {
|
||||||
|
t.Errorf("the Dockerfile and the Makefile build on different Go: %s, %s", pin.Find(raw), pin.Find(makefile))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A grant secret is read by whatever provisions, and that stopped being root (novox/hq issue 225).
|
||||||
|
//
|
||||||
|
// The mesh seals one credential per consumer beside the provider's contributions file. The
|
||||||
|
// provider's harness reads both: the file to learn who asked, the secret to set their password.
|
||||||
|
// While a module's own code ran in a container as root, a root-owned 0600 file was readable by
|
||||||
|
// the thing that needed it. ADR 0198 moved that code under the node's runtime, which runs as the
|
||||||
|
// operator's account — and the secret stayed root's.
|
||||||
|
//
|
||||||
|
// **The cost was silence.** The harness says `secret not readable yet`, which is true and
|
||||||
|
// ordinary on the first pass, so four thousand refusals in three hours read as patience. No user
|
||||||
|
// was ever created, and two consumers crash-looped against a database that had never heard of
|
||||||
|
// them.
|
||||||
|
//
|
||||||
|
// The same reasoning is already written for a module's *own* secrets, three hundred lines above:
|
||||||
|
// "a root-owned 0600 file is one that process cannot read". This is that rule reaching the other
|
||||||
|
// kind of secret the mesh writes for a module.
|
||||||
|
|
||||||
|
// aProviderWithABundle is a provider whose code is a bundle the node's runtime runs — the shape
|
||||||
|
// every TypeScript provisioner has since ADR 0198.
|
||||||
|
func aProviderWithABundle() Manifest {
|
||||||
|
return Manifest{
|
||||||
|
Module: "mongodb", Version: "1",
|
||||||
|
Provides: FromAnywhere("mongodb-database"),
|
||||||
|
Receives: map[string]string{"mongodb-database": "/var/lib/mongodb/grants/mesh.json"},
|
||||||
|
Grants: map[string]string{"mongodb-database": "/var/lib/mongodb/grants"},
|
||||||
|
Bundles: []Bundle{{Name: "code", Language: "typescript"}},
|
||||||
|
Resources: []map[string]any{{
|
||||||
|
"id": "server", "type": "container", "name": "mongodb-server",
|
||||||
|
"image": "mongo@sha256:" + strings.Repeat("a", 64),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAGrantSecretIsOwnedByTheAccountThatProvisions(t *testing.T) {
|
||||||
|
r, err := Resolve(shelf(aProviderWithABundle()), []string{"mongodb"}, reachable(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r.Account = "operator"
|
||||||
|
out, err := r.Declaration(Rendering{Grants: []Grant{{
|
||||||
|
Provision: "mongodb-database", Consumer: "workstation", From: "photos", Slug: "photos",
|
||||||
|
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var secret map[string]any
|
||||||
|
for _, res := range out {
|
||||||
|
if res["type"] == "file" && strings.HasSuffix(fmtPath(res), ".secret") {
|
||||||
|
secret = res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if secret == nil {
|
||||||
|
t.Fatalf("no grant secret was composed at all: %v", out)
|
||||||
|
}
|
||||||
|
if got := secret["owner"]; got != "operator" {
|
||||||
|
t.Fatalf("the grant secret at %v belongs to %v; the provisioner runs as %q and a "+
|
||||||
|
"root-owned 0600 file is one it cannot read — which is silent, because the harness "+
|
||||||
|
"calls it \"not readable yet\"", fmtPath(secret), got, "operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a provider whose code still runs in a container keeps the owner it declares, so this
|
||||||
|
// changes nothing for the modules the runtime has not taken.
|
||||||
|
func TestAContainerProvidersGrantSecretKeepsItsDeclaredOwner(t *testing.T) {
|
||||||
|
m := aProviderWithABundle()
|
||||||
|
m.Bundles = nil
|
||||||
|
m.SecretsOwner = "65534:65534"
|
||||||
|
r, err := Resolve(shelf(m), []string{"mongodb"}, reachable(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r.Account = "operator"
|
||||||
|
out, err := r.Declaration(Rendering{Grants: []Grant{{
|
||||||
|
Provision: "mongodb-database", Consumer: "workstation", From: "photos", Slug: "photos",
|
||||||
|
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, res := range out {
|
||||||
|
if res["type"] == "file" && strings.HasSuffix(fmtPath(res), ".secret") {
|
||||||
|
if got := res["owner"]; got != "65534:65534" {
|
||||||
|
t.Fatalf("a container provider's grant secret belongs to %v, not what it declares", got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("no grant secret was composed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtPath(r map[string]any) string {
|
||||||
|
p, _ := r["path"].(string)
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
// The graphical session's seats (novox/hq ADR 0208): one module per piece of software, each piece's
|
||||||
|
// role a node seat in the mesh's own set, so i3 and sway, xterm and foot, rofi and dmenu compete for
|
||||||
|
// a role rather than each inventing one — and a machine running two of one role is refused at
|
||||||
|
// assignment instead of found by two bars on one screen.
|
||||||
|
const (
|
||||||
|
LoginManagerSeat = "node-login-manager"
|
||||||
|
DisplayServerSeat = "node-display-server"
|
||||||
|
DisplaySessionSeat = "node-display-session"
|
||||||
|
TerminalEmulatorSeat = "node-terminal-emulator"
|
||||||
|
LauncherSeat = "node-launcher"
|
||||||
|
NotifierSeat = "node-notifier"
|
||||||
|
LockScreenSeat = "node-lock-screen"
|
||||||
|
ClipboardSeat = "node-clipboard"
|
||||||
|
BarSeat = "node-bar"
|
||||||
|
CompositorSeat = "node-compositor"
|
||||||
|
SecretServiceSeat = "node-secret-service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// graphicalSessionSeats are the eleven, in the order ADR 0208's table reads, each with the verbs
|
||||||
|
// research 026/05 starts it with. Three have none yet: the bar, the compositor and the secret
|
||||||
|
// service are roles a second holder competes for, and nothing has needed to ask them anything.
|
||||||
|
func graphicalSessionSeats() []Seat {
|
||||||
|
const decided = "novox/hq ADR 0208"
|
||||||
|
return []Seat{
|
||||||
|
{Name: LoginManagerSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||||
|
{Name: "sessions", Description: "The sessions the login manager offers on this machine, and which " +
|
||||||
|
"one the operator account starts by default.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
}},
|
||||||
|
{Name: DisplayServerSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||||
|
{Name: "displays", Description: "The monitors connected now, each with its identity, its modes and " +
|
||||||
|
"where it is placed; and the layout profile in force, if one matches.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
// Profiles are keyed by the monitors' identities and are the operator's data (ADR 0208 §6).
|
||||||
|
{Name: "layout", Description: "The monitor layout profiles, keyed by the connected monitors' " +
|
||||||
|
"identities: list them, save the current arrangement under a name, or apply one.",
|
||||||
|
Input: withEnum(schema(map[string]string{
|
||||||
|
"action": "list, save or apply",
|
||||||
|
"name": "the profile to save or apply (save and apply only)",
|
||||||
|
}, []string{"action"}), "action", "list", "save", "apply")},
|
||||||
|
}},
|
||||||
|
// It receives window-manager configuration lines from every other module (novox/hq ADR 0212):
|
||||||
|
// bindings, start-up commands, rules — placed by the session's holder, never written into
|
||||||
|
// its directory by the contributor.
|
||||||
|
{Name: DisplaySessionSeat, Scope: ScopeNode, Decision: decided,
|
||||||
|
Receives: []Receivable{{Kind: "config", Comment: "#"}}, Serves: []Verb{
|
||||||
|
{Name: "reload", Description: "Reload the session's configuration in place, keeping its windows.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
{Name: "workspaces", Description: "The session's workspaces: each one's name, output, and whether " +
|
||||||
|
"it is visible or focused.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
{Name: "windows", Description: "The session's windows: each one's title, class, workspace and " +
|
||||||
|
"whether it has focus; narrowed to one workspace when named.",
|
||||||
|
Input: schema(map[string]string{"workspace": "one workspace (optional)"}, nil)},
|
||||||
|
}},
|
||||||
|
{Name: TerminalEmulatorSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||||
|
{Name: "open", Description: "Open a terminal window in the operator's session, running a command " +
|
||||||
|
"or the login shell, in a directory or the account's home.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"command": "what to run in it (optional; the login shell when absent)",
|
||||||
|
"directory": "where it starts (optional; the account's home when absent)",
|
||||||
|
}, nil)},
|
||||||
|
}},
|
||||||
|
{Name: LauncherSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||||
|
{Name: "menu", Description: "Put a menu of choices in front of the operator and answer with the " +
|
||||||
|
"one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.",
|
||||||
|
Input: map[string]any{"type": "object", "required": []string{"choices"},
|
||||||
|
"properties": map[string]any{
|
||||||
|
"choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||||
|
"description": "the lines to choose between, in order"},
|
||||||
|
"prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"},
|
||||||
|
}}},
|
||||||
|
}},
|
||||||
|
{Name: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||||
|
{Name: "send", Description: "Show the operator a notification.",
|
||||||
|
Input: withEnum(schema(map[string]string{
|
||||||
|
"title": "the notification's summary",
|
||||||
|
"body": "its text (optional)",
|
||||||
|
"urgency": "low, normal (the default) or critical",
|
||||||
|
}, []string{"title"}), "urgency", "low", "normal", "critical")},
|
||||||
|
{Name: "history", Description: "The notifications shown lately, newest first.",
|
||||||
|
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
|
||||||
|
}},
|
||||||
|
{Name: LockScreenSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||||
|
{Name: "lock", Description: "Lock the operator's session now.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
}},
|
||||||
|
{Name: ClipboardSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||||
|
{Name: "history", Description: "What the clipboard held lately, newest first.",
|
||||||
|
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
|
||||||
|
{Name: "copy", Description: "Put text on the operator's clipboard.",
|
||||||
|
Input: schema(map[string]string{"text": "the text"}, []string{"text"})},
|
||||||
|
}},
|
||||||
|
{Name: BarSeat, Scope: ScopeNode, Decision: decided},
|
||||||
|
{Name: CompositorSeat, Scope: ScopeNode, Decision: decided},
|
||||||
|
{Name: SecretServiceSeat, Scope: ScopeNode, Decision: decided},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withEnum narrows one string property of a schema to the values it may take, so a caller is told
|
||||||
|
// the choices by the schema rather than by a refusal.
|
||||||
|
func withEnum(s map[string]any, property string, values ...string) map[string]any {
|
||||||
|
props := s["properties"].(map[string]any)
|
||||||
|
p := props[property].(map[string]any)
|
||||||
|
p["enum"] = values
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defends novox/hq ADR 0208: the graphical session is one module per piece, on the mesh's seats.
|
||||||
|
|
||||||
|
// §2: the eleven roles are the mesh's own node seats, each with the verbs it starts with.
|
||||||
|
func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) {
|
||||||
|
want := map[string][]string{
|
||||||
|
LoginManagerSeat: {"sessions"},
|
||||||
|
DisplayServerSeat: {"displays", "layout"},
|
||||||
|
DisplaySessionSeat: {"reload", "workspaces", "windows"},
|
||||||
|
TerminalEmulatorSeat: {"open"},
|
||||||
|
LauncherSeat: {"menu"},
|
||||||
|
NotifierSeat: {"send", "history"},
|
||||||
|
LockScreenSeat: {"lock"},
|
||||||
|
ClipboardSeat: {"history", "copy"},
|
||||||
|
BarSeat: nil,
|
||||||
|
CompositorSeat: nil,
|
||||||
|
SecretServiceSeat: nil,
|
||||||
|
}
|
||||||
|
for name, verbs := range want {
|
||||||
|
s, ok := SeatNamed(name)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s is not in the mesh's set", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.Scope != ScopeNode || s.Decision != "novox/hq ADR 0208" {
|
||||||
|
t.Errorf("%s is %s-scoped under %q", name, s.Scope, s.Decision)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for _, v := range s.Serves {
|
||||||
|
got = append(got, v.Name)
|
||||||
|
if v.Description == "" || v.Input["type"] != "object" {
|
||||||
|
t.Errorf("%s.%s has no description or no object schema", name, v.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, verbs) {
|
||||||
|
t.Errorf("%s serves %v, want %v", name, got, verbs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The launcher's menu takes a list, and the layout verb says its actions.
|
||||||
|
menu, _ := SeatNamed(LauncherSeat)
|
||||||
|
choices := menu.Serves[0].Input["properties"].(map[string]any)["choices"].(map[string]any)
|
||||||
|
if choices["type"] != "array" {
|
||||||
|
t.Errorf("menu's choices are %v, not a list", choices["type"])
|
||||||
|
}
|
||||||
|
display, _ := SeatNamed(DisplayServerSeat)
|
||||||
|
action := display.Serves[1].Input["properties"].(map[string]any)["action"].(map[string]any)
|
||||||
|
if !reflect.DeepEqual(action["enum"], []string{"list", "save", "apply"}) {
|
||||||
|
t.Errorf("layout's actions are %v", action["enum"])
|
||||||
|
}
|
||||||
|
// And they survive the store's JSON, which is where the live set comes from.
|
||||||
|
if _, err := json.Marshal(graphicalSessionSeats()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §2: a module may claim one of them, and may not declare it as its own.
|
||||||
|
func TestNoModuleMayDeclareAGraphicalSessionSeat(t *testing.T) {
|
||||||
|
raw := `{"module":"xorg","seats":[{"name":"node-display-server","scope":"node"}]}`
|
||||||
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "mesh's own namespace") {
|
||||||
|
t.Fatalf("a module declared node-display-server as its own: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayServer(name, display string, claims ...string) Manifest {
|
||||||
|
m := Manifest{Module: name, Provides: []Offer{{Name: display, Reach: ReachMachine}}}
|
||||||
|
for _, c := range claims {
|
||||||
|
m.Claims = append(m.Claims, Claim{Name: c})
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowManager() Manifest {
|
||||||
|
return Manifest{Module: "i3", Requires: []string{"x11-display"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §3: a display is resolved on the requiring module's own node.
|
||||||
|
func TestAMachineReachRequirementResolvesToTheProviderOnItsOwnNode(t *testing.T) {
|
||||||
|
cat := shelf(windowManager(), displayServer("xorg", "x11-display", DisplayServerSeat))
|
||||||
|
got, err := Resolve(cat, []string{"xorg", "i3"}, workstation(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("i3 beside xorg did not resolve: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(names(got), []string{"xorg", "i3"}) && !reflect.DeepEqual(names(got), []string{"i3", "xorg"}) {
|
||||||
|
t.Errorf("resolved %v", names(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §3: never answered by installing a provider, and never by another machine's.
|
||||||
|
func TestAMachineReachRequirementIsNotPulledInNorAnsweredFromAnotherNode(t *testing.T) {
|
||||||
|
cat := shelf(windowManager(),
|
||||||
|
displayServer("xorg", "x11-display", DisplayServerSeat),
|
||||||
|
displayServer("xwayland", "x11-display"))
|
||||||
|
// Another machine runs xorg and says so to the world; it does not count.
|
||||||
|
world := World{Offered: map[string][]Provider{
|
||||||
|
"x11-display": {{Node: "laptop", At: "laptop.mesh", Module: "xorg"}}}}
|
||||||
|
_, err := Resolve(cat, []string{"i3"}, workstation(), world)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("i3 resolved on a machine with no display of its own")
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`"x11-display" is wanted by i3`, "usable only on the machine that provides it",
|
||||||
|
"assign one to workstation", "xorg (holds node-display-server)", "xwayland",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("the refusal does not say %q:\n%v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// With a single provider in the catalogue too: one candidate is still not a choice to make
|
||||||
|
// for somebody, unlike a node-scoped provision without the machine's reach.
|
||||||
|
_, err = Resolve(shelf(windowManager(), displayServer("xorg", "x11-display", DisplayServerSeat)),
|
||||||
|
[]string{"i3"}, workstation(), World{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("xorg was pulled in for i3")
|
||||||
|
}
|
||||||
|
// Not in the first pass, whose refusals take the machine off the network.
|
||||||
|
if _, err := Resolve(cat, []string{"i3"}, workstation(), World{Unchecked: true}); err != nil {
|
||||||
|
t.Errorf("the first pass refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheMachinesReachIsAProvisionsOnlyReachAndIsNodeScoped(t *testing.T) {
|
||||||
|
for _, c := range []struct{ provides, want string }{
|
||||||
|
{`{"name":"x11-display","reach":"internal"}`, `with reach "internal"; a provision's reach is "machine" or nothing`},
|
||||||
|
{`{"name":"x11-display","scope":"mesh","reach":"machine"}`, `at scope "mesh" with the machine's reach`},
|
||||||
|
} {
|
||||||
|
_, err := ParseManifest([]byte(`{"module":"xorg","provides":[` + c.provides + `]}`))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Errorf("%s: want %q, got %v", c.provides, c.want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m, err := ParseManifest([]byte(`{"module":"xorg","provides":[{"name":"x11-display","reach":"machine"}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !m.Provides[0].MachineReach() {
|
||||||
|
t.Fatal("the reach was not read")
|
||||||
|
}
|
||||||
|
back, _ := json.Marshal(m.Provides[0])
|
||||||
|
if string(back) != `{"name":"x11-display","reach":"machine"}` {
|
||||||
|
t.Errorf("written back as %s", back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACatalogueDisagreeingAboutAProvisionsReachIsRefused(t *testing.T) {
|
||||||
|
cat := shelf(windowManager(), displayServer("xorg", "x11-display"),
|
||||||
|
Manifest{Module: "fake-x", Provides: Offers("x11-display")})
|
||||||
|
_, err := Resolve(cat, []string{"xorg", "i3"}, workstation(), World{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `the catalogue disagrees about "x11-display"`) {
|
||||||
|
t.Fatalf("a provision with and without the machine's reach gave %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §4: xinitrc and xresources slots, placed by the display server's holder alone.
|
||||||
|
func TestTheSessionsFilesArePlacedByTheDisplayServersHolderAlone(t *testing.T) {
|
||||||
|
xorg := Manifest{Module: "xorg", Claims: []Claim{{Name: DisplayServerSeat}},
|
||||||
|
Shell: []ShellCode{{For: "xinitrc", Slot: "first", Code: "xset s off"}},
|
||||||
|
Resources: []map[string]any{
|
||||||
|
{"id": "xinitrc", "type": "file", "path": "/home/op/.xinitrc",
|
||||||
|
"content": "${shell:xinitrc:first}${shell:xinitrc:normal}${shell:xinitrc:last}"},
|
||||||
|
{"id": "xresources", "type": "file", "path": "/home/op/.Xresources",
|
||||||
|
"content": "${shell:xresources:normal}"},
|
||||||
|
}}
|
||||||
|
i3 := Manifest{Module: "i3", Shell: []ShellCode{{For: "xinitrc", Slot: "last", Code: "exec i3"}}}
|
||||||
|
theme := Manifest{Module: "theme", Shell: []ShellCode{
|
||||||
|
{For: "xresources", Slot: "normal", Code: "Xft.dpi: 96"},
|
||||||
|
{For: "zsh", Slot: "normal", Code: "not for the session"},
|
||||||
|
}}
|
||||||
|
r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{xorg, i3, theme}}
|
||||||
|
out, err := r.Declaration(Rendering{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
by := map[string]any{}
|
||||||
|
for _, res := range out {
|
||||||
|
by[res["id"].(string)] = res["content"]
|
||||||
|
}
|
||||||
|
if got := by["xorg.xinitrc"]; got != "# xorg\nxset s off\n# i3\nexec i3\n" {
|
||||||
|
t.Errorf("the .xinitrc is %q", got)
|
||||||
|
}
|
||||||
|
if got := by["xorg.xresources"]; got != "# theme\nXft.dpi: 96\n" {
|
||||||
|
t.Errorf("the .Xresources is %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The contributions parse; the placeholders parse only in the holder.
|
||||||
|
if _, err := ParseManifest([]byte(`{"module":"i3","shell":[{"for":"xinitrc","slot":"last","code":"exec i3"},` +
|
||||||
|
`{"for":"xresources","slot":"normal","code":"i3.font: x"}]}`)); err != nil {
|
||||||
|
t.Fatalf("a session contribution was refused: %v", err)
|
||||||
|
}
|
||||||
|
for _, c := range []struct{ claims, content, want string }{
|
||||||
|
{``, "${shell:xinitrc:normal}", "does not claim node-display-server; every module's xinitrc is placed by the display server's holder alone"},
|
||||||
|
{`{"name":"node-login-shell"}`, "${shell:xresources:normal}", "does not claim node-display-server"},
|
||||||
|
{`{"name":"node-display-server"}`, "${shell:zsh:normal}", "does not claim node-login-shell"},
|
||||||
|
{`{"name":"node-display-server"}`, "${shell:xsession:normal}", "the session's file one of xinitrc, xresources"},
|
||||||
|
} {
|
||||||
|
raw := `{"module":"holder","claims":[` + c.claims + `],"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` +
|
||||||
|
c.content + `"}]}`
|
||||||
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Errorf("%s with claims [%s]: want %q, got %v", c.content, c.claims, c.want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := ParseManifest([]byte(`{"module":"xorg","claims":[{"name":"node-display-server"}],` +
|
||||||
|
`"resources":[{"id":"rc","type":"file","path":"/home/op/.xinitrc","content":"${shell:xinitrc:last}"}]}`)); err != nil {
|
||||||
|
t.Errorf("the display server's holder could not place the session's slots: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -147,8 +147,17 @@ type Offer struct {
|
|||||||
// shared by every consumer (novox/hq ADR 0158): software that holds one password or one key
|
// shared by every consumer (novox/hq ADR 0158): software that holds one password or one key
|
||||||
// cannot give each consumer a login of its own. The named secret must say how it is taken.
|
// cannot give each consumer a login of its own. The named secret must say how it is taken.
|
||||||
Credential *OfferCredential `json:"credential,omitempty"`
|
Credential *OfferCredential `json:"credential,omitempty"`
|
||||||
|
// Reach is ReachMachine for a provision usable only on the provider's own machine — a display
|
||||||
|
// (novox/hq ADR 0208 §3). Node scope already keeps a provision off other machines; what this adds
|
||||||
|
// is that a requirement for it is never answered by installing a provider: the display server is
|
||||||
|
// a seat's holder gated by the machine's graphical session, and pulling one in for whatever asked
|
||||||
|
// is the misassignment research 026 found. Unmet, the requirement is refused naming who could.
|
||||||
|
Reach string `json:"reach,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MachineReach is whether a provision is usable only on its provider's own machine.
|
||||||
|
func (o Offer) MachineReach() bool { return o.Reach == ReachMachine }
|
||||||
|
|
||||||
// OfferCredential names which of the provider's own secrets a provision's consumers receive.
|
// OfferCredential names which of the provider's own secrets a provision's consumers receive.
|
||||||
type OfferCredential struct {
|
type OfferCredential struct {
|
||||||
Own string `json:"own"`
|
Own string `json:"own"`
|
||||||
@@ -196,27 +205,29 @@ func (o *Offer) UnmarshalJSON(raw []byte) error {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
Credential *OfferCredential `json:"credential,omitempty"`
|
Credential *OfferCredential `json:"credential,omitempty"`
|
||||||
|
Reach string `json:"reach,omitempty"`
|
||||||
}
|
}
|
||||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
dec.DisallowUnknownFields()
|
dec.DisallowUnknownFields()
|
||||||
if err := dec.Decode(&full); err != nil {
|
if err := dec.Decode(&full); err != nil {
|
||||||
return fmt.Errorf("a provided name is either a string or {name, scope, credential}: %w", err)
|
return fmt.Errorf("a provided name is either a string or {name, scope, credential, reach}: %w", err)
|
||||||
}
|
}
|
||||||
o.Name, o.Scope, o.Credential = full.Name, full.Scope, full.Credential
|
o.Name, o.Scope, o.Credential, o.Reach = full.Name, full.Scope, full.Credential, full.Reach
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON writes back the short form when there is nothing else to say, so a manifest that
|
// MarshalJSON writes back the short form when there is nothing else to say, so a manifest that
|
||||||
// went through the mesh comes out looking like the one that went in.
|
// went through the mesh comes out looking like the one that went in.
|
||||||
func (o Offer) MarshalJSON() ([]byte, error) {
|
func (o Offer) MarshalJSON() ([]byte, error) {
|
||||||
if o.Scope == "" && o.Credential == nil {
|
if o.Scope == "" && o.Credential == nil && o.Reach == "" {
|
||||||
return json.Marshal(o.Name)
|
return json.Marshal(o.Name)
|
||||||
}
|
}
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
Credential *OfferCredential `json:"credential,omitempty"`
|
Credential *OfferCredential `json:"credential,omitempty"`
|
||||||
}{o.Name, o.Scope, o.Credential})
|
Reach string `json:"reach,omitempty"`
|
||||||
|
}{o.Name, o.Scope, o.Credential, o.Reach})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manifest is everything a module says about itself.
|
// Manifest is everything a module says about itself.
|
||||||
@@ -325,6 +336,15 @@ type Manifest struct {
|
|||||||
// person's account (design 25 §7) already had the same shape.
|
// person's account (design 25 §7) already had the same shape.
|
||||||
Invokes []string `json:"invokes,omitempty"`
|
Invokes []string `json:"invokes,omitempty"`
|
||||||
|
|
||||||
|
// State is the current state this module keeps on the bus, by local name: each a key-value
|
||||||
|
// bucket the controller creates, which every instance of the module writes and reads
|
||||||
|
// (novox/hq ADR 0201). Not history — that is an event — and never a secret, sealed or not.
|
||||||
|
State []StateDeclaration `json:"state,omitempty"`
|
||||||
|
|
||||||
|
// Reads are other modules' state this module reads and watches, each `<module>.<name>`
|
||||||
|
// (novox/hq ADR 0201). Read-only: only the owner's instances write.
|
||||||
|
Reads []string `json:"reads,omitempty"`
|
||||||
|
|
||||||
// Capabilities the machine must have. A different field from Requires because the remedy
|
// Capabilities the machine must have. A different field from Requires because the remedy
|
||||||
// differs: a missing module can be assigned, and a missing capability means the wrong
|
// differs: a missing module can be assigned, and a missing capability means the wrong
|
||||||
// machine.
|
// machine.
|
||||||
@@ -508,6 +528,22 @@ type Manifest struct {
|
|||||||
// holder. Like Filtering: one module per node gathers what every module declared and writes it.
|
// holder. Like Filtering: one module per node gathers what every module declared and writes it.
|
||||||
Jailing *Jailing `json:"jailing,omitempty"`
|
Jailing *Jailing `json:"jailing,omitempty"`
|
||||||
|
|
||||||
|
// Environment is what this module adds to the operator account's environment: variables, and
|
||||||
|
// entries on PATH (novox/hq ADR 0203). Facts, not lines of one shell's syntax — the holder of
|
||||||
|
// node-environment places them, and the controller writes them in each reader's format. Like
|
||||||
|
// Jails: any module contributes, gathered from every module on the node, written by the holder.
|
||||||
|
Environment *Environment `json:"environment,omitempty"`
|
||||||
|
|
||||||
|
// Shell is code this module adds to the login shell's startup, for a named shell in a named
|
||||||
|
// slot (novox/hq ADR 0204). The controller never reads it: it is placed, in module order, where
|
||||||
|
// the holder of node-login-shell put the slot's placeholder.
|
||||||
|
Shell []ShellCode `json:"shell,omitempty"`
|
||||||
|
|
||||||
|
// Contributions are configuration this module gives the holder of a seat it does not hold, in
|
||||||
|
// that tool's own grammar (novox/hq ADR 0212): a seat, a kind the seat receives, and the text. The
|
||||||
|
// holder places them; the module depends on the seat (ADR 0210 §3).
|
||||||
|
Contributions []SeatContribution `json:"contributions,omitempty"`
|
||||||
|
|
||||||
// Guards are ports of this module's the mesh refuses on an adopted node except from the
|
// Guards are ports of this module's the mesh refuses on an adopted node except from the
|
||||||
// private network and from the machine itself (novox/hq ADR 0100) — the store's port and the
|
// private network and from the machine itself (novox/hq ADR 0100) — the store's port and the
|
||||||
// broker's management port. The ports the software uses; the mesh guards where the machine
|
// broker's management port. The ports the software uses; the mesh guards where the machine
|
||||||
@@ -540,6 +576,10 @@ type Manifest struct {
|
|||||||
// `restart-on` names to restart when the roster changes.
|
// `restart-on` names to restart when the roster changes.
|
||||||
Facts map[string]RosterFile `json:"facts,omitempty"`
|
Facts map[string]RosterFile `json:"facts,omitempty"`
|
||||||
|
|
||||||
|
// Zone is the zone of names this module answers itself, and the listen that answers it (novox/hq
|
||||||
|
// ADR 0199). The mesh's resolver forwards the zone to it; nothing here names an address.
|
||||||
|
Zone *Zone `json:"zone,omitempty"`
|
||||||
|
|
||||||
// Certificate is where this module wants a certificate for its machine's name inside the
|
// Certificate is where this module wants a certificate for its machine's name inside the
|
||||||
// mesh, and where the key that goes with it can be found.
|
// mesh, and where the key that goes with it can be found.
|
||||||
//
|
//
|
||||||
@@ -757,6 +797,11 @@ type Artifact struct {
|
|||||||
// list twice. A module declaring no tools has nothing the runtime loads, whatever it compiles.
|
// list twice. A module declaring no tools has nothing the runtime loads, whatever it compiles.
|
||||||
Loads []string `json:"loads,omitempty"`
|
Loads []string `json:"loads,omitempty"`
|
||||||
|
|
||||||
|
// External are packages a TypeScript bundle keeps as imports rather than inlining — a native
|
||||||
|
// addon, a package that reads its own files — and so carries the toolchain's node_modules for
|
||||||
|
// (novox/hq ADR 0193). Absent for nearly every bundle, which is then one file per entrypoint.
|
||||||
|
External []string `json:"external,omitempty"`
|
||||||
|
|
||||||
// Env is what a tools bundle is given on a machine (novox/hq ADR 0192): words and their values,
|
// Env is what a tools bundle is given on a machine (novox/hq ADR 0192): words and their values,
|
||||||
// paths and constants composed with ${dir:…} and ${port:…} exactly as a container's environment
|
// paths and constants composed with ${dir:…} and ${port:…} exactly as a container's environment
|
||||||
// is, never a secret's content. The node's runtime hands it to this bundle and to no other.
|
// is, never a secret's content. The node's runtime hands it to this bundle and to no other.
|
||||||
@@ -1292,6 +1337,19 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
"%s provides %q at scope %q; a provision is %q or %q",
|
"%s provides %q at scope %q; a provision is %q or %q",
|
||||||
m.Module, p, s, ScopeNode, ScopeMesh))
|
m.Module, p, s, ScopeNode, ScopeMesh))
|
||||||
}
|
}
|
||||||
|
switch {
|
||||||
|
case offer.Reach == "":
|
||||||
|
case offer.Reach != ReachMachine:
|
||||||
|
// The one reach a provision has (novox/hq ADR 0208): a provision reached over the private
|
||||||
|
// network is mesh scope, and the world reaches nothing but a name.
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s provides %q with reach %q; a provision's reach is %q or nothing",
|
||||||
|
m.Module, p, offer.Reach, ReachMachine))
|
||||||
|
case offer.At() != ScopeNode:
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s provides %q at scope %q with the machine's reach; a provision usable only on its own "+
|
||||||
|
"machine is node-scoped (novox/hq ADR 0208)", m.Module, p, offer.At()))
|
||||||
|
}
|
||||||
if p == m.Module {
|
if p == m.Module {
|
||||||
// Harmless and worth saying: a module always provides its own name, so writing it
|
// Harmless and worth saying: a module always provides its own name, so writing it
|
||||||
// suggests the author expected it not to.
|
// suggests the author expected it not to.
|
||||||
@@ -1311,6 +1369,8 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
// module whose event names are wrong installs, starts, connects and reacts to nothing, with
|
// module whose event names are wrong installs, starts, connects and reacts to nothing, with
|
||||||
// every log line saying it is fine (novox/hq 04-ISSUES/127).
|
// every log line saying it is fine (novox/hq 04-ISSUES/127).
|
||||||
problems = append(problems, EventProblems(m)...)
|
problems = append(problems, EventProblems(m)...)
|
||||||
|
// And what it may call its state, and whose it may read (state.go, novox/hq ADR 0201).
|
||||||
|
problems = append(problems, StateProblems(m)...)
|
||||||
wellFormed := true
|
wellFormed := true
|
||||||
for _, c := range m.Claims {
|
for _, c := range m.Claims {
|
||||||
if !name.MatchString(c.Name) {
|
if !name.MatchString(c.Name) {
|
||||||
@@ -1373,6 +1433,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
problems = append(problems, m.Build.problems(m.Module)...)
|
problems = append(problems, m.Build.problems(m.Module)...)
|
||||||
|
problems = append(problems, undeliveredBundles(m)...)
|
||||||
// **What provides the artifact store cannot be delivered through it** (novox/hq 04-ISSUES/029).
|
// **What provides the artifact store cannot be delivered through it** (novox/hq 04-ISSUES/029).
|
||||||
//
|
//
|
||||||
// Building publishes to the store, and the builder will not start without one. So a module
|
// Building publishes to the store, and the builder will not start without one. So a module
|
||||||
@@ -1422,6 +1483,10 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
"%s serves %q to whoever requires it, and does not provide it", m.Module, to))
|
"%s serves %q to whoever requires it, and does not provide it", m.Module, to))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A served value may be derived for the consumer it is served to (novox/hq ADR 0201). Read
|
||||||
|
// here, where the definition is, rather than when somebody first requires it: a rule that
|
||||||
|
// would be refused at the first consumer is wrong from the moment it is written.
|
||||||
|
problems = append(problems, CheckServes(m)...)
|
||||||
for to, where := range m.Binds {
|
for to, where := range m.Binds {
|
||||||
if !placedOrAbsolute(where) {
|
if !placedOrAbsolute(where) {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
@@ -1509,6 +1574,53 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
"program that reads what the mesh delivered and reconciles",
|
"program that reads what the mesh delivered and reconciles",
|
||||||
m.Module, r["id"]))
|
m.Module, r["id"]))
|
||||||
}
|
}
|
||||||
|
// **What a process replaces is something the module no longer declares** (novox/hq issue 213).
|
||||||
|
// The host keeps it running until the process is, then removes it: so it is named by the id the
|
||||||
|
// module used to give it, it is never a resource the module still declares — that would be
|
||||||
|
// applied and removed by one declaration — and only a process that stays up has anything to
|
||||||
|
// hand over to. Said here, near the author, as the host would refuse it far away.
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
ids[fmt.Sprint(r["id"])] = true
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
raw, present := r["replaces"]
|
||||||
|
if !present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fmt.Sprint(r["type"]) != "process" {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s: %v says what it replaces, and only a process does", m.Module, r["id"]))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if once, _ := r["run-once"].(bool); once || r["schedule"] != nil {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s: %v replaces something and runs once or on a schedule — only a process that stays "+
|
||||||
|
"up is there a moment later to hand over to", m.Module, r["id"]))
|
||||||
|
}
|
||||||
|
list, ok := raw.([]any)
|
||||||
|
if !ok {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s: %v replaces %v; replaces is a list of the ids this module no longer declares",
|
||||||
|
m.Module, r["id"], raw))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, item := range list {
|
||||||
|
id, ok := item.(string)
|
||||||
|
switch {
|
||||||
|
case !ok || strings.TrimSpace(id) == "":
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s: %v replaces %v, which is not an id", m.Module, r["id"], item))
|
||||||
|
case strings.Contains(id, "."):
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s: %v replaces %q; a process replaces only a resource of its own module, named "+
|
||||||
|
"by its own id", m.Module, r["id"], id))
|
||||||
|
case ids[id]:
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s: %v replaces %q, which this module still declares", m.Module, r["id"], id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// **A module that prepares its state must have code the mesh can run** (novox/hq ADR 0135). The
|
// **A module that prepares its state must have code the mesh can run** (novox/hq ADR 0135). The
|
||||||
// preparation is the module's own program in its preparation mode, so it is derived from the
|
// preparation is the module's own program in its preparation mode, so it is derived from the
|
||||||
// resource that runs that program — and a module declaring none has asked for something the mesh
|
// resource that runs that program — and a module declaring none has asked for something the mesh
|
||||||
@@ -1516,7 +1628,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
// quietly prepares nothing.
|
// quietly prepares nothing.
|
||||||
if m.Prepares && preparationTarget(m) == "" {
|
if m.Prepares && preparationTarget(m) == "" {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s says it prepares its state, and declares no container running an artifact it built — "+
|
"%s says it prepares its state, and declares no container or process running an artifact it built — "+
|
||||||
"the preparation is this module's own program, so there has to be one for the mesh to "+
|
"the preparation is this module's own program, so there has to be one for the mesh to "+
|
||||||
"run it in", m.Module))
|
"run it in", m.Module))
|
||||||
}
|
}
|
||||||
@@ -1569,6 +1681,13 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// **A scheduled step may hold this module's own containers still while it runs**
|
||||||
|
// (novox/hq ADR 0189). What the host judges is the declaration it receives — whether each
|
||||||
|
// id is a container placed on that machine; what belongs here is what only the definition
|
||||||
|
// shows: that the ids are this module's, that they are containers, and that the step is
|
||||||
|
// scheduled. A module naming a neighbour's container would be a module that can stop the
|
||||||
|
// mesh, and the manifest is where that is visible.
|
||||||
|
problems = append(problems, whileStoppedProblems(m, r, hasSchedule(r))...)
|
||||||
}
|
}
|
||||||
for name, own := range m.OwnSecrets {
|
for name, own := range m.OwnSecrets {
|
||||||
if !placedOrAbsolute(own.Path) {
|
if !placedOrAbsolute(own.Path) {
|
||||||
@@ -1730,6 +1849,13 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
problems = append(problems, m.unknownDirRefs()...)
|
problems = append(problems, m.unknownDirRefs()...)
|
||||||
problems = append(problems, m.unknownAccessRefs()...)
|
problems = append(problems, m.unknownAccessRefs()...)
|
||||||
problems = append(problems, m.jailProblems()...)
|
problems = append(problems, m.jailProblems()...)
|
||||||
|
// What a module adds to the account's environment and to the login shell, and the holder's
|
||||||
|
// placeholders for them (novox/hq ADR 0203, ADR 0204) — here, so the catalogue check refuses
|
||||||
|
// them in the words registration does.
|
||||||
|
problems = append(problems, m.environmentProblems()...)
|
||||||
|
problems = append(problems, m.shellProblems()...)
|
||||||
|
problems = append(problems, m.contributionPlaceholderProblems()...)
|
||||||
|
problems = append(problems, m.seatContributionProblems()...)
|
||||||
|
|
||||||
for i, r := range m.Resources {
|
for i, r := range m.Resources {
|
||||||
id, _ := r["id"].(string)
|
id, _ := r["id"].(string)
|
||||||
@@ -1741,6 +1867,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
problems = append(problems, zoneProblems(m)...)
|
||||||
if len(problems) > 0 {
|
if len(problems) > 0 {
|
||||||
sort.Strings(problems)
|
sort.Strings(problems)
|
||||||
return Manifest{}, fmt.Errorf("this manifest cannot be used:\n - %s",
|
return Manifest{}, fmt.Errorf("this manifest cannot be used:\n - %s",
|
||||||
@@ -2100,3 +2227,71 @@ func (o OwnSecrets) Paths() map[string]string {
|
|||||||
// InstancesInterchangeable is the one value of a definition's `instances`: the module is the same
|
// InstancesInterchangeable is the one value of a definition's `instances`: the module is the same
|
||||||
// on every machine, so any instance may answer for the module.
|
// on every machine, so any instance may answer for the module.
|
||||||
const InstancesInterchangeable = "interchangeable"
|
const InstancesInterchangeable = "interchangeable"
|
||||||
|
|
||||||
|
// WhileStopped is the resource key naming the containers a scheduled step holds still while it
|
||||||
|
// runs (novox/hq ADR 0189). Carried to the host unchanged, like `schedule`.
|
||||||
|
const WhileStopped = "while-stopped"
|
||||||
|
|
||||||
|
// hasSchedule is whether a resource declares a cadence, as a string.
|
||||||
|
func hasSchedule(r map[string]any) bool {
|
||||||
|
s, _ := r["schedule"].(string)
|
||||||
|
return s != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// whileStoppedProblems judges one container's maintenance window against its own definition
|
||||||
|
// (novox/hq ADR 0189).
|
||||||
|
//
|
||||||
|
// Three things the manifest is the only place to see: that the step is scheduled (a one-time
|
||||||
|
// offline job says *before* rather than *instead of* — at apply the host already has a window,
|
||||||
|
// because the declaration is applied in order and a run-once step gates what follows); that every
|
||||||
|
// id it names is **this module's own** container; and that it does not name itself.
|
||||||
|
//
|
||||||
|
// The host checks the fourth — that the container is actually placed on that machine — because
|
||||||
|
// that is a fact about the declaration and not about the definition.
|
||||||
|
func whileStoppedProblems(m Manifest, r map[string]any, scheduled bool) []string {
|
||||||
|
raw, present := r[WhileStopped]
|
||||||
|
if !present {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ids, ok := raw.([]any)
|
||||||
|
if !ok {
|
||||||
|
return []string{fmt.Sprintf(
|
||||||
|
"%s declares %s on %v as a %T; it is a list of this module's container ids",
|
||||||
|
m.Module, WhileStopped, r["id"], raw)}
|
||||||
|
}
|
||||||
|
var problems []string
|
||||||
|
if len(ids) > 0 && !scheduled {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s declares %s on %v, which has no schedule. A maintenance window is for a recurring "+
|
||||||
|
"step: at apply the mesh already has one, because a run-once step gates what is "+
|
||||||
|
"declared after it (novox/hq ADR 0189)", m.Module, WhileStopped, r["id"]))
|
||||||
|
}
|
||||||
|
containers := map[string]bool{}
|
||||||
|
for _, own := range m.Resources {
|
||||||
|
if fmt.Sprint(own["type"]) == "container" {
|
||||||
|
containers[fmt.Sprint(own["id"])] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, each := range ids {
|
||||||
|
id, ok := each.(string)
|
||||||
|
if !ok {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s declares %s on %v naming a %T; each entry is a container's id",
|
||||||
|
m.Module, WhileStopped, r["id"], each))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id == fmt.Sprint(r["id"]) {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s declares %s on %v naming itself", m.Module, WhileStopped, r["id"]))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !containers[id] {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s declares %s on %v naming %q, which is not a container this module declares. "+
|
||||||
|
"A step may hold still its own module's containers and nobody else's — one "+
|
||||||
|
"that could quiesce a neighbour could stop the mesh",
|
||||||
|
m.Module, WhileStopped, r["id"], id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import (
|
|||||||
//
|
//
|
||||||
// So a module asks. `${port:8080}` is "the machine-side port you gave me for the 8080 I said I
|
// So a module asks. `${port:8080}` is "the machine-side port you gave me for the 8080 I said I
|
||||||
// listen on", and the module writes that where it would otherwise have written a literal — in a
|
// listen on", and the module writes that where it would otherwise have written a literal — in a
|
||||||
// file's content, or in a value of a container's `env`.
|
// file's content, or in a value of a container's or a process's `env`.
|
||||||
//
|
//
|
||||||
// **The environment is filled by the control plane, exactly as a bound value is.** A port is not
|
// **The environment is filled by the control plane, exactly as a bound value is.** A port is not
|
||||||
// secret — the mesh holds it in the clear — so there is nothing for the host to be the only
|
// secret — the mesh holds it in the clear — so there is nothing for the host to be the only
|
||||||
@@ -64,7 +64,12 @@ func portsUsed(content string) []int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// portInto replaces a resource's ${port:…} placeholders with what this machine assigned — in a
|
// portInto replaces a resource's ${port:…} placeholders with what this machine assigned — in a
|
||||||
// file's content, and in a value of a container's environment.
|
// file's content, and in a value of a container's or a process's environment.
|
||||||
|
//
|
||||||
|
// **A process's environment is a container's** (novox/hq to-be 38 WP4c). A module's code moving out
|
||||||
|
// of its container becomes a process on the machine and still has to be told what the container
|
||||||
|
// was told; filled for one kind and not the other, the literal reached the process and was read as
|
||||||
|
// a port, and the modules that moved first wrote their run-once steps a 0600 env file instead.
|
||||||
//
|
//
|
||||||
// A port the module did not say it listens on is refused, for the same reason a binding's unknown
|
// A port the module did not say it listens on is refused, for the same reason a binding's unknown
|
||||||
// key is: the module is asking about something it never declared, and the answer would be a guess.
|
// key is: the module is asking about something it never declared, and the answer would be a guess.
|
||||||
@@ -84,7 +89,7 @@ func portInto(resource map[string]any, module string, listens []Listening, with
|
|||||||
}
|
}
|
||||||
resource["content"] = filled
|
resource["content"] = filled
|
||||||
|
|
||||||
case "container":
|
case "container", "process":
|
||||||
env, ok := resource["env"].(map[string]any)
|
env, ok := resource["env"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
@@ -106,8 +111,8 @@ func portInto(resource map[string]any, module string, listens []Listening, with
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
value, err := portsFilledInto(written,
|
value, err := portsFilledInto(written,
|
||||||
fmt.Sprintf("%s's container %s sets %s to something that",
|
fmt.Sprintf("%s's %s %s sets %s to something that",
|
||||||
module, resource["name"], key), module, listens, with)
|
module, resource["type"], resource["name"], key), module, listens, with)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **A process's environment is composed as a container's is** (novox/hq to-be 38 WP4c).
|
||||||
|
//
|
||||||
|
// A module's code moving out of its container becomes a process on the machine, and what its
|
||||||
|
// container's environment asked for — the port this machine gave the module, the place it put the
|
||||||
|
// module's directory — it still has to be told. Filled for a container and not for a process, the
|
||||||
|
// literal `${port:8080}` reached the process as its environment and was read as a port; the modules
|
||||||
|
// that moved first wrote their run-once steps an env file instead.
|
||||||
|
func processModule(env map[string]any) Manifest {
|
||||||
|
return Manifest{
|
||||||
|
Module: "showcase",
|
||||||
|
Listens: []Listening{{Port: 8080, From: FromMesh}},
|
||||||
|
Resources: []map[string]any{
|
||||||
|
{"id": "data", "type": "directory", "mode": "0700"},
|
||||||
|
{"id": "setup", "type": "process", "name": "showcase-setup", "run-once": true,
|
||||||
|
"run": []any{"/usr/bin/showcase", "setup"}, "env": env},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAProcessIsToldItsPortAndItsPlaceInItsEnvironment(t *testing.T) {
|
||||||
|
env := map[string]any{
|
||||||
|
"SHOWCASE_URL": "http://127.0.0.1:${port:8080}",
|
||||||
|
"SHOWCASE_DATA": "${dir:data}/objects",
|
||||||
|
"SHOWCASE_DB": "127.0.0.1:${seat:mesh-store:5432}",
|
||||||
|
"GREETING": "hello",
|
||||||
|
}
|
||||||
|
out, err := Resolution{Node: "anchor", Modules: []Manifest{processModule(env)}}.Declaration(Rendering{
|
||||||
|
Ports: map[string]map[int]int{"showcase": {8080: 21000}},
|
||||||
|
Seats: map[string]map[int]int{"mesh-store": {5432: 6852}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a process asking for its port and its place does not compose: %v", err)
|
||||||
|
}
|
||||||
|
setup := fileNamed(out, "showcase.setup")
|
||||||
|
if setup == nil {
|
||||||
|
t.Fatalf("the process is not in the declaration: %v", out)
|
||||||
|
}
|
||||||
|
got, _ := setup["env"].(map[string]any)
|
||||||
|
for key, want := range map[string]string{
|
||||||
|
"SHOWCASE_URL": "http://127.0.0.1:21000",
|
||||||
|
"SHOWCASE_DATA": "/var/lib/showcase/data/objects",
|
||||||
|
"SHOWCASE_DB": "127.0.0.1:6852",
|
||||||
|
"GREETING": "hello",
|
||||||
|
} {
|
||||||
|
if got[key] != want {
|
||||||
|
t.Errorf("the process is told %s=%v, want %q", key, got[key], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if env["SHOWCASE_URL"] != "http://127.0.0.1:${port:8080}" {
|
||||||
|
t.Fatalf("composing for one machine edited the module's own manifest: %v", env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown reference in a process's environment is refused as a container's is, naming the
|
||||||
|
// process and the variable — left alone, it would reach the machine as a literal.
|
||||||
|
func TestAProcessAskingAboutAnUndeclaredPortIsRefused(t *testing.T) {
|
||||||
|
env := map[string]any{"SHOWCASE_URL": "http://127.0.0.1:${port:9999}"}
|
||||||
|
_, err := Resolution{Node: "anchor", Modules: []Manifest{processModule(env)}}.Declaration(Rendering{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a process was told a port its module never said it listens on")
|
||||||
|
}
|
||||||
|
for _, said := range []string{"showcase-setup", "SHOWCASE_URL", "${port:9999}", "8080"} {
|
||||||
|
if !strings.Contains(err.Error(), said) {
|
||||||
|
t.Errorf("the refusal does not say %q: %v", said, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
env = map[string]any{"SHOWCASE_DATA": "${dir:date}/objects"}
|
||||||
|
if _, err := (Resolution{Node: "anchor", Modules: []Manifest{processModule(env)}}).Declaration(Rendering{}); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "${dir:date}") {
|
||||||
|
t.Fatalf("a process naming no directory of its module was not refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A container publishes only a port its module declares (novox/hq issue 227).
|
||||||
|
//
|
||||||
|
// **The short form is a question the mesh answers.** `"80"` means *publish what the software
|
||||||
|
// calls 80*, and the mesh fills in the machine's half from the port it assigned
|
||||||
|
// ([ADR 0038](0038)). It can only assign one for a port the module declared in `listens` — so a
|
||||||
|
// container publishing a number that appears nowhere in `listens` gets no assignment, and
|
||||||
|
// `publishedOn` falls back to the number as written. It escapes to the machine.
|
||||||
|
//
|
||||||
|
// That is how the photo module asked for port 80 on the control node, where the reverse proxy
|
||||||
|
// holds it: it declared its web endpoint at 4001, published a bare 80, and the container never
|
||||||
|
// started. Four other modules publish 80 quite safely — because they declare 80, so the mesh
|
||||||
|
// gives them a machine port for it. The difference is the declaration, not the number.
|
||||||
|
//
|
||||||
|
// A mapping written the long way is a module pinning both halves on purpose and is left alone.
|
||||||
|
func TestEveryPublishedPortIsOneItsModuleDeclares(t *testing.T) {
|
||||||
|
root := catalogueRoot(t)
|
||||||
|
entries, err := os.ReadDir(filepath.Join(root, "modules"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var escaped []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(root, "modules", entry.Name(), "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m, err := ParseManifest(raw)
|
||||||
|
if err != nil {
|
||||||
|
// Whether every manifest parses is TestEveryCatalogueManifestParses's question.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
declared := map[int]bool{}
|
||||||
|
for _, l := range m.Listens {
|
||||||
|
declared[l.Port] = true
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if fmt.Sprint(r["type"]) != "container" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
listed, _ := r["ports"].([]any)
|
||||||
|
for _, p := range listed {
|
||||||
|
written := strings.Split(fmt.Sprint(p), "/")[0]
|
||||||
|
if strings.Contains(written, ":") {
|
||||||
|
continue // pinned by hand, both halves, on purpose
|
||||||
|
}
|
||||||
|
port, err := strconv.Atoi(strings.TrimSpace(written))
|
||||||
|
if err != nil || declared[port] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
escaped = append(escaped, fmt.Sprintf(
|
||||||
|
"%s's %v publishes %d, and %s declares no such port — the mesh has nothing "+
|
||||||
|
"to assign, so %d reaches the machine as written",
|
||||||
|
m.Module, r["id"], port, m.Module, port))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(escaped) > 0 {
|
||||||
|
t.Fatalf("a container may publish only a port its module declares:\n - %s",
|
||||||
|
strings.Join(escaped, "\n - "))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -147,6 +147,10 @@ type Resolution struct {
|
|||||||
// dropped nor fatal to the rest. A module that is *required* by something running here is a
|
// dropped nor fatal to the rest. A module that is *required* by something running here is a
|
||||||
// different case — that set is incoherent and is refused (see checkCapabilities).
|
// different case — that set is incoherent and is refused (see checkCapabilities).
|
||||||
Unhostable []Unhostable
|
Unhostable []Unhostable
|
||||||
|
// Unheld is every dependency of this node's modules on a seat nothing here holds (novox/hq ADR
|
||||||
|
// 0207) — reported rather than refused while enforceSeatDependencies is off, so a node short of a
|
||||||
|
// holder still converges and `status` says what it is short of.
|
||||||
|
Unheld []Unheld
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unhostable is one directly-assigned module the machine cannot run.
|
// Unhostable is one directly-assigned module the machine cannot run.
|
||||||
@@ -229,6 +233,27 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
local[o.Name] = true
|
local[o.Name] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Which names are usable only on their provider's own machine (novox/hq ADR 0208 §3). Also a
|
||||||
|
// property of the name: a display one provider says is the machine's and another says is not
|
||||||
|
// would be pulled in for one consumer and refused for the next.
|
||||||
|
machineReach := map[string]bool{}
|
||||||
|
plainLocal := map[string]bool{}
|
||||||
|
for _, m := range catalogue {
|
||||||
|
for _, o := range m.Provides {
|
||||||
|
if o.MachineReach() {
|
||||||
|
machineReach[o.Name] = true
|
||||||
|
} else if o.At() == ScopeNode {
|
||||||
|
plainLocal[o.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for want := range machineReach {
|
||||||
|
if plainLocal[want] {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"the catalogue disagrees about %q: some modules provide it with the machine's reach and "+
|
||||||
|
"others without, so a requirement for it would be met differently by each", want))
|
||||||
|
}
|
||||||
|
}
|
||||||
for want := range brokered {
|
for want := range brokered {
|
||||||
if local[want] {
|
if local[want] {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
@@ -495,6 +520,23 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Usable only on its provider's own machine, and not here: refused, never answered by
|
||||||
|
// installing a provider (novox/hq ADR 0208 §3). The display server is the machine's own role,
|
||||||
|
// gated by its graphical session; one pulled in for a window manager is the misassignment
|
||||||
|
// research 026 found. Another node's provider never counts — node scope is never brokered.
|
||||||
|
if machineReach[want] && !isModule(catalogue, want) {
|
||||||
|
reported[want] = true
|
||||||
|
if world.Unchecked {
|
||||||
|
// The first pass's refusals take a machine off the network; the second says it.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%q is wanted by %s and is usable only on the machine that provides it, and nothing "+
|
||||||
|
"assigned to %s does — %s", want, because[want], node.Name,
|
||||||
|
machineReachRemedy(catalogue, want, node.Name)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
candidates := offers[want]
|
candidates := offers[want]
|
||||||
switch len(candidates) {
|
switch len(candidates) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -628,6 +670,22 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
problems = append(problems, checkResources(resolution.Modules)...)
|
problems = append(problems, checkResources(resolution.Modules)...)
|
||||||
resolution.Claims = claims
|
resolution.Claims = claims
|
||||||
|
|
||||||
|
// Judged over the closure — what this node will actually run — so a holder pulled in by a
|
||||||
|
// requirement counts, and the holders' mutual dependence resolves (novox/hq ADR 0207 §3).
|
||||||
|
// Reported until the switch; refused after it, though never in the first pass, whose refusals
|
||||||
|
// make a machine vanish from the network rather than report anything.
|
||||||
|
resolution.Unheld = UnheldDependencies(catalogue, node.Name, resolution.Modules, nil)
|
||||||
|
// Only a dependency some module in the catalogue could meet is refused: one with no possible
|
||||||
|
// holder has no remedy to name, and stays a report in `status` (novox/hq ADR 0207 §4, read with
|
||||||
|
// the assign rule above it).
|
||||||
|
if enforceSeatDependencies && !world.Unchecked {
|
||||||
|
for _, u := range resolution.Unheld {
|
||||||
|
if len(u.Holders) > 0 {
|
||||||
|
problems = append(problems, u.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(problems) > 0 {
|
if len(problems) > 0 {
|
||||||
sort.Strings(problems)
|
sort.Strings(problems)
|
||||||
return Resolution{}, &Refusal{Problems: problems}
|
return Resolution{}, &Refusal{Problems: problems}
|
||||||
@@ -804,6 +862,28 @@ func checkResources(modules []Manifest) []string {
|
|||||||
// does not exist is the manifest's own problem, refused where it was made.
|
// does not exist is the manifest's own problem, refused where it was made.
|
||||||
dirs := dirsFor(m, Rendering{})
|
dirs := dirsFor(m, Rendering{})
|
||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
|
if fmt.Sprint(r["type"]) == "user" {
|
||||||
|
// **An account is shared; what it is set to is not.** Several modules may need one
|
||||||
|
// login: the shell's module sets its shell, the container runtime's puts it in the
|
||||||
|
// `docker` group. The host only ever adds groups — it never takes the account out of
|
||||||
|
// one, not even when the resource that named it is undeclared — so groups from
|
||||||
|
// several modules cannot contradict each other and are not owned. A shell or a home
|
||||||
|
// is one value, and two modules setting it would each be undone by the other's
|
||||||
|
// apply: each stays one module's per node, and two are refused naming both.
|
||||||
|
name, _ := r["name"].(string)
|
||||||
|
for _, field := range []string{"shell", "home"} {
|
||||||
|
if v, ok := r[field].(string); !ok || v == "" || name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := "user " + field + " " + name
|
||||||
|
if other, taken := owner[key]; taken && other != m.Module {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s and %s both set the %s of the user %q", other, m.Module, field, name))
|
||||||
|
}
|
||||||
|
owner[key] = m.Module
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
for _, field := range []string{"path", "unit", "name", "package"} {
|
for _, field := range []string{"path", "unit", "name", "package"} {
|
||||||
value, ok := r[field].(string)
|
value, ok := r[field].(string)
|
||||||
if !ok || value == "" {
|
if !ok || value == "" {
|
||||||
@@ -1019,3 +1099,38 @@ func eachLocal(needs []Needed, catalogue map[string]Manifest, n Needed) []Needed
|
|||||||
}
|
}
|
||||||
return needs
|
return needs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// machineReachRemedy names what would meet a requirement with the machine's reach: every module in
|
||||||
|
// the catalogue that provides it, each with the node seats it holds — for a display, the holders of
|
||||||
|
// node-display-server (novox/hq ADR 0208 §3), named by the seat because that is the role being
|
||||||
|
// asked for, without this code knowing which seat any provision belongs to.
|
||||||
|
func machineReachRemedy(catalogue map[string]Manifest, want, node string) string {
|
||||||
|
var named []string
|
||||||
|
for _, name := range sortedKeys(catalogue) {
|
||||||
|
m := catalogue[name]
|
||||||
|
provides := false
|
||||||
|
for _, o := range m.Provides {
|
||||||
|
if o.Name == want {
|
||||||
|
provides = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !provides {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var held []string
|
||||||
|
for _, c := range m.Claims {
|
||||||
|
if c.At() == ScopeNode {
|
||||||
|
held = append(held, c.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(held) > 0 {
|
||||||
|
named = append(named, fmt.Sprintf("%s (holds %s)", name, strings.Join(held, ", ")))
|
||||||
|
} else {
|
||||||
|
named = append(named, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(named) == 0 {
|
||||||
|
return "and nothing in the catalogue provides it"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("assign one to %s: %s", node, strings.Join(named, "; "))
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,9 +48,12 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
|||||||
}
|
}
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n",
|
"\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n",
|
||||||
// Loopback is the mesh-wide setting's default; a machine answering its own LAN adds its
|
// The private address and loopback, never a LAN's (novox/hq ADR 0194): a device that is not a
|
||||||
// address there (novox/hq issue 198).
|
// member cannot reach what the mesh's names point at.
|
||||||
"\nlisten-address=${setting:listen-addresses}\n", "\nlisten-address=${machine:address}\n", "\nbind-dynamic\n",
|
"\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n", "\nbind-dynamic\n",
|
||||||
|
// No hosts file and no operator's files: the mesh's resolver answers every node (ADR 0199).
|
||||||
|
"\nno-hosts\n",
|
||||||
|
"\nconf-file=" + m.Facts["zones"].Path + "\n",
|
||||||
"\ndomain-needed\n", "\nbogus-priv\n",
|
"\ndomain-needed\n", "\nbogus-priv\n",
|
||||||
"\nconf-file=" + m.Facts["node-zones"].Path + "\n",
|
"\nconf-file=" + m.Facts["node-zones"].Path + "\n",
|
||||||
} {
|
} {
|
||||||
@@ -73,7 +76,15 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
|||||||
t.Errorf("the resolver listens on %s", taken)
|
t.Errorf("the resolver listens on %s", taken)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// And the file that decides what the machine asks names it there, alone.
|
// Never a directory or a file the operator keeps: a line written for one machine's programs would
|
||||||
|
// become an answer for every node (ADR 0199).
|
||||||
|
for _, never := range []string{"conf-dir=", "addn-hosts=", "listen-address=${setting:"} {
|
||||||
|
if strings.Contains(config, never) {
|
||||||
|
t.Errorf("the mesh's resolver still reads or listens on %q", never)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And the file that decides what the machine asks names the mesh's resolver first, by address,
|
||||||
|
// and a public one second, asked only when the first is silent (ADR 0196).
|
||||||
var resolv string
|
var resolv string
|
||||||
for _, r := range catalogueManifest(t, "resolv-conf").Resources {
|
for _, r := range catalogueManifest(t, "resolv-conf").Resources {
|
||||||
if r["path"] == "/etc/resolv.conf" {
|
if r["path"] == "/etc/resolv.conf" {
|
||||||
@@ -86,13 +97,16 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
|||||||
nameservers = append(nameservers, strings.TrimPrefix(line, "nameserver "))
|
nameservers = append(nameservers, strings.TrimPrefix(line, "nameserver "))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(nameservers) != 1 || nameservers[0] != "127.0.0.1" {
|
if len(nameservers) != 2 || nameservers[0] != "${bound:wildcard-resolution:address}" || nameservers[1] != "1.1.1.1" {
|
||||||
t.Errorf("resolv.conf names %v; the predecessor's names the mesh's resolver alone at 127.0.0.1", nameservers)
|
t.Errorf("resolv.conf names %v; the mesh's resolver by address first, a public one second", nameservers)
|
||||||
}
|
}
|
||||||
// The split-DNS alternative points at the same address, or a machine that keeps
|
if !strings.Contains(resolv, "\noptions timeout:1 attempts:1") {
|
||||||
|
t.Errorf("the fallback is not reached after one short attempt:\n%s", resolv)
|
||||||
|
}
|
||||||
|
// The split-DNS alternative points at the same resolver, or a machine that keeps
|
||||||
// systemd-resolved in charge would route the mesh's suffix to nothing.
|
// systemd-resolved in charge would route the mesh's suffix to nothing.
|
||||||
for _, r := range catalogueManifest(t, "resolved-split-dns").Resources {
|
for _, r := range catalogueManifest(t, "resolved-split-dns").Resources {
|
||||||
if content, _ := r["content"].(string); content != "" && !strings.Contains(content, "DNS=127.0.0.1\n") {
|
if content, _ := r["content"].(string); content != "" && !strings.Contains(content, "DNS=${bound:wildcard-resolution:address}\n") {
|
||||||
t.Errorf("resolved-split-dns does not point at the resolver's address:\n%s", content)
|
t.Errorf("resolved-split-dns does not point at the resolver's address:\n%s", content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,7 +114,8 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
|||||||
|
|
||||||
// The resolver and what points the machine at it compose on one machine, and what arrives is the
|
// The resolver and what points the machine at it compose on one machine, and what arrives is the
|
||||||
// mesh's account of every machine as a wildcard, the suffix kept local, the daemon restarting on
|
// mesh's account of every machine as a wildcard, the suffix kept local, the daemon restarting on
|
||||||
// that file, and the runtime pointed at this machine's own address.
|
// that file, the machine pointed at the resolver by address, and the runtime given no resolver of
|
||||||
|
// its own but kept running across a restart (ADR 0196).
|
||||||
func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
||||||
got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "resolv-conf"},
|
got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "resolv-conf"},
|
||||||
Node{Name: "anchor", At: "anchor.internal"}, World{})
|
Node{Name: "anchor", At: "anchor.internal"}, World{})
|
||||||
@@ -115,6 +130,7 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
|||||||
// issue 111) — the resolver's zones read only the second, and in this scenario the two
|
// issue 111) — the resolver's zones read only the second, and in this scenario the two
|
||||||
// happen to be the same map, since nothing routed is part of it.
|
// happen to be the same map, since nothing routed is part of it.
|
||||||
Names: twoMachines, Machines: twoMachines, Suffix: "internal",
|
Names: twoMachines, Machines: twoMachines, Suffix: "internal",
|
||||||
|
Zones: []ZoneAt{{Zone: "incus", Address: "10.42.0.2", Port: 5353}},
|
||||||
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
||||||
Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}},
|
Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}},
|
||||||
})
|
})
|
||||||
@@ -144,24 +160,29 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
|||||||
for _, id := range service["restart-on"].([]any) {
|
for _, id := range service["restart-on"].([]any) {
|
||||||
reflects[id.(string)] = true
|
reflects[id.(string)] = true
|
||||||
}
|
}
|
||||||
if !reflects["dnsmasq.config"] || !reflects["dnsmasq.fact-node-zones"] {
|
if !reflects["dnsmasq.config"] || !reflects["dnsmasq.fact-node-zones"] || !reflects["dnsmasq.fact-zones"] {
|
||||||
t.Errorf("the daemon does not restart on its configuration and the machines file both: %v", service["restart-on"])
|
t.Errorf("the daemon does not restart on its configuration, the machines and the zones: %v", service["restart-on"])
|
||||||
|
}
|
||||||
|
if z, _ := ids["dnsmasq.fact-zones"]["content"].(string); !strings.Contains(z, "server=/incus/10.42.0.2#5353\n") {
|
||||||
|
t.Errorf("the resolver was not told to forward the zone to its answerer:\n%s", z)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The runtime's own file, written into (novox/hq ADR 0102) with the keys this module states:
|
// The runtime's own file, written into (novox/hq ADR 0102) with one key, by what decides how the
|
||||||
// where containers resolve, and that a restart keeps them running — because the runtime reads
|
// machine resolves: a restart keeps every container running. No `dns` — a container copies its
|
||||||
// `dns` only when it starts, and the one restart that needs is the operator's (issue 110).
|
// machine's resolvers (ADR 0196), and the mesh's resolver is not written into the runtime twice.
|
||||||
runtime := ids["dnsmasq.runtime-dns"]
|
if ids["dnsmasq.runtime-dns"] != nil {
|
||||||
|
t.Errorf("the resolver still writes the runtime's dns: %v", ids["dnsmasq.runtime-dns"])
|
||||||
|
}
|
||||||
|
runtime := ids["resolv-conf.runtime-config"]
|
||||||
if runtime == nil || runtime["path"] != "/etc/docker/daemon.json" || runtime["into"] != "json" {
|
if runtime == nil || runtime["path"] != "/etc/docker/daemon.json" || runtime["into"] != "json" {
|
||||||
t.Fatalf("the runtime's dns is not written into its file: %v", runtime)
|
t.Fatalf("live-restore is not written into the runtime's file: %v", runtime)
|
||||||
}
|
}
|
||||||
var keys map[string]any
|
var keys map[string]any
|
||||||
if err := json.Unmarshal([]byte(runtime["content"].(string)), &keys); err != nil {
|
if err := json.Unmarshal([]byte(runtime["content"].(string)), &keys); err != nil {
|
||||||
t.Fatalf("the runtime's keys are not JSON: %v", err)
|
t.Fatalf("the runtime's keys are not JSON: %v", err)
|
||||||
}
|
}
|
||||||
dns, _ := keys["dns"].([]any)
|
if len(keys) != 1 || keys["live-restore"] != true {
|
||||||
if len(keys) != 2 || len(dns) != 1 || dns[0] != "10.42.0.1" || keys["live-restore"] != true {
|
t.Errorf("the runtime is given %v; live-restore and nothing else", keys)
|
||||||
t.Errorf("the runtime is given %v; containers resolve at this machine's own private-network address, a restart keeps them, and nothing else is written", keys)
|
|
||||||
}
|
}
|
||||||
// The runtime is reloaded when that file changes, and never restarted: a restart stops every
|
// The runtime is reloaded when that file changes, and never restarted: a restart stops every
|
||||||
// container on the machine (ADR 0102), and a reload is what turns live-restore on.
|
// container on the machine (ADR 0102), and a reload is what turns live-restore on.
|
||||||
@@ -171,10 +192,10 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, restarts := r["restart-on"]; restarts {
|
if _, restarts := r["restart-on"]; restarts {
|
||||||
t.Errorf("the resolver orders the runtime restarted, which stops every container (ADR 0102): %v", r)
|
t.Errorf("the runtime is ordered restarted, which stops every container (ADR 0102): %v", r)
|
||||||
}
|
}
|
||||||
for _, on := range asStrings(r["reload-on"]) {
|
for _, on := range asStrings(r["reload-on"]) {
|
||||||
if on == "dnsmasq.runtime-dns" {
|
if on == "resolv-conf.runtime-config" {
|
||||||
reloaded = true
|
reloaded = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,8 +205,8 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resolv := ids["resolv-conf.resolv"]
|
resolv := ids["resolv-conf.resolv"]
|
||||||
if resolv == nil || !strings.Contains(resolv["content"].(string), "\nnameserver 127.0.0.1\n") {
|
if resolv == nil || !strings.Contains(resolv["content"].(string), "\nnameserver 10.42.0.1\nnameserver 1.1.1.1\n") {
|
||||||
t.Fatalf("the machine is not pointed at the resolver: %v", resolv)
|
t.Fatalf("the machine is not pointed at the resolver by address, with the public fallback: %v", resolv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ type rosterView struct {
|
|||||||
Suffix string
|
Suffix string
|
||||||
Names []rosterEntry
|
Names []rosterEntry
|
||||||
Machines []rosterEntry
|
Machines []rosterEntry
|
||||||
|
// Zones is every zone a module in the mesh answers itself, with where its answerer is (novox/hq
|
||||||
|
// ADR 0199) — what the mesh's resolver forwards. Ordered by zone.
|
||||||
|
Zones []rosterZone
|
||||||
|
}
|
||||||
|
|
||||||
|
// rosterZone is one zone as a template sees it: the zone, and the address and port answering it.
|
||||||
|
type rosterZone struct {
|
||||||
|
Zone string
|
||||||
|
Address string
|
||||||
|
Port int
|
||||||
}
|
}
|
||||||
|
|
||||||
// rosterEntry is one machine as a template sees it: its bare name, its full mesh name, its address,
|
// rosterEntry is one machine as a template sees it: its bare name, its full mesh name, its address,
|
||||||
@@ -80,6 +90,12 @@ type rosterEntry struct {
|
|||||||
// `machines` is only the machines — the two must not be confused (novox/hq 04-ISSUES/111), so both
|
// `machines` is only the machines — the two must not be confused (novox/hq 04-ISSUES/111), so both
|
||||||
// are given and the template chooses.
|
// are given and the template chooses.
|
||||||
func FactsInto(m Manifest, r Resolution, every, machines, accounts map[string]string, suffix string) ([]map[string]any, error) {
|
func FactsInto(m Manifest, r Resolution, every, machines, accounts map[string]string, suffix string) ([]map[string]any, error) {
|
||||||
|
return FactsWithZonesInto(m, r, every, machines, accounts, suffix, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FactsWithZonesInto is FactsInto with the mesh's zones in the view, for a template that ranges them.
|
||||||
|
func FactsWithZonesInto(m Manifest, r Resolution, every, machines, accounts map[string]string, suffix string,
|
||||||
|
zones []ZoneAt) ([]map[string]any, error) {
|
||||||
if len(m.Facts) == 0 {
|
if len(m.Facts) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -94,6 +110,7 @@ func FactsInto(m Manifest, r Resolution, every, machines, accounts map[string]st
|
|||||||
Suffix: strings.TrimPrefix(suffixOr(suffix), "."),
|
Suffix: strings.TrimPrefix(suffixOr(suffix), "."),
|
||||||
Names: entriesFrom(every, accounts, suffix),
|
Names: entriesFrom(every, accounts, suffix),
|
||||||
Machines: entriesFrom(machines, accounts, suffix),
|
Machines: entriesFrom(machines, accounts, suffix),
|
||||||
|
Zones: zonesFrom(zones),
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]map[string]any, 0, len(names))
|
out := make([]map[string]any, 0, len(names))
|
||||||
@@ -223,3 +240,13 @@ func sortedNames(addresses map[string]string) []string {
|
|||||||
sort.Strings(out)
|
sort.Strings(out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// zonesFrom is the zones a template ranges, ordered by zone so two renderings of one mesh are one file.
|
||||||
|
func zonesFrom(zones []ZoneAt) []rosterZone {
|
||||||
|
out := make([]rosterZone, 0, len(zones))
|
||||||
|
for _, z := range zones {
|
||||||
|
out = append(out, rosterZone{Zone: z.Zone, Address: z.Address, Port: z.Port})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Zone < out[j].Zone })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -390,6 +390,31 @@ func TestARuntimeCompiledToABinaryRunsItself(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// novox/hq 04-ISSUES/216: a bundle nothing loads, runs or unpacks is refused at registration; saying
|
||||||
|
// `loads`, listing `tools`, or a resource naming it admits it.
|
||||||
|
func TestABundleNothingDeliversIsRefused(t *testing.T) {
|
||||||
|
base := func() Manifest {
|
||||||
|
return Manifest{Module: "baserow", Version: "1", Build: &Build{Artifacts: []Artifact{
|
||||||
|
{Name: "tools", Kind: ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"}}}}}
|
||||||
|
}
|
||||||
|
if p := undeliveredBundles(base()); len(p) != 1 || !strings.Contains(p[0], "never reach a machine") {
|
||||||
|
t.Fatalf("a bundle nothing delivers was admitted: %v", p)
|
||||||
|
}
|
||||||
|
loads := base()
|
||||||
|
loads.Build.Artifacts[0].Loads = []string{"tools/index.js"}
|
||||||
|
tools := base()
|
||||||
|
tools.Tools = []string{"baserow_list_rows"}
|
||||||
|
run := base()
|
||||||
|
run.Resources = []map[string]any{{"id": "daemon", "type": "process", "artifact": "tools", "run": []any{"node", "tools/index.js"}}}
|
||||||
|
runtime := base()
|
||||||
|
runtime.Module = RuntimeModule
|
||||||
|
for name, m := range map[string]Manifest{"loads": loads, "tools": tools, "a process": run, "the runtime": runtime} {
|
||||||
|
if p := undeliveredBundles(m); len(p) != 0 {
|
||||||
|
t.Errorf("a bundle delivered by %s was refused: %v", name, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// novox/hq ADR 0193: a Go tools bundle is served — its binary is what the runtime starts, delivered
|
// novox/hq ADR 0193: a Go tools bundle is served — its binary is what the runtime starts, delivered
|
||||||
// like any tools bundle, named to the runtime where a TypeScript bundle names its launcher.
|
// like any tools bundle, named to the runtime where a TypeScript bundle names its launcher.
|
||||||
func TestAGoToolsBundleIsServedByItsBinary(t *testing.T) {
|
func TestAGoToolsBundleIsServedByItsBinary(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module contributes to a seat it does not hold (novox/hq ADR 0212).
|
||||||
|
//
|
||||||
|
// ADR 0210 made a tool's configuration its seat holder's, and every other module's way in a
|
||||||
|
// contribution to the seat. The environment, the shell's slots and the power moments each became a
|
||||||
|
// field of their own; this is the general form, so a new seat that takes contributions is a row in
|
||||||
|
// the seat table rather than a change to the manifest: a contribution names a seat, a kind that
|
||||||
|
// seat receives, and text in the tool's own grammar, which the controller never reads.
|
||||||
|
|
||||||
|
// HotkeysSeat is the machine's hotkey daemon (novox/hq ADR 0212 §5).
|
||||||
|
const HotkeysSeat = "node-hotkeys"
|
||||||
|
|
||||||
|
// SeatContribution is one piece of configuration a module gives a seat's holder to place.
|
||||||
|
type SeatContribution struct {
|
||||||
|
// Seat is the seat whose holder places it.
|
||||||
|
Seat string `json:"seat"`
|
||||||
|
// Kind is which of the seat's receivable kinds it is.
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
// Content is the text, in the tool's own grammar. Never interpreted.
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ofContribution is where a holder places a kind: ${contribution:<seat>:<kind>}. Loose inside the
|
||||||
|
// braces, so a misspelt seat or kind is found and refused rather than written out as text.
|
||||||
|
var ofContribution = regexp.MustCompile(`\$\{contribution:([^}]*)\}`)
|
||||||
|
|
||||||
|
// receivable is what a seat receives of a kind, with the seat's canonical name; false when the seat
|
||||||
|
// is unknown or does not receive it.
|
||||||
|
func receivable(seat, kind string) (Seat, Receivable, bool) {
|
||||||
|
s, known := SeatNamed(seat)
|
||||||
|
if !known {
|
||||||
|
return Seat{}, Receivable{}, false
|
||||||
|
}
|
||||||
|
for _, r := range s.Receives {
|
||||||
|
if r.Kind == kind {
|
||||||
|
return s, r, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, Receivable{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// kindsOf names a seat's receivable kinds for a refusal.
|
||||||
|
func kindsOf(s Seat) string {
|
||||||
|
if len(s.Receives) == 0 {
|
||||||
|
return "it receives no contributions"
|
||||||
|
}
|
||||||
|
var kinds []string
|
||||||
|
for _, r := range s.Receives {
|
||||||
|
kinds = append(kinds, r.Kind)
|
||||||
|
}
|
||||||
|
return "it receives " + strings.Join(kinds, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatContributionProblems is what is wrong with this module's contributions, from the manifest
|
||||||
|
// alone (novox/hq ADR 0212 §2).
|
||||||
|
func (m Manifest) seatContributionProblems() []string {
|
||||||
|
var problems []string
|
||||||
|
for i, c := range m.Contributions {
|
||||||
|
s, _, ok := receivable(c.Seat, c.Kind)
|
||||||
|
switch {
|
||||||
|
case s.Name == "":
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s's contribution %d is to the seat %q, which the mesh does not define", m.Module, i+1, c.Seat))
|
||||||
|
case !ok:
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s's contribution %d to %s is of the kind %q; %s (novox/hq ADR 0212)",
|
||||||
|
m.Module, i+1, s.Name, c.Kind, kindsOf(s)))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.Content) == "" {
|
||||||
|
problems = append(problems, fmt.Sprintf("%s's contribution %d has no content", m.Module, i+1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatPlaceholderProblems is what is wrong with one resource's ${contribution:…}: placed only in a
|
||||||
|
// file's content, naming a seat and a kind it receives, and only by a module that claims that seat
|
||||||
|
// — another would be a second writer of a file there is one of (novox/hq ADR 0212 §3).
|
||||||
|
func seatPlaceholderProblems(m Manifest, r map[string]any) []string {
|
||||||
|
var problems []string
|
||||||
|
for _, field := range sortedKeys(r) {
|
||||||
|
v, ok := r[field].(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found := ofContribution.FindAllStringSubmatch(v, -1)
|
||||||
|
if len(found) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if field != "content" {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s's resource %v names %s in its %s; contributions are placed only in a file's content",
|
||||||
|
m.Module, r["id"], found[0][0], field))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, f := range found {
|
||||||
|
seat, kind, two := strings.Cut(f[1], ":")
|
||||||
|
s, _, ok := receivable(seat, kind)
|
||||||
|
if !two || !ok {
|
||||||
|
detail := "the mesh defines no seat " + fmt.Sprintf("%q", seat)
|
||||||
|
if s.Name != "" {
|
||||||
|
detail = s.Name + ": " + kindsOf(s)
|
||||||
|
}
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s's resource %v names %s; a contribution is ${contribution:<seat>:<kind>} (%s)",
|
||||||
|
m.Module, r["id"], f[0], detail))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !m.ClaimsSeat(s.Name) {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s's resource %v names %s and %s does not claim %s; every module's contributions to a "+
|
||||||
|
"seat are placed by its holder alone (novox/hq ADR 0212)",
|
||||||
|
m.Module, r["id"], f[0], m.Module, s.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatContributions is every module's contribution of one kind to one seat (novox/hq ADR 0212 §3):
|
||||||
|
// in module order, each module's pieces in the order it declared them, each module's preceded by a
|
||||||
|
// comment line naming it in the tool's grammar, and empty when nothing is contributed.
|
||||||
|
func seatContributions(modules []Manifest, seat, kind string) string {
|
||||||
|
s, r, ok := receivable(seat, kind)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, m := range inModuleOrder(modules) {
|
||||||
|
named := false
|
||||||
|
for _, c := range m.Contributions {
|
||||||
|
if c.Kind != kind {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cs, known := SeatNamed(c.Seat); !known || cs.Name != s.Name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !named {
|
||||||
|
fmt.Fprintf(&b, "%s %s\n", r.Comment, m.Module)
|
||||||
|
named = true
|
||||||
|
}
|
||||||
|
b.WriteString(c.Content)
|
||||||
|
if !strings.HasSuffix(c.Content, "\n") {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defends novox/hq ADR 0212: a seat says what it receives, its holder places it, and a contribution
|
||||||
|
// depends on the seat.
|
||||||
|
|
||||||
|
func hotkeysHolder() Manifest {
|
||||||
|
return Manifest{Module: "triggerhappy", Claims: []Claim{{Name: HotkeysSeat}}, Resources: []map[string]any{
|
||||||
|
{"id": "triggers", "type": "file", "path": "/etc/triggerhappy/triggers.d/mesh.conf",
|
||||||
|
"content": "# the mesh's triggers\n${contribution:node-hotkeys:trigger}"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAContributionReachesTheHoldersFileInModuleOrderNamedByModule(t *testing.T) {
|
||||||
|
laptop := Manifest{Module: "laptop", Contributions: []SeatContribution{
|
||||||
|
{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_PROG1 1 play ${machine:account-home}"},
|
||||||
|
{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_F21 1 touchpad\n"},
|
||||||
|
}}
|
||||||
|
another := Manifest{Module: "another", Contributions: []SeatContribution{
|
||||||
|
{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_MUTE 1 mute ${shell:zsh:first}"},
|
||||||
|
}}
|
||||||
|
unrelated := Manifest{Module: "bar", Contributions: []SeatContribution{
|
||||||
|
{Seat: DisplaySessionSeat, Kind: "config", Content: "bar { }"},
|
||||||
|
}}
|
||||||
|
r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{hotkeysHolder(), laptop, another, unrelated}}
|
||||||
|
out, err := r.Declaration(Rendering{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var file map[string]any
|
||||||
|
for _, res := range out {
|
||||||
|
if res["id"] == "triggerhappy.triggers" {
|
||||||
|
file = res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if file == nil {
|
||||||
|
t.Fatalf("the holder's file was not composed: %v", out)
|
||||||
|
}
|
||||||
|
// Module order; each module named once; text never read, so neither ${machine:…} nor ${shell:…}
|
||||||
|
// inside a contribution is filled.
|
||||||
|
want := "# the mesh's triggers\n" +
|
||||||
|
"# another\nKEY_MUTE 1 mute ${shell:zsh:first}\n" +
|
||||||
|
"# laptop\nKEY_PROG1 1 play ${machine:account-home}\nKEY_F21 1 touchpad\n"
|
||||||
|
if got := file["content"]; got != want {
|
||||||
|
t.Fatalf("the holder's file is\n%s\nnot\n%s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoContributionPlacesNothing(t *testing.T) {
|
||||||
|
r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{hotkeysHolder()}}
|
||||||
|
out, err := r.Declaration(Rendering{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, res := range out {
|
||||||
|
if res["id"] == "triggerhappy.triggers" && res["content"] != "# the mesh's triggers\n" {
|
||||||
|
t.Fatalf("an empty kind left %q", res["content"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAContributionToASeatThatDoesNotReceiveItIsRefused(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
`{"seat":"node-hotkeys","kind":"config","content":"x"}`: "it receives trigger",
|
||||||
|
`{"seat":"node-nothing","kind":"trigger","content":"x"}`: "the mesh does not define",
|
||||||
|
`{"seat":"node-hotkeys","kind":"trigger","content":" "}`: "has no content",
|
||||||
|
`{"seat":"node-display-session","kind":"trigger","content":"x"}`: "it receives config",
|
||||||
|
}
|
||||||
|
for c, want := range cases {
|
||||||
|
raw := `{"module":"laptop","contributions":[` + c + `]}`
|
||||||
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("%s: accepted, or refused without %q: %v", c, want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAContributionPlaceholderOutsideTheHolderIsRefused(t *testing.T) {
|
||||||
|
raw := `{"module":"laptop","resources":[{"id":"t","type":"file","path":"/etc/t","content":"${contribution:node-hotkeys:trigger}"}]}`
|
||||||
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "laptop does not claim node-hotkeys") {
|
||||||
|
t.Errorf("a placeholder outside the holder was accepted: %v", err)
|
||||||
|
}
|
||||||
|
raw = `{"module":"triggerhappy","claims":[{"name":"node-hotkeys"}],"resources":[{"id":"t","type":"file","path":"/etc/t","content":"${contribution:node-hotkeys:keys}"}]}`
|
||||||
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "it receives trigger") {
|
||||||
|
t.Errorf("a placeholder for a kind the seat does not receive was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAContributionDependsOnItsSeat(t *testing.T) {
|
||||||
|
m := Manifest{Module: "laptop", Contributions: []SeatContribution{
|
||||||
|
{Seat: HotkeysSeat, Kind: "trigger", Content: "x"},
|
||||||
|
{Seat: DisplaySessionSeat, Kind: "config", Content: "y"},
|
||||||
|
}}
|
||||||
|
if got, want := DependsOn(m), []string{DisplaySessionSeat, HotkeysSeat}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("depends on %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
catalogue := map[string]Manifest{"laptop": m, "triggerhappy": hotkeysHolder()}
|
||||||
|
if _, err := AssignRefusal(catalogue, "laptop", nil, []string{"laptop"}); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), HotkeysSeat) {
|
||||||
|
t.Errorf("a contributor without the holder was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheHotkeysSeatIsTheMeshsAndReceivesTriggers(t *testing.T) {
|
||||||
|
s, ok := SeatNamed(HotkeysSeat)
|
||||||
|
if !ok || s.Scope != ScopeNode || len(s.Receives) != 1 || s.Receives[0].Kind != "trigger" {
|
||||||
|
t.Fatalf("%+v %v", s, ok)
|
||||||
|
}
|
||||||
|
d, _ := SeatNamed(DisplaySessionSeat)
|
||||||
|
if len(d.Receives) != 1 || d.Receives[0].Kind != "config" {
|
||||||
|
t.Fatalf("the display session receives %+v", d.Receives)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module depends on the node seats that apply its resources (novox/hq ADR 0207), and on the
|
||||||
|
// seats it contributes to (novox/hq ADR 0210).
|
||||||
|
//
|
||||||
|
// Some of what a module declares is applied through software on the machine that is itself a
|
||||||
|
// module: a service through the service manager, a package through the package manager, a container
|
||||||
|
// through the container runtime. A *capability* only says that software is installed; it does not
|
||||||
|
// say that a module of the mesh holds the role and answers for it. So the dependency is derived from
|
||||||
|
// the resources — never stated in a manifest, because a module that adds a service and forgets a
|
||||||
|
// field would pass — and is met when some module assigned to the same node holds the seat.
|
||||||
|
|
||||||
|
// The three seats that apply resources (novox/hq ADR 0207 §1). Named once, because the derivation,
|
||||||
|
// the seed and the messages all turn on these strings.
|
||||||
|
const (
|
||||||
|
ServiceManagerSeat = "node-service-manager"
|
||||||
|
PackageManagerSeat = "node-package-manager"
|
||||||
|
ContainerRuntimeSeat = "node-container-runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// appliedThrough is which seat applies a resource of each type. **Only these three**: ADR 0207
|
||||||
|
// names them and no more. A process is supervised by the host itself, a file, a directory, an
|
||||||
|
// archive, a user or an action is the host's own act, and a module's other kinds reach the machine
|
||||||
|
// without a role in between — adding one here is a decision, not a refinement.
|
||||||
|
var appliedThrough = map[string]string{
|
||||||
|
"service": ServiceManagerSeat,
|
||||||
|
"package": PackageManagerSeat,
|
||||||
|
"container": ContainerRuntimeSeat,
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforceSeatDependencies is the one-line switch ADR 0207 §4 names. Off, an unmet dependency at
|
||||||
|
// composition is *reported* — in the resolution, in `status`, once in the log — and the node still
|
||||||
|
// resolves; on, it is refused like any unresolved requirement. Off until `status` reports none,
|
||||||
|
// which is when the three holders are assigned to every node: switching it before then would stop
|
||||||
|
// every machine lacking one from being sent anything at all.
|
||||||
|
//
|
||||||
|
// Switched on 2026-10-04, when `status` first reported no unmet dependency on any node: systemd,
|
||||||
|
// pacman and docker were assigned to all four machines that afternoon (novox/hq to-be 42).
|
||||||
|
//
|
||||||
|
// A variable rather than a constant only so a test can hold both behaviours; nothing else sets it.
|
||||||
|
var enforceSeatDependencies = true
|
||||||
|
|
||||||
|
// EnforcingSeatDependencies sets the switch and returns what puts it back. For tests in other
|
||||||
|
// packages that hold the behaviour from before the switch; nothing else calls it.
|
||||||
|
func EnforcingSeatDependencies(on bool) (restore func()) {
|
||||||
|
was := enforceSeatDependencies
|
||||||
|
enforceSeatDependencies = on
|
||||||
|
return func() { enforceSeatDependencies = was }
|
||||||
|
}
|
||||||
|
|
||||||
|
// foundationModules are the pieces genesis lays before any module exists (novox/hq ADR 0207 §5):
|
||||||
|
// the host and the private network. Registered as modules so they can be assigned, but what they
|
||||||
|
// declare is the installation's, not a module's, so it is never judged. The third piece, the
|
||||||
|
// bootstrap container runtime, is not a module at all: its package and service are in the genesis
|
||||||
|
// bundle the host applies itself, and never pass through a resolution here.
|
||||||
|
//
|
||||||
|
// The private network's module is overlay.Name, written out because the overlay package composes
|
||||||
|
// on top of this one; its resources are computed, which exempts it by the rule below as well.
|
||||||
|
var foundationModules = map[string]bool{
|
||||||
|
"mesh-host": true,
|
||||||
|
"mesh-wireguard": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// isFoundation is whether a module's declarations are the foundation's rather than its own. A
|
||||||
|
// module whose resources are computed is the mesh's by construction — the private network's peer
|
||||||
|
// list and its tools are the controller's, written per node — so it counts whatever its name.
|
||||||
|
func isFoundation(m Manifest) bool {
|
||||||
|
return foundationModules[m.Module] || m.Computed != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// DependsOn is every seat a module needs held on its node, derived from the resource types it
|
||||||
|
// declares itself (novox/hq ADR 0207 §2), sorted.
|
||||||
|
//
|
||||||
|
// **The module's own `resources` only.** What the controller composes around a module — its
|
||||||
|
// filter, jails, certificates, kept files, bundles, the guard — is the mesh's, put there because the
|
||||||
|
// module is assigned, and depending on it would make the module answer for the mesh's choices.
|
||||||
|
func DependsOn(m Manifest) []string {
|
||||||
|
if isFoundation(m) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if seat, applied := appliedThrough[fmt.Sprint(r["type"])]; applied {
|
||||||
|
seen[seat] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, seat := range contributedTo(m) {
|
||||||
|
seen[seat] = true
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for s := range seen {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// contributedTo is every seat a module contributes to (novox/hq ADR 0210 §3): a contribution is
|
||||||
|
// configuration only the seat's holder applies, so it is a dependency on that seat exactly as a
|
||||||
|
// resource is on the seat that applies it. The environment goes to node-environment's holder
|
||||||
|
// (ADR 0203); shell code to the holder that places it for its target — the login shell's for a
|
||||||
|
// shell, the display server's for the session's start and resources (ADR 0204, ADR 0208 §4).
|
||||||
|
func contributedTo(m Manifest) []string {
|
||||||
|
var out []string
|
||||||
|
if e := m.Environment; e != nil && (len(e.Variables) > 0 || len(e.Path) > 0) {
|
||||||
|
out = append(out, EnvironmentSeat)
|
||||||
|
}
|
||||||
|
for _, c := range m.Shell {
|
||||||
|
out = append(out, placerOf(c.For))
|
||||||
|
}
|
||||||
|
// Any other seat's contribution (novox/hq ADR 0212 §4), by the seat's canonical name; one the
|
||||||
|
// mesh does not define is refused at registration and depends on nothing here.
|
||||||
|
for _, c := range m.Contributions {
|
||||||
|
if s, known := SeatNamed(c.Seat); known {
|
||||||
|
out = append(out, s.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimsSeat is whether a module claims a node seat, by its current name or one it used to have
|
||||||
|
// (ADR 0122), so a rename leaves the dependency met.
|
||||||
|
func claimsSeat(m Manifest, seat string) bool {
|
||||||
|
for _, c := range m.Claims {
|
||||||
|
if c.At() != ScopeNode {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := c.Name
|
||||||
|
if s, known := SeatNamed(name); known {
|
||||||
|
name = s.Name
|
||||||
|
}
|
||||||
|
if name == seat {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PossibleHolders is every module in the catalogue that claims a seat at node scope — what a
|
||||||
|
// refusal names as the remedy.
|
||||||
|
func PossibleHolders(catalogue map[string]Manifest, seat string) []string {
|
||||||
|
var out []string
|
||||||
|
for name, m := range catalogue {
|
||||||
|
if claimsSeat(m, seat) {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unheld is one dependency of one module on a node that nothing on that node holds.
|
||||||
|
type Unheld struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
Seat string `json:"seat"`
|
||||||
|
// Holders are the modules in the catalogue that could hold the seat: assigning one meets it.
|
||||||
|
Holders []string `json:"holders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// String is the line a refusal and a report both say, so the two never drift.
|
||||||
|
func (u Unheld) String() string {
|
||||||
|
remedy := "and no module in the catalogue claims it yet"
|
||||||
|
if len(u.Holders) > 0 {
|
||||||
|
remedy = "— assign one that holds it: " + strings.Join(u.Holders, ", ")
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s on %s depends on %s, which nothing on %s holds (novox/hq ADR 0207) %s",
|
||||||
|
u.Module, u.Node, u.Seat, u.Node, remedy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnheldDependencies is every dependency of the modules in `judged` that the node's whole set
|
||||||
|
// leaves unmet (novox/hq ADR 0207 §3).
|
||||||
|
//
|
||||||
|
// **Judged over the whole set, never one module at a time.** The holders depend on each other:
|
||||||
|
// the service manager's own package needs the package manager, and the package manager's timer
|
||||||
|
// needs the service manager. Asked one by one, neither could ever be first; asked of the set, the
|
||||||
|
// two assigned together meet each other. A module holding a seat it depends on meets its own
|
||||||
|
// dependency. `judged` nil judges every module of the set.
|
||||||
|
func UnheldDependencies(catalogue map[string]Manifest, node string, set []Manifest, judged map[string]bool) []Unheld {
|
||||||
|
held := map[string]bool{}
|
||||||
|
for _, m := range set {
|
||||||
|
for _, seat := range nodeSeatsClaimed(m) {
|
||||||
|
held[seat] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []Unheld
|
||||||
|
for _, m := range set {
|
||||||
|
if judged != nil && !judged[m.Module] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, seat := range DependsOn(m) {
|
||||||
|
if held[seat] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, Unheld{Node: node, Module: m.Module, Seat: seat,
|
||||||
|
Holders: PossibleHolders(catalogue, seat)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Module != out[j].Module {
|
||||||
|
return out[i].Module < out[j].Module
|
||||||
|
}
|
||||||
|
return out[i].Seat < out[j].Seat
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// nodeSeatsClaimed is every node seat a module claims, each by its current name (ADR 0122), so
|
||||||
|
// a dependency on any of them — a resource's or a contribution's — is met by the claim.
|
||||||
|
func nodeSeatsClaimed(m Manifest) []string {
|
||||||
|
var out []string
|
||||||
|
for _, c := range m.Claims {
|
||||||
|
if c.At() != ScopeNode {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := c.Name
|
||||||
|
if s, known := SeatNamed(name); known {
|
||||||
|
name = s.Name
|
||||||
|
}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// manifestsOf is the catalogue's definitions of the named modules; a name the catalogue does not
|
||||||
|
// know contributes nothing, as it does to a resolution.
|
||||||
|
func manifestsOf(catalogue map[string]Manifest, names []string) []Manifest {
|
||||||
|
var out []Manifest
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, n := range names {
|
||||||
|
if m, known := catalogue[n]; known && !seen[n] {
|
||||||
|
seen[n] = true
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignRefusal is why assigning `adding` to a node already running `assigned` is refused, or
|
||||||
|
// nothing (novox/hq ADR 0207 §4): each new module's dependency the node's assignments, the new ones
|
||||||
|
// included, leave unmet.
|
||||||
|
//
|
||||||
|
// **Only the new modules are judged.** A node already short of a holder is reported by `status`;
|
||||||
|
// refusing an unrelated assignment for it would make the remedy — assigning the holder — refused too.
|
||||||
|
//
|
||||||
|
// **A dependency nothing in the catalogue can meet is said, not refused.** A refusal names the
|
||||||
|
// module that would meet it; with none registered there is no remedy to name, and refusing would
|
||||||
|
// stop every assignment of that kind until a module that does not exist yet is written. The answer
|
||||||
|
// still says it, and `status` reports it — before the switch and after it alike: there is never a
|
||||||
|
// remedy to name for it. The first return is those lines.
|
||||||
|
func AssignRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) ([]string, error) {
|
||||||
|
set := manifestsOf(catalogue, append(append([]string(nil), assigned...), adding...))
|
||||||
|
judged := map[string]bool{}
|
||||||
|
for _, a := range adding {
|
||||||
|
judged[a] = true
|
||||||
|
}
|
||||||
|
var refused, said []string
|
||||||
|
for _, u := range UnheldDependencies(catalogue, node, set, judged) {
|
||||||
|
if len(u.Holders) == 0 {
|
||||||
|
said = append(said, u.String())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
refused = append(refused, u.String())
|
||||||
|
}
|
||||||
|
if len(refused) > 0 {
|
||||||
|
return said, &Refusal{Problems: append(refused,
|
||||||
|
fmt.Sprintf("holders that depend on each other are assigned together: `assign %s <module> <module>…`", node))}
|
||||||
|
}
|
||||||
|
return said, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnassignRefusal is why taking `removing` off a node running `assigned` is refused, or nothing
|
||||||
|
// (novox/hq ADR 0207): a seat the removed modules hold that nothing left on the node holds, while
|
||||||
|
// a module left there depends on it. Names the dependents, because they are what must go first —
|
||||||
|
// or the holder's replacement come.
|
||||||
|
func UnassignRefusal(catalogue map[string]Manifest, node string, assigned, removing []string) error {
|
||||||
|
gone := map[string]bool{}
|
||||||
|
for _, r := range removing {
|
||||||
|
gone[r] = true
|
||||||
|
}
|
||||||
|
var left []string
|
||||||
|
for _, a := range assigned {
|
||||||
|
if !gone[a] {
|
||||||
|
left = append(left, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
before := map[string]bool{}
|
||||||
|
for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, assigned), nil) {
|
||||||
|
before[u.Module+"\x00"+u.Seat] = true
|
||||||
|
}
|
||||||
|
dependents := map[string][]string{}
|
||||||
|
for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, left), nil) {
|
||||||
|
if before[u.Module+"\x00"+u.Seat] {
|
||||||
|
continue // unmet already; not this removal's doing
|
||||||
|
}
|
||||||
|
dependents[u.Seat] = append(dependents[u.Seat], u.Module)
|
||||||
|
}
|
||||||
|
if len(dependents) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seats := make([]string, 0, len(dependents))
|
||||||
|
for s := range dependents {
|
||||||
|
seats = append(seats, s)
|
||||||
|
}
|
||||||
|
sort.Strings(seats)
|
||||||
|
var problems []string
|
||||||
|
for _, s := range seats {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s holds %s on %s, and %s depend on it (novox/hq ADR 0207) — unassign them with it, "+
|
||||||
|
"or assign another holder first", strings.Join(removing, ", "), s, node,
|
||||||
|
strings.Join(dependents[s], ", ")))
|
||||||
|
}
|
||||||
|
return &Refusal{Problems: problems}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollisionRefusal is why assigning `adding` beside `assigned` is refused for what two modules
|
||||||
|
// would both declare, or nothing (novox/hq ADR 0210 §1, 04-ISSUES/235).
|
||||||
|
//
|
||||||
|
// **Refused, not kept like an unresolved provision.** An assignment is otherwise kept when the
|
||||||
|
// node does not resolve, because assignment is not an ordering: a consumer's provider can follow.
|
||||||
|
// A collision is not an order anything can complete — no further assignment makes two owners of one
|
||||||
|
// package one owner — and kept, it leaves the node unresolvable, so the next push of anything drops
|
||||||
|
// it from the mesh. Only collisions involving a module being added are refused; one already on the
|
||||||
|
// node is `status`'s, and refusing an unrelated assignment for it would block its own remedy.
|
||||||
|
func CollisionRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) error {
|
||||||
|
before := map[string]bool{}
|
||||||
|
for _, p := range checkResources(manifestsOf(catalogue, assigned)) {
|
||||||
|
before[p] = true
|
||||||
|
}
|
||||||
|
var problems []string
|
||||||
|
for _, p := range checkResources(manifestsOf(catalogue, append(append([]string(nil), assigned...), adding...))) {
|
||||||
|
if before[p] {
|
||||||
|
continue // on the node already; not this assignment's doing
|
||||||
|
}
|
||||||
|
problems = append(problems, p+" (novox/hq ADR 0210: one owner per node; the other module "+
|
||||||
|
"depends on the owner's seat instead)")
|
||||||
|
}
|
||||||
|
if len(problems) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sort.Strings(problems)
|
||||||
|
return &Refusal{Problems: append(problems, fmt.Sprintf("nothing was assigned to %s", node))}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defends novox/hq ADR 0207: a module depends on the node seats that apply its resources.
|
||||||
|
|
||||||
|
func res(kind, id string) map[string]any {
|
||||||
|
r := map[string]any{"id": id, "type": kind}
|
||||||
|
switch kind {
|
||||||
|
case "service":
|
||||||
|
r["unit"] = id + ".service"
|
||||||
|
case "package":
|
||||||
|
r["package"] = id
|
||||||
|
case "container":
|
||||||
|
r["image"] = id
|
||||||
|
case "file":
|
||||||
|
r["path"] = "/etc/" + id
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func withResources(m Manifest, rs ...map[string]any) Manifest {
|
||||||
|
m.Resources = rs
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three holders as to-be 42 names them, each declaring what it really does: systemd's own
|
||||||
|
// package needs the package manager, pacman's timer needs the service manager, docker's package and
|
||||||
|
// service need both.
|
||||||
|
func coreThree() []Manifest {
|
||||||
|
return []Manifest{
|
||||||
|
withResources(mod("systemd", nil, nil, nil, Claim{Name: ServiceManagerSeat}), res("package", "systemd")),
|
||||||
|
withResources(mod("pacman", nil, nil, nil, Claim{Name: PackageManagerSeat}), res("service", "pacman-refresh")),
|
||||||
|
withResources(mod("docker", nil, nil, nil, Claim{Name: ContainerRuntimeSeat}),
|
||||||
|
res("package", "docker"), res("service", "docker")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADependencyIsDerivedFromEachResourceTypeThatAppliesThroughASeat(t *testing.T) {
|
||||||
|
cases := map[string][]string{
|
||||||
|
"service": {ServiceManagerSeat},
|
||||||
|
"package": {PackageManagerSeat},
|
||||||
|
"container": {ContainerRuntimeSeat},
|
||||||
|
// The host's own acts, or the mesh's: nothing in between holds a role for them.
|
||||||
|
"file": nil, "directory": nil, "process": nil, "archive": nil, "user": nil,
|
||||||
|
"action": nil, "network": nil, "access": nil,
|
||||||
|
}
|
||||||
|
for kind, want := range cases {
|
||||||
|
got := DependsOn(withResources(mod("m", nil, nil, nil), res(kind, "x")))
|
||||||
|
if len(got) == 0 {
|
||||||
|
got = nil
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("a %s resource depends on %v, want %v", kind, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all := DependsOn(withResources(mod("m", nil, nil, nil),
|
||||||
|
res("container", "a"), res("service", "b"), res("package", "c"), res("package", "d")))
|
||||||
|
if want := []string{ContainerRuntimeSeat, PackageManagerSeat, ServiceManagerSeat}; !reflect.DeepEqual(all, want) {
|
||||||
|
t.Errorf("a module of every kind depends on %v, want each seat once: %v", all, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheSeatsThatApplyResourcesAreTheMeshsOwnAtNodeScope(t *testing.T) {
|
||||||
|
for _, name := range []string{ServiceManagerSeat, PackageManagerSeat, ContainerRuntimeSeat} {
|
||||||
|
s, ok := SeatNamed(name)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s is not in the mesh's set", name)
|
||||||
|
}
|
||||||
|
if s.Scope != ScopeNode {
|
||||||
|
t.Errorf("%s is held per %s, want per node", name, s.Scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No verbs yet for either new seat: ADR 0207 seeds the package manager without a protocol, and
|
||||||
|
// the runtime's verbs wait for ADR 0166's acceptance.
|
||||||
|
for _, name := range []string{PackageManagerSeat, ContainerRuntimeSeat} {
|
||||||
|
if s, _ := SeatNamed(name); len(s.Serves)+len(s.Accepts)+len(s.Emits) > 0 {
|
||||||
|
t.Errorf("%s carries a protocol; ADR 0207 seeds it with none", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestANodeWhoseAssignmentsHoldTheSeatsResolvesWithNothingUnheld(t *testing.T) {
|
||||||
|
web := withResources(mod("web", nil, nil, nil), res("container", "web"), res("service", "web-timer"))
|
||||||
|
cat := shelf(append(coreThree(), web)...)
|
||||||
|
got, err := Resolve(cat, []string{"systemd", "pacman", "docker", "web"}, workstation(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Unheld) != 0 {
|
||||||
|
t.Errorf("a node holding all three seats reports %v", got.Unheld)
|
||||||
|
}
|
||||||
|
if _, err := AssignRefusal(cat, "workstation", []string{"systemd", "pacman", "docker"}, []string{"web"}); err != nil {
|
||||||
|
t.Errorf("assigning beside the three holders was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAssignmentMissingAHolderIsRefusedNamingTheSeatAndItsPossibleHolders(t *testing.T) {
|
||||||
|
web := withResources(mod("web", nil, nil, nil), res("container", "web"), res("service", "web-timer"))
|
||||||
|
cat := shelf(append(coreThree(), web)...)
|
||||||
|
_, err := AssignRefusal(cat, "workstation", []string{"systemd", "pacman"}, []string{"web"})
|
||||||
|
var refusal *Refusal
|
||||||
|
if !errors.As(err, &refusal) {
|
||||||
|
t.Fatalf("web assigned to a node without a container runtime was not refused: %v", err)
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
for _, want := range []string{"web on workstation depends on " + ContainerRuntimeSeat, "docker", "ADR 0207"} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Errorf("the refusal does not say %q:\n%s", want, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// What the node does hold is not named as missing.
|
||||||
|
if strings.Contains(msg, "depends on "+ServiceManagerSeat) {
|
||||||
|
t.Errorf("the refusal names a seat systemd already holds:\n%s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADependencyNoCatalogueModuleCanMeetIsSaidNeverRefused(t *testing.T) {
|
||||||
|
// No runtime module in the catalogue: refusing would stop every container's assignment until one
|
||||||
|
// is written, with no remedy to name — so it is said, with the switch on as with it off.
|
||||||
|
web := withResources(mod("web", nil, nil, nil), res("container", "web"))
|
||||||
|
cat := shelf(web)
|
||||||
|
for _, on := range []bool{false, true} {
|
||||||
|
enforceSeatDependencies = on
|
||||||
|
said, err := AssignRefusal(cat, "workstation", nil, []string{"web"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("switch %v: a dependency nothing could meet was refused: %v", on, err)
|
||||||
|
}
|
||||||
|
if len(said) != 1 || !strings.Contains(said[0], "no module in the catalogue claims it yet") {
|
||||||
|
t.Errorf("switch %v: the assignment does not say what it depends on: %v", on, said)
|
||||||
|
}
|
||||||
|
if _, err := Resolve(cat, []string{"web"}, workstation(), World{}); err != nil {
|
||||||
|
t.Errorf("switch %v: a dependency nothing could meet refused the node: %v", on, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enforceSeatDependencies = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheHoldersMutualDependenceResolvesWhenAssignedTogether(t *testing.T) {
|
||||||
|
cat := shelf(coreThree()...)
|
||||||
|
// Alone, each needs the other.
|
||||||
|
if _, err := AssignRefusal(cat, "workstation", nil, []string{"systemd"}); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "pacman") {
|
||||||
|
t.Errorf("systemd alone was not refused naming pacman: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := AssignRefusal(cat, "workstation", nil, []string{"pacman"}); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "systemd") {
|
||||||
|
t.Errorf("pacman alone was not refused naming systemd: %v", err)
|
||||||
|
}
|
||||||
|
// Together, in one act, they meet each other — and docker meets its own seat.
|
||||||
|
if _, err := AssignRefusal(cat, "workstation", nil, []string{"systemd", "pacman", "docker"}); err != nil {
|
||||||
|
t.Errorf("the three holders assigned together were refused: %v", err)
|
||||||
|
}
|
||||||
|
got, err := Resolve(cat, []string{"systemd", "pacman"}, workstation(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Unheld) != 0 {
|
||||||
|
t.Errorf("systemd and pacman together report %v", got.Unheld)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusIsToldOfAnUnmetDependencyAndTheNodeStillResolves(t *testing.T) {
|
||||||
|
// Before the switch (the state the mesh ran in until every node held the three seats).
|
||||||
|
enforceSeatDependencies = false
|
||||||
|
defer func() { enforceSeatDependencies = true }()
|
||||||
|
web := withResources(mod("web", nil, nil, nil), res("container", "web"))
|
||||||
|
cat := shelf(append(coreThree(), web)...)
|
||||||
|
got, err := Resolve(cat, []string{"web"}, workstation(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("an unmet dependency refused the node before the switch: %v", err)
|
||||||
|
}
|
||||||
|
want := []Unheld{{Node: "workstation", Module: "web", Seat: ContainerRuntimeSeat, Holders: []string{"docker"}}}
|
||||||
|
if !reflect.DeepEqual(got.Unheld, want) {
|
||||||
|
t.Errorf("reported %+v, want %+v", got.Unheld, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithTheSwitchFlippedAnUnmetDependencyRefusesTheNode(t *testing.T) {
|
||||||
|
enforceSeatDependencies = true
|
||||||
|
defer func() { enforceSeatDependencies = true }()
|
||||||
|
web := withResources(mod("web", nil, nil, nil), res("container", "web"))
|
||||||
|
cat := shelf(append(coreThree(), web)...)
|
||||||
|
_, err := Resolve(cat, []string{"web"}, workstation(), World{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), ContainerRuntimeSeat) || !strings.Contains(err.Error(), "docker") {
|
||||||
|
t.Fatalf("with the switch on, an unmet dependency gave %v", err)
|
||||||
|
}
|
||||||
|
// Never in the first pass, whose refusals take a machine off the network instead.
|
||||||
|
if _, err := Resolve(cat, []string{"web"}, workstation(), World{Unchecked: true}); err != nil {
|
||||||
|
t.Errorf("the first pass refused an unmet dependency: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheFoundationsDeclarationsAreNotJudged(t *testing.T) {
|
||||||
|
// The private network, as the controller computes it: its tools' package and its service are
|
||||||
|
// the mesh's, written per node, and so are never a module's dependency.
|
||||||
|
network := withResources(mod("mesh-wireguard", []string{"private-network"}, nil, nil),
|
||||||
|
res("package", "wireguard-tools"), res("service", "overlay-up"))
|
||||||
|
network.Computed = "mesh-wireguard"
|
||||||
|
// The host, whatever it declares.
|
||||||
|
host := withResources(mod("mesh-host", nil, nil, nil), res("file", "launcher"), res("service", "nox-mesh-host"))
|
||||||
|
cat := shelf(append(coreThree(), network, host)...)
|
||||||
|
|
||||||
|
for _, m := range []Manifest{network, host} {
|
||||||
|
if d := DependsOn(m); len(d) != 0 {
|
||||||
|
t.Errorf("%s, the foundation's, depends on %v", m.Module, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := Resolve(cat, []string{"mesh-wireguard", "mesh-host"}, workstation(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Unheld) != 0 {
|
||||||
|
t.Errorf("the foundation on a node with no holders reports %v", got.Unheld)
|
||||||
|
}
|
||||||
|
if _, err := AssignRefusal(cat, "workstation", nil, []string{"mesh-wireguard", "mesh-host"}); err != nil {
|
||||||
|
t.Errorf("assigning the foundation was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnassigningTheLastHolderWhileDependentsRemainIsRefused(t *testing.T) {
|
||||||
|
sshd := withResources(mod("sshd", nil, nil, nil), res("service", "sshd"))
|
||||||
|
cat := shelf(append(coreThree(), sshd)...)
|
||||||
|
on := []string{"systemd", "pacman", "docker", "sshd"}
|
||||||
|
|
||||||
|
err := UnassignRefusal(cat, "workstation", on, []string{"systemd"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("the last service manager came off a node still running services")
|
||||||
|
}
|
||||||
|
for _, want := range []string{ServiceManagerSeat, "sshd", "pacman", "docker"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("the refusal does not name %q:\n%v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A dependent comes off freely, and the holders with everything depending on them in one act.
|
||||||
|
if err := UnassignRefusal(cat, "workstation", on, []string{"sshd"}); err != nil {
|
||||||
|
t.Errorf("a dependent's unassignment was refused: %v", err)
|
||||||
|
}
|
||||||
|
if err := UnassignRefusal(cat, "workstation", on, on); err != nil {
|
||||||
|
t.Errorf("unassigning everything together was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,8 +43,8 @@ import (
|
|||||||
var ofSeat = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):([0-9]+)\}`)
|
var ofSeat = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):([0-9]+)\}`)
|
||||||
|
|
||||||
// seatInto replaces a resource's ${seat:…} placeholders with where this machine put each seat's
|
// seatInto replaces a resource's ${seat:…} placeholders with where this machine put each seat's
|
||||||
// holder — in a file's content, and in a value of a container's environment. The same two places
|
// holder — in a file's content, and in a value of a container's or a process's environment. The
|
||||||
// portInto fills, for the same reason: they are where a process reads a number from.
|
// same places portInto fills, for the same reason: they are where a program reads a number from.
|
||||||
func seatInto(resource map[string]any, module string, with Rendering) error {
|
func seatInto(resource map[string]any, module string, with Rendering) error {
|
||||||
switch fmt.Sprint(resource["type"]) {
|
switch fmt.Sprint(resource["type"]) {
|
||||||
case "file":
|
case "file":
|
||||||
@@ -58,7 +58,7 @@ func seatInto(resource map[string]any, module string, with Rendering) error {
|
|||||||
}
|
}
|
||||||
resource["content"] = filled
|
resource["content"] = filled
|
||||||
|
|
||||||
case "container":
|
case "container", "process":
|
||||||
env, ok := resource["env"].(map[string]any)
|
env, ok := resource["env"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
@@ -78,8 +78,8 @@ func seatInto(resource map[string]any, module string, with Rendering) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
value, err := seatsFilledInto(written,
|
value, err := seatsFilledInto(written,
|
||||||
fmt.Sprintf("%s's container %s sets %s to something that",
|
fmt.Sprintf("%s's %s %s sets %s to something that",
|
||||||
module, resource["name"], key), with)
|
module, resource["type"], resource["name"], key), with)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// The manifest itself names them now; withSeatPorts is a no-op on it, and this holds it so.
|
// The manifest itself names them now; withSeatPorts is a no-op on it, and this holds it so.
|
||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
if r["type"] != "container" {
|
if r["type"] != "process" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
env, _ := r["env"].(map[string]any)
|
env, _ := r["env"].(map[string]any)
|
||||||
@@ -113,8 +113,9 @@ func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
m = withSeatPorts(m)
|
m = withSeatPorts(m)
|
||||||
control, err := m.Resolve([]Built{{
|
control, err := m.Resolve([]Built{{
|
||||||
Name: "server", Kind: ArtifactImage,
|
Name: "controller", Kind: ArtifactBundle,
|
||||||
Reference: ArtifactStoreScheme + "mesh-controller/server@sha256:" + strings.Repeat("c", 64),
|
Reference: ArtifactStoreScheme + "mesh-controller/controller@sha256:" + strings.Repeat("c", 64),
|
||||||
|
Digest: "sha256:" + strings.Repeat("c", 64),
|
||||||
}})
|
}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -135,9 +136,9 @@ func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("the control plane does not compose: %v", err)
|
t.Fatalf("the control plane does not compose: %v", err)
|
||||||
}
|
}
|
||||||
server := fileNamed(out, "mesh-controller.server")
|
server := fileNamed(out, "mesh-controller.controller")
|
||||||
if server == nil {
|
if server == nil {
|
||||||
t.Fatalf("the control plane's container is not in the declaration: %v", out)
|
t.Fatalf("the control plane's process is not in the declaration: %v", out)
|
||||||
}
|
}
|
||||||
env, _ := server["env"].(map[string]any)
|
env, _ := server["env"].(map[string]any)
|
||||||
for key, want := range map[string]string{
|
for key, want := range map[string]string{
|
||||||
@@ -151,8 +152,8 @@ func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
|
|||||||
t.Errorf("the control plane is told %s=%v; the node put it on %s", key, env[key], want)
|
t.Errorf("the control plane is told %s=%v; the node put it on %s", key, env[key], want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if got := server["image"]; got != "anchor.internal:5100/mesh-controller/server@sha256:"+strings.Repeat("c", 64) {
|
if got := server["source"]; got != "anchor.internal:5100/mesh-controller/controller@sha256:"+strings.Repeat("c", 64) {
|
||||||
t.Errorf("the control plane's own image is %v, not routed through the store", got)
|
t.Errorf("the control plane's own bundle is fetched from %v, not routed through the store", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// And on a mesh where the foundation is where genesis raised it, nothing is added.
|
// And on a mesh where the foundation is where genesis raised it, nothing is added.
|
||||||
@@ -160,7 +161,7 @@ func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
env, _ = fileNamed(out, "mesh-controller.server")["env"].(map[string]any)
|
env, _ = fileNamed(out, "mesh-controller.controller")["env"].(map[string]any)
|
||||||
if env["MESH_STORE_INVENTORY_PORT"] != "" {
|
if env["MESH_STORE_INVENTORY_PORT"] != "" {
|
||||||
t.Errorf("with no settings, the control plane is told %v", env)
|
t.Errorf("with no settings, the control plane is told %v", env)
|
||||||
}
|
}
|
||||||
@@ -186,7 +187,7 @@ func withSeatPorts(m Manifest) Manifest {
|
|||||||
out := m
|
out := m
|
||||||
out.Resources = nil
|
out.Resources = nil
|
||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
if r["type"] != "container" {
|
if r["type"] != "container" && r["type"] != "process" {
|
||||||
out.Resources = append(out.Resources, r)
|
out.Resources = append(out.Resources, r)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,16 +40,28 @@ type Seat struct {
|
|||||||
// Serves carries each verb in full — name, description, schema — because a role's tools are the
|
// Serves carries each verb in full — name, description, schema — because a role's tools are the
|
||||||
// mesh's to define and an agent's to call (novox/hq ADR 0132, design 33 §2).
|
// mesh's to define and an agent's to call (novox/hq ADR 0132, design 33 §2).
|
||||||
Serves []Verb
|
Serves []Verb
|
||||||
|
// Receives is what other modules may contribute to the seat's holder, by kind (novox/hq ADR
|
||||||
|
// 0212): each kind is text in the tool's own grammar, placed by the holder with
|
||||||
|
// ${contribution:<seat>:<kind>}. Compiled, never stored: like the protocol, it is the mesh's
|
||||||
|
// definition of the role, and the store's rows carry no column for it.
|
||||||
|
Receives []Receivable
|
||||||
// Decision is the record that made it a seat.
|
// Decision is the record that made it a seat.
|
||||||
Decision string
|
Decision string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Receivable is one kind of contribution a seat receives (novox/hq ADR 0212 §2): its name, and the
|
||||||
|
// comment prefix of the tool's grammar, with which the controller names each contributing module.
|
||||||
|
type Receivable struct {
|
||||||
|
Kind string
|
||||||
|
Comment string
|
||||||
|
}
|
||||||
|
|
||||||
// defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the
|
// defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the
|
||||||
// fallback when it has none (novox/hq ADR 0122). It is the one place the closed set 0110 defines is
|
// fallback when it has none (novox/hq ADR 0122). It is the one place the closed set 0110 defines is
|
||||||
// written; the store's table is seeded from it and thereafter is the live, editable copy.
|
// written; the store's table is seeded from it and thereafter is the live, editable copy.
|
||||||
//
|
//
|
||||||
// In the order a person reads it: the mesh's own, then a node's.
|
// In the order a person reads it: the mesh's own, then a node's.
|
||||||
var defaultSeats = []Seat{
|
var defaultSeats = append([]Seat{
|
||||||
// The control plane states what it did under the seat it holds (novox/hq ADR 0134): a role's
|
// The control plane states what it did under the seat it holds (novox/hq ADR 0134): a role's
|
||||||
// events belong to the role, so they keep their address while the holder is replaced. No accepts,
|
// events belong to the role, so they keep their address while the holder is replaced. No accepts,
|
||||||
// so no work queue is raised for it — only what its holder may say.
|
// so no work queue is raised for it — only what its holder may say.
|
||||||
@@ -107,7 +119,31 @@ var defaultSeats = []Seat{
|
|||||||
// that machine unresolvable in the meantime. Deleted once no registered manifest claims it.
|
// that machine unresolvable in the meantime. Deleted once no registered manifest claims it.
|
||||||
{Name: "mesh-build-machine", Scope: ScopeMesh,
|
{Name: "mesh-build-machine", Scope: ScopeMesh,
|
||||||
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0190"},
|
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0190"},
|
||||||
|
// **The mesh's one resolver** (novox/hq ADR 0194, 0196): every node's internal domain, held in one
|
||||||
|
// place, and every node and container asks it first. Delivers what a machine's resolver
|
||||||
|
// configuration requires, so that requirement resolves to the holder wherever it is placed.
|
||||||
|
{Name: "mesh-dns-resolver", Scope: ScopeMesh, Delivers: "wildcard-resolution", Decision: "novox/hq ADR 0194"},
|
||||||
|
// **Retired by ADR 0194, kept while a manifest still claims it** — the same reason as
|
||||||
|
// mesh-build-machine above: a machine still holds it until the mesh's resolver replaces it, and
|
||||||
|
// removing the row first would make that machine unresolvable. Deleted once nothing claims it.
|
||||||
{Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
{Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
||||||
|
// **A machine's /etc/hosts is one module's** (novox/hq ADR 0199): its holder writes the machine's
|
||||||
|
// own lines and keeps every other line as the operator's, changed through these three verbs on that
|
||||||
|
// machine alone. The controller holds none of it.
|
||||||
|
{Name: "node-hosts-file", Scope: ScopeNode, Decision: "novox/hq ADR 0199",
|
||||||
|
Serves: []Verb{
|
||||||
|
{Name: "entries", Description: "Every line of this machine's /etc/hosts, each marked whose it is: " +
|
||||||
|
"the operator's, or the block of the module or tool that writes it.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
{Name: "add", Description: "Add one address and its names to the operator's lines of this machine's " +
|
||||||
|
"/etc/hosts — a name for this machine's own programs, not the mesh's.",
|
||||||
|
Input: schema(map[string]string{"address": "the IPv4 or IPv6 address",
|
||||||
|
"names": "the names for it, separated by spaces"}, []string{"address", "names"})},
|
||||||
|
{Name: "remove", Description: "Remove one name, or every line of one address, from the operator's " +
|
||||||
|
"lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.",
|
||||||
|
Input: schema(map[string]string{"name": "a host name, or an address to remove every line of"},
|
||||||
|
[]string{"name"})},
|
||||||
|
}},
|
||||||
// The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list
|
// The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list
|
||||||
// whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all
|
// whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all
|
||||||
// four; the jails themselves are composed from the modules the machine runs (to-be 31).
|
// four; the jails themselves are composed from the modules the machine runs (to-be 31).
|
||||||
@@ -148,8 +184,39 @@ var defaultSeats = []Seat{
|
|||||||
// system or user scope; the holder answers questions and operator acts about them, each verb
|
// system or user scope; the holder answers questions and operator acts about them, each verb
|
||||||
// taking the unit and an optional scope. The holder runs nothing of its own: its verbs are
|
// taking the unit and an optional scope. The holder runs nothing of its own: its verbs are
|
||||||
// served by the node tools runtime (ADR 0175).
|
// served by the node tools runtime (ADR 0175).
|
||||||
{Name: "node-service-manager", Scope: ScopeNode, Decision: "novox/hq ADR 0177",
|
{Name: ServiceManagerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0177",
|
||||||
Serves: serviceManagerVerbs()},
|
Serves: serviceManagerVerbs()},
|
||||||
|
// The machine's package manager (novox/hq ADR 0207). A module declaring a `package` depends on
|
||||||
|
// it being held on its node, as one declaring a `service` depends on node-service-manager: the
|
||||||
|
// mesh's word for "something on this machine answers for installing", where a capability only
|
||||||
|
// says the software is there. No verbs yet — the seat says who answers, and what may be asked
|
||||||
|
// of it is decided when someone needs to ask.
|
||||||
|
{Name: PackageManagerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0207"},
|
||||||
|
// The machine's container runtime (novox/hq ADR 0166, seeded now by ADR 0207): a module
|
||||||
|
// declaring a `container` depends on it being held on its node. Its verbs, and the host creating
|
||||||
|
// containers through its holder, wait for ADR 0166's acceptance — seeded without them so the
|
||||||
|
// dependency has a seat to name and the runtime's module has one to claim.
|
||||||
|
{Name: ContainerRuntimeSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0166, ADR 0207"},
|
||||||
|
// The operator account's environment (novox/hq ADR 0203): one module per machine writes it, and
|
||||||
|
// every module contributes to it. No verbs — the seat says who places the environment's files,
|
||||||
|
// and their path is its protocol: a shell sources ~/.config/mesh/environment.sh without knowing
|
||||||
|
// which module wrote it.
|
||||||
|
{Name: EnvironmentSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0203"},
|
||||||
|
// The login shell (novox/hq ADR 0204, replacing the module-declared `login-shell` of ADR 0176):
|
||||||
|
// the mesh's, so a second shell module claims the seat rather than declaring a second one, and
|
||||||
|
// the seat exists whether or not zsh's definition is registered. `execute` is the contract any
|
||||||
|
// node may call; the holder places every module's shell code in its slots.
|
||||||
|
{Name: LoginShellSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0204",
|
||||||
|
Serves: loginShellVerbs()},
|
||||||
|
// A machine's power (novox/hq ADR 0211): its holder owns logind's power handling, places the
|
||||||
|
// code modules contribute for the power moments, and publishes the machine's power states as
|
||||||
|
// its events. Every machine has one — every machine boots and shuts down. No verbs yet.
|
||||||
|
{Name: PowerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0211"},
|
||||||
|
// The machine's hotkeys (novox/hq ADR 0212): the daemon that sees the keys the window manager
|
||||||
|
// does not — a laptop's vendor keys — run by one module per machine, which owns its
|
||||||
|
// configuration. Every other module with keys contributes trigger lines to it.
|
||||||
|
{Name: HotkeysSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0212",
|
||||||
|
Receives: []Receivable{{Kind: "trigger", Comment: "#"}}},
|
||||||
// Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client
|
// Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client
|
||||||
// model change, not a rename, so it stays until that is built.
|
// model change, not a rename, so it stays until that is built.
|
||||||
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
||||||
@@ -161,7 +228,9 @@ var defaultSeats = []Seat{
|
|||||||
// rather than a condition in the resolver's module, so a machine running two managers is
|
// rather than a condition in the resolver's module, so a machine running two managers is
|
||||||
// refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117).
|
// refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117).
|
||||||
{Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117"},
|
{Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117"},
|
||||||
}
|
},
|
||||||
|
// The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's.
|
||||||
|
graphicalSessionSeats()...)
|
||||||
|
|
||||||
// A system seat name is the control plane's namespace: `mesh-*` for a mesh-wide role, `node-*` for
|
// A system seat name is the control plane's namespace: `mesh-*` for a mesh-wide role, `node-*` for
|
||||||
// a per-node one (novox/hq ADR 0121). A claim to a system name the mesh does not define is refused;
|
// a per-node one (novox/hq ADR 0121). A claim to a system name the mesh does not define is refused;
|
||||||
@@ -210,6 +279,10 @@ func UseSeats(s []Seat) {
|
|||||||
row.Accepts, row.Emits, row.Serves = d.Accepts, d.Emits, d.Serves
|
row.Accepts, row.Emits, row.Serves = d.Accepts, d.Emits, d.Serves
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// What a seat receives is never stored (novox/hq ADR 0212), so it is always the compiled one.
|
||||||
|
if d, known := byName[row.Name]; known {
|
||||||
|
row.Receives = d.Receives
|
||||||
|
}
|
||||||
merged = append(merged, row)
|
merged = append(merged, row)
|
||||||
}
|
}
|
||||||
seats = merged
|
seats = merged
|
||||||
@@ -456,3 +529,17 @@ func serviceManagerVerbs() []Verb {
|
|||||||
Input: scoped(map[string]string{"unit": unit["unit"], "lines": "how many lines from the end (default 100)"}, []string{"unit"})},
|
Input: scoped(map[string]string{"unit": unit["unit"], "lines": "how many lines from the end (default 100)"}, []string{"unit"})},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loginShellVerbs is the contract every holder of node-login-shell serves (novox/hq ADR 0176, ADR
|
||||||
|
// 0204): one command, run the way the operator's own terminal would run it, bounded below the
|
||||||
|
// runtime's thirty-second call limit so a hung command answers rather than times the caller out.
|
||||||
|
func loginShellVerbs() []Verb {
|
||||||
|
return []Verb{
|
||||||
|
{Name: "execute", Description: "Run one command on this machine as the operator account, in a " +
|
||||||
|
"non-interactive login shell in its home; answers with what it printed and how it exited.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"command": "the command line, as you would type it",
|
||||||
|
"timeout_seconds": "give up after this long, at most 25 (default 20)",
|
||||||
|
}, []string{"command"})},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import (
|
|||||||
// and nothing to keep in step when a mesh seat is added.
|
// and nothing to keep in step when a mesh seat is added.
|
||||||
const meshSeatPrefix = "mesh-"
|
const meshSeatPrefix = "mesh-"
|
||||||
|
|
||||||
|
// retiredLoginShell is the one name outside the prefix a module may not declare: the login shell's,
|
||||||
|
// from when a module declared it (novox/hq ADR 0176), before it became the mesh's (ADR 0204).
|
||||||
|
const retiredLoginShell = "login-shell"
|
||||||
|
|
||||||
// A SeatDeclaration is a role a module offers on the bus: what may be sent to it, what it says,
|
// A SeatDeclaration is a role a module offers on the bus: what may be sent to it, what it says,
|
||||||
// and what it answers. A caller declares that it uses the *seat*, never the module, so the
|
// and what it answers. A caller declares that it uses the *seat*, never the module, so the
|
||||||
// implementation can be replaced under it.
|
// implementation can be replaced under it.
|
||||||
@@ -88,6 +92,15 @@ func declaredSeatProblems(m Manifest) []string {
|
|||||||
"seats (novox/hq ADR 0118)", m.Module, s.Name, meshSeatPrefix+"*"))
|
"seats (novox/hq ADR 0118)", m.Module, s.Name, meshSeatPrefix+"*"))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if s.Name == retiredLoginShell {
|
||||||
|
// The name ADR 0176 gave the login shell when the zsh module declared it. The seat is
|
||||||
|
// the mesh's now, so a module declaring the old name would be a second login shell
|
||||||
|
// beside it, with a protocol of its own (novox/hq ADR 0204).
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s declares a seat named %q; the login shell is the mesh's own seat %s, which a shell "+
|
||||||
|
"module claims and none declares (novox/hq ADR 0204)", m.Module, s.Name, LoginShellSeat))
|
||||||
|
continue
|
||||||
|
}
|
||||||
if seen[s.Name] {
|
if seen[s.Name] {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s declares the seat %q twice", m.Module, s.Name))
|
"%s declares the seat %q twice", m.Module, s.Name))
|
||||||
@@ -215,6 +228,13 @@ func CatalogueProblems(shelf Shelf) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A read of a module's state that module does not keep (novox/hq ADR 0201) — said only where the
|
||||||
|
// owner is on the shelf, as a consumer may be installed before its emitter.
|
||||||
|
var manifests []Manifest
|
||||||
|
for _, module := range shelfOrder(shelf) {
|
||||||
|
manifests = append(manifests, shelf[module])
|
||||||
|
}
|
||||||
|
problems = append(problems, StateReadsNothingDeclares(manifests)...)
|
||||||
sort.Strings(problems)
|
sort.Strings(problems)
|
||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import (
|
|||||||
// vocabulary test follows. If this fails because a seat was added, the fix is a record in novox/hq
|
// vocabulary test follows. If this fails because a seat was added, the fix is a record in novox/hq
|
||||||
// and a row in to-be 26, not a new number here.
|
// and a row in to-be 26, not a new number here.
|
||||||
func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||||
record := regexp.MustCompile(`^novox/hq ADR \d{4}$`)
|
// A seat a later record extends names both, "novox/hq ADR 0166, ADR 0207": the one that defined
|
||||||
|
// it and the one that seeded it.
|
||||||
|
record := regexp.MustCompile(`^novox/hq ADR \d{4}(, ADR \d{4})*$`)
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
delivered := map[string]string{}
|
delivered := map[string]string{}
|
||||||
for _, s := range Seats() {
|
for _, s := range Seats() {
|
||||||
@@ -44,10 +46,14 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
|||||||
delivered[s.Delivers] = s.Name
|
delivered[s.Delivers] = s.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Seventeen since node-build-agent (novox/hq ADR 0190) — sixteen once the retired
|
// Thirty-six with mesh-dns-resolver (novox/hq ADR 0194) and node-hosts-file (ADR 0199); thirty-four
|
||||||
// mesh-build-machine row goes, when no registered manifest claims it any more.
|
// with node-hotkeys (ADR 0212); thirty-three with node-power (ADR 0211); thirty-two since the
|
||||||
if len(Seats()) != 17 {
|
// graphical session's eleven (ADR 0208); twenty-one with node-package-manager and
|
||||||
t.Errorf("the mesh defines %d seats rather than 17; the set is closed, so a change here is "+
|
// node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203,
|
||||||
|
// ADR 0204); seventeen with node-build-agent (ADR 0190). Two fewer once the retired
|
||||||
|
// mesh-build-machine and node-dns-resolver rows go, when no registered manifest claims either.
|
||||||
|
if len(Seats()) != 36 {
|
||||||
|
t.Errorf("the mesh defines %d seats rather than 36; the set is closed, so a change here is "+
|
||||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// One account, several modules: the shell's module sets its shell, the container runtime's adds it
|
||||||
|
// to a group. Groups are only ever added by the host, so they are contributed; a shell or a home is
|
||||||
|
// one value, owned by one module per node.
|
||||||
|
|
||||||
|
func userResource(fields map[string]any) map[string]any {
|
||||||
|
r := map[string]any{"id": "operator", "type": "user", "name": "op"}
|
||||||
|
for k, v := range fields {
|
||||||
|
r[k] = v
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAShellAndAGroupOnOneAccountFromTwoModulesResolve(t *testing.T) {
|
||||||
|
zsh := Manifest{Module: "zsh", Resources: []map[string]any{userResource(map[string]any{"shell": "/usr/bin/zsh"})}}
|
||||||
|
docker := Manifest{Module: "docker", Resources: []map[string]any{userResource(map[string]any{"groups": []any{"docker"}})}}
|
||||||
|
other := Manifest{Module: "media", Resources: []map[string]any{userResource(map[string]any{"groups": []any{"video"}})}}
|
||||||
|
if problems := checkResources([]Manifest{zsh, docker, other}); len(problems) != 0 {
|
||||||
|
t.Fatalf("a shell and two modules' groups on one account were refused: %v", problems)
|
||||||
|
}
|
||||||
|
if _, err := Resolve(shelf(zsh, docker, other), []string{"zsh", "docker", "media"}, workstation(), World{}); err != nil {
|
||||||
|
t.Fatalf("the three did not resolve together: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTwoModulesSettingOneAccountsShellOrHomeAreRefused(t *testing.T) {
|
||||||
|
for _, field := range []string{"shell", "home"} {
|
||||||
|
a := Manifest{Module: "zsh", Resources: []map[string]any{userResource(map[string]any{field: "/one"})}}
|
||||||
|
b := Manifest{Module: "fish", Resources: []map[string]any{userResource(map[string]any{field: "/two", "groups": []any{"x"}})}}
|
||||||
|
problems := checkResources([]Manifest{a, b})
|
||||||
|
want := `zsh and fish both set the ` + field + ` of the user "op"`
|
||||||
|
if len(problems) != 1 || !strings.Contains(problems[0], want) {
|
||||||
|
t.Errorf("two modules setting %s gave %v, want %q", field, problems, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,19 +5,23 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The ssh-client module, composed as a machine receives it (novox/hq to-be 29): every other node's
|
// The ssh-client module, composed as a machine receives it (novox/hq to-be 29, research 027/03): a
|
||||||
// Host block written into a marked region of the operator's ~/.ssh/config, owned by the account,
|
// region at the START of the operator's ~/.ssh/config that includes ~/.ssh/config.d/* — first,
|
||||||
// with ~/.ssh created 0700 — the operator's own config kept.
|
// because ssh takes the first value it finds for each option — and the mesh's Host blocks as the
|
||||||
|
// whole of ~/.ssh/config.d/00-mesh, every other node with its account. ~/.ssh and ~/.ssh/config.d
|
||||||
|
// are created 0700 and owned by the account; the operator's own config below the region is kept.
|
||||||
func TestSSHClientOwnsTheOperatorsSSHConfig(t *testing.T) {
|
func TestSSHClientOwnsTheOperatorsSSHConfig(t *testing.T) {
|
||||||
shelf := shelf(catalogueManifest(t, "ssh-client"))
|
shelf := shelf(catalogueManifest(t, "ssh-client"))
|
||||||
got, err := Resolve(shelf, []string{"ssh-client"},
|
got, err := Resolve(shelf, []string{"ssh-client"},
|
||||||
Node{Name: "homer", At: "homer.internal", Account: "jo"}, World{})
|
Node{Name: "homer", At: "homer.internal", Account: "jo", AccountHome: "/home/jo"}, World{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
names := map[string]string{"homer.internal": "10.10.0.1", "marge.internal": "10.10.0.2"}
|
names := map[string]string{"homer.internal": "10.10.0.1", "marge.internal": "10.10.0.2",
|
||||||
|
"bart.internal": "10.10.0.3"}
|
||||||
out, err := got.Declaration(Rendering{
|
out, err := got.Declaration(Rendering{
|
||||||
Names: names, Machines: names, Accounts: map[string]string{"homer": "jo", "marge": "jo"},
|
Names: names, Machines: names,
|
||||||
|
Accounts: map[string]string{"homer": "jo", "marge": "jo", "bart": "op"},
|
||||||
Suffix: "internal",
|
Suffix: "internal",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -28,19 +32,39 @@ func TestSSHClientOwnsTheOperatorsSSHConfig(t *testing.T) {
|
|||||||
by[r["id"].(string)] = r
|
by[r["id"].(string)] = r
|
||||||
}
|
}
|
||||||
|
|
||||||
dir := by["ssh-client.ssh-dir"]
|
for id, path := range map[string]string{
|
||||||
if dir == nil || dir["path"] != "/home/jo/.ssh" || dir["owner"] != "jo" || dir["mode"] != "0700" {
|
"ssh-client.ssh-dir": "/home/jo/.ssh", "ssh-client.config-d": "/home/jo/.ssh/config.d"} {
|
||||||
t.Fatalf("~/.ssh is not created 0700 owned by the account: %v", dir)
|
dir := by[id]
|
||||||
|
if dir == nil || dir["type"] != "directory" || dir["path"] != path || dir["owner"] != "jo" || dir["mode"] != "0700" {
|
||||||
|
t.Fatalf("%s is not created 0700 owned by the account: %v", path, dir)
|
||||||
}
|
}
|
||||||
cfg := by["ssh-client.fact-ssh-config"]
|
|
||||||
if cfg == nil || cfg["path"] != "/home/jo/.ssh/config" || cfg["owner"] != "jo" || cfg["into"] != "block" {
|
|
||||||
t.Fatalf("the ssh config is not written into the operator's ~/.ssh/config as a region: %v", cfg)
|
|
||||||
}
|
}
|
||||||
body := cfg["content"].(string)
|
|
||||||
if !strings.Contains(body, "Host marge marge.internal") || !strings.Contains(body, "User jo") {
|
cfg := by["ssh-client.config"]
|
||||||
t.Fatalf("the config does not name the peer node and its account:\n%s", body)
|
if cfg == nil || cfg["path"] != "/home/jo/.ssh/config" || cfg["owner"] != "jo" ||
|
||||||
|
cfg["into"] != "block" || cfg["at"] != "start" {
|
||||||
|
t.Fatalf("the mesh's region is not the first thing in the operator's ~/.ssh/config: %v", cfg)
|
||||||
|
}
|
||||||
|
if body := cfg["content"].(string); !strings.Contains(body, "\nInclude ~/.ssh/config.d/*\n") || strings.Contains(body, "\nHost ") {
|
||||||
|
t.Fatalf("the region does not just include config.d:\n%s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hosts map[string]any
|
||||||
|
for _, r := range out {
|
||||||
|
if r["path"] == "/home/jo/.ssh/config.d/00-mesh" {
|
||||||
|
hosts = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hosts == nil || hosts["type"] != "file" || hosts["into"] != nil {
|
||||||
|
t.Fatalf("the mesh's hosts are not the whole of ~/.ssh/config.d/00-mesh: %v", hosts)
|
||||||
|
}
|
||||||
|
body := hosts["content"].(string)
|
||||||
|
for _, want := range []string{"Host marge marge.internal", "Host bart bart.internal", "User jo", "User op"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("00-mesh does not say %q — every other node with its account:\n%s", want, body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if strings.Contains(body, "Host homer ") {
|
if strings.Contains(body, "Host homer ") {
|
||||||
t.Fatalf("the config names the machine itself, not only its peers:\n%s", body)
|
t.Fatalf("00-mesh names the machine itself, not only its peers:\n%s", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What a module may call its state, and whose state it may ask to read (novox/hq ADR 0201).
|
||||||
|
//
|
||||||
|
// A module names its state **locally** — `servers`, never a bucket or a subject — and another
|
||||||
|
// module's as `<module>.<name>`, the way a consumed event names its emitter (design 32 §1). The
|
||||||
|
// mesh derives the bucket from the two names, so the module and the local name must each be one
|
||||||
|
// token: the bucket joins them with an underscore, which neither may contain, so two modules can
|
||||||
|
// never derive one bucket.
|
||||||
|
|
||||||
|
// stateName is one local name of a module's state: lower-case, no dot, no underscore.
|
||||||
|
var stateName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||||
|
|
||||||
|
// The mesh's caps on what a module may ask of a bucket's history.
|
||||||
|
const (
|
||||||
|
// StateMostHistory is the most past values a key may keep. The server's own limit.
|
||||||
|
StateMostHistory = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
// StateDeclaration is one bucket a module owns: its local name, and the options that are the
|
||||||
|
// owner's to choose, as a seat chooses how long its backlog survives (design 32 §3).
|
||||||
|
type StateDeclaration struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
// History is how many values a key keeps, the current one included; zero is one.
|
||||||
|
History int `json:"history,omitempty"`
|
||||||
|
// TTLSeconds is how long a value lives once written; zero is until it is replaced or deleted.
|
||||||
|
TTLSeconds int `json:"ttl-seconds,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds}.
|
||||||
|
func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
|
||||||
|
trimmed := bytes.TrimSpace(raw)
|
||||||
|
if len(trimmed) > 0 && trimmed[0] == '"' {
|
||||||
|
return json.Unmarshal(trimmed, &s.Name)
|
||||||
|
}
|
||||||
|
type plain StateDeclaration
|
||||||
|
var full plain
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(trimmed))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(&full); err != nil {
|
||||||
|
return fmt.Errorf("a state is either a name or {name, history, ttl-seconds}: %w", err)
|
||||||
|
}
|
||||||
|
*s = StateDeclaration(full)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON writes back the short form when there is nothing else to say.
|
||||||
|
func (s StateDeclaration) MarshalJSON() ([]byte, error) {
|
||||||
|
if s.History == 0 && s.TTLSeconds == 0 {
|
||||||
|
return json.Marshal(s.Name)
|
||||||
|
}
|
||||||
|
type plain StateDeclaration
|
||||||
|
return json.Marshal(plain(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadState splits a read into the owning module and the local name, or says why it is not one.
|
||||||
|
func ReadState(read string) (module, local string, err error) {
|
||||||
|
at := strings.LastIndex(read, ".")
|
||||||
|
if at <= 0 || at == len(read)-1 {
|
||||||
|
return "", "", fmt.Errorf("%q does not name a module and its state: a read is <module>.<name>", read)
|
||||||
|
}
|
||||||
|
module, local = read[:at], read[at+1:]
|
||||||
|
if !stateName.MatchString(module) {
|
||||||
|
return "", "", fmt.Errorf("%q cannot own state: a module whose state is read is one plain name", module)
|
||||||
|
}
|
||||||
|
if !stateName.MatchString(local) {
|
||||||
|
return "", "", fmt.Errorf("%q is not a state name: lower-case letters, digits and hyphens", local)
|
||||||
|
}
|
||||||
|
return module, local, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateProblems is what is wrong with a manifest's state and reads.
|
||||||
|
//
|
||||||
|
// Refused at registration, because a bucket name the bus cannot hold is a module that installs,
|
||||||
|
// starts, and is refused on its first write with a reason about a bucket nobody named.
|
||||||
|
func StateProblems(m Manifest) []string {
|
||||||
|
var problems []string
|
||||||
|
if len(m.State) > 0 && !stateName.MatchString(m.Module) {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s keeps state, and a module's name is part of its buckets' names, which take one plain "+
|
||||||
|
"name — no dot (novox/hq ADR 0201)", m.Module))
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, s := range m.State {
|
||||||
|
switch {
|
||||||
|
case !stateName.MatchString(s.Name):
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s keeps state %q: a state is named locally — lower-case letters, digits and hyphens, "+
|
||||||
|
"no dot and no underscore; the mesh derives the bucket (novox/hq ADR 0201)", m.Module, s.Name))
|
||||||
|
case seen[s.Name]:
|
||||||
|
problems = append(problems, fmt.Sprintf("%s keeps state %q twice", m.Module, s.Name))
|
||||||
|
}
|
||||||
|
seen[s.Name] = true
|
||||||
|
if s.History < 0 || s.History > StateMostHistory {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s keeps %d values of %q; a key keeps between 1 and %d", m.Module, s.History, s.Name, StateMostHistory))
|
||||||
|
}
|
||||||
|
if s.TTLSeconds < 0 {
|
||||||
|
problems = append(problems, fmt.Sprintf("%s gives %q a negative lifetime", m.Module, s.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range m.Reads {
|
||||||
|
module, _, err := ReadState(r)
|
||||||
|
if err != nil {
|
||||||
|
problems = append(problems, fmt.Sprintf("%s reads %v", m.Module, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if module == m.Module {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s reads %q, which is its own state: a module reads and writes what it keeps already", m.Module, r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateReadsNothingDeclares is every read across a catalogue whose owner is present and declares no
|
||||||
|
// such state. An absent owner says nothing — a module may be installed long before the one whose
|
||||||
|
// state it reads, as a consumer may before its emitter (design 32 §1).
|
||||||
|
func StateReadsNothingDeclares(manifests []Manifest) []string {
|
||||||
|
declared := map[string]map[string]bool{}
|
||||||
|
for _, m := range manifests {
|
||||||
|
own := map[string]bool{}
|
||||||
|
for _, s := range m.State {
|
||||||
|
own[s.Name] = true
|
||||||
|
}
|
||||||
|
declared[m.Module] = own
|
||||||
|
}
|
||||||
|
var problems []string
|
||||||
|
for _, m := range manifests {
|
||||||
|
for _, r := range m.Reads {
|
||||||
|
module, local, err := ReadState(r)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if own, present := declared[module]; present && !own[local] {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s reads %q, and %s keeps no state called %q", m.Module, r, module, local))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module declares the state it keeps and the state it reads (novox/hq ADR 0201), a bucket by its
|
||||||
|
// bare name or with the owner's options.
|
||||||
|
func TestAManifestMaySayWhatStateItKeepsAndReads(t *testing.T) {
|
||||||
|
m, err := ParseManifest([]byte(`{"module":"claude-code","version":"1",` +
|
||||||
|
`"state":["servers",{"name":"seen","history":5,"ttl-seconds":3600}],` +
|
||||||
|
`"reads":["licence-manager.bindings"]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(m.State) != 2 || m.State[0].Name != "servers" || m.State[1].History != 5 || m.State[1].TTLSeconds != 3600 {
|
||||||
|
t.Fatalf("state not read: %+v", m.State)
|
||||||
|
}
|
||||||
|
if len(m.Reads) != 1 || m.Reads[0] != "licence-manager.bindings" {
|
||||||
|
t.Fatalf("reads not read: %v", m.Reads)
|
||||||
|
}
|
||||||
|
// Written back as it came in: the short form where nothing else is said.
|
||||||
|
out, _ := json.Marshal(m.State)
|
||||||
|
if string(out) != `["servers",{"name":"seen","history":5,"ttl-seconds":3600}]` {
|
||||||
|
t.Fatalf("written back as %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A name the bus could not hold, or that would let two modules derive one bucket, is refused at
|
||||||
|
// registration in the manifest's words.
|
||||||
|
func TestAStateNameIsLocalAndOneToken(t *testing.T) {
|
||||||
|
for _, c := range []struct{ manifest, says string }{
|
||||||
|
{`{"module":"a","version":"1","state":["mesh.servers"]}`, `keeps state "mesh.servers": a state is named locally`},
|
||||||
|
{`{"module":"a","version":"1","state":["my_servers"]}`, `keeps state "my_servers"`},
|
||||||
|
{`{"module":"a","version":"1","state":["s","s"]}`, `keeps state "s" twice`},
|
||||||
|
{`{"module":"a","version":"1","state":[{"name":"s","history":65}]}`, `a key keeps between 1 and 64`},
|
||||||
|
{`{"module":"a.b","version":"1","state":["s"]}`, `no dot`},
|
||||||
|
{`{"module":"a","version":"1","reads":["bindings"]}`, `a read is <module>.<name>`},
|
||||||
|
{`{"module":"a","version":"1","reads":["a.s"]}`, `which is its own state`},
|
||||||
|
{`{"module":"a","version":"1","state":[{"name":"s","shared":true}]}`, `{name, history, ttl-seconds}`},
|
||||||
|
} {
|
||||||
|
_, err := ParseManifest([]byte(c.manifest))
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("%s was accepted", c.manifest)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), c.says) {
|
||||||
|
t.Errorf("%s refused for the wrong reason: %v", c.manifest, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A read whose owner is present must name a state that owner keeps; an absent owner says nothing,
|
||||||
|
// because a module may be installed before the one whose state it reads.
|
||||||
|
func TestAReadNamesStateItsOwnerKeeps(t *testing.T) {
|
||||||
|
owner := Manifest{Module: "licence-manager", State: []StateDeclaration{{Name: "bindings"}}}
|
||||||
|
good := Manifest{Module: "claude-code", Reads: []string{"licence-manager.bindings", "absent.anything"}}
|
||||||
|
bad := Manifest{Module: "other", Reads: []string{"licence-manager.tokens"}}
|
||||||
|
if p := StateReadsNothingDeclares([]Manifest{owner, good}); len(p) != 0 {
|
||||||
|
t.Fatalf("a read of declared state was refused: %v", p)
|
||||||
|
}
|
||||||
|
p := StateReadsNothingDeclares([]Manifest{owner, bad})
|
||||||
|
if len(p) != 1 || !strings.Contains(p[0], `licence-manager keeps no state called "tokens"`) {
|
||||||
|
t.Fatalf("a read of state nobody keeps was not named: %v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Across the whole catalogue**: every state name is local, and every read whose owner is present
|
||||||
|
// names state that owner keeps.
|
||||||
|
func TestEveryManifestsStateIsLocalAndEveryReadIsKept(t *testing.T) {
|
||||||
|
manifests := theCatalogue(t)
|
||||||
|
var problems []string
|
||||||
|
for _, m := range manifests {
|
||||||
|
problems = append(problems, StateProblems(m)...)
|
||||||
|
}
|
||||||
|
problems = append(problems, StateReadsNothingDeclares(manifests)...)
|
||||||
|
if len(problems) > 0 {
|
||||||
|
t.Fatalf("the catalogue's state is not what ADR 0201 says:\n %s", strings.Join(problems, "\n "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `module check` says it too: the cross-catalogue pass names a read nothing on the shelf keeps.
|
||||||
|
func TestTheCataloguePassNamesAReadItsOwnerDoesNotKeep(t *testing.T) {
|
||||||
|
shelf := Shelf{
|
||||||
|
"licence-manager": {Module: "licence-manager", State: []StateDeclaration{{Name: "bindings"}}},
|
||||||
|
"claude-code": {Module: "claude-code", Reads: []string{"licence-manager.tokens"}},
|
||||||
|
}
|
||||||
|
problems := CatalogueProblems(shelf)
|
||||||
|
if len(problems) != 1 || !strings.Contains(problems[0], `keeps no state called "tokens"`) {
|
||||||
|
t.Fatalf("the catalogue pass said %v", problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,10 +102,13 @@ var ControllerVerbs = []Verb{
|
|||||||
}, nil)},
|
}, nil)},
|
||||||
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it.",
|
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it.",
|
||||||
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
||||||
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there.",
|
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there, " +
|
||||||
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
|
"or when a seat its resources are applied through is held by nothing on the machine (novox/hq ADR 0207).",
|
||||||
{Name: "unassign", Description: "Take a module off a machine.",
|
Input: schema(map[string]string{"node": "the machine's name",
|
||||||
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
|
"module": "the module's name; several comma-separated are judged together"}, []string{"node", "module"})},
|
||||||
|
{Name: "unassign", Description: "Take a module off a machine. Refused when it holds a seat a module left there depends on.",
|
||||||
|
Input: schema(map[string]string{"node": "the machine's name",
|
||||||
|
"module": "the module's name; several comma-separated are judged together"}, []string{"node", "module"})},
|
||||||
{Name: "pin", Description: "Tell a machine which provider answers a provision for it — the module, and the node " +
|
{Name: "pin", Description: "Tell a machine which provider answers a provision for it — the module, and the node " +
|
||||||
"it runs on, both. Asked for when more than one could answer; the refusal lists them.",
|
"it runs on, both. Asked for when more than one could answer; the refusal lists them.",
|
||||||
Input: schema(map[string]string{
|
Input: schema(map[string]string{
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A scheduled step may hold its module's own containers still while it runs (novox/hq ADR 0189).
|
||||||
|
//
|
||||||
|
// The host judges what it receives — whether each id is a container on that machine. What the
|
||||||
|
// definition is the only place to see is judged here, near whoever wrote it.
|
||||||
|
|
||||||
|
func aStoreManifest(step map[string]any) []byte {
|
||||||
|
m := map[string]any{
|
||||||
|
"module": "distribution", "version": "1",
|
||||||
|
"resources": []any{
|
||||||
|
map[string]any{"id": "store", "type": "container", "name": "mesh-registry",
|
||||||
|
"image": "registry@sha256:" + strings.Repeat("a", 64)},
|
||||||
|
step,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(m)
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMaintenanceWindowOnItsOwnModulesContainerIsAccepted(t *testing.T) {
|
||||||
|
raw := aStoreManifest(map[string]any{
|
||||||
|
"id": "collect", "type": "container", "name": "mesh-registry-collect",
|
||||||
|
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||||
|
"schedule": "30 3 * * *", "while-stopped": []any{"store"},
|
||||||
|
})
|
||||||
|
if _, err := ParseManifest(raw); err != nil {
|
||||||
|
t.Fatalf("a step holding its own module's container still was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMaintenanceWindowIsRefusedWhereTheDefinitionShowsItCannotMean(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
step map[string]any
|
||||||
|
says string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"on a step with no schedule",
|
||||||
|
map[string]any{"id": "collect", "type": "container", "name": "c",
|
||||||
|
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||||
|
"while-stopped": []any{"store"}},
|
||||||
|
"gates what is declared after it",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"on a run-once step, which already has order",
|
||||||
|
map[string]any{"id": "collect", "type": "container", "name": "c",
|
||||||
|
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||||
|
"run-once": true, "while-stopped": []any{"store"}},
|
||||||
|
"A maintenance window is for a recurring step",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"naming a container this module does not declare",
|
||||||
|
map[string]any{"id": "collect", "type": "container", "name": "c",
|
||||||
|
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||||
|
"schedule": "30 3 * * *", "while-stopped": []any{"the-broker"}},
|
||||||
|
"could quiesce a neighbour could stop the mesh",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"naming itself",
|
||||||
|
map[string]any{"id": "collect", "type": "container", "name": "c",
|
||||||
|
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||||
|
"schedule": "30 3 * * *", "while-stopped": []any{"collect"}},
|
||||||
|
"naming itself",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"written as something that is not a list",
|
||||||
|
map[string]any{"id": "collect", "type": "container", "name": "c",
|
||||||
|
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||||
|
"schedule": "30 3 * * *", "while-stopped": "store"},
|
||||||
|
"a list of this module's container ids",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
_, err := ParseManifest(aStoreManifest(c.step))
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("%s was accepted", c.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), c.says) {
|
||||||
|
t.Errorf("%s: the refusal does not say %q:\n%v", c.name, c.says, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A composed declaration names the step's held containers the way the machine knows them.
|
||||||
|
//
|
||||||
|
// **The gap that let a bug through to the control node.** The manifest says `while-stopped:
|
||||||
|
// ["store"]`, because a module names its own resources locally; the declaration a machine
|
||||||
|
// receives calls that container `distribution.store`, because every resource is composed under
|
||||||
|
// its module. `restart-on` and `reload-on` are rewritten for exactly this reason, and
|
||||||
|
// `while-stopped` was not — so the host found no container by that id and refused the whole
|
||||||
|
// declaration, every push, until it was fixed.
|
||||||
|
//
|
||||||
|
// It passed every test on both sides: the controller's tests read manifests, the host's read
|
||||||
|
// hand-written declarations with bare ids. Only composing one and judging the result catches it.
|
||||||
|
func TestAComposedWindowNamesTheContainerAsTheMachineKnowsIt(t *testing.T) {
|
||||||
|
store := Manifest{
|
||||||
|
Module: "distribution", Version: "1",
|
||||||
|
Provides: FromAnywhere("artifact-store"),
|
||||||
|
Listens: []Listening{{Port: 5000, Protocol: "tcp", From: FromMesh}},
|
||||||
|
Serves: map[string]map[string]any{"artifact-store": {"port": 5000}},
|
||||||
|
Resources: []map[string]any{
|
||||||
|
{"id": "store", "type": "container", "name": "mesh-registry",
|
||||||
|
"image": "registry@sha256:" + strings.Repeat("a", 64), "ports": []any{"5000"}},
|
||||||
|
{"id": "collect", "type": "container", "name": "mesh-registry-collect",
|
||||||
|
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||||
|
"schedule": "30 3 * * *", WhileStopped: []any{"store"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r, err := Resolve(shelf(store), []string{"distribution"}, reachable(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := r.Declaration(Rendering{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
collect := fileNamed(out, "distribution.collect")
|
||||||
|
if collect == nil {
|
||||||
|
for _, res := range out {
|
||||||
|
if res["id"] == "distribution.collect" {
|
||||||
|
collect = res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if collect == nil {
|
||||||
|
t.Fatalf("the step was not composed at all: %v", out)
|
||||||
|
}
|
||||||
|
held, _ := collect[WhileStopped].([]any)
|
||||||
|
if len(held) != 1 {
|
||||||
|
t.Fatalf("the composed step holds %v still; want one container", collect[WhileStopped])
|
||||||
|
}
|
||||||
|
if got := fmt.Sprint(held[0]); got != "distribution.store" {
|
||||||
|
t.Fatalf("the composed step says it holds %q still, and the machine's container is "+
|
||||||
|
"called %q — the host refuses a declaration naming a container it does not have, "+
|
||||||
|
"whole, so the machine would take nothing at all", got, "distribution.store")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Zones: names a module answers itself (novox/hq ADR 0199).
|
||||||
|
//
|
||||||
|
// The mesh's resolver holds each node's internal domain and nothing else (ADR 0191, 0194). A module
|
||||||
|
// whose names are its own — the lab's scenario machines, known only while a scenario runs — declares
|
||||||
|
// the zone it answers and the listen that answers it; the controller hands the resolver's holder every
|
||||||
|
// zone with the declaring node's private address and the port that listen is published on, and the
|
||||||
|
// holder forwards the zone there. **A definition names no address** (ADR 0112): the zone is a setting,
|
||||||
|
// the listen is the module's own, and where they are is the mesh's fact.
|
||||||
|
|
||||||
|
// Zone is the manifest's declaration that a module answers the names in one zone.
|
||||||
|
type Zone struct {
|
||||||
|
// Name is the zone: a label or a dotted name, normally `${setting:<key>}`, so the operator chooses
|
||||||
|
// it and the definition does not.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Listen names one of the module's listens: the DNS answerer for the zone.
|
||||||
|
Listen string `json:"listen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZoneAt is a declared zone where the mesh placed it: what the resolver's holder forwards, and where.
|
||||||
|
type ZoneAt struct {
|
||||||
|
Zone string
|
||||||
|
Node string
|
||||||
|
Module string
|
||||||
|
Address string
|
||||||
|
Port int
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneProblems is what is wrong with a module's zone declaration on its own, before any node.
|
||||||
|
func zoneProblems(m Manifest) []string {
|
||||||
|
if m.Zone == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var problems []string
|
||||||
|
if strings.TrimSpace(m.Zone.Name) == "" {
|
||||||
|
problems = append(problems, fmt.Sprintf("%s declares a zone with no name", m.Module))
|
||||||
|
}
|
||||||
|
if !m.hasListen(m.Zone.Listen) {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s declares zone %q answered by listen %q, and has no listen of that name",
|
||||||
|
m.Module, m.Zone.Name, m.Zone.Listen))
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manifest) hasListen(name string) bool {
|
||||||
|
if name == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, l := range m.Listens {
|
||||||
|
if l.Name == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZoneOn is one module's zone as one node places it: the name settled from the node's settings, the
|
||||||
|
// port its answering listen is published on there. Nothing when the module declares no zone.
|
||||||
|
func ZoneOn(m Manifest, layers []Layer, published map[int]int, node, address string) (*ZoneAt, error) {
|
||||||
|
if m.Zone == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
settled, err := Settle(map[string]any{"zone": m.Zone.Name}, layers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s's zone on %s: %w", m.Module, node, err)
|
||||||
|
}
|
||||||
|
zone := strings.Trim(strings.ToLower(fmt.Sprint(settled["zone"])), ".")
|
||||||
|
var port int
|
||||||
|
for _, l := range m.Listens {
|
||||||
|
if l.Name == m.Zone.Listen {
|
||||||
|
port = l.Port
|
||||||
|
if at, given := published[l.Port]; given {
|
||||||
|
port = at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &ZoneAt{Zone: zone, Node: node, Module: m.Module, Address: address, Port: port}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZonesProblems is what the mesh refuses about its zones together: one zone declared twice, a zone
|
||||||
|
// that is the mesh's suffix or under it, a zone that is a node's public domain or under one. A module
|
||||||
|
// may not shadow names the mesh's resolver or the public DNS answers.
|
||||||
|
func ZonesProblems(zones []ZoneAt, suffix string, publicDomains []string) []string {
|
||||||
|
var problems []string
|
||||||
|
under := func(zone, domain string) bool {
|
||||||
|
domain = strings.Trim(strings.ToLower(domain), ".")
|
||||||
|
return domain != "" && (zone == domain || strings.HasSuffix(zone, "."+domain))
|
||||||
|
}
|
||||||
|
seen := map[string]ZoneAt{}
|
||||||
|
for _, z := range zones {
|
||||||
|
if other, twice := seen[z.Zone]; twice && (other.Node != z.Node || other.Module != z.Module) {
|
||||||
|
problems = append(problems, fmt.Sprintf("zone %q is declared by %s on %s and by %s on %s; one module answers a zone",
|
||||||
|
z.Zone, other.Module, other.Node, z.Module, z.Node))
|
||||||
|
}
|
||||||
|
seen[z.Zone] = z
|
||||||
|
if under(z.Zone, suffix) {
|
||||||
|
problems = append(problems, fmt.Sprintf("%s on %s declares zone %q, which is the mesh's own suffix or under it",
|
||||||
|
z.Module, z.Node, z.Zone))
|
||||||
|
}
|
||||||
|
for _, d := range publicDomains {
|
||||||
|
if under(z.Zone, d) {
|
||||||
|
problems = append(problems, fmt.Sprintf("%s on %s declares zone %q, which is the public domain %q or under it",
|
||||||
|
z.Module, z.Node, z.Zone, d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(problems)
|
||||||
|
return problems
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A zone names the listen that answers it, or there is nothing to forward to (novox/hq ADR 0199).
|
||||||
|
func TestAZoneMustNameOneOfTheModulesListens(t *testing.T) {
|
||||||
|
_, err := ParseManifest([]byte(`{"module":"lab","version":"1",
|
||||||
|
"listens":[{"name":"dns","port":5353,"protocol":"udp","from":"mesh","why":"the lab's names"}],
|
||||||
|
"zone":{"name":"${setting:zone}","listen":"web"}}`))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `has no listen of that name`) {
|
||||||
|
t.Fatalf("a zone answered by a listen the module does not have was accepted: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := ParseManifest([]byte(`{"module":"lab","version":"1",
|
||||||
|
"listens":[{"name":"dns","port":5353,"protocol":"udp","from":"mesh","why":"the lab's names"}],
|
||||||
|
"zone":{"name":"${setting:zone}","listen":"dns"}}`)); err != nil {
|
||||||
|
t.Fatalf("a well-formed zone was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The zone is the operator's (a setting) and the port is where this machine publishes the listen —
|
||||||
|
// neither is the definition's to state.
|
||||||
|
func TestAZoneIsPlacedFromTheNodesSettingAndPublishedPort(t *testing.T) {
|
||||||
|
m := Manifest{Module: "lab", Zone: &Zone{Name: "${setting:zone}", Listen: "dns"},
|
||||||
|
Listens: []Listening{{Name: "dns", Port: 5353, From: FromMesh}}}
|
||||||
|
z, err := ZoneOn(m, []Layer{{From: "node", Values: map[string]any{"zone": "Incus."}}},
|
||||||
|
map[int]int{5353: 15353}, "workstation", "10.77.0.3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if z.Zone != "incus" || z.Address != "10.77.0.3" || z.Port != 15353 || z.Node != "workstation" {
|
||||||
|
t.Fatalf("the zone was placed as %+v", *z)
|
||||||
|
}
|
||||||
|
if _, err := ZoneOn(m, nil, nil, "workstation", "10.77.0.3"); err == nil {
|
||||||
|
t.Fatal("a zone nobody named was placed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One module answers a zone, and none may shadow the mesh's names or a public domain.
|
||||||
|
func TestTheMeshRefusesAZoneTwiceOrOneThatShadows(t *testing.T) {
|
||||||
|
one := ZoneAt{Zone: "incus", Node: "workstation", Module: "lab", Address: "10.77.0.3", Port: 53}
|
||||||
|
if p := ZonesProblems([]ZoneAt{one}, "internal", []string{"example.tld"}); len(p) != 0 {
|
||||||
|
t.Fatalf("one ordinary zone was refused: %v", p)
|
||||||
|
}
|
||||||
|
twice := one
|
||||||
|
twice.Node, twice.Module = "laptop", "other"
|
||||||
|
cases := map[string][]ZoneAt{
|
||||||
|
"declared by": {one, twice},
|
||||||
|
"mesh's own suffix": {{Zone: "lab.internal", Node: "a", Module: "m"}},
|
||||||
|
"public domain": {{Zone: "dev.example.tld", Node: "a", Module: "m"}},
|
||||||
|
}
|
||||||
|
for want, zones := range cases {
|
||||||
|
p := strings.Join(ZonesProblems(zones, "internal", []string{"example.tld"}), "\n")
|
||||||
|
if !strings.Contains(p, want) {
|
||||||
|
t.Errorf("not refused for %q: %q", want, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The resolver's template sees every zone with where it is answered, in zone order.
|
||||||
|
func TestTheResolversTemplateRangesTheZones(t *testing.T) {
|
||||||
|
m := Manifest{Module: "dnsmasq", Facts: map[string]RosterFile{"zones": {
|
||||||
|
Path: "/etc/mesh-resolver/zones.conf",
|
||||||
|
Template: "{{range .Zones}}server=/{{.Zone}}/{{.Address}}#{{.Port}}\n{{end}}",
|
||||||
|
}}}
|
||||||
|
out, err := FactsWithZonesInto(m, Resolution{Node: "anchor"}, nil, nil, nil, "", []ZoneAt{
|
||||||
|
{Zone: "zeta", Address: "10.77.0.2", Port: 53},
|
||||||
|
{Zone: "incus", Address: "10.77.0.3", Port: 15353},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := out[0]["content"]; got != "server=/incus/10.77.0.3#15353\nserver=/zeta/10.77.0.2#53\n" {
|
||||||
|
t.Fatalf("the resolver was told %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,9 +42,29 @@ type Build struct {
|
|||||||
// Failed is the builder's own words, empty when it worked.
|
// Failed is the builder's own words, empty when it worked.
|
||||||
Failed string
|
Failed string
|
||||||
Made []Artifact
|
Made []Artifact
|
||||||
|
// Asked is when the build was requested, zero when that is not known (an id of another shape,
|
||||||
|
// or a build recorded before the mesh kept it). **What orders one build of a module against
|
||||||
|
// another** (novox/hq 04-ISSUES/219): builds in flight together finish in any order, and the
|
||||||
|
// one asked last stood on the newest bases.
|
||||||
|
Asked time.Time
|
||||||
|
// At is when the outcome was recorded — when it finished, not when it was asked.
|
||||||
At time.Time
|
At time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AskedOrAt is when the build was asked, or when it was recorded when that is not known — the
|
||||||
|
// order the mesh had before it kept the request time.
|
||||||
|
func (b Build) AskedOrAt() time.Time {
|
||||||
|
if !b.Asked.IsZero() {
|
||||||
|
return b.Asked
|
||||||
|
}
|
||||||
|
return b.At
|
||||||
|
}
|
||||||
|
|
||||||
|
// newestRequestFirst is the ordering every "what a module currently is" question uses: the newest
|
||||||
|
// request wins, whenever it finished (novox/hq 04-ISSUES/219). A build whose request time is not
|
||||||
|
// known is placed at the moment it was recorded, which is the rule that held before.
|
||||||
|
const newestRequestFirst = `coalesce(asked, at) desc, at desc`
|
||||||
|
|
||||||
// ReadRepository is a repository a build read source from besides the module's own.
|
// ReadRepository is a repository a build read source from besides the module's own.
|
||||||
type ReadRepository struct {
|
type ReadRepository struct {
|
||||||
Repository string `json:"repository"`
|
Repository string `json:"repository"`
|
||||||
@@ -83,13 +103,17 @@ func (i *Inventory) RecordBuild(ctx context.Context, b Build) error {
|
|||||||
if b.Module != "" {
|
if b.Module != "" {
|
||||||
module = &b.Module
|
module = &b.Module
|
||||||
}
|
}
|
||||||
|
var asked *time.Time
|
||||||
|
if !b.Asked.IsZero() {
|
||||||
|
asked = &b.Asked
|
||||||
|
}
|
||||||
_, err = i.store.Pool().Exec(ctx,
|
_, err = i.store.Pool().Exec(ctx,
|
||||||
`insert into build (id, repository, ref, module, commit_hash, built_on, failed, made,
|
`insert into build (id, repository, ref, module, commit_hash, built_on, failed, made,
|
||||||
source_path, manifest, built_against, built_contexts)
|
source_path, manifest, built_against, built_contexts, asked)
|
||||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
on conflict (id) do nothing`,
|
on conflict (id) do nothing`,
|
||||||
b.ID, b.Repository, b.Ref, module, b.Commit, b.On, b.Failed, made,
|
b.ID, b.Repository, b.Ref, module, b.Commit, b.On, b.Failed, made,
|
||||||
b.Path, manifestOrNil(b.Manifest), against, read)
|
b.Path, manifestOrNil(b.Manifest), against, read, asked)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,11 +126,11 @@ func (i *Inventory) Builds(ctx context.Context, module string, limit int) ([]Bui
|
|||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 20
|
limit = 20
|
||||||
}
|
}
|
||||||
query := `select id, repository, ref, coalesce(module,''), commit_hash, built_on, failed, made, at
|
query := `select id, repository, ref, coalesce(module,''), commit_hash, built_on, failed, made, asked, at
|
||||||
from build order by at desc limit $1`
|
from build order by at desc limit $1`
|
||||||
args := []any{limit}
|
args := []any{limit}
|
||||||
if module != "" {
|
if module != "" {
|
||||||
query = `select id, repository, ref, coalesce(module,''), commit_hash, built_on, failed, made, at
|
query = `select id, repository, ref, coalesce(module,''), commit_hash, built_on, failed, made, asked, at
|
||||||
from build where module = $2 order by at desc limit $1`
|
from build where module = $2 order by at desc limit $1`
|
||||||
args = append(args, module)
|
args = append(args, module)
|
||||||
}
|
}
|
||||||
@@ -121,10 +145,14 @@ func (i *Inventory) Builds(ctx context.Context, module string, limit int) ([]Bui
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var b Build
|
var b Build
|
||||||
var made []byte
|
var made []byte
|
||||||
|
var asked *time.Time
|
||||||
if err := rows.Scan(&b.ID, &b.Repository, &b.Ref, &b.Module, &b.Commit,
|
if err := rows.Scan(&b.ID, &b.Repository, &b.Ref, &b.Module, &b.Commit,
|
||||||
&b.On, &b.Failed, &made, &b.At); err != nil {
|
&b.On, &b.Failed, &made, &asked, &b.At); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if asked != nil {
|
||||||
|
b.Asked = *asked
|
||||||
|
}
|
||||||
if err := json.Unmarshal(made, &b.Made); err != nil {
|
if err := json.Unmarshal(made, &b.Made); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -135,8 +163,9 @@ func (i *Inventory) Builds(ctx context.Context, module string, limit int) ([]Bui
|
|||||||
|
|
||||||
// Held is every artifact this mesh has built, keyed "<module>/<artifact>".
|
// Held is every artifact this mesh has built, keyed "<module>/<artifact>".
|
||||||
//
|
//
|
||||||
// **The newest successful build of each module wins**, which is the same rule the rest of the mesh
|
// **The successful build of each module asked last wins**, which is the same rule the rest of the
|
||||||
// uses for what a module currently is. A module rebuilt to something broken and then rebuilt again
|
// mesh uses for what a module currently is — asked last, not finished last (novox/hq
|
||||||
|
// 04-ISSUES/219): an older request that finishes later stood on older bases. A module rebuilt to something broken and then rebuilt again
|
||||||
// is at the second one; a module whose last build failed is at the last one that worked, because a
|
// is at the second one; a module whose last build failed is at the last one that worked, because a
|
||||||
// failure published nothing and the thing it published before is still what exists.
|
// failure published nothing and the thing it published before is still what exists.
|
||||||
//
|
//
|
||||||
@@ -147,7 +176,7 @@ func (i *Inventory) Held(ctx context.Context) (map[string]string, error) {
|
|||||||
`select distinct on (module) module, made
|
`select distinct on (module) module, made
|
||||||
from build
|
from build
|
||||||
where module is not null and module <> '' and failed = ''
|
where module is not null and module <> '' and failed = ''
|
||||||
order by module, at desc`)
|
order by module, `+newestRequestFirst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -185,7 +214,7 @@ func (i *Inventory) BuiltAgainst(ctx context.Context) (map[string][]string, erro
|
|||||||
`select distinct on (module) module, built_against
|
`select distinct on (module) module, built_against
|
||||||
from build
|
from build
|
||||||
where module is not null and module <> '' and failed = ''
|
where module is not null and module <> '' and failed = ''
|
||||||
order by module, at desc`)
|
order by module, `+newestRequestFirst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -223,7 +252,7 @@ func (i *Inventory) ReadRepositories(ctx context.Context) (map[string][]ReadRepo
|
|||||||
`select distinct on (module) module, built_contexts
|
`select distinct on (module) module, built_contexts
|
||||||
from build
|
from build
|
||||||
where module is not null and module <> '' and failed = ''
|
where module is not null and module <> '' and failed = ''
|
||||||
order by module, at desc`)
|
order by module, `+newestRequestFirst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -274,7 +303,7 @@ func manifestOrNil(raw []byte) any {
|
|||||||
// follows when it decides whether to announce at all.
|
// follows when it decides whether to announce at all.
|
||||||
//
|
//
|
||||||
// One row per module and commit: a module built twice at the same commit is one fact, and the
|
// One row per module and commit: a module built twice at the same commit is one fact, and the
|
||||||
// latest row is the one whose artifacts are current.
|
// row asked last is the one whose artifacts are current (novox/hq 04-ISSUES/219).
|
||||||
func (i *Inventory) Announceable(ctx context.Context) ([]Build, error) {
|
func (i *Inventory) Announceable(ctx context.Context) ([]Build, error) {
|
||||||
rows, err := i.store.Pool().Query(ctx,
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
`select distinct on (module, commit_hash)
|
`select distinct on (module, commit_hash)
|
||||||
@@ -282,7 +311,7 @@ func (i *Inventory) Announceable(ctx context.Context) ([]Build, error) {
|
|||||||
source_path, manifest, built_against, at
|
source_path, manifest, built_against, at
|
||||||
from build
|
from build
|
||||||
where failed = '' and module is not null and module <> '' and commit_hash <> ''
|
where failed = '' and module is not null and module <> '' and commit_hash <> ''
|
||||||
order by module, commit_hash, at desc`)
|
order by module, commit_hash, `+newestRequestFirst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Who holds each seat held once for the mesh, where the mesh recorded it (novox/hq issue 218).
|
||||||
|
holdings, err := i.Holdings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return broker.Records{}, fmt.Errorf("cannot read who holds the mesh's seats: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
out := broker.Records{Assigned: map[string][]broker.Declared{}, People: map[string][]string{},
|
out := broker.Records{Assigned: map[string][]broker.Declared{}, People: map[string][]string{},
|
||||||
Interchangeable: map[string]bool{}}
|
Interchangeable: map[string]bool{}}
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
@@ -72,7 +78,9 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
|
|||||||
"%s is assigned to %s and is not in the catalogue, so what it may say cannot "+
|
"%s is assigned to %s and is not in the catalogue, so what it may say cannot "+
|
||||||
"be derived", module, n.Name)
|
"be derived", module, n.Name)
|
||||||
}
|
}
|
||||||
out.Assigned[n.Name] = append(out.Assigned[n.Name], declaredFor(m, seats))
|
d := declaredFor(m, seats)
|
||||||
|
d.Holds = heldHere(d.Holds, holdings, n.Name, module)
|
||||||
|
out.Assigned[n.Name] = append(out.Assigned[n.Name], d)
|
||||||
if m.Instances == catalogue.InstancesInterchangeable {
|
if m.Instances == catalogue.InstancesInterchangeable {
|
||||||
out.Interchangeable[m.Module] = true
|
out.Interchangeable[m.Module] = true
|
||||||
}
|
}
|
||||||
@@ -124,6 +132,14 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
|||||||
Serves: m.Tools,
|
Serves: m.Tools,
|
||||||
// And what it calls (novox/hq ADR 0152) — the console's `*`, nothing else's.
|
// And what it calls (novox/hq ADR 0152) — the console's `*`, nothing else's.
|
||||||
Invokes: m.Invokes,
|
Invokes: m.Invokes,
|
||||||
|
// And the state it keeps and reads (novox/hq ADR 0201).
|
||||||
|
State: bucketsOf(m),
|
||||||
|
Reads: m.Reads,
|
||||||
|
}
|
||||||
|
// Whether it can be given an account at all: delivered as its own secret named broker, so one
|
||||||
|
// that declares none has nowhere to read it (novox/hq issue 195).
|
||||||
|
if _, reads := m.OwnSecrets["broker"]; !reads {
|
||||||
|
d.NoAccount = true
|
||||||
}
|
}
|
||||||
for _, c := range m.Claims {
|
for _, c := range m.Claims {
|
||||||
// Every seat with a protocol, the mesh's own included. One that says only who does a job is
|
// Every seat with a protocol, the mesh's own included. One that says only who does a job is
|
||||||
@@ -140,6 +156,30 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
|||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bucketsOf is the state a module keeps, as the bus holds it.
|
||||||
|
func bucketsOf(m catalogue.Manifest) []broker.Bucket {
|
||||||
|
var out []broker.Bucket
|
||||||
|
for _, s := range m.State {
|
||||||
|
out = append(out, broker.Bucket{Module: m.Module, Name: s.Name, History: s.History, TTLSeconds: s.TTLSeconds})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeclaredBuckets is every bucket the catalogue declares, registered modules assigned or not: a
|
||||||
|
// bucket exists from registration, like a seat's stream, so a module reading it may watch before its
|
||||||
|
// owner runs anywhere (novox/hq ADR 0201).
|
||||||
|
func (i *Inventory) DeclaredBuckets(ctx context.Context) ([]broker.Bucket, error) {
|
||||||
|
declared, err := i.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read the catalogue: %w", err)
|
||||||
|
}
|
||||||
|
var out []broker.Bucket
|
||||||
|
for _, m := range declared {
|
||||||
|
out = append(out, bucketsOf(m)...)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func asSeat(s catalogue.SeatDeclaration) broker.Seat {
|
func asSeat(s catalogue.SeatDeclaration) broker.Seat {
|
||||||
return broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits,
|
return broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits,
|
||||||
Serves: catalogue.VerbNames(s.Serves)}
|
Serves: catalogue.VerbNames(s.Serves)}
|
||||||
@@ -183,3 +223,33 @@ func (i *Inventory) NodesWithALiveToken(ctx context.Context) ([]string, error) {
|
|||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// heldHere keeps of what a module claims only the seats it holds on this machine (novox/hq issue 218).
|
||||||
|
// A seat held once per machine is held by every assignment that claims it. A seat held once for the
|
||||||
|
// mesh is held by one assignment: where the mesh recorded who holds it, a claim on any other machine
|
||||||
|
// grants nothing and issues nothing — or the module would serve the role's verbs from a machine that
|
||||||
|
// is not the role's, and a question to the mesh's store would be answered from the wrong database. A
|
||||||
|
// mesh seat with no holder on record is left as it was derived.
|
||||||
|
func heldHere(claimed []broker.Seat, holdings []catalogue.Held, node, module string) []broker.Seat {
|
||||||
|
recorded := map[string][]catalogue.Held{}
|
||||||
|
for _, h := range holdings {
|
||||||
|
if h.Scope == catalogue.ScopeMesh {
|
||||||
|
recorded[h.Claim] = append(recorded[h.Claim], h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []broker.Seat
|
||||||
|
for _, s := range claimed {
|
||||||
|
holders, onRecord := recorded[s.Name]
|
||||||
|
if s.Scope != catalogue.ScopeMesh || !onRecord {
|
||||||
|
out = append(out, s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, h := range holders {
|
||||||
|
if h.Node == node && h.Module == module {
|
||||||
|
out = append(out, s)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import (
|
|||||||
// ErrNoSuchModule is what the mesh says about a module it has never been told about.
|
// ErrNoSuchModule is what the mesh says about a module it has never been told about.
|
||||||
var ErrNoSuchModule = errors.New("no module of that name")
|
var ErrNoSuchModule = errors.New("no module of that name")
|
||||||
|
|
||||||
|
// ErrSuperseded is a registration from a build asked before the one the module is already at
|
||||||
|
// (novox/hq 04-ISSUES/219). The build is recorded; what the module is does not change.
|
||||||
|
var ErrSuperseded = errors.New("a build asked later is already what the module is")
|
||||||
|
|
||||||
// ErrStillAssigned is why a module cannot be forgotten.
|
// ErrStillAssigned is why a module cannot be forgotten.
|
||||||
//
|
//
|
||||||
// Its own error because it is not a fault: it means a machine is running that module now, and
|
// Its own error because it is not a fault: it means a machine is running that module now, and
|
||||||
@@ -47,6 +51,10 @@ type Source struct {
|
|||||||
// itself no longer carries its build (novox/hq to-be 38 WP2.4). Empty for a manifest handed over
|
// itself no longer carries its build (novox/hq to-be 38 WP2.4). Empty for a manifest handed over
|
||||||
// by hand, which carries its `build.on` itself.
|
// by hand, which carries its `build.on` itself.
|
||||||
Against []string
|
Against []string
|
||||||
|
// Asked is when the build this manifest came from was requested (novox/hq 04-ISSUES/219). Zero
|
||||||
|
// is a manifest handed over by hand, or a build whose request time is not known: either is
|
||||||
|
// taken as asked at the moment it is registered.
|
||||||
|
Asked time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current reports whether what the mesh holds is what the source last had.
|
// Current reports whether what the mesh holds is what the source last had.
|
||||||
@@ -70,11 +78,12 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
|||||||
// (novox/hq ADR 0175, to-be 38 WP2.4): a module serving its tools from a container built on the
|
// (novox/hq ADR 0175, to-be 38 WP2.4): a module serving its tools from a container built on the
|
||||||
// runtime's image. Refused at registration, by name, for a module that is new to the catalogue
|
// runtime's image. Refused at registration, by name, for a module that is new to the catalogue
|
||||||
// or that was registered in another shape — the mechanism that keeps the old pattern from
|
// or that was registered in another shape — the mechanism that keeps the old pattern from
|
||||||
// returning by habit. **Not refused for a module already registered in that shape**: the
|
// returning by habit. Before the runtime exists the pattern is accepted as it always was.
|
||||||
// catalogue holds some thirty of them the day the runtime arrives, each moves to a bundle in
|
//
|
||||||
// its own change (to-be 38 WP4 onward), and a gate that refused every rebuild of every unmoved
|
// *Since 2026-10-04 (to-be 38 WP4b's last step):* refused for **every** module. While some
|
||||||
// module in the meantime would stop the whole pipeline to make a point the record already makes.
|
// thirty modules still stood in that shape, one already registered so was rebuilt without
|
||||||
// Before the runtime exists the pattern is accepted as it always was.
|
// complaint, so the pipeline kept running while each moved; every module has moved since, and
|
||||||
|
// the exception would only let one move back.
|
||||||
if m.Module != catalogue.RuntimeModule {
|
if m.Module != catalogue.RuntimeModule {
|
||||||
if why := catalogue.ToolContainerOnTheRuntime(m, from.Against); why != "" {
|
if why := catalogue.ToolContainerOnTheRuntime(m, from.Against); why != "" {
|
||||||
runtime, err := i.hasModule(ctx, catalogue.RuntimeModule)
|
runtime, err := i.hasModule(ctx, catalogue.RuntimeModule)
|
||||||
@@ -82,28 +91,32 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if runtime {
|
if runtime {
|
||||||
already, err := i.registeredInThatShape(ctx, m.Module)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !already {
|
|
||||||
return fmt.Errorf("%s is not registered: %s", m.Module, why)
|
return fmt.Errorf("%s is not registered: %s", m.Module, why)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
raw, err := json.Marshal(m)
|
raw, err := json.Marshal(m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
asked := from.Asked
|
||||||
|
if asked.IsZero() {
|
||||||
|
asked = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
// A module registered without provenance keeps whatever it had. Handing over a manifest by
|
// A module registered without provenance keeps whatever it had. Handing over a manifest by
|
||||||
// hand is a legitimate way to fix something in a hurry, and it should not silently erase the
|
// hand is a legitimate way to fix something in a hurry, and it should not silently erase the
|
||||||
// record of where the module normally comes from — which is the only thing that would say,
|
// record of where the module normally comes from — which is the only thing that would say,
|
||||||
// afterwards, that the machine is running something nobody can rebuild.
|
// afterwards, that the machine is running something nobody can rebuild.
|
||||||
_, err = i.store.Pool().Exec(ctx,
|
//
|
||||||
`insert into module (name, manifest, version, source, source_path, source_seat, ref, built_from, source_head)
|
// **An older request never replaces a newer one** (novox/hq 04-ISSUES/219). Builds of one
|
||||||
values ($1, $2, nullif($3,''), nullif($4,''), $7, $8, nullif($5,''), nullif($6,''), nullif($6,''))
|
// module in flight together finish in any order, and each stood on the bases the mesh held when
|
||||||
|
// it was asked; the one asked later is what the module is, whichever is heard last. An outcome
|
||||||
|
// of an earlier request is kept in the build records and changes nothing here.
|
||||||
|
tag, err := i.store.Pool().Exec(ctx,
|
||||||
|
`insert into module (name, manifest, version, source, source_path, source_seat, ref, built_from, source_head, built_asked)
|
||||||
|
values ($1, $2, nullif($3,''), nullif($4,''), $7, $8, nullif($5,''), nullif($6,''), nullif($6,''), $9)
|
||||||
on conflict (name) do update set
|
on conflict (name) do update set
|
||||||
manifest = excluded.manifest,
|
manifest = excluded.manifest,
|
||||||
version = excluded.version,
|
version = excluded.version,
|
||||||
@@ -115,29 +128,23 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
|||||||
else excluded.source_seat end,
|
else excluded.source_seat end,
|
||||||
ref = coalesce(excluded.ref, module.ref),
|
ref = coalesce(excluded.ref, module.ref),
|
||||||
built_from = coalesce(excluded.built_from, module.built_from),
|
built_from = coalesce(excluded.built_from, module.built_from),
|
||||||
source_head = coalesce(excluded.built_from, module.source_head)`,
|
source_head = coalesce(excluded.built_from, module.source_head),
|
||||||
m.Module, raw, m.Version, from.Repository, from.Ref, from.BuiltFrom, from.Path, from.Seat)
|
built_asked = excluded.built_asked
|
||||||
|
where module.built_asked is null or module.built_asked <= excluded.built_asked`,
|
||||||
|
m.Module, raw, m.Version, from.Repository, from.Ref, from.BuiltFrom, from.Path, from.Seat, asked)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
// registeredInThatShape is whether the catalogue already holds this module as a tools container on
|
|
||||||
// the runtime's image — judged from the manifest it holds and what that module's newest build stood
|
|
||||||
// on, the same two things the gate judges a new registration by. False for a module the catalogue
|
|
||||||
// does not hold.
|
|
||||||
func (i *Inventory) registeredInThatShape(ctx context.Context, name string) (bool, error) {
|
|
||||||
held, err := i.Catalogue(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
}
|
||||||
stored, has := held[name]
|
if tag.RowsAffected() == 0 {
|
||||||
if !has {
|
var current time.Time
|
||||||
return false, nil
|
if err := i.store.Pool().QueryRow(ctx,
|
||||||
|
`select built_asked from module where name = $1`, m.Module).Scan(¤t); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
against, err := i.BuiltAgainst(ctx)
|
return fmt.Errorf("%w: %s is at a build asked %s, and this one was asked %s",
|
||||||
if err != nil {
|
ErrSuperseded, m.Module, current.UTC().Format(time.RFC3339), asked.UTC().Format(time.RFC3339))
|
||||||
return false, err
|
|
||||||
}
|
}
|
||||||
return catalogue.ToolContainerOnTheRuntime(stored, against[name]) != "", nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasModule is whether the catalogue holds a module of that name.
|
// hasModule is whether the catalogue holds a module of that name.
|
||||||
|
|||||||
@@ -688,9 +688,9 @@ func TestRegisteringWithoutProvenanceKeepsTheSeat(t *testing.T) {
|
|||||||
|
|
||||||
// Once the node's tool runtime is in the catalogue, a module serving its tools from a container
|
// Once the node's tool runtime is in the catalogue, a module serving its tools from a container
|
||||||
// built on the runtime's image is refused at registration, naming the record (novox/hq ADR 0175,
|
// built on the runtime's image is refused at registration, naming the record (novox/hq ADR 0175,
|
||||||
// to-be 38 WP2.4) — for a module new to the catalogue or one that had moved away from it; a module
|
// to-be 38 WP2.4) — for every module, since every module has moved (WP4b's last step; WP3's
|
||||||
// already standing in that shape is rebuilt as before, so the catalogue's pipeline keeps running
|
// amendment let one already standing in that shape be rebuilt while each moved). Before the
|
||||||
// while each moves (WP3's amendment). Before the runtime, it is accepted as it always was — so a
|
// runtime, it is accepted as it always was — so a
|
||||||
// mesh converts in the order the design says and nothing is refused before there is anything to
|
// mesh converts in the order the design says and nothing is refused before there is anything to
|
||||||
// move to.
|
// move to.
|
||||||
func TestAToolContainerIsRefusedOnceTheRuntimeIsRegistered(t *testing.T) {
|
func TestAToolContainerIsRefusedOnceTheRuntimeIsRegistered(t *testing.T) {
|
||||||
@@ -715,11 +715,11 @@ func TestAToolContainerIsRefusedOnceTheRuntimeIsRegistered(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// **A module already registered in that shape is rebuilt without complaint** (to-be 38 WP2.4 as
|
// **A module already registered in that shape is refused too** (WP4b's last step): every module
|
||||||
// amended by WP3): some thirty of them stand the day the runtime arrives, and each moves in its
|
// has moved, and a rebuild in the old shape is one moving back.
|
||||||
// own change. The gate is against the pattern spreading, not against the pipeline running.
|
if err := inv.RegisterModule(ctx, filter, Source{Repository: "/r", Against: stoodOn}); err == nil ||
|
||||||
if err := inv.RegisterModule(ctx, filter, Source{Repository: "/r", Against: stoodOn}); err != nil {
|
!strings.Contains(err.Error(), "ADR 0175") {
|
||||||
t.Fatalf("a rebuild of a module that already had the pattern was refused: %v", err)
|
t.Fatalf("a rebuild of a module in the old pattern was registered beside the runtime: %v", err)
|
||||||
}
|
}
|
||||||
// A module new to the catalogue in that shape is refused, naming the record.
|
// A module new to the catalogue in that shape is refused, naming the record.
|
||||||
newcomer := filter
|
newcomer := filter
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What the artifact store keeps, and what it may let go (novox/hq ADR 0189, issue 108).
|
||||||
|
//
|
||||||
|
// The store has never collected anything: every build pushes another layer set and nothing has
|
||||||
|
// ever removed one. The registry's own answer — collect what no tag names — is wrong here, because
|
||||||
|
// the mesh pushes each artifact under one moving tag and pins machines by digest, so every build
|
||||||
|
// but the newest is untagged and some machine may still be running it.
|
||||||
|
//
|
||||||
|
// **So the mesh decides, from its own records, and it never has to look in the store to do it.**
|
||||||
|
// It has never put anything there it did not record, which means every digest it could remove is
|
||||||
|
// already in a build row. A digest the mesh did not record making is therefore never named here —
|
||||||
|
// not as a safety margin but as the rule restated, and it is what keeps the sweep away from the
|
||||||
|
// images genesis pushed before any record existed (04-ISSUES/102, F4).
|
||||||
|
|
||||||
|
// KeptBuilds is how many successful builds of each module keep their artifacts, counting the
|
||||||
|
// newest. The newest is what the mesh hands a machine now; the four behind it are how far back a
|
||||||
|
// release that turns out wrong can be taken.
|
||||||
|
const KeptBuilds = 5
|
||||||
|
|
||||||
|
// ToCollect is every artifact the mesh made, no longer keeps, and has not already collected.
|
||||||
|
//
|
||||||
|
// Three reasons an artifact stays, and nothing else is a reason:
|
||||||
|
//
|
||||||
|
// - **a definition names it** — the reference appears in a module's recorded manifest, which is
|
||||||
|
// what the mesh would hand a machine now. No age limit: this is the floor;
|
||||||
|
// - **the mesh can still go back to it** — it is an artifact of one of the KeptBuilds most
|
||||||
|
// recent successful builds of its module;
|
||||||
|
// - it was already collected, in which case there is nothing left to do.
|
||||||
|
//
|
||||||
|
// Returned in a stated order so two runs over the same records ask for the same things in the
|
||||||
|
// same sequence, which is what makes a failed sweep safe to simply run again.
|
||||||
|
func (i *Inventory) ToCollect(ctx context.Context) ([]string, error) {
|
||||||
|
keep, err := i.keptReferences(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
|
// Every artifact of every successful build, oldest first, minus what has already been
|
||||||
|
// collected. A failed build published nothing, so it names nothing to remove.
|
||||||
|
`select b.made
|
||||||
|
from build b
|
||||||
|
where b.failed = '' and b.module is not null and b.module <> ''
|
||||||
|
order by b.at asc, b.id asc`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
collected, err := i.alreadyCollected(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var raw []byte
|
||||||
|
if err := rows.Scan(&raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var made []Artifact
|
||||||
|
if err := json.Unmarshal(raw, &made); err != nil {
|
||||||
|
// One unreadable record must not stop the rest being collected — and an artifact this
|
||||||
|
// row named is simply not offered, which errs toward keeping.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, a := range made {
|
||||||
|
reference := asRecorded(a.Reference)
|
||||||
|
if reference == "" || keep[reference] || collected[reference] || seen[reference] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[reference] = true
|
||||||
|
out = append(out, reference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// keptReferences is every artifact reference the mesh still keeps, for either of the two reasons.
|
||||||
|
func (i *Inventory) keptReferences(ctx context.Context) (map[string]bool, error) {
|
||||||
|
keep := map[string]bool{}
|
||||||
|
|
||||||
|
// **Whatever a definition the mesh holds names.** Read as text rather than by walking the
|
||||||
|
// resource shapes: a reference may be a container's image, a bundle's source, or a field some
|
||||||
|
// later kind of resource grows, and what matters is only whether the mesh could hand this
|
||||||
|
// string to a machine. A manifest that mentions it is a manifest that might.
|
||||||
|
manifests, err := i.store.Pool().Query(ctx, `select manifest::text from module where manifest is not null`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer manifests.Close()
|
||||||
|
var named []string
|
||||||
|
for manifests.Next() {
|
||||||
|
var text string
|
||||||
|
if err := manifests.Scan(&text); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
named = append(named, text)
|
||||||
|
}
|
||||||
|
if err := manifests.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The KeptBuilds most recent successful builds of each module, whole.
|
||||||
|
recent, err := i.store.Pool().Query(ctx,
|
||||||
|
`select made from (
|
||||||
|
select made, row_number() over (partition by module order by at desc, id desc) as back
|
||||||
|
from build
|
||||||
|
where failed = '' and module is not null and module <> ''
|
||||||
|
) ranked where back <= $1`, KeptBuilds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer recent.Close()
|
||||||
|
for recent.Next() {
|
||||||
|
var raw []byte
|
||||||
|
if err := recent.Scan(&raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var made []Artifact
|
||||||
|
if err := json.Unmarshal(raw, &made); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, a := range made {
|
||||||
|
if reference := asRecorded(a.Reference); reference != "" {
|
||||||
|
keep[reference] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := recent.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// And anything a manifest mentions. Done after the recent set so the scan runs over the
|
||||||
|
// candidates rather than over every reference ever recorded: a manifest holds a reference
|
||||||
|
// composed with the store's address or kept bare, so the search is for the digest within it.
|
||||||
|
if len(named) > 0 {
|
||||||
|
all, err := i.everyReferenceMade(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, reference := range all {
|
||||||
|
if keep[reference] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
digest := digestIn(reference)
|
||||||
|
if digest == "" {
|
||||||
|
// Not something the store holds by digest; nothing here can speak for it, so it
|
||||||
|
// is kept rather than guessed about.
|
||||||
|
keep[reference] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, text := range named {
|
||||||
|
if strings.Contains(text, digest) {
|
||||||
|
keep[reference] = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keep, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// everyReferenceMade is every artifact reference any successful build recorded.
|
||||||
|
func (i *Inventory) everyReferenceMade(ctx context.Context) ([]string, error) {
|
||||||
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
|
`select made from build where failed = '' and module is not null and module <> ''`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var raw []byte
|
||||||
|
if err := rows.Scan(&raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var made []Artifact
|
||||||
|
if err := json.Unmarshal(raw, &made); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, a := range made {
|
||||||
|
reference := asRecorded(a.Reference)
|
||||||
|
if reference == "" || seen[reference] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[reference] = true
|
||||||
|
out = append(out, reference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// asRecorded is an artifact reference in the one vocabulary the sweep speaks (novox/hq issue 226).
|
||||||
|
//
|
||||||
|
// **Every reference here came from a build record, so every one of them is the mesh's own.** That
|
||||||
|
// is what makes it safe to normalise: references kept before the store's address stopped being
|
||||||
|
// written are `<host>:<port>/<path>@sha256:…` (04-ISSUES/102), and `Recorded` reads those as the
|
||||||
|
// `artifact-store://` references the rest of the mesh uses. Done here rather than when the store
|
||||||
|
// is asked, because `Recorded` cannot tell one registry host from another — only the provenance
|
||||||
|
// can, and the provenance is here.
|
||||||
|
//
|
||||||
|
// The oldest artifacts are exactly the ones recorded the old way, and exactly the ones a
|
||||||
|
// sweep reaches first. Untranslated, the first of them ended every sweep.
|
||||||
|
func asRecorded(reference string) string {
|
||||||
|
if reference == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return catalogue.Recorded(reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
// digestIn is the `sha256:<hex>` a reference names, empty when it names none.
|
||||||
|
func digestIn(reference string) string {
|
||||||
|
for _, marker := range []string{"@sha256:", "/sha256:"} {
|
||||||
|
if _, after, ok := strings.Cut(reference, marker); ok {
|
||||||
|
return "sha256:" + after
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// alreadyCollected is what the store has already been asked to let go.
|
||||||
|
func (i *Inventory) alreadyCollected(ctx context.Context) (map[string]bool, error) {
|
||||||
|
rows, err := i.store.Pool().Query(ctx, `select reference from artifact_collected`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var reference string
|
||||||
|
if err := rows.Scan(&reference); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[reference] = true
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkCollected records that the store no longer holds these.
|
||||||
|
//
|
||||||
|
// **A store that answered "not found" is recorded too.** The outcome wanted is that the artifact
|
||||||
|
// is gone, and it is; retrying it every sweep for ever is the failure this table exists to
|
||||||
|
// prevent. Only a store that could not be reached, or refused, leaves a reference unmarked — and
|
||||||
|
// then the next sweep asks again, which is what should happen.
|
||||||
|
func (i *Inventory) MarkCollected(ctx context.Context, references []string) error {
|
||||||
|
for _, reference := range references {
|
||||||
|
if _, err := i.store.Pool().Exec(ctx,
|
||||||
|
`insert into artifact_collected (reference) values ($1) on conflict (reference) do nothing`,
|
||||||
|
reference); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What the store keeps, and what it may let go (novox/hq ADR 0189, issue 108).
|
||||||
|
//
|
||||||
|
// The store has collected nothing since it was raised, and the registry's own answer — collect
|
||||||
|
// what no tag names — would delete images machines are running, because the mesh pushes under one
|
||||||
|
// moving tag and pins by digest. So the rule is the mesh's, read from its own records, and these
|
||||||
|
// are the three reasons an artifact stays and the one reason it goes.
|
||||||
|
|
||||||
|
// ref is an artifact reference as the mesh records one.
|
||||||
|
func ref(module, artifact string, n int) string {
|
||||||
|
return fmt.Sprintf("%s%s/%s@sha256:%064x", catalogue.ArtifactStoreScheme, module, artifact, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// built records one successful build of a module publishing one image.
|
||||||
|
func built(t *testing.T, inv *Inventory, id, module string, n int) string {
|
||||||
|
t.Helper()
|
||||||
|
reference := ref(module, "app", n)
|
||||||
|
b := aBuild(id, module, "")
|
||||||
|
b.Made = []Artifact{{Name: "app", Kind: "image", Reference: reference}}
|
||||||
|
if err := inv.RecordBuild(context.Background(), b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return reference
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheStoreKeepsTheRecentBuildsAndLetsGoOfTheRest(t *testing.T) {
|
||||||
|
inv := fresh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Eight builds of one module, oldest first. Five are kept — the newest, and the four a
|
||||||
|
// release that turns out wrong can be taken back to.
|
||||||
|
var made []string
|
||||||
|
for i := 1; i <= 8; i++ {
|
||||||
|
made = append(made, built(t, inv, fmt.Sprintf("b%02d", i), "web", i))
|
||||||
|
}
|
||||||
|
|
||||||
|
go_, err := inv.ToCollect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := made[:3] // the three oldest
|
||||||
|
if len(go_) != len(want) {
|
||||||
|
t.Fatalf("offered %v to collect; want the %d oldest of %d", go_, len(want), len(made))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if go_[i] != want[i] {
|
||||||
|
t.Fatalf("offered %v; want %v — and in that order, so a failed sweep is safe to run again",
|
||||||
|
go_, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADefinitionNamingAnArtifactKeepsItHoweverOldItIs(t *testing.T) {
|
||||||
|
// The floor: no age limit. A module recorded at an older commit still names what the mesh
|
||||||
|
// would hand a machine now, and that is what must not be collected out from under it.
|
||||||
|
inv := fresh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var made []string
|
||||||
|
for i := 1; i <= 8; i++ {
|
||||||
|
made = append(made, built(t, inv, fmt.Sprintf("b%02d", i), "web", i))
|
||||||
|
}
|
||||||
|
oldest := made[0]
|
||||||
|
|
||||||
|
// A definition the mesh holds, whose container runs that oldest image.
|
||||||
|
m := catalogue.Manifest{Module: "web", Version: "1", Resources: []map[string]any{{
|
||||||
|
"id": "app", "type": "container", "name": "web", "image": oldest,
|
||||||
|
}}}
|
||||||
|
if err := inv.RegisterModule(ctx, m, Source{Repository: "https://forge.invalid/web.git"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go_, err := inv.ToCollect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, reference := range go_ {
|
||||||
|
if reference == oldest {
|
||||||
|
t.Fatalf("the mesh offered to collect %s, which a definition it holds names", oldest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(go_) != 2 {
|
||||||
|
t.Fatalf("offered %v; want the two oldest that nothing names", go_)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhatHasBeenCollectedIsNotOfferedAgain(t *testing.T) {
|
||||||
|
// Without this the sweep reissues a delete for every artifact it has ever collected, every
|
||||||
|
// time it runs, for ever — a number of requests that grows with the mesh's whole history.
|
||||||
|
inv := fresh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
for i := 1; i <= 7; i++ {
|
||||||
|
built(t, inv, fmt.Sprintf("b%02d", i), "web", i)
|
||||||
|
}
|
||||||
|
first, err := inv.ToCollect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(first) != 2 {
|
||||||
|
t.Fatalf("offered %v, want two", first)
|
||||||
|
}
|
||||||
|
if err := inv.MarkCollected(ctx, first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, err := inv.ToCollect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(again) != 0 {
|
||||||
|
t.Fatalf("offered %v again after collecting it", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFailedBuildNamesNothingToCollectAndEachModuleIsCountedOnItsOwn(t *testing.T) {
|
||||||
|
inv := fresh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// A failed build published nothing, so it is neither kept nor collected — and it must not
|
||||||
|
// count against the module's five.
|
||||||
|
for i := 1; i <= 6; i++ {
|
||||||
|
built(t, inv, fmt.Sprintf("w%02d", i), "web", i)
|
||||||
|
}
|
||||||
|
if err := inv.RecordBuild(ctx, aBuild("w99", "web", "the recipe would not build")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// And a second module with three builds keeps all three: five each, not five between them.
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
built(t, inv, fmt.Sprintf("d%02d", i), "db", 100+i)
|
||||||
|
}
|
||||||
|
|
||||||
|
go_, err := inv.ToCollect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(go_) != 1 || go_[0] != ref("web", "app", 1) {
|
||||||
|
t.Fatalf("offered %v; want only web's oldest — db's three are all within its five", go_)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An artifact recorded with the store's old address is offered for collection, in the vocabulary
|
||||||
|
// the rest of the mesh speaks (novox/hq issue 226).
|
||||||
|
//
|
||||||
|
// Before references were kept without an address the mesh recorded
|
||||||
|
// `<host>:<port>/<path>@sha256:…` (04-ISSUES/102). Those are the oldest artifacts, which makes
|
||||||
|
// them exactly the ones an oldest-first sweep reaches first — and the first live run met one,
|
||||||
|
// read "I will not address this" as "the store refuses everything", and collected none of 1681.
|
||||||
|
func TestAnArtifactRecordedWithAnAddressIsOfferedAsTheMeshRecordsOne(t *testing.T) {
|
||||||
|
inv := fresh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// The oldest build published the old way; five newer ones fill the module's five.
|
||||||
|
old := aBuild("a00", "tools", "")
|
||||||
|
old.Made = []Artifact{{Name: "build", Kind: "image",
|
||||||
|
Reference: "127.0.0.1:5100/tools/build@sha256:" + fmt.Sprintf("%064x", 1)}}
|
||||||
|
if err := inv.RecordBuild(ctx, old); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 2; i <= 6; i++ {
|
||||||
|
built(t, inv, fmt.Sprintf("a%02d", i), "tools", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
go_, err := inv.ToCollect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := ref("tools", "build", 1)
|
||||||
|
if len(go_) != 1 || go_[0] != want {
|
||||||
|
t.Fatalf("offered %v; want %q — the address is a route to the artifact, not part of its "+
|
||||||
|
"name, and the sweep speaks the name", go_, want)
|
||||||
|
}
|
||||||
|
// And marking it collected uses that same name, so the next sweep does not offer it again
|
||||||
|
// under a spelling it has not seen.
|
||||||
|
if err := inv.MarkCollected(ctx, go_); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, err := inv.ToCollect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(again) != 0 {
|
||||||
|
t.Fatalf("offered %v again after collecting it", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,3 +100,23 @@ func TestABundleStandsOnTheToolchainItIsCompiledIn(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// novox/hq 04-ISSUES/212: a toolchain standing on the SDK's package is planned after the SDK, so a
|
||||||
|
// release of the SDK rebuilds the toolchain, and every bundle compiled in it after that.
|
||||||
|
func TestAToolchainStandingOnTheSDKFollowsIt(t *testing.T) {
|
||||||
|
entries := []Entry{
|
||||||
|
{Manifest: catalogue.Manifest{Module: "mesh-sdk"}},
|
||||||
|
{Manifest: catalogue.Manifest{Module: "mesh-tools", Build: &catalogue.Build{
|
||||||
|
On: []catalogue.BuildsOn{{Arg: "MESH_SDK", Module: "mesh-sdk", Artifact: "lib"}}}}},
|
||||||
|
}
|
||||||
|
edges := dependenciesOf(entries, nil, nil)
|
||||||
|
found := false
|
||||||
|
for _, e := range edges {
|
||||||
|
if e.From == "mesh-tools" && e.To == "mesh-sdk" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("no edge from the toolchain to the SDK: %v", edges)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq issue 218: a seat held once for the mesh is granted and issued only to the holder on record;
|
||||||
|
// a node seat to every machine's claimant; a mesh seat with no holder on record as derived.
|
||||||
|
func TestOnlyTheRecordedHolderHoldsAMeshSeat(t *testing.T) {
|
||||||
|
claimed := []broker.Seat{
|
||||||
|
{Name: "mesh-store", Scope: catalogue.ScopeMesh},
|
||||||
|
{Name: "node-packet-filter", Scope: catalogue.ScopeNode},
|
||||||
|
{Name: "unrecorded", Scope: catalogue.ScopeMesh},
|
||||||
|
}
|
||||||
|
holdings := []catalogue.Held{{Claim: "mesh-store", Scope: catalogue.ScopeMesh, Node: "control", Module: "postgres"}}
|
||||||
|
names := func(ss []broker.Seat) (out []string) {
|
||||||
|
for _, s := range ss {
|
||||||
|
out = append(out, s.Name)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got := names(heldHere(claimed, holdings, "control", "postgres")); len(got) != 3 {
|
||||||
|
t.Errorf("the holder lost a seat: %v", got)
|
||||||
|
}
|
||||||
|
got := names(heldHere(claimed, holdings, "other", "postgres"))
|
||||||
|
if len(got) != 2 || got[0] != "node-packet-filter" || got[1] != "unrecorded" {
|
||||||
|
t.Errorf("a claimant on another machine holds %v; want the node seat and the unrecorded one, not the store", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,3 +87,62 @@ func (i *Inventory) tryHold(ctx context.Context, sorted []string) (func(), strin
|
|||||||
}
|
}
|
||||||
return release, "", nil
|
return release, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrPlansBusy is the plans held by another act — on a machine replacing its controller, the other
|
||||||
|
// controller — for longer than a caller waits, or at all for one that does not wait.
|
||||||
|
var ErrPlansBusy = errors.New("another controller is working the plans")
|
||||||
|
|
||||||
|
// HoldPlans makes working the plans one act at a time, across every controller on the store
|
||||||
|
// (novox/hq issue 213). A plan is read, changed and written whole; two controllers doing that at
|
||||||
|
// once — the old and the new for the moment a machine hands its controller over, or a controller
|
||||||
|
// and a person's `plans stop` — each act on what the other has not saved yet: a tier asked twice,
|
||||||
|
// an outcome written over. A session-level advisory lock on one connection, released by the
|
||||||
|
// returned function and by the session ending, so a controller that dies holding it holds nothing.
|
||||||
|
//
|
||||||
|
// wait false gives ErrPlansBusy at once when another holds them — the timer's way: the holder is
|
||||||
|
// moving the plans already. wait true looks again every HoldPoll for up to HoldWaitFor — an
|
||||||
|
// outcome's or a merge's way, which must be written.
|
||||||
|
func (i *Inventory) HoldPlans(ctx context.Context, wait bool) (func(), error) {
|
||||||
|
deadline := time.Now().Add(HoldWaitFor)
|
||||||
|
for {
|
||||||
|
release, took, err := i.tryLock(ctx, "mesh-plans")
|
||||||
|
if err != nil || took {
|
||||||
|
return release, err
|
||||||
|
}
|
||||||
|
if !wait || time.Now().After(deadline) {
|
||||||
|
return nil, ErrPlansBusy
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(HoldPoll):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryLock takes one named advisory lock on a connection of its own, or gives the connection back.
|
||||||
|
func (i *Inventory) tryLock(ctx context.Context, key string) (func(), bool, error) {
|
||||||
|
conn, err := i.store.Pool().Acquire(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
var once sync.Once
|
||||||
|
release := func() {
|
||||||
|
once.Do(func() {
|
||||||
|
if _, err := conn.Exec(context.WithoutCancel(ctx), `select pg_advisory_unlock_all()`); err != nil {
|
||||||
|
_ = conn.Conn().Close(context.WithoutCancel(ctx))
|
||||||
|
}
|
||||||
|
conn.Release()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
var took bool
|
||||||
|
if err := conn.QueryRow(ctx, `select pg_try_advisory_lock(hashtext($1)::bigint)`, key).Scan(&took); err != nil {
|
||||||
|
release()
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if !took {
|
||||||
|
release()
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return release, true, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq issue 213: while a machine hands its controller over from the container to the process,
|
||||||
|
// two controllers run on one store for a moment. Working the plans is one act at a time across them.
|
||||||
|
func TestThePlansAreWorkedByOneControllerAtATime(t *testing.T) {
|
||||||
|
first := ForTest(t)
|
||||||
|
// A second controller: its own connections to the same store.
|
||||||
|
second, err := Open(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(second.Close)
|
||||||
|
|
||||||
|
release, err := first.HoldPlans(t.Context(), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the plans could not be held when nobody held them: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(release) // a pool closing waits for a connection still held; release is idempotent
|
||||||
|
if _, err := second.HoldPlans(t.Context(), false); !errors.Is(err, ErrPlansBusy) {
|
||||||
|
t.Fatalf("a second controller held the plans while the first did: %v", err)
|
||||||
|
}
|
||||||
|
// A waiter gets them once they are let go.
|
||||||
|
was := HoldPoll
|
||||||
|
HoldPoll = 10 * time.Millisecond
|
||||||
|
defer func() { HoldPoll = was }()
|
||||||
|
go func() { time.Sleep(50 * time.Millisecond); release() }()
|
||||||
|
again, err := second.HoldPlans(t.Context(), true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a waiting controller never got the plans once they were let go: %v", err)
|
||||||
|
}
|
||||||
|
again()
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- A build is ordered by when it was asked, not when it finished (novox/hq 04-ISSUES/219).
|
||||||
|
--
|
||||||
|
-- Two builds of one module can be in flight together — two merge plans a few minutes apart, each
|
||||||
|
-- asking for everything standing on what it changed — and they finish in any order. Each build
|
||||||
|
-- stands on the bases the mesh held when it was *asked*, so the one asked later is the newer one.
|
||||||
|
-- The mesh ordered builds by `at`, which is when the outcome was recorded, and registered whatever
|
||||||
|
-- it heard last: an older request that took longer replaced a newer one as what the module is, and
|
||||||
|
-- the next push sent machines an image built on a base the mesh had already replaced.
|
||||||
|
--
|
||||||
|
-- `build.asked` is when the build was requested, read from the correlation id the controller wrote
|
||||||
|
-- (`build-<unix nanoseconds>`). Nullable: an id of any other shape says no request time, and such a
|
||||||
|
-- build is placed where it was recorded, which is the order the mesh had before this.
|
||||||
|
alter table build add column asked timestamptz;
|
||||||
|
|
||||||
|
update build
|
||||||
|
set asked = to_timestamp((substring(id from '^build-([0-9]{19})$'))::numeric / 1000000000)
|
||||||
|
where id ~ '^build-[0-9]{19}$';
|
||||||
|
|
||||||
|
-- `module.built_asked` is when the build the module's registered manifest came from was asked, so
|
||||||
|
-- a later-heard outcome of an earlier request is recorded and not registered. A manifest handed over
|
||||||
|
-- by hand is a request made when it is handed over. Null for a module registered before this was
|
||||||
|
-- kept: its next registration, whichever it is, sets it.
|
||||||
|
alter table module add column built_asked timestamptz;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- What the artifact store no longer keeps (novox/hq ADR 0189, issue 108).
|
||||||
|
--
|
||||||
|
-- The mesh removes from its store only what it put there and can account for: every digest it
|
||||||
|
-- could remove is already in a build record, so the sweep reads its own records rather than
|
||||||
|
-- enumerating the store. What it does not get from those records is whether it has already
|
||||||
|
-- removed something -- `build.made` says what that build published, for ever, which is history
|
||||||
|
-- and not an index of what is on disk.
|
||||||
|
--
|
||||||
|
-- Without this the sweep would reissue a delete for every artifact it has ever collected, every
|
||||||
|
-- time it runs, and each one would answer 404 -- a number of requests that grows with the mesh's
|
||||||
|
-- whole history and never shrinks.
|
||||||
|
--
|
||||||
|
-- Keyed by the reference as the mesh records it (`artifact-store://<module>/<artifact>@sha256:…`),
|
||||||
|
-- because that is the identity the record uses everywhere else. Not a foreign key to build: two
|
||||||
|
-- builds can publish the same digest (the same source built twice produces the same bytes), and
|
||||||
|
-- what is collected is the artifact, not the attempt that made it.
|
||||||
|
create table artifact_collected (
|
||||||
|
reference text primary key,
|
||||||
|
|
||||||
|
-- When the store answered. Kept so a reader of an old build record can tell "this artifact is
|
||||||
|
-- gone" from "this artifact was never there", which are different kinds of surprise.
|
||||||
|
at timestamptz not null default now()
|
||||||
|
);
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq 04-ISSUES/219: two builds of one module in flight together, the one asked first heard
|
||||||
|
// last. The newer request stood on the newer base; the older one's late outcome is recorded and is
|
||||||
|
// not what the module is.
|
||||||
|
|
||||||
|
func postgresBuild(id string, asked time.Time, image string) Build {
|
||||||
|
b := aBuild(id, "postgres", "")
|
||||||
|
b.Asked = asked
|
||||||
|
b.Against = []string{"mesh-tools/runtime@sha256:" + id}
|
||||||
|
b.Made = []Artifact{{Name: "server", Kind: "image", Reference: "postgres@sha256:" + image}}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnOlderRequestFinishingLaterIsNotWhatTheModuleHolds(t *testing.T) {
|
||||||
|
inv := fresh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
older := time.Date(2026, 10, 3, 21, 33, 45, 0, time.UTC)
|
||||||
|
newer := time.Date(2026, 10, 3, 21, 51, 57, 0, time.UTC)
|
||||||
|
|
||||||
|
// The newer request finishes first, the older one last — recorded in that order.
|
||||||
|
if err := inv.RecordBuild(ctx, postgresBuild("newer", newer, "4bcd5f73")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RecordBuild(ctx, postgresBuild("older", older, "0ab07fa9")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
held, err := inv.Held(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := held["postgres/server"]; got != "postgres@sha256:4bcd5f73" {
|
||||||
|
t.Errorf("postgres holds %q; want the newer request's image 4bcd5f73", got)
|
||||||
|
}
|
||||||
|
against, err := inv.BuiltAgainst(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := against["postgres"]; len(got) != 1 || got[0] != "mesh-tools/runtime@sha256:newer" {
|
||||||
|
t.Errorf("postgres stands on %v; want what the newer request stood on", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both are still recorded, the late one first as what happened lately.
|
||||||
|
builds, err := inv.Builds(ctx, "postgres", 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(builds) != 2 || builds[0].ID != "older" || !builds[0].Asked.Equal(older) {
|
||||||
|
t.Fatalf("both builds, newest heard first, with when they were asked: %+v", builds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABuildWithNoKnownRequestTimeIsOrderedByWhenItWasRecorded(t *testing.T) {
|
||||||
|
// What the mesh did before it kept the request time, so a row from before still answers.
|
||||||
|
inv := fresh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, id := range []string{"first", "second"} {
|
||||||
|
b := aBuild(id, "shell", "")
|
||||||
|
b.Made = []Artifact{{Name: "config", Kind: "archive", Reference: "…/" + id}}
|
||||||
|
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
held, err := inv.Held(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := held["shell/config"]; got != "…/second" {
|
||||||
|
t.Errorf("shell holds %q; want the one recorded last", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARegistrationFromAnOlderRequestDoesNotReplaceANewerOne(t *testing.T) {
|
||||||
|
inv := fresh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
older := time.Date(2026, 10, 3, 21, 33, 45, 0, time.UTC)
|
||||||
|
newer := time.Date(2026, 10, 3, 21, 51, 57, 0, time.UTC)
|
||||||
|
from := func(asked time.Time) Source {
|
||||||
|
return Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/postgres",
|
||||||
|
BuiltFrom: "efff5415", Asked: asked}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "fixed"}, from(newer)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "stale"}, from(older))
|
||||||
|
if !errors.Is(err, ErrSuperseded) {
|
||||||
|
t.Fatalf("an older request's registration was not refused as superseded: %v", err)
|
||||||
|
}
|
||||||
|
shelf, err := inv.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := shelf["postgres"].Version; got != "fixed" {
|
||||||
|
t.Fatalf("postgres is %q; want the newer request's manifest", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A later request, and a manifest handed over by hand — asked when it is handed over — both
|
||||||
|
// replace it as before.
|
||||||
|
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "later"},
|
||||||
|
from(newer.Add(time.Minute))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "by-hand"}, Source{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if shelf, _ := inv.Catalogue(ctx); shelf["postgres"].Version != "by-hand" {
|
||||||
|
t.Fatalf("postgres is %q; want the manifest handed over by hand", shelf["postgres"].Version)
|
||||||
|
}
|
||||||
|
src, err := inv.SourceOf(ctx, "postgres")
|
||||||
|
if err != nil || src.Repository != "novox/mesh-catalog" {
|
||||||
|
t.Fatalf("a hand registration erased the provenance: %+v %v", src, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,8 +44,15 @@ func TestAPersonMayCallToolsAndNothingElse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// The one tool, both ways it is addressed (novox/hq ADR 0159): to whichever instance
|
// The one tool, both ways it is addressed (novox/hq ADR 0159): to whichever instance
|
||||||
// answers, and to the instance on one machine. Nothing else.
|
// answers, and to the instance on one machine. Nothing else.
|
||||||
if len(perms.Publish) != 2 || perms.Publish[0] != "mesh.mod.mesh-catalog.tool.catalog_tools" ||
|
// And asking what answers (novox/hq ADR 0197), which claims nothing and calls nothing.
|
||||||
perms.Publish[1] != "mesh.mod.mesh-catalog.tool.catalog_tools.*" {
|
var tools []string
|
||||||
|
for _, s := range perms.Publish {
|
||||||
|
if !strings.HasPrefix(s, "$SRV.") {
|
||||||
|
tools = append(tools, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tools) != 2 || tools[0] != "mesh.mod.mesh-catalog.tool.catalog_tools" ||
|
||||||
|
tools[1] != "mesh.mod.mesh-catalog.tool.catalog_tools.*" {
|
||||||
t.Errorf("ada may publish %v, which should be the one tool, both ways addressed, and nothing else", perms.Publish)
|
t.Errorf("ada may publish %v, which should be the one tool, both ways addressed, and nothing else", perms.Publish)
|
||||||
}
|
}
|
||||||
for _, s := range perms.Publish {
|
for _, s := range perms.Publish {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package link
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Asking a machine to build a module, and hearing what came out.
|
// Asking a machine to build a module, and hearing what came out.
|
||||||
@@ -17,6 +20,32 @@ import (
|
|||||||
// holds no opinion about what they contain, and a host that also built things would be a host
|
// holds no opinion about what they contain, and a host that also built things would be a host
|
||||||
// with a container runtime requirement and a git dependency (novox/hq ADR 0005).
|
// with a container runtime requirement and a git dependency (novox/hq ADR 0005).
|
||||||
|
|
||||||
|
// NewBuildID is the correlation for a build asked at that moment: `build-<unix nanoseconds>`.
|
||||||
|
//
|
||||||
|
// **The id carries when the build was asked, and that is read back** (novox/hq 04-ISSUES/219). Builds
|
||||||
|
// of one module can be in flight together and finish in any order; what a module currently is must
|
||||||
|
// be the newest *request's* outcome, not the last one heard, and the id is the one thing every
|
||||||
|
// outcome echoes whichever builder answered it. One place writes the shape and one reads it.
|
||||||
|
func NewBuildID(asked time.Time) string {
|
||||||
|
return "build-" + strconv.FormatInt(asked.UnixNano(), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildAskedAt is when the build with this id was asked, as NewBuildID wrote it. False for an id
|
||||||
|
// of any other shape — one written before this was read, or by hand — whose request time the mesh
|
||||||
|
// does not know.
|
||||||
|
func BuildAskedAt(id string) (time.Time, bool) {
|
||||||
|
digits, ok := strings.CutPrefix(id, "build-")
|
||||||
|
if !ok || digits == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
nanos, err := strconv.ParseInt(digits, 10, 64)
|
||||||
|
// A number too small to be a moment this mesh could have asked at is a name, not a time.
|
||||||
|
if err != nil || nanos < time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).UnixNano() {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return time.Unix(0, nanos).UTC(), true
|
||||||
|
}
|
||||||
|
|
||||||
// BuildRequest is one module to build.
|
// BuildRequest is one module to build.
|
||||||
type BuildRequest struct {
|
type BuildRequest struct {
|
||||||
// ID correlates the answer with the asking. Not the module name: two builds of one module can
|
// ID correlates the answer with the asking. Not the module name: two builds of one module can
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -78,10 +79,15 @@ func (n *natsInbound) Receive(ctx context.Context, act func(context.Context, Con
|
|||||||
// than creating one here: the consumer is an object with a configuration — ack policy, ack
|
// than creating one here: the consumer is an object with a configuration — ack policy, ack
|
||||||
// wait, redelivery — and a client that creates its own would be a second opinion about it.
|
// wait, redelivery — and a client that creates its own would be a second opinion about it.
|
||||||
control := make(chan *nats.Msg, Prefetch)
|
control := make(chan *nats.Msg, Prefetch)
|
||||||
said, err := js.ChanSubscribe("", control, nats.Bind("CONTROL", broker.ControllerName))
|
said, err := standingBy(ctx, log.Default(), "CONTROL", func() (*nats.Subscription, error) {
|
||||||
|
return js.ChanSubscribe("", control, nats.Bind("CONTROL", broker.ControllerName))
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("subscribing to what nodes say: %w", err)
|
return fmt.Errorf("subscribing to what nodes say: %w", err)
|
||||||
}
|
}
|
||||||
|
if said == nil {
|
||||||
|
return nil // stopped while standing by
|
||||||
|
}
|
||||||
defer func() { _ = said.Unsubscribe() }()
|
defer func() { _ = said.Unsubscribe() }()
|
||||||
|
|
||||||
// Heartbeats, on core NATS and off any stream (design 25 §3). Their own subscription because
|
// Heartbeats, on core NATS and off any stream (design 25 §3). Their own subscription because
|
||||||
@@ -97,10 +103,15 @@ func (n *natsInbound) Receive(ctx context.Context, act func(context.Context, Con
|
|||||||
var events chan *nats.Msg
|
var events chan *nats.Msg
|
||||||
if len(n.follows) > 0 {
|
if len(n.follows) > 0 {
|
||||||
events = make(chan *nats.Msg, Prefetch)
|
events = make(chan *nats.Msg, Prefetch)
|
||||||
followed, err := js.ChanSubscribe("", events, nats.Bind("EVENTS", broker.ControllerName))
|
followed, err := standingBy(ctx, log.Default(), "EVENTS", func() (*nats.Subscription, error) {
|
||||||
|
return js.ChanSubscribe("", events, nats.Bind("EVENTS", broker.ControllerName))
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("subscribing to what the catalogue says: %w", err)
|
return fmt.Errorf("subscribing to what the catalogue says: %w", err)
|
||||||
}
|
}
|
||||||
|
if followed == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
defer func() { _ = followed.Unsubscribe() }()
|
defer func() { _ = followed.Unsubscribe() }()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,3 +335,44 @@ func (m *natsControl) forget() {
|
|||||||
type replyAddressed struct {
|
type replyAddressed struct {
|
||||||
ReplyTo string `json:"reply_to,omitempty"`
|
ReplyTo string `json:"reply_to,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StandbyPoll is how often a controller standing by looks again for its consumers. A variable so a
|
||||||
|
// test need not wait.
|
||||||
|
var StandbyPoll = 2 * time.Second
|
||||||
|
|
||||||
|
// standingBy binds one of the controller's consumers, waiting while another controller holds it.
|
||||||
|
//
|
||||||
|
// **Two controllers, one consumer** (novox/hq issue 213). The controller's consumers are push
|
||||||
|
// consumers with no delivery group, so the server lets one subscription bind each — on purpose:
|
||||||
|
// two would each act on every message (issue 146). When a machine hands its controller over from
|
||||||
|
// the container to the process, the host starts the process first and removes the container only
|
||||||
|
// once the process is up; the process then finds the consumers bound. Exiting on that would never
|
||||||
|
// be up, so the container would never go. It stands by instead — the seat's verbs are already
|
||||||
|
// served from a queue group, and the plans wait on their lock — and binds as soon as the other lets
|
||||||
|
// go. Nil and no error is ctx ending while it waited.
|
||||||
|
func standingBy(ctx context.Context, logger interface{ Printf(string, ...any) }, stream string,
|
||||||
|
bind func() (*nats.Subscription, error)) (*nats.Subscription, error) {
|
||||||
|
said := false
|
||||||
|
for {
|
||||||
|
sub, err := bind()
|
||||||
|
if err == nil {
|
||||||
|
if said {
|
||||||
|
logger.Printf("took the controller's consumer on %s: the controller that held it let go", stream)
|
||||||
|
}
|
||||||
|
return sub, nil
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "already bound") {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !said {
|
||||||
|
logger.Printf("another controller holds the controller's consumer on %s; standing by "+
|
||||||
|
"until it lets go", stream)
|
||||||
|
said = true
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, nil
|
||||||
|
case <-time.After(StandbyPoll):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq issue 213: while a machine hands its controller over, the new controller (the process)
|
||||||
|
// is started while the old one (the container) still holds the controller's consumers. It must not
|
||||||
|
// exit — the host would read that as a replacement that did not come up and never remove the
|
||||||
|
// container — and must not act on what the old one is handed. It stands by, and takes the consumers
|
||||||
|
// when the old one lets go.
|
||||||
|
func TestNatsASecondControllerStandsByAndTakesOverWhenTheFirstLetsGo(t *testing.T) {
|
||||||
|
js := aBus(t)
|
||||||
|
was := StandbyPoll
|
||||||
|
StandbyPoll = 50 * time.Millisecond
|
||||||
|
defer func() { StandbyPoll = was }()
|
||||||
|
|
||||||
|
old := &counted{}
|
||||||
|
_, stopOld := servingOn(t, js, old)
|
||||||
|
eventually(t, "the first controller binding its consumer", func() bool {
|
||||||
|
info, err := js.Context().ConsumerInfo("CONTROL", broker.ControllerName)
|
||||||
|
return err == nil && info.PushBound
|
||||||
|
})
|
||||||
|
|
||||||
|
// The new one, on a connection of its own as the process would have.
|
||||||
|
second, err := broker.Dial(os.Getenv("MESH_TEST_NATS"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(second.Close)
|
||||||
|
fresh := &counted{}
|
||||||
|
s := &Server{inbound: Nats(second), bus: OverNATS{Conn: second.Conn(), JS: second.Context()},
|
||||||
|
listener: fresh, log: quiet()}
|
||||||
|
ctx, stopNew := context.WithCancel(context.Background())
|
||||||
|
defer stopNew()
|
||||||
|
ended := make(chan error, 1)
|
||||||
|
go func() { ended <- s.Serve(ctx) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-ended:
|
||||||
|
t.Fatalf("the second controller stopped instead of standing by: %v", err)
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
report := func(declared string) {
|
||||||
|
body, _ := json.Marshal(Report{Node: "anchor", Declared: declared, Applied: []string{"store"}})
|
||||||
|
if _, err := js.Context().Publish(ReportSubject("anchor"), body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report("d1")
|
||||||
|
eventually(t, "the holding controller hearing the report", func() bool { return old.count() == 1 })
|
||||||
|
if fresh.count() != 0 {
|
||||||
|
t.Fatal("the controller standing by acted on a report the holder was handed")
|
||||||
|
}
|
||||||
|
|
||||||
|
stopOld()
|
||||||
|
report("d2")
|
||||||
|
eventually(t, "the second controller taking over once the first let go", func() bool { return fresh.count() == 1 })
|
||||||
|
if old.count() != 1 {
|
||||||
|
t.Errorf("the first controller heard %d reports", old.count())
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user