Compare commits
1
Commits
main
..
420a85855d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
420a85855d |
+2
-8
@@ -1,11 +1,5 @@
|
|||||||
# The Go it builds with, pinned here because genesis builds this file with no arguments (novox/hq
|
ARG GO_BASE=golang:1.25-alpine
|
||||||
# issue 223) — the Makefile passes the same digest. A tag older than go.mod asks for is how
|
# The control plane's image.
|
||||||
# `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,21 +27,17 @@ build:
|
|||||||
IMAGE ?= mesh-controller:$(VERSION)
|
IMAGE ?= mesh-controller:$(VERSION)
|
||||||
DEV_TAG ?= mesh-controller:development
|
DEV_TAG ?= mesh-controller:development
|
||||||
|
|
||||||
# The Go base the image is built on.
|
# The base the module declares, read from the manifest rather than written here twice.
|
||||||
#
|
#
|
||||||
# **`make image` was broken and stayed broken**, because the Dockerfile's fallback base was a Go
|
# **`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 "no GO_BASE; pass GO_BASE=<image>"; exit 1; }
|
@test -n "$(GO_BASE)" || { echo "module.json declares no GO_BASE; pass GO_BASE=<image> or fix the manifest"; exit 1; }
|
||||||
docker build --build-arg GO_BASE=$(GO_BASE) --build-arg VERSION=$(VERSION) -t $(IMAGE) -t $(DEV_TAG) .
|
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'
|
||||||
@@ -52,7 +48,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 "no GO_BASE; pass GO_BASE=<image>"; exit 1; }
|
@test -n "$(GO_BASE)" || { echo "module.json declares no GO_BASE; pass GO_BASE=<image> or fix the manifest"; exit 1; }
|
||||||
docker build --build-arg GO_BASE=$(GO_BASE) -f cmd/mesh-builder/Dockerfile -t $(BUILDER_IMAGE) -t $(BUILDER_DEV_TAG) .
|
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'
|
||||||
@@ -63,7 +59,7 @@ PROVISIONER_IMAGE ?= mesh-provision-postgres:$(VERSION)
|
|||||||
PROVISIONER_DEV_TAG ?= mesh-provision-postgres:development
|
PROVISIONER_DEV_TAG ?= mesh-provision-postgres:development
|
||||||
|
|
||||||
provisioner-image:
|
provisioner-image:
|
||||||
docker build --build-arg GO_BASE=$(GO_BASE) -f examples/postgres-provisioner/Dockerfile \
|
docker build -f examples/postgres-provisioner/Dockerfile \
|
||||||
-t $(PROVISIONER_IMAGE) -t $(PROVISIONER_DEV_TAG) .
|
-t $(PROVISIONER_IMAGE) -t $(PROVISIONER_DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
@@ -74,7 +70,7 @@ OBJECTSTORE_IMAGE ?= mesh-provision-objectstore:$(VERSION)
|
|||||||
OBJECTSTORE_DEV_TAG ?= mesh-provision-objectstore:development
|
OBJECTSTORE_DEV_TAG ?= mesh-provision-objectstore:development
|
||||||
|
|
||||||
objectstore-image:
|
objectstore-image:
|
||||||
docker build --build-arg GO_BASE=$(GO_BASE) -f examples/objectstore-provisioner/Dockerfile \
|
docker build -f examples/objectstore-provisioner/Dockerfile \
|
||||||
-t $(OBJECTSTORE_IMAGE) -t $(OBJECTSTORE_DEV_TAG) .
|
-t $(OBJECTSTORE_IMAGE) -t $(OBJECTSTORE_DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(OBJECTSTORE_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(OBJECTSTORE_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
@@ -85,7 +81,7 @@ REDIS_PROVISIONER_IMAGE ?= mesh-provision-redis:$(VERSION)
|
|||||||
REDIS_PROVISIONER_DEV_TAG ?= mesh-provision-redis:development
|
REDIS_PROVISIONER_DEV_TAG ?= mesh-provision-redis:development
|
||||||
|
|
||||||
redis-provisioner-image:
|
redis-provisioner-image:
|
||||||
docker build --build-arg GO_BASE=$(GO_BASE) -f examples/redis-provisioner/Dockerfile \
|
docker build -f examples/redis-provisioner/Dockerfile \
|
||||||
-t $(REDIS_PROVISIONER_IMAGE) -t $(REDIS_PROVISIONER_DEV_TAG) .
|
-t $(REDIS_PROVISIONER_IMAGE) -t $(REDIS_PROVISIONER_DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(REDIS_PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(REDIS_PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
@@ -95,7 +91,7 @@ PROXY_IMAGE ?= mesh-route-proxy:$(VERSION)
|
|||||||
PROXY_DEV_TAG ?= mesh-route-proxy:development
|
PROXY_DEV_TAG ?= mesh-route-proxy:development
|
||||||
|
|
||||||
proxy-image:
|
proxy-image:
|
||||||
docker build --build-arg GO_BASE=$(GO_BASE) -f examples/route-proxy/Dockerfile -t $(PROXY_IMAGE) -t $(PROXY_DEV_TAG) .
|
docker build -f examples/route-proxy/Dockerfile -t $(PROXY_IMAGE) -t $(PROXY_DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(PROXY_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(PROXY_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
|
|
||||||
|
|||||||
@@ -193,13 +193,6 @@ 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.
|
||||||
|
|
||||||
|
|||||||
@@ -137,12 +137,7 @@ func takeWorkFrom(credential Credential, on string) (link.BuildMachine, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// **The seat this machine serves is the one its credential claims** (novox/hq ADR 0190, the
|
return link.MachineOverNATS(js, on), nil
|
||||||
// handover): the mesh issues a build machine's credential naming the seat its module claims,
|
|
||||||
// and one binary serves the old role as `builder` and the new as `build-agent` from that alone.
|
|
||||||
seat := link.BuildSeatClaimed(credential.seatsClaimed())
|
|
||||||
fmt.Fprintf(os.Stderr, "taking build work as a holder of %s\n", seat)
|
|
||||||
return link.MachineOverNATSOn(js, on, seat), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// answer does one build and says what happened, whichever way it went.
|
// answer does one build and says what happened, whichever way it went.
|
||||||
@@ -458,21 +453,6 @@ type Credential struct {
|
|||||||
// as two fields and this machine joins them once, here, to dial.
|
// as two fields and this machine joins them once, here, to dial.
|
||||||
User string `json:"user,omitempty"`
|
User string `json:"user,omitempty"`
|
||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
// Claims are the seats the module this credential was issued for claims, as the mesh writes
|
|
||||||
// them beside the credential (novox/hq ADR 0159). The first is the build role this machine
|
|
||||||
// serves; a credential naming none is from before claims travelled in it.
|
|
||||||
Claims []struct {
|
|
||||||
Seat string `json:"seat"`
|
|
||||||
} `json:"claims,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// seatsClaimed is the seats the credential names, in order.
|
|
||||||
func (c Credential) seatsClaimed() []string {
|
|
||||||
out := make([]string, 0, len(c.Claims))
|
|
||||||
for _, claim := range c.Claims {
|
|
||||||
out = append(out, claim.Seat)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// onTheNewBus is whether a credential is for the bus being built: its address says so, and the
|
// onTheNewBus is whether a credential is for the bus being built: its address says so, and the
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The seat a build machine serves comes from its credential (novox/hq ADR 0190 handover).
|
|
||||||
func TestTheCredentialSaysWhichBuildRoleThisMachineServes(t *testing.T) {
|
|
||||||
var held Credential
|
|
||||||
if err := json.Unmarshal([]byte(`{"url":"nats://bus:4222","user":"anchor.builder","password":"x",
|
|
||||||
"claims":[{"seat":"mesh-build-machine","scope":"mesh","serves":[]}]}`), &held); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := link.BuildSeatClaimed(held.seatsClaimed()); got != "mesh-build-machine" {
|
|
||||||
t.Errorf("the old builder's credential serves %q", got)
|
|
||||||
}
|
|
||||||
var bare Credential
|
|
||||||
if err := json.Unmarshal([]byte(`{"url":"nats://bus:4222","user":"anchor.build-agent","password":"x"}`), &bare); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := link.BuildSeatClaimed(bare.seatsClaimed()); got != link.TheBuildMachine {
|
|
||||||
t.Errorf("a credential without claims serves %q, want %s", got, link.TheBuildMachine)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+20
-178
@@ -3,8 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
"slices"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -40,10 +38,7 @@ 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 string, modules ...string) (string, error) {
|
func assign(ctx context.Context, open *stores, node, module 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})
|
||||||
@@ -51,16 +46,6 @@ func assign(ctx context.Context, open *stores, node string, modules ...string) (
|
|||||||
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.
|
||||||
@@ -68,171 +53,58 @@ func assign(ctx context.Context, open *stores, node string, modules ...string) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
var lines []string
|
fresh, err := open.inventory.Assign(ctx, node, module)
|
||||||
var added []string
|
if err != nil {
|
||||||
for _, module := range modules {
|
return "", err
|
||||||
fresh, err := open.inventory.Assign(ctx, node, module)
|
|
||||||
if err != nil {
|
|
||||||
return strings.Join(lines, "\n"), err
|
|
||||||
}
|
|
||||||
if !fresh {
|
|
||||||
// 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).
|
|
||||||
lines = append(lines, fmt.Sprintf(
|
|
||||||
"%s already runs %s — one node runs one of each (ADR 0115); nothing changed", node, module))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
added = append(added, module)
|
|
||||||
lines = append(lines, fmt.Sprintf("%s is assigned %s", node, module))
|
|
||||||
}
|
}
|
||||||
if len(added) == 0 {
|
if !fresh {
|
||||||
return strings.Join(lines, "\n"), nil
|
// 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).
|
||||||
|
return fmt.Sprintf("%s already runs %s — one node runs one of each (ADR 0115); nothing changed",
|
||||||
|
node, module), nil
|
||||||
}
|
}
|
||||||
answer := strings.Join(lines, "\n")
|
said := fmt.Sprintf("%s is assigned %s", node, module)
|
||||||
for _, line := range settled {
|
for _, line := range settled {
|
||||||
answer += "\n " + line
|
said += "\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
|
|
||||||
// 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
|
|
||||||
// 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 != "" {
|
|
||||||
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 answer + blockedElsewhere(ctx, open, node), err
|
return said + 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 !isAdded[u.Module] {
|
if u.Module != module {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, c := range u.Missing {
|
for _, c := range u.Missing {
|
||||||
answer += "\n but " + catalogue.WrongMachine(u.Module, c, node)
|
said += "\n but " + catalogue.WrongMachine(u.Module, c, node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return answer + fmt.Sprintf("\n run `push %s` to send it", node) +
|
return said + fmt.Sprintf("\n run `push %s` to send it", node) +
|
||||||
blockedElsewhere(ctx, open, node), nil
|
blockedElsewhere(ctx, open, node), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// seatDependenciesOnAssign is the refusal ADR 0207 makes at assignment, or nothing, with the
|
// unassign takes a module off a node. What it leaves behind is the host's business: a directory
|
||||||
// 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
|
|
||||||
}
|
|
||||||
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 := open.inventory.Unassign(ctx, node, module); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
assigned, err := open.inventory.Assigned(ctx, node)
|
return fmt.Sprintf("%s no longer runs %s — run `push %s` to make it so",
|
||||||
if err != nil {
|
node, module, node) + blockedElsewhere(ctx, open, node), 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 {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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.
|
||||||
@@ -280,33 +152,3 @@ func blockedElsewhere(ctx context.Context, open *stores, except string) string {
|
|||||||
out.WriteString("\nThis may or may not be what just changed — it is what is true now.")
|
out.WriteString("\nThis may or may not be what just changed — it is what is true now.")
|
||||||
return out.String()
|
return out.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// issueOnAssign gives a newly assigned module its bus credential, the way `module issue` does, and
|
|
||||||
// says what it did in one line. Nothing for a module that declares no broker secret; nothing for one
|
|
||||||
// whose user is already minted (a credential is rotated on purpose, never by re-assigning); and when
|
|
||||||
// the bus cannot be reached from here, the line names the verb and the push that would refuse the
|
|
||||||
// module until it is run — never a silent placeholder (novox/hq issue 203).
|
|
||||||
func issueOnAssign(ctx context.Context, open *stores, node, module string) string {
|
|
||||||
inv := open.inventory
|
|
||||||
shelf, err := inv.Catalogue(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
m, known := shelf[module]
|
|
||||||
if !known || mayIssue(m) != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
user := broker.Principal{Kind: broker.KindModule, Node: node, Module: module}.Username()
|
|
||||||
if _, minted, err := inv.BusUserHash(ctx, user); err != nil || minted {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
busAddress, err := broker.BusAddress()
|
|
||||||
if err == nil {
|
|
||||||
err = issueOnTheNewBus(ctx, inv, m, node, busAddress)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Sprintf("its bus credential is not issued (%v): `module issue %s --node %s` first — "+
|
|
||||||
"`push %s` refuses to send %s until it is", err, module, node, node, module)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("its bus credential is issued and sealed to %s, and arrives with the push", node)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
// process is told so beside the sealed connection genesis wrote.
|
// container 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: "controller", Kind: catalogue.ArtifactBundle,
|
control, err := withSeatPorts(m).Resolve([]catalogue.Built{{Name: "server", Kind: catalogue.ArtifactImage,
|
||||||
Reference: "https://registry.example/mesh-controller/controller.tar.gz", Digest: aDigest}})
|
Reference: "registry.example/control@" + aDigest}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -175,12 +175,6 @@ func TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker(t *testing.T)
|
|||||||
Guards: []int{15672},
|
Guards: []int{15672},
|
||||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-broker",
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-broker",
|
||||||
"ports": []any{"5671:5671", "5672:5672", "127.0.0.1:15672:15672"}, "image": "mq@" + aDigest}}})
|
"ports": []any{"5671:5671", "5672:5672", "127.0.0.1:15672:15672"}, "image": "mq@" + aDigest}}})
|
||||||
// The control plane's own bus user is the installer's, seeded at genesis before the controller
|
|
||||||
// runs (SeedBusUser); without it a push now refuses the credential nobody issued (issue 203).
|
|
||||||
if err := open.inventory.SeedBusUser(ctx, inventory.BusUser{Username: "anchor.mesh-controller",
|
|
||||||
Kind: inventory.BusController, Node: "anchor", Module: "mesh-controller"}, "bootstrap"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := assign(ctx, open, "anchor", "mesh-controller"); err != nil {
|
if _, err := assign(ctx, open, "anchor", "mesh-controller"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -200,12 +194,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.controller" {
|
if r["id"] == "mesh-controller.server" {
|
||||||
env, _ = r["env"].(map[string]any)
|
env, _ = r["env"].(map[string]any)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if env == nil {
|
if env == nil {
|
||||||
t.Fatal("the control plane's process is not in its own node's declaration")
|
t.Fatal("the control plane's container 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 +232,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" && r["type"] != "process" {
|
if r["type"] != "container" {
|
||||||
out.Resources = append(out.Resources, r)
|
out.Resources = append(out.Resources, r)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,22 +95,11 @@ func reportsReaching(t *testing.T, open *stores, reachable []link.Reach, held ..
|
|||||||
// filter is not sent to one that has not. The anchor reports one, as a real host does; this
|
// filter is not sent to one that has not. The anchor reports one, as a real host does; this
|
||||||
// fixture lacked it from 2026-09-28 and nothing ran the test (issue 177).
|
// fixture lacked it from 2026-09-28 and nothing ran the test (issue 177).
|
||||||
Outward: []string{"eth0"},
|
Outward: []string{"eth0"},
|
||||||
// And what filters it (ADR 0168): its front end, the runtime's own, and a chain a
|
|
||||||
// predecessor left in the runtime's user chain.
|
|
||||||
Filters: anchorFilters,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// anchorFilters is what the adopted anchor says filters it: ufw's chains, the runtime's, and a
|
|
||||||
// predecessor's chain the mesh did not write.
|
|
||||||
var anchorFilters = []link.Filter{
|
|
||||||
{Where: "table ip filter, chain ufw-reject-input", Owner: "found-firewall", Refuses: "reject"},
|
|
||||||
{Where: "table ip filter, chain DOCKER", Owner: "runtime", Refuses: `iifname != "docker0" oifname "docker0" drop`},
|
|
||||||
{Where: "table ip filter, chain DOCKER-USER", Owner: "other", Refuses: `iifname "eth0" tcp dport 6000 drop`},
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
heldContainer = link.Held{ID: "hello-web.server", Module: "hello-web", Kind: "container",
|
heldContainer = link.Held{ID: "hello-web.server", Module: "hello-web", Kind: "container",
|
||||||
Target: "hello-web", Since: time.Now()}
|
Target: "hello-web", Since: time.Now()}
|
||||||
@@ -275,20 +264,6 @@ func TestConvergingPreviewsThenChangesAndAdoptingKeepsWhatWasTaken(t *testing.T)
|
|||||||
if strings.Contains(preview, "15672") {
|
if strings.Contains(preview, "15672") {
|
||||||
t.Errorf("a loopback listener is in the preview:\n%s", preview)
|
t.Errorf("a loopback listener is in the preview:\n%s", preview)
|
||||||
}
|
}
|
||||||
// What filters the machine now, and the fate of each (novox/hq ADR 0168): the predecessor's
|
|
||||||
// chain is named as not the mesh's and left, so the reader knows before the flip.
|
|
||||||
for _, want := range []string{
|
|
||||||
"table ip filter, chain DOCKER-USER",
|
|
||||||
"NOT THE MESH'S; left in force",
|
|
||||||
`iifname "eth0" tcp dport 6000 drop`,
|
|
||||||
"table ip filter, chain ufw-reject-input",
|
|
||||||
"the found firewall's; retired with it",
|
|
||||||
"the container runtime's own; left",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(preview, want) {
|
|
||||||
t.Errorf("the preview does not say %q:\n%s", want, preview)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(preview, "\n") {
|
for _, line := range strings.Split(preview, "\n") {
|
||||||
if strings.Contains(line, "5000") && !strings.Contains(line, "WILL CLOSE") {
|
if strings.Contains(line, "5000") && !strings.Contains(line, "WILL CLOSE") {
|
||||||
t.Errorf("an undeclared published port is not said to close: %s", line)
|
t.Errorf("an undeclared published port is not said to close: %s", line)
|
||||||
|
|||||||
@@ -29,10 +29,6 @@ func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node
|
|||||||
if said, err := inv.AdoptionOf(ctx, node.Name); err == nil && len(said.Strays) > 0 {
|
if said, err := inv.AdoptionOf(ctx, node.Name); err == nil && len(said.Strays) > 0 {
|
||||||
showStrays(said.Strays)
|
showStrays(said.Strays)
|
||||||
}
|
}
|
||||||
// And what filters it, truthfully (novox/hq ADR 0168): the mesh alone, or not.
|
|
||||||
if filtering, err := inv.FilteringOf(ctx, node.Name); err == nil {
|
|
||||||
showFiltering(filtering, false)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fmt.Printf(" mode adopted since %s\n",
|
fmt.Printf(" mode adopted since %s\n",
|
||||||
@@ -76,65 +72,10 @@ func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
showStrays(said.Strays)
|
showStrays(said.Strays)
|
||||||
if filtering, err := inv.FilteringOf(ctx, node.Name); err == nil {
|
|
||||||
showFiltering(filtering, true)
|
|
||||||
}
|
|
||||||
fmt.Printf(" as of %s\n", said.At.Local().Format(time.DateTime))
|
fmt.Printf(" as of %s\n", said.At.Local().Format(time.DateTime))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// showFiltering says what filters a machine, with owners (novox/hq ADR 0168), and for a converged
|
|
||||||
// machine the state of the firewall it was found with. A machine that has not said is not said to
|
|
||||||
// be filtered by anything.
|
|
||||||
func showFiltering(f inventory.Filtering, adopted bool) {
|
|
||||||
if len(f.Filters) == 0 && f.FoundFirewall == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if fw := f.FoundFirewall; fw != nil && !adopted {
|
|
||||||
switch {
|
|
||||||
case fw.Active:
|
|
||||||
fmt.Printf(" found firewall %s is ACTIVE on this converged machine; the next apply retires it again\n", fw.Kind)
|
|
||||||
case fw.RetiredBy == "removed":
|
|
||||||
fmt.Printf(" found firewall %s, removed: the mesh's filter is what filters this machine (novox/hq ADR 0180)\n", fw.Kind)
|
|
||||||
case fw.RetiredBy == inventory.FilterMesh || fw.RetiredBy == "mesh":
|
|
||||||
fmt.Printf(" found firewall %s, retired by the mesh; its configuration stays on disk\n", fw.Kind)
|
|
||||||
case fw.RetiredBy != "":
|
|
||||||
fmt.Printf(" found firewall %s, found inactive — not by the mesh\n", fw.Kind)
|
|
||||||
default:
|
|
||||||
fmt.Printf(" found firewall %s, inactive\n", fw.Kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(f.Filters) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if f.Alone() {
|
|
||||||
fmt.Printf(" filtered by the mesh alone (%s)\n", filterSummary(f.Filters))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Printf(" filtered by NOT the mesh alone: %d rule set(s) the mesh did not write refuse traffic here\n", len(f.Others()))
|
|
||||||
for _, x := range f.Filters {
|
|
||||||
if x.Owner == inventory.FilterOther || x.Owner == inventory.FilterFoundFirewall {
|
|
||||||
fmt.Printf(" %-17s %s — %s: %s\n", "", x.Where, x.Owner, x.Refuses)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf(" %-17s and its own: %s\n", "", filterSummary(f.Filters))
|
|
||||||
}
|
|
||||||
|
|
||||||
// filterSummary counts a machine's filters by owner: "mesh 2, runtime 3, ban 1".
|
|
||||||
func filterSummary(filters []inventory.Filter) string {
|
|
||||||
counts := map[string]int{}
|
|
||||||
for _, x := range filters {
|
|
||||||
counts[x.Owner]++
|
|
||||||
}
|
|
||||||
var parts []string
|
|
||||||
for _, owner := range []string{inventory.FilterMesh, inventory.FilterRuntime, inventory.FilterBan, inventory.FilterFoundFirewall, inventory.FilterOther} {
|
|
||||||
if n := counts[owner]; n > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("%s %d", owner, n))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// showStrays says what a machine runs that the mesh neither wrote nor holds (ADR 0163).
|
// showStrays says what a machine runs that the mesh neither wrote nor holds (ADR 0163).
|
||||||
func showStrays(strays []inventory.Stray) {
|
func showStrays(strays []inventory.Stray) {
|
||||||
if len(strays) == 0 {
|
if len(strays) == 0 {
|
||||||
@@ -720,11 +661,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
|
|||||||
}
|
}
|
||||||
derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh,
|
derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh,
|
||||||
outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks}
|
outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks}
|
||||||
filtering, err := inv.FilteringOf(ctx, node)
|
preview, saw := previewOf(node, reported, derived, plan, taken, filter, runs[filter])
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
preview, saw := previewOf(node, reported, filtering, derived, plan, taken, filter, runs[filter])
|
|
||||||
preview += "\n\n preview " + saw
|
preview += "\n\n preview " + saw
|
||||||
if !yes {
|
if !yes {
|
||||||
return preview + fmt.Sprintf("\n\nNothing has changed. Run `converge %s --yes %s` to do "+
|
return preview + fmt.Sprintf("\n\nNothing has changed. Run `converge %s --yes %s` to do "+
|
||||||
@@ -794,7 +731,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
|
|||||||
// previewOf is what converging a node will change, before it changes it, and a short digest of
|
// previewOf is what converging a node will change, before it changes it, and a short digest of
|
||||||
// what it said: every reachable thing and its fate, the modules the flip takes and the filter. The
|
// what it said: every reachable thing and its fate, the modules the flip takes and the filter. The
|
||||||
// digest is what the flip is asked to act on, so it changes whenever any of those would.
|
// digest is what the flip is asked to act on, so it changes whenever any of those would.
|
||||||
func previewOf(node string, reported inventory.Adoption, filtering inventory.Filtering, derived derivedFilter,
|
func previewOf(node string, reported inventory.Adoption, derived derivedFilter,
|
||||||
plan catalogue.Resolution, taken []string, filter string, filterAssigned bool) (string, string) {
|
plan catalogue.Resolution, taken []string, filter string, filterAssigned bool) (string, string) {
|
||||||
var said []string
|
var said []string
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -886,30 +823,6 @@ func previewOf(node string, reported inventory.Adoption, filtering inventory.Fil
|
|||||||
fmt.Fprintf(&b, " the found firewall (%s) is disabled, never flushed: its configuration stays on disk\n", fw)
|
fmt.Fprintf(&b, " the found firewall (%s) is disabled, never flushed: its configuration stays on disk\n", fw)
|
||||||
}
|
}
|
||||||
said = append(said, fmt.Sprintf("filter %s assigned=%t firewall=%s", filter, filterAssigned, fw))
|
said = append(said, fmt.Sprintf("filter %s assigned=%t firewall=%s", filter, filterAssigned, fw))
|
||||||
// What filters the machine now, and the fate of each (novox/hq ADR 0168): the found firewall
|
|
||||||
// retired, the runtime's own and bans left, and what the mesh did not write left and named —
|
|
||||||
// so the reader knows before the flip that the machine will not be filtered by the mesh alone.
|
|
||||||
if len(filtering.Filters) > 0 {
|
|
||||||
b.WriteString("\n what filters the machine now, and what the flip does to each:\n")
|
|
||||||
for _, x := range filtering.Filters {
|
|
||||||
fate := "left: " + x.Owner + "'s"
|
|
||||||
switch x.Owner {
|
|
||||||
case inventory.FilterMesh:
|
|
||||||
fate = "the mesh's guard; replaced by its filter"
|
|
||||||
case inventory.FilterFoundFirewall:
|
|
||||||
fate = "the found firewall's; retired with it"
|
|
||||||
case inventory.FilterRuntime:
|
|
||||||
fate = "the container runtime's own; left"
|
|
||||||
case inventory.FilterBan:
|
|
||||||
fate = "a ban list; left"
|
|
||||||
case inventory.FilterOther:
|
|
||||||
fate = "NOT THE MESH'S; left in force — the machine is not filtered by the mesh alone until you remove it"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, " %-50s %s\n", x.Where, fate)
|
|
||||||
fmt.Fprintf(&b, " %-50s %s\n", "", x.Refuses)
|
|
||||||
said = append(said, "filter "+x.Owner+" "+x.Where)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Sorted: the same account, reported in another order, is the same preview.
|
// Sorted: the same account, reported in another order, is the same preview.
|
||||||
sort.Strings(said)
|
sort.Strings(said)
|
||||||
sum := sha256.Sum256([]byte(strings.Join(said, "\n")))
|
sum := sha256.Sum256([]byte(strings.Join(said, "\n")))
|
||||||
|
|||||||
@@ -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, splitModules(in.Module)...)
|
return assign(ctx, open, in.Node, 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, splitModules(in.Module)...)
|
return unassign(ctx, open, in.Node, 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,11 +148,6 @@ 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))
|
||||||
}
|
}
|
||||||
@@ -414,7 +409,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: link.NewBuildID(time.Now()),
|
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()),
|
||||||
Repository: repository,
|
Repository: repository,
|
||||||
Path: path,
|
Path: path,
|
||||||
Ref: ref,
|
Ref: ref,
|
||||||
@@ -435,13 +430,11 @@ func buildOne(ctx context.Context, source buildSource, path, ref string, wait ti
|
|||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
seat := buildSeatHeld(ctx)
|
ask, err := askOver(server)
|
||||||
ask, err := askOverOn(seat)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer ask.Close()
|
defer ask.Close()
|
||||||
fmt.Printf(" of %s\n", seat)
|
|
||||||
|
|
||||||
if wait == 0 {
|
if wait == 0 {
|
||||||
// Asked and not waited for (novox/hq issue 176): the outcome is the role's event, and the
|
// Asked and not waited for (novox/hq issue 176): the outcome is the role's event, and the
|
||||||
@@ -529,39 +522,17 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
|||||||
recorded := inventory.Source{
|
recorded := inventory.Source{
|
||||||
Repository: result.Repository, Path: result.Path, Ref: result.Ref,
|
Repository: result.Repository, Path: result.Path, Ref: result.Ref,
|
||||||
BuiltFrom: result.Commit, Head: result.Commit,
|
BuiltFrom: result.Commit, Head: result.Commit,
|
||||||
// What it stood on, so registration can judge a built manifest's base (to-be 38 WP2.4).
|
|
||||||
Against: kept.Against,
|
|
||||||
// When it was asked, so an older request heard later does not replace a newer one
|
|
||||||
// (novox/hq 04-ISSUES/219).
|
|
||||||
Asked: kept.Asked,
|
|
||||||
}
|
}
|
||||||
if result.Source != nil && result.Source.Seat != "" {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,14 +553,14 @@ func buildAndShow(ctx context.Context, source buildSource, path, ref string, wai
|
|||||||
}
|
}
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
ask, err := askOverOn(buildSeatHeld(ctx))
|
ask, err := askOver(server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer ask.Close()
|
defer ask.Close()
|
||||||
|
|
||||||
result, err := ask.Submit(ctx, link.BuildRequest{
|
result, err := ask.Submit(ctx, link.BuildRequest{
|
||||||
ID: link.NewBuildID(time.Now()),
|
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()),
|
||||||
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)
|
||||||
@@ -636,10 +607,6 @@ type answers struct {
|
|||||||
// a consequence of the refusals above: a node that does not resolve is not on the network, and
|
// a consequence of the refusals above: a node that does not resolve is not on the network, and
|
||||||
// a mesh whose hub is that node has no hub.
|
// a mesh whose hub is that node has no hub.
|
||||||
network string
|
network string
|
||||||
// filtered is every converged machine that is not filtered by the mesh alone (novox/hq ADR
|
|
||||||
// 0168): what filters it beyond the mesh's own, the runtime's plumbing and bans, by name — a
|
|
||||||
// predecessor's chain, a found firewall in force again. Such a machine is not "all well".
|
|
||||||
filtered map[string]inventory.Filtering
|
|
||||||
// untaken is, per machine, each assigned module whose resources the machine is holding as it
|
// untaken is, per machine, each assigned module whose resources the machine is holding as it
|
||||||
// found them, and how many — a module that was assigned, sent, and is running none of what it
|
// found them, and how many — a module that was assigned, sent, and is running none of what it
|
||||||
// declares because nothing has taken it (novox/hq ADR 0100, 04-ISSUES/125).
|
// declares because nothing has taken it (novox/hq ADR 0100, 04-ISSUES/125).
|
||||||
@@ -650,11 +617,6 @@ 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.
|
||||||
@@ -700,56 +662,12 @@ func heldBy(ctx context.Context) map[string]string {
|
|||||||
// **One place chooses**, as everywhere else the bus change went (novox/hq ADR 0116 step 5). On the bus
|
// **One place chooses**, as everywhere else the bus change went (novox/hq ADR 0116 step 5). On the bus
|
||||||
// the mesh runs on today this needs the controller's own connection, so it is handed one; on the bus
|
// the mesh runs on today this needs the controller's own connection, so it is handed one; on the bus
|
||||||
// being built it dials, because a build request is a one-shot and holds nothing else.
|
// being built it dials, because a build request is a one-shot and holds nothing else.
|
||||||
func askOverOn(seat string) (link.Builders, error) {
|
func askOver(_ *link.Server) (link.Builders, error) {
|
||||||
address, err := broker.BusAddress()
|
address, err := broker.BusAddress()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return link.BuildsOverNATSOn(address, seat)
|
return link.BuildsOverNATS(address)
|
||||||
}
|
|
||||||
|
|
||||||
// buildSeatHeld is the build role to ask: the one some assigned module claims (novox/hq ADR 0190,
|
|
||||||
// the handover). Read from the catalogue at ask time, because the answer changes exactly once, the
|
|
||||||
// moment the first build-agent is assigned — and a controller that asked the new role before then
|
|
||||||
// would queue work nothing takes, while the outcome that registers build-agent itself has to come
|
|
||||||
// from the old builder. When the catalogue cannot be read the current role is asked, said aloud.
|
|
||||||
func buildSeatHeld(ctx context.Context) string {
|
|
||||||
open, err := openStores(ctx)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "could not read what is assigned, so the build is asked of %s: %v\n",
|
|
||||||
link.TheBuildMachine, err)
|
|
||||||
return link.TheBuildMachine
|
|
||||||
}
|
|
||||||
defer open.Close()
|
|
||||||
entries, err := open.inventory.Catalogued(ctx)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "could not read the catalogue, so the build is asked of %s: %v\n",
|
|
||||||
link.TheBuildMachine, err)
|
|
||||||
return link.TheBuildMachine
|
|
||||||
}
|
|
||||||
return buildSeatAmong(entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildSeatAmong is the rule, over what the catalogue holds: the current build role when any
|
|
||||||
// assigned module claims it; else the retired role while an assigned module still claims that; else
|
|
||||||
// the current role, which is where every ask goes once the handover is done.
|
|
||||||
func buildSeatAmong(entries []inventory.Entry) string {
|
|
||||||
heldBefore := false
|
|
||||||
for _, e := range entries {
|
|
||||||
if len(e.On) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if e.Manifest.ClaimsSeat(link.TheBuildMachine) {
|
|
||||||
return link.TheBuildMachine
|
|
||||||
}
|
|
||||||
if e.Manifest.ClaimsSeat(link.TheBuildMachineBefore) {
|
|
||||||
heldBefore = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if heldBefore {
|
|
||||||
return link.TheBuildMachineBefore
|
|
||||||
}
|
|
||||||
return link.TheBuildMachine
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildLog prints everything a build machine said about one build, read back from the bus.
|
// buildLog prints everything a build machine said about one build, read back from the bus.
|
||||||
@@ -769,13 +687,10 @@ func buildLog(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
defer js.Close()
|
defer js.Close()
|
||||||
|
|
||||||
// Under whichever build role did it: a build asked of the retired role during the handover
|
sub, err := js.Context().PullSubscribe(link.BuildLog(id), "",
|
||||||
// (ADR 0190) said its lines as that role's events, and a reader should not have to know which.
|
|
||||||
lines := link.BuildLogOf("*", id)
|
|
||||||
sub, err := js.Context().PullSubscribe(lines, "",
|
|
||||||
nats.BindStream(broker.EventsStream), nats.DeliverAll(), nats.AckNone())
|
nats.BindStream(broker.EventsStream), nats.DeliverAll(), nats.AckNone())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot read %s from the bus: %w", lines, err)
|
return fmt.Errorf("cannot read %s from the bus: %w", link.BuildLog(id), err)
|
||||||
}
|
}
|
||||||
defer func() { _ = sub.Unsubscribe() }()
|
defer func() { _ = sub.Unsubscribe() }()
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
func claiming(module, seat string, on ...string) inventory.Entry {
|
|
||||||
return inventory.Entry{
|
|
||||||
Manifest: catalogue.Manifest{Module: module, Claims: []catalogue.Claim{{Name: seat}}},
|
|
||||||
On: on,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The controller asks the build role that has a holder (novox/hq ADR 0190 handover): the retired
|
|
||||||
// one while only the builder is assigned, the current one from the first build-agent on, and the
|
|
||||||
// current one when nothing holds either — where every ask goes once the handover is done.
|
|
||||||
func TestTheControllerAsksTheBuildRoleThatHasAHolder(t *testing.T) {
|
|
||||||
onlyTheBuilder := []inventory.Entry{
|
|
||||||
claiming("builder", link.TheBuildMachineBefore, "anchor"),
|
|
||||||
claiming("build-agent", link.TheBuildMachine), // registered, assigned nowhere yet
|
|
||||||
}
|
|
||||||
if got := buildSeatAmong(onlyTheBuilder); got != link.TheBuildMachineBefore {
|
|
||||||
t.Errorf("with only the builder assigned, asked %q", got)
|
|
||||||
}
|
|
||||||
bothHeld := []inventory.Entry{
|
|
||||||
claiming("builder", link.TheBuildMachineBefore, "anchor"),
|
|
||||||
claiming("build-agent", link.TheBuildMachine, "home-server"),
|
|
||||||
}
|
|
||||||
if got := buildSeatAmong(bothHeld); got != link.TheBuildMachine {
|
|
||||||
t.Errorf("with a build-agent assigned anywhere, asked %q", got)
|
|
||||||
}
|
|
||||||
neither := []inventory.Entry{claiming("builder", link.TheBuildMachineBefore)}
|
|
||||||
if got := buildSeatAmong(neither); got != link.TheBuildMachine {
|
|
||||||
t.Errorf("with no holder of either, asked %q, want the current role", got)
|
|
||||||
}
|
|
||||||
if got := buildSeatAmong(nil); got != link.TheBuildMachine {
|
|
||||||
t.Errorf("an empty catalogue asks %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,9 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,87 +63,3 @@ 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,7 +7,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
)
|
)
|
||||||
@@ -91,17 +90,6 @@ 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 {
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A fresh assignment is pushed before its credential exists (novox/hq issue 203): `assign` recorded
|
|
||||||
// the module, `push` sealed a random own secret where the bus credential belongs, and the process
|
|
||||||
// crash-looped until a person ran `module issue` and pushed again. Now assigning a module that speaks
|
|
||||||
// on the bus issues its credential in the same act — or, when the bus cannot be reached from here,
|
|
||||||
// says which verb to run — and a push never seals a placeholder in a credential's place.
|
|
||||||
|
|
||||||
func aTalker() catalogue.Manifest {
|
|
||||||
return catalogue.Manifest{Module: "talker", Version: "1",
|
|
||||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/talker/broker"}},
|
|
||||||
Resources: []map[string]any{
|
|
||||||
{"id": "state", "type": "directory", "path": "/var/lib/mesh/talker", "mode": "0700"},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAssigningAModuleThatSpeaksOnTheBusNamesItsCredential(t *testing.T) {
|
|
||||||
open := aMesh(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
register(t, open, aTalker())
|
|
||||||
|
|
||||||
// No bus is known to this process, so the credential cannot be issued here: the assignment
|
|
||||||
// stands and says exactly what must happen before a push — never silently.
|
|
||||||
said, err := assign(ctx, open, "laptop", "talker")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(said, "module issue talker --node laptop") {
|
|
||||||
t.Fatalf("an assignment whose credential could not be issued does not name the verb:\n%s", said)
|
|
||||||
}
|
|
||||||
|
|
||||||
// And the push refuses to send it, naming the same verb, rather than sealing a placeholder.
|
|
||||||
plan, settings, err := planFor(ctx, open, "laptop")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, err = declarationFor(ctx, open, "laptop", plan, settings)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a push sealed a placeholder where talker's bus credential belongs")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "module issue talker --node laptop") || !strings.Contains(err.Error(), "issue 203") {
|
|
||||||
t.Fatalf("the refusal does not say what to run: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once the user is minted, the push goes on to the credential the mesh sealed, and re-assigning
|
|
||||||
// does not mint again: a credential rotates on purpose, never by habit.
|
|
||||||
if _, err := open.inventory.MintBusPassword(ctx, inventory.BusUser{
|
|
||||||
Username: "laptop.talker", Kind: inventory.BusModule, Node: "laptop", Module: "talker"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
hash, _, err := open.inventory.BusUserHash(ctx, "laptop.talker")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
said, err = assign(ctx, open, "laptop", "talker")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if strings.Contains(said, "module issue") {
|
|
||||||
t.Fatalf("a module with a minted credential was told to issue one:\n%s", said)
|
|
||||||
}
|
|
||||||
again, _, err := open.inventory.BusUserHash(ctx, "laptop.talker")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if again != hash {
|
|
||||||
t.Fatal("re-assigning rotated the credential")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A module that declares no broker secret is left alone: nothing to issue, nothing said.
|
|
||||||
func TestAssigningAModuleThatDoesNotSpeakSaysNothingOfCredentials(t *testing.T) {
|
|
||||||
open := aMesh(t)
|
|
||||||
register(t, open, helloWeb())
|
|
||||||
said, err := assign(t.Context(), open, "laptop", "hello-web")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if strings.Contains(said, "credential") {
|
|
||||||
t.Fatalf("a module without a broker secret was told about credentials:\n%s", said)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The broker opening belongs only on the node that listens on it (novox/hq: it leaked onto
|
||||||
|
// every enrolled node's declaration, opening a from-anywhere hole for a port nothing there
|
||||||
|
// serves). foundationPortsFor is the scope.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTheBrokerHostGetsTheFoundationOpening(t *testing.T) {
|
||||||
|
broker := catalogue.Manifest{Module: "lavinmq", Listens: []catalogue.Listening{
|
||||||
|
{Port: 5671, Protocol: "tcp", From: "mesh"},
|
||||||
|
{Port: 5672, Protocol: "tcp", From: "mesh"},
|
||||||
|
}}
|
||||||
|
got := foundationPortsFor(5671, []catalogue.Manifest{broker})
|
||||||
|
if len(got) != 1 || got[0] != 5671 {
|
||||||
|
t.Fatalf("the node that listens on the broker port keeps it; got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestANodeThatOnlyDialsTheBrokerGetsNoOpening(t *testing.T) {
|
||||||
|
// ace's set: things that reach the broker as a client, none listening on 5671.
|
||||||
|
ace := []catalogue.Manifest{
|
||||||
|
{Module: "plex", Listens: []catalogue.Listening{{Port: 32400, Protocol: "tcp", From: "anywhere"}}},
|
||||||
|
{Module: "postgres", Listens: []catalogue.Listening{{Port: 5432, Protocol: "tcp", From: "mesh"}}},
|
||||||
|
}
|
||||||
|
if got := foundationPortsFor(5671, ace); got != nil {
|
||||||
|
t.Fatalf("a node that only dials out opens nothing for the broker; got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A declaration composed earlier is numbered lower than one composed later, whatever order the two
|
|
||||||
// are sent in (novox/hq issue 204). The number used to be taken at send time, after composing, so a
|
|
||||||
// declaration composed before an assignment changed and sent after a newer one carried the higher
|
|
||||||
// number — and the machine, which refuses a lower number, took the older content as the mesh's
|
|
||||||
// newest word. Taken before the composition reads anything, the order of numbers is the order of
|
|
||||||
// compositions, and the host's refusal does what it is for.
|
|
||||||
func TestADeclarationComposedEarlierIsNumberedLowerWhateverOrderItIsSent(t *testing.T) {
|
|
||||||
allot := numbered()
|
|
||||||
var composed []string
|
|
||||||
compose := func(stamp string) func(string) (sendable, error) {
|
|
||||||
return func(node string) (sendable, error) {
|
|
||||||
composed = append(composed, stamp)
|
|
||||||
return sendable{Resources: []map[string]any{{"id": node + "." + stamp}}}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Composed first — before an assignment changed — and sent last.
|
|
||||||
stale, _ := composeEach([]string{"anchor"}, allot, compose("before"))
|
|
||||||
// Composed after the change, sent first.
|
|
||||||
fresh, _ := composeEach([]string{"anchor"}, allot, compose("after"))
|
|
||||||
|
|
||||||
if stale[0].declared.Sequence != 1 || fresh[0].declared.Sequence != 2 {
|
|
||||||
t.Fatalf("the numbers do not follow the compositions: before=%d after=%d",
|
|
||||||
stale[0].declared.Sequence, fresh[0].declared.Sequence)
|
|
||||||
}
|
|
||||||
// Sent in the other order, the numbers do not change — so the machine that has applied the
|
|
||||||
// fresh one (2) refuses the stale one (1) when it arrives late.
|
|
||||||
if !(stale[0].declared.Sequence < fresh[0].declared.Sequence) {
|
|
||||||
t.Fatal("a declaration composed earlier must carry the lower number, however late it is sent")
|
|
||||||
}
|
|
||||||
if len(composed) != 2 || composed[0] != "before" {
|
|
||||||
t.Fatalf("compositions happened in an unexpected order: %v", composed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The number is taken before the first read of the composition, not after it: an allotter that
|
|
||||||
// fails leaves nothing composed for that machine, and the others are still composed.
|
|
||||||
func TestTheNumberIsTakenBeforeComposingAndItsFailureIsARefusal(t *testing.T) {
|
|
||||||
calls := 0
|
|
||||||
allot := func(node string) (int64, error) {
|
|
||||||
if node == "anchor" {
|
|
||||||
return 0, context.DeadlineExceeded
|
|
||||||
}
|
|
||||||
return 7, nil
|
|
||||||
}
|
|
||||||
sending, refusals := composeEach([]string{"anchor", "laptop"}, allot, func(node string) (sendable, error) {
|
|
||||||
calls++
|
|
||||||
if node == "anchor" {
|
|
||||||
t.Fatal("anchor was composed although its number could not be taken")
|
|
||||||
}
|
|
||||||
return sendable{}, nil
|
|
||||||
})
|
|
||||||
if calls != 1 || len(sending) != 1 || sending[0].node != "laptop" || sending[0].declared.Sequence != 7 {
|
|
||||||
t.Fatalf("laptop should be composed with its number and anchor refused: %v / %v", sending, refusals)
|
|
||||||
}
|
|
||||||
if len(refusals) != 1 {
|
|
||||||
t.Fatalf("anchor's failed number should be a refusal naming it: %v", refusals)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// What was sent is written down even when the sender's context is already cancelled (issue 204): a
|
|
||||||
// controller replaced mid-send had told the machine and never recorded it, so status read "applied,
|
|
||||||
// current" over a machine that had just been sent something else.
|
|
||||||
func TestASendIsRecordedEvenWhenTheSenderIsBeingCancelled(t *testing.T) {
|
|
||||||
inv := inventory.ForTest(t)
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
|
||||||
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
cancel() // the sender is going away: its context is cancelled between the send and the record
|
|
||||||
body := []byte(`{"declaration":1,"resources":[]}`)
|
|
||||||
digest, err := recordSent(ctx, inv, "anchor", body)
|
|
||||||
if err != nil {
|
|
||||||
// NodeByName on the cancelled context may itself refuse; the record must still be possible
|
|
||||||
// through the detached context, so look the node up again on a live one.
|
|
||||||
t.Fatalf("recording a send after cancellation failed: %v", err)
|
|
||||||
}
|
|
||||||
outstanding, err := inv.Outstanding(t.Context(), "anchor")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if outstanding != digest || digest != digestOf(body) {
|
|
||||||
t.Fatalf("the send was not recorded: outstanding %q, sent %q", outstanding, digest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -179,8 +178,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 modules on a node, judged together (ADR 0207)
|
assign <node> <module> put a module on a node
|
||||||
unassign <node> <module>... take them off
|
unassign <node> <module> take it 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
|
||||||
@@ -254,34 +253,25 @@ 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, asked)
|
planBuilt(ctx, b.open, result.Module, result.Commit, result.Failed)
|
||||||
} 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(), asked)
|
planBuilt(ctx, b.open, manifest.Module, result.Commit, err.Error())
|
||||||
}
|
}
|
||||||
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, "", asked)
|
planBuilt(ctx, b.open, manifest.Module, result.Commit, "")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,40 +147,6 @@ func moduleCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// **The same list, for something other than a person** (novox/hq ADR 0195): what each module
|
|
||||||
// is, where it runs, whether it is current, and what it says of itself.
|
|
||||||
if len(args) > 1 && args[1] == "--json" {
|
|
||||||
type listed struct {
|
|
||||||
Module string `json:"module"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
Built string `json:"built,omitempty"`
|
|
||||||
Head string `json:"head,omitempty"`
|
|
||||||
Current bool `json:"current"`
|
|
||||||
Provided bool `json:"provided,omitempty"`
|
|
||||||
// Tools says whether the module answers tools anywhere it runs: a list of its own,
|
|
||||||
// a bundle the runtime serves, or a seat's verbs it claims (novox/hq ADR 0197) —
|
|
||||||
// what the console checks the bus's answers against.
|
|
||||||
Tools bool `json:"tools"`
|
|
||||||
On []string `json:"on"`
|
|
||||||
Provides []string `json:"provides,omitempty"`
|
|
||||||
Requires []string `json:"requires,omitempty"`
|
|
||||||
Claims []string `json:"claims,omitempty"`
|
|
||||||
Capabilities []string `json:"capabilities,omitempty"`
|
|
||||||
}
|
|
||||||
out := make([]listed, 0, len(entries))
|
|
||||||
for _, e := range entries {
|
|
||||||
m := e.Manifest
|
|
||||||
l := listed{Module: m.Module, Version: m.Version, Built: e.Source.BuiltFrom, Head: e.Source.Head,
|
|
||||||
Current: e.Provided || e.Source.Repository == "" || e.Source.Current(), Provided: e.Provided,
|
|
||||||
On: append([]string{}, e.On...), Provides: m.Offers(), Requires: m.Requires,
|
|
||||||
Capabilities: m.Capabilities, Tools: declaresTools(m)}
|
|
||||||
for _, c := range m.Claims {
|
|
||||||
l.Claims = append(l.Claims, c.At()+"/"+c.Name)
|
|
||||||
}
|
|
||||||
out = append(out, l)
|
|
||||||
}
|
|
||||||
return printJSON(out)
|
|
||||||
}
|
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
fmt.Println("this mesh knows about no modules yet")
|
fmt.Println("this mesh knows about no modules yet")
|
||||||
return nil
|
return nil
|
||||||
@@ -334,10 +300,8 @@ 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 {
|
||||||
// Several modules in one act (novox/hq ADR 0207): holders that depend on each other — the
|
if len(args) != 2 {
|
||||||
// service manager and the package manager — can only go on, or come off, together.
|
return fmt.Errorf("%s <node> <module>", verb)
|
||||||
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 {
|
||||||
@@ -351,7 +315,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)
|
||||||
}
|
}
|
||||||
@@ -388,14 +352,10 @@ func settingsCommand(ctx context.Context, args []string) error {
|
|||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "set":
|
case "set":
|
||||||
if len(positionals) != 2 {
|
if len(positionals) != 2 {
|
||||||
return errors.New("settings set <module> <settings.json | {…}> [--node <node>]")
|
return errors.New("settings set <module> <settings.json> [--node <node>]")
|
||||||
}
|
}
|
||||||
// A file, or the values themselves when they begin with `{` — which is how the mesh's own
|
raw, err := os.ReadFile(positionals[1])
|
||||||
// `settings` tool passes them, having no file to hand over (novox/hq issue 198).
|
if err != nil {
|
||||||
var raw []byte
|
|
||||||
if strings.HasPrefix(strings.TrimSpace(positionals[1]), "{") {
|
|
||||||
raw = []byte(positionals[1])
|
|
||||||
} else if raw, err = os.ReadFile(positionals[1]); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var values map[string]any
|
var values map[string]any
|
||||||
@@ -593,7 +553,7 @@ func issueOnTheNewBus(ctx context.Context, inv *inventory.Inventory, m catalogue
|
|||||||
|
|
||||||
user := broker.Principal{Kind: broker.KindModule, Node: node, Module: m.Module}.Username()
|
user := broker.Principal{Kind: broker.KindModule, Node: node, Module: m.Module}.Username()
|
||||||
password, err := inv.MintBusPassword(ctx, inventory.BusUser{
|
password, err := inv.MintBusPassword(ctx, inventory.BusUser{
|
||||||
Username: user, Kind: busKindOf(m.Module), Node: node, Module: m.Module,
|
Username: user, Kind: inventory.BusModule, Node: node, Module: m.Module,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -613,16 +573,6 @@ func issueOnTheNewBus(ctx context.Context, inv *inventory.Inventory, m catalogue
|
|||||||
return issueWith(ctx, inv, m, node, busAddress, known, reachable, user, password)
|
return issueWith(ctx, inv, m, node, busAddress, known, reachable, user, password)
|
||||||
}
|
}
|
||||||
|
|
||||||
// busKindOf is what a module's bus user is recorded as: the node's tool runtime where the module is
|
|
||||||
// the runtime (novox/hq ADR 0175), a module otherwise. The username is the same either way — the
|
|
||||||
// runtime is issued through this same path — and the kind is what a reader of the records sees.
|
|
||||||
func busKindOf(module string) string {
|
|
||||||
if module == catalogue.RuntimeModule {
|
|
||||||
return inventory.BusNodeTools
|
|
||||||
}
|
|
||||||
return inventory.BusModule
|
|
||||||
}
|
|
||||||
|
|
||||||
// issueWith is the delivery half: the minted password sealed to the machine as the module's broker
|
// issueWith is the delivery half: the minted password sealed to the machine as the module's broker
|
||||||
// secret, and the module's consumer created where the bus can be reached. Split from the minting
|
// secret, and the module's consumer created where the bus can be reached. Split from the minting
|
||||||
// so the move can issue every module against a bus whose address it worked out itself
|
// so the move can issue every module against a bus whose address it worked out itself
|
||||||
@@ -769,23 +719,3 @@ func claimsFor(ctx context.Context, inv *inventory.Inventory, m catalogue.Manife
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// declaresTools is whether a module answers tools wherever it runs (novox/hq ADR 0197): it names
|
|
||||||
// tools of its own, its build delivers a bundle the node's runtime serves, or it claims a seat
|
|
||||||
// whose verbs it serves. A module with none is never expected to announce anything.
|
|
||||||
func declaresTools(m catalogue.Manifest) bool {
|
|
||||||
if len(m.Tools) > 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, b := range m.Bundles {
|
|
||||||
if len(b.Loads) > 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, c := range m.Claims {
|
|
||||||
if len(c.Serves) > 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -266,12 +265,6 @@ 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 {
|
||||||
@@ -310,28 +303,3 @@ func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *t
|
|||||||
t.Fatalf("a machine that left the network is still a wildcard, or the one that stayed is not:\n%s", after)
|
t.Fatalf("a machine that left the network is still a wildcard, or the one that stayed is not:\n%s", after)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The roster is the machines and nothing else (novox/hq ADR 0191): each node's internal domain covers
|
|
||||||
// every route on it, and a node's public domains are public DNS's. A routed name in `.Names` was a
|
|
||||||
// private answer for a public name, handed by a resolver serving a LAN to a phone that could not use it.
|
|
||||||
func TestTheRosterNamesOnlyTheMachines(t *testing.T) {
|
|
||||||
open := aMesh(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
gens, err := generators(ctx, open)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, node := range []string{"anchor", "laptop"} {
|
|
||||||
plan, settings, err := planFor(ctx, open, node)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
with, _, err := renderingFor(ctx, open, node, plan, settings, gens, Reading)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(with.Names, with.Machines) {
|
|
||||||
t.Fatalf("%s's roster names more than the machines:\n names %v\n machines %v", node, with.Names, with.Machines)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -45,21 +44,6 @@ func nodeCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// **The same list, for something other than a person** — the console's discovery reads it
|
|
||||||
// (novox/hq ADR 0195), and a reader that parses a printed column breaks when it is reworded.
|
|
||||||
if len(args) > 1 && args[1] == "--json" {
|
|
||||||
type listed struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Heard string `json:"heard"`
|
|
||||||
Mode string `json:"mode"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
out := make([]listed, 0, len(nodes))
|
|
||||||
for _, n := range nodes {
|
|
||||||
out = append(out, listed{Name: n.Name, Heard: heardFrom(n), Mode: modeOf(n), ID: n.ID})
|
|
||||||
}
|
|
||||||
return printJSON(out)
|
|
||||||
}
|
|
||||||
if len(nodes) == 0 {
|
if len(nodes) == 0 {
|
||||||
// Said rather than printed as nothing: an empty list and a failed read must never
|
// Said rather than printed as nothing: an empty list and a failed read must never
|
||||||
// look the same, and this command answering "none" is only honest because getting
|
// look the same, and this command answering "none" is only honest because getting
|
||||||
@@ -516,13 +500,3 @@ func orNotReported(s string) string {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// printJSON prints a value as indented JSON, the shape every `--json` answers in.
|
|
||||||
func printJSON(v any) error {
|
|
||||||
body, err := json.MarshalIndent(v, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Println(string(body))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -192,7 +191,7 @@ func TestWhatAHandedOverModuleRecordsAboutItsSource(t *testing.T) {
|
|||||||
t.Fatalf("the source records as %+v", from)
|
t.Fatalf("the source records as %+v", from)
|
||||||
}
|
}
|
||||||
// A manifest with no provenance at all is legitimate: fixing something in a hurry.
|
// A manifest with no provenance at all is legitimate: fixing something in a hurry.
|
||||||
if from, err := whereItComesFrom("", "", "", "", false); err != nil || !reflect.DeepEqual(from, inventory.Source{}) {
|
if from, err := whereItComesFrom("", "", "", "", false); err != nil || from != (inventory.Source{}) {
|
||||||
t.Fatalf("a manifest handed over with no provenance was refused: %+v, %v", from, err)
|
t.Fatalf("a manifest handed over with no provenance was refused: %+v, %v", from, err)
|
||||||
}
|
}
|
||||||
for _, c := range []struct {
|
for _, c := range []struct {
|
||||||
@@ -211,27 +210,3 @@ 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+120
-120
@@ -8,16 +8,16 @@ 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"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/licences"
|
"github.com/novox/mesh-controller/internal/licences"
|
||||||
"github.com/novox/mesh-controller/internal/overlay"
|
"github.com/novox/mesh-controller/internal/overlay"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// working out what one machine should be.
|
// working out what one machine should be.
|
||||||
@@ -129,7 +129,6 @@ 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
|
||||||
@@ -398,7 +397,6 @@ func declarationWith(ctx context.Context, open *stores, node string,
|
|||||||
return sendable{}, err
|
return sendable{}, err
|
||||||
}
|
}
|
||||||
return sendable{Resources: composed.Resources, Adoption: adoption,
|
return sendable{Resources: composed.Resources, Adoption: adoption,
|
||||||
Received: composed.Received, Mesh: with.Mesh,
|
|
||||||
LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut}, nil
|
LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,23 +534,6 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
var sealed string
|
var sealed string
|
||||||
var err error
|
var err error
|
||||||
if choosing == Allocating {
|
if choosing == Allocating {
|
||||||
// **The broker credential is never invented here** (novox/hq issue 203). Every other
|
|
||||||
// own secret is the mesh's to make — a password nobody else knows — but this one
|
|
||||||
// is an account on the bus, minted by `module issue` and sealed by it; a push that
|
|
||||||
// made a random one would deliver a file the process cannot read and report the
|
|
||||||
// machine applied. Refused by name, with the verb.
|
|
||||||
if name == "broker" {
|
|
||||||
user := broker.Principal{Kind: broker.KindModule, Node: node, Module: m.Module}.Username()
|
|
||||||
if _, minted, err := inv.BusUserHash(ctx, user); err != nil {
|
|
||||||
return catalogue.Rendering{}, inventory.Node{}, err
|
|
||||||
} else if !minted {
|
|
||||||
return catalogue.Rendering{}, inventory.Node{}, fmt.Errorf(
|
|
||||||
"%s on %s has no bus credential: nothing was issued for %s, and a push "+
|
|
||||||
"would seal a placeholder its process cannot read (novox/hq issue 203). "+
|
|
||||||
"`module issue %s --node %s`, then push again",
|
|
||||||
m.Module, node, user, m.Module, node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sealed, err = inv.SecretForModule(ctx, node, m.Module, name)
|
sealed, err = inv.SecretForModule(ctx, node, m.Module, name)
|
||||||
} else {
|
} else {
|
||||||
var held bool
|
var held bool
|
||||||
@@ -648,24 +629,43 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// **The roster is the machines and nothing else** (novox/hq ADR 0191). Each node has one internal
|
// And every routed name → the node that serves it (novox/hq ADR 0066). Alongside the
|
||||||
// domain, `<node>.internal`, and every route on it is a name under that domain (ADR 0151), which
|
// `<node>.internal` names above, so a container — or an internal ACME validator — resolves a
|
||||||
// the resolver answers with one wildcard per machine — so no route needs a line of its own. A
|
// routed name to the proxy that serves it, mesh-wide. The mesh publishes the names it was told
|
||||||
// node's public domains are the operator's and public DNS answers them; the mesh gives no private
|
// to serve and knows nothing about what they mean.
|
||||||
// answer for any of them. The roster once carried every routed name, public ones included, and a
|
// Kept apart from the machines, because a fact about the machines must not be handed the names
|
||||||
// resolver that also serves a LAN handed a phone a tunnel address for the mail server.
|
// the mesh merely serves (novox/hq 04-ISSUES/111).
|
||||||
// `.Names` and `.Machines` stay two fields so a module's template keeps rendering (issue 111).
|
|
||||||
machines := make(map[string]string, len(names))
|
machines := make(map[string]string, len(names))
|
||||||
for name, at := range names {
|
for name, at := range names {
|
||||||
machines[name] = at
|
machines[name] = at
|
||||||
}
|
}
|
||||||
|
routes, err := routeNamesInTheMesh(ctx, open)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
|
}
|
||||||
|
for name, at := range routes {
|
||||||
|
names[name] = at
|
||||||
|
}
|
||||||
|
|
||||||
// **The bus is never public** (novox/hq ADR 0169). It was a foundation port — widened from the
|
// The ports the mesh itself needs open, which no module declares. Read from the broker this
|
||||||
// broker's own `from: mesh` to from-anywhere on the broker's host, so a machine could enrol
|
// control plane was told about rather than written down twice: the address a node is handed in
|
||||||
// before it had an address on the private network. A machine joins through the tunnel now, and
|
// its token and the port its machine must accept on are the same fact.
|
||||||
// every link to the bus crosses it, so its reach is what the `nats` module declares: the mesh.
|
//
|
||||||
// Nothing the mesh itself needs is opened beyond what a module declares.
|
// **Only on the node that listens on it** (novox/hq issue: the broker opening leaked onto
|
||||||
|
// every node). The opening exists to WIDEN the broker's port to from-anywhere — a machine
|
||||||
|
// enrolling is not on the mesh yet, so the broker's own `from: mesh` listen would refuse its
|
||||||
|
// first dial. That widening belongs on the broker's host and nowhere else: a node that only
|
||||||
|
// dials out needs no incoming rule, and an opening for a port nothing here listens on is a
|
||||||
|
// from-anywhere hole for a dead port. So the foundation port is kept only when a module
|
||||||
|
// resolved onto THIS node actually listens on it.
|
||||||
var foundation []int
|
var foundation []int
|
||||||
|
if b, err := broker.FromEnvironment(); err == nil {
|
||||||
|
if _, port, err := net.SplitHostPort(b.Address); err == nil {
|
||||||
|
if n, err := strconv.Atoi(port); err == nil {
|
||||||
|
foundation = foundationPortsFor(n, plan.Modules)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// And, for a module that keeps them, every operator-sealed secret in the mesh — the vault's
|
// And, for a module that keeps them, every operator-sealed secret in the mesh — the vault's
|
||||||
// copy, outside the store (novox/hq ADR 0085, amended). Read only; nothing here mints. The
|
// copy, outside the store (novox/hq ADR 0085, amended). Read only; nothing here mints. The
|
||||||
@@ -737,6 +737,76 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
}, record, nil
|
}, record, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// routeNamesInTheMesh is every routed name and the address of the node that serves it (novox/hq
|
||||||
|
// ADR 0066).
|
||||||
|
//
|
||||||
|
// **Mesh-wide, so any container resolves any routed name to its proxy** — including an internal
|
||||||
|
// ACME validator, which cannot complete a challenge for a name it cannot reach. A routed name is
|
||||||
|
// composed on the consumer's node (from its label and that node's public domain) and served by the
|
||||||
|
// node answering the consumer's route requirement; this gathers both.
|
||||||
|
//
|
||||||
|
// It reads route names off resolutions rather than a table because there is no table: a route is a
|
||||||
|
// contribution, computed from what each node runs. Name-agnostic — a contribution counts as a
|
||||||
|
// routed name only because it carried a label the mesh composed, never because the mesh knows what
|
||||||
|
// "route" means. A node that does not resolve is skipped, so one machine's broken set does not cost
|
||||||
|
// the rest their names.
|
||||||
|
//
|
||||||
|
// **A node that could not be READ is a different matter and is raised.** Skipping one states, to
|
||||||
|
// every machine at once, that its names do not exist — and since the roster is part of every
|
||||||
|
// container's identity, that withdraws them and replaces every container (novox/hq 04-ISSUES/152,
|
||||||
|
// 151). So every failure here says which machine and which read, because the alternative is a
|
||||||
|
// mesh-wide refusal with nothing named in it.
|
||||||
|
func routeNamesInTheMesh(ctx context.Context, open *stores) (map[string]string, error) {
|
||||||
|
inv := open.inventory
|
||||||
|
places, err := inv.Overlays(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("where the machines are cannot be read: %w", err)
|
||||||
|
}
|
||||||
|
address := map[string]string{}
|
||||||
|
for _, p := range places {
|
||||||
|
if strings.TrimSpace(p.Address) != "" {
|
||||||
|
address[p.Name] = p.Address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes, err := inv.Nodes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("which machines the mesh has cannot be read: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every machine's resolution first, then the names across them at once: which node serves a
|
||||||
|
// name is a question about the graph — the consumer on one machine, the provider on another —
|
||||||
|
// and answered wrongly by looking at one contribution at a time (novox/hq issue 178).
|
||||||
|
plans := map[string]catalogue.Resolution{}
|
||||||
|
settings := map[string]catalogue.SettingsBy{}
|
||||||
|
for _, n := range nodes {
|
||||||
|
plan, layers, err := planFor(ctx, open, n.Name)
|
||||||
|
switch {
|
||||||
|
case unresolvable(err):
|
||||||
|
// Their set does not compose, so they serve no names. Passed over, so one machine's
|
||||||
|
// broken set does not cost the rest theirs.
|
||||||
|
continue
|
||||||
|
case err != nil:
|
||||||
|
// The mesh could not be asked. Returning the roster without this machine's names would
|
||||||
|
// state that they do not exist — to every machine, and indistinguishably from the
|
||||||
|
// operator having withdrawn them (novox/hq 04-ISSUES/152).
|
||||||
|
return nil, fmt.Errorf("the names %s serves cannot be read: %w", n.Name, err)
|
||||||
|
}
|
||||||
|
plans[n.Name], settings[n.Name] = plan, layers
|
||||||
|
}
|
||||||
|
served, err := catalogue.NamesServed(plans, settings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
for name, node := range served {
|
||||||
|
if at := address[node]; at != "" {
|
||||||
|
out[name] = at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// certificateFor is what the mesh certifies about one machine's internal name.
|
// 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
|
||||||
@@ -1309,6 +1379,23 @@ func composeBusUsers(ctx context.Context, inv *inventory.Inventory,
|
|||||||
return broker.ComposeAccounts(filled)
|
return broker.ComposeAccounts(filled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// foundationPortsFor is the broker port, kept only when a module resolved onto this node listens
|
||||||
|
// on it (novox/hq issue: the broker opening leaked onto every node). The foundation opening
|
||||||
|
// exists to WIDEN the broker's `from: mesh` port to from-anywhere, because a machine enrolling is
|
||||||
|
// not on the mesh yet and its first dial would be refused. That widening belongs on the broker's
|
||||||
|
// host alone: a node that only dials out needs no incoming rule, and an opening for a port
|
||||||
|
// nothing here listens on is a from-anywhere hole for a dead port.
|
||||||
|
func foundationPortsFor(brokerPort int, modules []catalogue.Manifest) []int {
|
||||||
|
for _, m := range modules {
|
||||||
|
for _, l := range m.Listens {
|
||||||
|
if l.Port == brokerPort {
|
||||||
|
return []int{brokerPort}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// providerModuleOf is which module answers a need on the providing node: the one in this node's
|
// providerModuleOf is which module answers a need on the providing node: the one in this node's
|
||||||
// own set when the provider is here, else the one the catalogue says offers it.
|
// own set when the provider is here, else the one the catalogue says offers it.
|
||||||
func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.Context, n catalogue.Needed) string {
|
func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.Context, n catalogue.Needed) string {
|
||||||
@@ -1328,90 +1415,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+50
-159
@@ -8,7 +8,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -63,9 +62,6 @@ 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
|
||||||
@@ -135,17 +131,10 @@ func serve(ctx context.Context) error {
|
|||||||
|
|
||||||
// And the mesh's own verbs, as the seat this control plane holds (novox/hq ADR 0154). Served
|
// And the mesh's own verbs, as the seat this control plane holds (novox/hq ADR 0154). Served
|
||||||
// from the store's row, so what the seat declares is what is answered.
|
// from the store's row, so what the seat declares is what is answered.
|
||||||
handlers, behind, err := seatToolHandlers()
|
handlers, err := seatToolHandlers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(behind) > 0 {
|
|
||||||
// Said once, loudly, and then served anyway (novox/hq ADR 0185): the mesh keeps answering
|
|
||||||
// while whatever put an older control plane here is undone.
|
|
||||||
fmt.Printf("this control plane is behind the %s row: it cannot run %s. "+
|
|
||||||
"Those answer the reason when called; everything else is served as usual\n",
|
|
||||||
catalogue.ControllerSeatName, strings.Join(behind, ", "))
|
|
||||||
}
|
|
||||||
bus, isNATS := server.Bus().(link.OverNATS)
|
bus, isNATS := server.Bus().(link.OverNATS)
|
||||||
if !isNATS {
|
if !isNATS {
|
||||||
return errors.New("the mesh's verbs are served over the bus, and this control plane is not on it")
|
return errors.New("the mesh's verbs are served over the bus, and this control plane is not on it")
|
||||||
@@ -155,12 +144,6 @@ func serve(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer stopServing()
|
defer stopServing()
|
||||||
// And says so on the bus (novox/hq ADR 0197): what it serves, as the NATS services protocol asks.
|
|
||||||
stopAnnouncing, err := bus.Announce(seatAnnouncement(handlers), log.New(os.Stdout, "", log.LstdFlags))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer stopAnnouncing()
|
|
||||||
|
|
||||||
return server.Serve(ctx)
|
return server.Serve(ctx)
|
||||||
}
|
}
|
||||||
@@ -215,12 +198,6 @@ func declare(ctx context.Context, args []string) error {
|
|||||||
if err := link.Declare(ctx, server.Bus(), ident, node, raw, 15*time.Second); err != nil {
|
if err := link.Declare(ctx, server.Bus(), ident, node, raw, 15*time.Second); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Written down like every other send (novox/hq issue 204): a declaration a person sent by hand
|
|
||||||
// is still what the machine was last told, and status must not read it as current for the one
|
|
||||||
// the mesh would compose.
|
|
||||||
if _, err := recordSent(ctx, inv, node, raw); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("sent %s a signed declaration (%d bytes)\n", node, len(raw))
|
fmt.Printf("sent %s a signed declaration (%d bytes)\n", node, len(raw))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -364,10 +341,7 @@ 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
|
sending, refusals := composeEach(asked, func(node string) (sendable, error) {
|
||||||
// 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) {
|
|
||||||
plan, settings, err := planFor(held, open, node)
|
plan, settings, err := planFor(held, open, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return sendable{}, err
|
return sendable{}, err
|
||||||
@@ -376,7 +350,6 @@ 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.
|
||||||
@@ -390,8 +363,12 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
sentDigest := map[string]string{}
|
sentDigest := map[string]string{}
|
||||||
defer release()
|
defer release()
|
||||||
for _, s := range sending {
|
for _, s := range sending {
|
||||||
// The number is inside the signed bytes, so a replayed older declaration cannot borrow a
|
// Numbered under the hold, one higher than the last, before the body exists — the number is
|
||||||
// newer one's (novox/hq 04-ISSUES/107); it was taken when the composition began (issue 204).
|
// inside the signed bytes, so a replayed older declaration cannot borrow a newer one's
|
||||||
|
// (novox/hq 04-ISSUES/107).
|
||||||
|
if err := number(ctx, inv, &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
body, err := s.declared.Body()
|
body, err := s.declared.Body()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -401,19 +378,26 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
// After it is away, not before. A digest recorded for something that failed to send would
|
// After it is away, not before. A digest recorded for something that failed to send would
|
||||||
// make the machine look current for a declaration it never received.
|
// make the machine look current for a declaration it never received.
|
||||||
digest, err := recordSent(ctx, inv, s.node, body)
|
record, err := inv.NodeByName(ctx, s.node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
digest := digestOf(body)
|
||||||
|
if err := inv.RecordSent(ctx, record.ID, digest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
sentDigest[s.node] = digest
|
sentDigest[s.node] = digest
|
||||||
fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
||||||
}
|
}
|
||||||
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 {
|
var told []string
|
||||||
|
for _, s := range sending {
|
||||||
|
told = append(told, s.node)
|
||||||
|
}
|
||||||
|
if err := issueMemberships(ctx, open, server, told); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,7 +473,11 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
15*time.Second); err != nil {
|
15*time.Second); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := recordSent(ctx, inv, s.node, body); err != nil {
|
record, err := inv.NodeByName(ctx, s.node)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := inv.RecordSent(ctx, record.ID, digestOf(body)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
||||||
@@ -578,31 +566,17 @@ type readyNode struct {
|
|||||||
//
|
//
|
||||||
// The all-or-nothing rule is kept where it means something — sendTo, which rotates a credential
|
// The all-or-nothing rule is kept where it means something — sendTo, which rotates a credential
|
||||||
// across two machines that must agree — and dropped here, where it never did.
|
// across two machines that must agree — and dropped here, where it never did.
|
||||||
func composeEach(names []string, allot func(node string) (int64, error),
|
func composeEach(names []string,
|
||||||
compose func(node string) (sendable, error)) ([]readyNode, []string) {
|
compose func(node string) (sendable, error)) ([]readyNode, []string) {
|
||||||
|
|
||||||
var sending []readyNode
|
var sending []readyNode
|
||||||
var refusals []string
|
var refusals []string
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
// **Numbered before it is composed, not before it is sent** (novox/hq issue 204). The
|
|
||||||
// number says where this declaration stands against every other the mesh composed for the
|
|
||||||
// machine, and the host refuses one lower than the last it applied. Taken at send time, as
|
|
||||||
// it was, a declaration composed a minute ago — before an assignment changed — went out with
|
|
||||||
// a number higher than one composed after the change and sent before it, and the machine
|
|
||||||
// took the older content as the newer word: on 2026-10-02 a runtime assigned and applied on
|
|
||||||
// two machines was undone two seconds later by exactly that. Taken here, before the first
|
|
||||||
// read, what was composed earlier is numbered lower whatever order the sends happen in.
|
|
||||||
seq, err := allot(name)
|
|
||||||
if err != nil {
|
|
||||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
declared, err := compose(name)
|
declared, err := compose(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
declared.Sequence = seq
|
|
||||||
if len(declared.Resources) == 0 {
|
if len(declared.Resources) == 0 {
|
||||||
// Sent, not skipped (novox/hq issue 127). A node whose declaration composes to
|
// Sent, not skipped (novox/hq issue 127). A node whose declaration composes to
|
||||||
// nothing may have HELD something before — the broker opening a placement gave it,
|
// nothing may have HELD something before — the broker opening a placement gave it,
|
||||||
@@ -630,10 +604,13 @@ func sendRound(ctx context.Context, open *stores, names []string,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer release()
|
defer release()
|
||||||
sending, refused := composeEach(names, allotting(held, open.inventory), func(node string) (sendable, error) {
|
sending, refused := composeEach(names, func(node string) (sendable, error) {
|
||||||
return compose(held, node)
|
return compose(held, node)
|
||||||
})
|
})
|
||||||
for _, s := range sending {
|
for _, s := range sending {
|
||||||
|
if err := number(ctx, open.inventory, &s); err != nil {
|
||||||
|
return refused, err
|
||||||
|
}
|
||||||
body, err := s.declared.Body()
|
body, err := s.declared.Body()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return refused, err
|
return refused, err
|
||||||
@@ -690,12 +667,6 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
|||||||
var sending []readyNode
|
var sending []readyNode
|
||||||
var refusals []string
|
var refusals []string
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
// Numbered before composing, for the reason composeEach gives (novox/hq issue 204).
|
|
||||||
seq, err := allot(ctx, inv, name)
|
|
||||||
if err != nil {
|
|
||||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
plan, settings, err := planFor(ctx, open, name)
|
plan, settings, err := planFor(ctx, open, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||||
@@ -707,7 +678,6 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
|||||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
declared.Sequence = seq
|
|
||||||
reportLeftOut(name, declared)
|
reportLeftOut(name, declared)
|
||||||
sending = append(sending, readyNode{name, declared})
|
sending = append(sending, readyNode{name, declared})
|
||||||
}
|
}
|
||||||
@@ -723,6 +693,9 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
for _, s := range sending {
|
for _, s := range sending {
|
||||||
|
if err := number(ctx, inv, &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
body, err := s.declared.Body()
|
body, err := s.declared.Body()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -730,7 +703,11 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
|||||||
if err := link.Declare(ctx, server.Bus(), ident, s.node, body, 15*time.Second); err != nil {
|
if err := link.Declare(ctx, server.Bus(), ident, s.node, body, 15*time.Second); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := recordSent(ctx, inv, s.node, body); err != nil {
|
record, err := inv.NodeByName(ctx, s.node)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := inv.RecordSent(ctx, record.ID, digestOf(body)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf(" sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
fmt.Printf(" sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
||||||
@@ -738,15 +715,11 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
|||||||
// And every assignment on those machines its membership (novox/hq ADR 0160): composed from the
|
// And every assignment on those machines its membership (novox/hq ADR 0160): composed from the
|
||||||
// same records the bus's accounts are, so what a runtime serves and what its account may are one
|
// same records the bus's accounts are, so what a runtime serves and what its account may are one
|
||||||
// composition. Issued after the declaration, because the runtime it is for arrives with it.
|
// composition. Issued after the declaration, because the runtime it is for arrives with it.
|
||||||
return issueMemberships(ctx, open, server, sending)
|
return issueMemberships(ctx, open, server, names)
|
||||||
}
|
}
|
||||||
|
|
||||||
// issueMemberships publishes the membership of every module on the machines just sent.
|
// issueMemberships publishes the membership of every module on the named machines.
|
||||||
//
|
func issueMemberships(ctx context.Context, open *stores, server *link.Server, names []string) error {
|
||||||
// Each carries what its module receives and the private network's addresses, from the same
|
|
||||||
// composition as the declaration it was sent (novox/hq ADR 0167): a provider reads what it is
|
|
||||||
// given on the bus, and the file written beside it says the same thing.
|
|
||||||
func issueMemberships(ctx context.Context, open *stores, server *link.Server, sent []readyNode) error {
|
|
||||||
records, err := open.inventory.BusRecords(ctx)
|
records, err := open.inventory.BusRecords(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -756,37 +729,14 @@ 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.
|
||||||
issued, failed := 0, 0
|
issued, failed := 0, 0
|
||||||
var first error
|
var first error
|
||||||
for _, s := range sent {
|
for _, node := range names {
|
||||||
node := s.node
|
|
||||||
for _, d := range records.Assigned[node] {
|
for _, d := range records.Assigned[node] {
|
||||||
membership := broker.MembershipFor(node, d, where)
|
body, err := json.Marshal(broker.MembershipFor(node, d, where))
|
||||||
membership.Mesh = s.declared.Mesh
|
|
||||||
for requirement, given := range s.declared.Received[d.Module] {
|
|
||||||
raw, err := json.Marshal(given)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if membership.Receives == nil {
|
|
||||||
membership.Receives = map[string]json.RawMessage{}
|
|
||||||
}
|
|
||||||
membership.Receives[requirement] = raw
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(membership)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -917,21 +867,6 @@ 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
|
||||||
@@ -955,8 +890,8 @@ func raiseTheBus(ctx context.Context, inv *inventory.Inventory, address string)
|
|||||||
}
|
}
|
||||||
hearing++
|
hearing++
|
||||||
}
|
}
|
||||||
fmt.Printf("the bus at %s has its streams, %d machine(s) can hear a declaration, %d module(s) "+
|
fmt.Printf("the bus at %s has its streams, %d machine(s) can hear a declaration, and %d module(s) "+
|
||||||
"can hear what they consume, and %d bucket(s) of state\n", broker.BareAddress(address), len(names), hearing, len(buckets))
|
"can hear what they consume\n", broker.BareAddress(address), len(names), hearing)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -996,59 +931,15 @@ func seatHolders(ctx context.Context, inv *inventory.Inventory) (map[string]brok
|
|||||||
}
|
}
|
||||||
|
|
||||||
// number gives one send the next sequence for its node (novox/hq 04-ISSUES/107).
|
// number gives one send the next sequence for its node (novox/hq 04-ISSUES/107).
|
||||||
// allotting is allot over one inventory, in the shape composeEach takes.
|
func number(ctx context.Context, inv *inventory.Inventory, s *readyNode) error {
|
||||||
func allotting(ctx context.Context, inv *inventory.Inventory) func(node string) (int64, error) {
|
record, err := inv.NodeByName(ctx, s.node)
|
||||||
return func(node string) (int64, error) { return allot(ctx, inv, node) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// allot takes the next sequence for a machine — the number its next declaration carries.
|
|
||||||
func allot(ctx context.Context, inv *inventory.Inventory, node string) (int64, error) {
|
|
||||||
record, err := inv.NodeByName(ctx, node)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return err
|
||||||
}
|
}
|
||||||
return inv.NextSequence(ctx, record.ID)
|
seq, err := inv.NextSequence(ctx, record.ID)
|
||||||
}
|
|
||||||
|
|
||||||
// recordSent writes down what a machine was just sent, and returns the digest.
|
|
||||||
//
|
|
||||||
// **On a context that outlives the caller's** (novox/hq issue 204). The record is written after the
|
|
||||||
// declaration is away, so a send that failed is never recorded as current — and a controller being
|
|
||||||
// replaced mid-send had its context cancelled between the two, so the machine was told and the mesh
|
|
||||||
// never wrote it down: status read "applied, current" over a machine that had just been sent
|
|
||||||
// something else. What was sent was sent; the record of it must not depend on the sender living
|
|
||||||
// another second. Bounded, so a store that is away does not hold a dying process open for ever.
|
|
||||||
func recordSent(ctx context.Context, inv *inventory.Inventory, node string, body []byte) (string, error) {
|
|
||||||
kept, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
record, err := inv.NodeByName(kept, node)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return err
|
||||||
}
|
|
||||||
digest := digestOf(body)
|
|
||||||
if err := inv.RecordSent(kept, record.ID, digest); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
s.declared.Sequence = seq
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
// the wrong machine no longer refuses the whole node), applied one level up.
|
// the wrong machine no longer refuses the whole node), applied one level up.
|
||||||
func TestOneUnresolvableNodeStillLetsTheRestBeSent(t *testing.T) {
|
func TestOneUnresolvableNodeStillLetsTheRestBeSent(t *testing.T) {
|
||||||
sending, refusals := composeEach(
|
sending, refusals := composeEach(
|
||||||
[]string{"anchor", "home-server", "laptop"}, numbered(),
|
[]string{"anchor", "home-server", "laptop"},
|
||||||
func(node string) (sendable, error) {
|
func(node string) (sendable, error) {
|
||||||
if node == "anchor" {
|
if node == "anchor" {
|
||||||
return sendable{}, errors.New(`nothing provides "acme-ca", wanted by route-proxy`)
|
return sendable{}, errors.New(`nothing provides "acme-ca", wanted by route-proxy`)
|
||||||
@@ -43,7 +43,7 @@ func TestOneUnresolvableNodeStillLetsTheRestBeSent(t *testing.T) {
|
|||||||
// (novox/hq issue 127): it may have held something before, and only sending the empty
|
// (novox/hq issue 127): it may have held something before, and only sending the empty
|
||||||
// declaration tells it to drop what the mesh owned. It is never a refusal.
|
// declaration tells it to drop what the mesh owned. It is never a refusal.
|
||||||
func TestAnEmptyDeclarationIsSentSoTheNodeDropsWhatItHeld(t *testing.T) {
|
func TestAnEmptyDeclarationIsSentSoTheNodeDropsWhatItHeld(t *testing.T) {
|
||||||
sending, refusals := composeEach([]string{"spare"}, numbered(),
|
sending, refusals := composeEach([]string{"spare"},
|
||||||
func(string) (sendable, error) { return sendable{}, nil })
|
func(string) (sendable, error) { return sendable{}, nil })
|
||||||
if len(sending) != 1 || len(refusals) != 0 {
|
if len(sending) != 1 || len(refusals) != 0 {
|
||||||
t.Errorf("an empty declaration must be sent, not skipped or refused: %v / %v", sending, refusals)
|
t.Errorf("an empty declaration must be sent, not skipped or refused: %v / %v", sending, refusals)
|
||||||
@@ -74,9 +74,3 @@ func TestASkippedMachineIsStillAnError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// numbered is an allotter for tests: one higher per call, as the inventory's is per machine.
|
|
||||||
func numbered() func(string) (int64, error) {
|
|
||||||
var n int64
|
|
||||||
return func(string) (int64, error) { n++; return n, nil }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -68,25 +66,6 @@ type meshStatus struct {
|
|||||||
// **A document without this said an outage was a well mesh.** Read from what each machine
|
// **A document without this said an outage was a well mesh.** Read from what each machine
|
||||||
// reported, so it is the machine's account and not the mesh's take-time listing.
|
// reported, so it is the machine's account and not the mesh's take-time listing.
|
||||||
Untaken []machineUntaken `json:"untaken,omitempty"`
|
Untaken []machineUntaken `json:"untaken,omitempty"`
|
||||||
// Filtered is every converged machine that is not filtered by the mesh alone (novox/hq ADR
|
|
||||||
// 0168), one entry per rule set the mesh did not write — the found firewall in force again,
|
|
||||||
// or a chain nobody speaks for. Absent when every converged machine is filtered by the mesh
|
|
||||||
// alone. A document without this called a machine well while a predecessor's chain refused
|
|
||||||
// what the mesh declared open.
|
|
||||||
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
|
|
||||||
// refuses traffic: where it is, whose the host reads it as, and what it refuses.
|
|
||||||
type machineFiltered struct {
|
|
||||||
Node string `json:"node"`
|
|
||||||
Where string `json:"where"`
|
|
||||||
Owner string `json:"owner"`
|
|
||||||
Refuses string `json:"refuses"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// machineUntaken is one module a machine is holding rather than running, and how many resources of
|
// machineUntaken is one module a machine is holding rather than running, and how many resources of
|
||||||
@@ -194,22 +173,6 @@ func statusAsJSON(asked answers) ([]byte, error) {
|
|||||||
machineUntaken{Node: name, Module: m, Held: asked.untaken[name][m]})
|
machineUntaken{Node: name, Module: m, Held: asked.untaken[name][m]})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
filteredNodes := make([]string, 0, len(asked.filtered))
|
|
||||||
for name := range asked.filtered {
|
|
||||||
filteredNodes = append(filteredNodes, name)
|
|
||||||
}
|
|
||||||
sort.Strings(filteredNodes)
|
|
||||||
for _, name := range filteredNodes {
|
|
||||||
f := asked.filtered[name]
|
|
||||||
if fw := f.FoundFirewall; fw != nil && fw.Active {
|
|
||||||
out.Filtered = append(out.Filtered, machineFiltered{Node: name, Where: "the found firewall",
|
|
||||||
Owner: inventory.FilterFoundFirewall, Refuses: fw.Kind + " is in force again"})
|
|
||||||
}
|
|
||||||
for _, x := range f.Others() {
|
|
||||||
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]})
|
||||||
|
|||||||
@@ -167,46 +167,3 @@ func TestAMachineFailingTheSameWayIsSaidToBeStuck(t *testing.T) {
|
|||||||
t.Fatalf("one failure is not stuck: %v", once)
|
t.Fatalf("one failure is not stuck: %v", once)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A converged machine something other than the mesh filters is named, per rule set, and is not
|
|
||||||
// well (novox/hq ADR 0168); one filtered by the mesh alone is not in the list.
|
|
||||||
func TestAMachineNotFilteredByTheMeshAloneIsNamedAndNotWell(t *testing.T) {
|
|
||||||
alone := inventory.Filtering{Filters: []inventory.Filter{
|
|
||||||
{Where: "table inet mesh, chain input", Owner: inventory.FilterMesh, Refuses: "policy drop"},
|
|
||||||
{Where: "table ip filter, chain DOCKER", Owner: inventory.FilterRuntime, Refuses: "drop"},
|
|
||||||
{Where: "table ip filter, chain f2b-sshd", Owner: inventory.FilterBan, Refuses: "ip saddr 192.0.2.1 reject"},
|
|
||||||
}}
|
|
||||||
if !alone.Alone() {
|
|
||||||
t.Fatal("the mesh's own, the runtime's and a ban are not the mesh alone")
|
|
||||||
}
|
|
||||||
notAlone := inventory.Filtering{
|
|
||||||
Filters: append(alone.Filters, inventory.Filter{Where: "chain HAL-MESH-ONLY (iptables-legacy)",
|
|
||||||
Owner: inventory.FilterOther, Refuses: `-A HAL-MESH-ONLY -m comment --comment "not public" -j DROP`}),
|
|
||||||
FoundFirewall: &inventory.FoundFirewall{Kind: "ufw", Active: true},
|
|
||||||
}
|
|
||||||
asked := answers{nodes: []inventory.Node{{Name: "home-server"}, {Name: "laptop"}},
|
|
||||||
filtered: map[string]inventory.Filtering{"home-server": notAlone}}
|
|
||||||
if asked.well() {
|
|
||||||
t.Fatal("a machine not filtered by the mesh alone reads as well")
|
|
||||||
}
|
|
||||||
body, err := statusAsJSON(asked)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var parsed struct {
|
|
||||||
Filtered []map[string]string `json:"filtered"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(parsed.Filtered) != 2 {
|
|
||||||
t.Fatalf("filtered: %v", parsed.Filtered)
|
|
||||||
}
|
|
||||||
if parsed.Filtered[0]["node"] != "home-server" || parsed.Filtered[0]["owner"] != inventory.FilterFoundFirewall ||
|
|
||||||
parsed.Filtered[1]["where"] != "chain HAL-MESH-ONLY (iptables-legacy)" || parsed.Filtered[1]["owner"] != inventory.FilterOther {
|
|
||||||
t.Fatalf("filtered: %v", parsed.Filtered)
|
|
||||||
}
|
|
||||||
if body, _ := statusAsJSON(answers{nodes: asked.nodes}); strings.Contains(string(body), `"filtered"`) {
|
|
||||||
t.Fatal("a mesh filtered by itself alone carries a filtered list")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -37,29 +36,17 @@ func tiersOf(set []string, edges []inventory.Edge) [][]string {
|
|||||||
for _, m := range set {
|
for _, m := range set {
|
||||||
deps[m] = map[string]bool{}
|
deps[m] = map[string]bool{}
|
||||||
}
|
}
|
||||||
// The build seat's holders follow the controller that defines their worker (EdgeWorkerOf,
|
|
||||||
// novox/hq issue 206), so the built-by edge from that controller to such a holder yields: the
|
|
||||||
// controller is built by whichever build machine is running, as the runtime image always was.
|
|
||||||
worker := map[string]map[string]bool{}
|
|
||||||
for _, e := range edges {
|
|
||||||
if e.Kind == inventory.EdgeWorkerOf && in[e.From] && in[e.To] {
|
|
||||||
if worker[e.To] == nil {
|
|
||||||
worker[e.To] = map[string]bool{}
|
|
||||||
}
|
|
||||||
worker[e.To][e.From] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, e := range edges {
|
for _, e := range edges {
|
||||||
// A code dependency — B packages A's source — rebuilds B with A, in the same tier: B's
|
// A code dependency — B packages A's source — rebuilds B with A, in the same tier: B's
|
||||||
// build needs nothing of A's first. The other kinds order: stands-on and declared after
|
// build needs nothing of A's first. The other kinds order: stands-on and declared after
|
||||||
// the base is built, built-by after the build machine is built and running — except for
|
// the base is built, built-by after the build machine is built and running — except for
|
||||||
// what the build machine itself stands on, and for the controller whose worker the build
|
// what the build machine itself stands on. The runtime image is built by the builder and
|
||||||
// machine binds. The runtime image is built by the builder and the builder is built on the
|
// the builder is built on the runtime image; the image comes first, built by the builder
|
||||||
// runtime image; the image comes first, built by the builder that is running.
|
// that is running, which is the only one there could be.
|
||||||
if !in[e.From] || !in[e.To] || e.From == e.To || e.Kind == inventory.EdgePackages {
|
if !in[e.From] || !in[e.To] || e.From == e.To || e.Kind == inventory.EdgePackages {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if e.Kind == inventory.EdgeBuiltBy && (isBaseOf(e.From, e.To, edges, in) || worker[e.From][e.To]) {
|
if e.Kind == inventory.EdgeBuiltBy && isBaseOf(e.From, e.To, edges, in) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
deps[e.From][e.To] = true
|
deps[e.From][e.To] = true
|
||||||
@@ -135,10 +122,7 @@ func reachableFrom(moved []string, edges []inventory.Edge) []string {
|
|||||||
for grew := true; grew; {
|
for grew := true; grew; {
|
||||||
grew = false
|
grew = false
|
||||||
for _, e := range edges {
|
for _, e := range edges {
|
||||||
// Built-by and worker-of order a plan; neither widens it. A new build machine changes
|
if e.Kind == inventory.EdgeBuiltBy {
|
||||||
// nothing it builds, and a new controller changes nothing about the holder it orders —
|
|
||||||
// what packages the controller's source is already a code edge.
|
|
||||||
if e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if in[e.To] && !in[e.From] {
|
if in[e.To] && !in[e.From] {
|
||||||
@@ -273,8 +257,7 @@ 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)
|
||||||
// The branch it follows, never a commit a build once named (novox/hq 04-ISSUES/215).
|
if err := buildOne(ctx, source, e.Source.Path, e.Source.Ref, 0); err != nil {
|
||||||
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
|
||||||
@@ -289,23 +272,8 @@ 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)
|
||||||
@@ -331,9 +299,6 @@ 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
|
||||||
@@ -352,30 +317,13 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
advanceHeld(ctx, open)
|
advancePlans(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 {
|
||||||
@@ -436,24 +384,6 @@ 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 {
|
||||||
@@ -585,8 +515,7 @@ 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 {
|
||||||
asked, _ := link.BuildAskedAt(result.ID)
|
planBuilt(ctx, open, e.Manifest.Module, result.Commit, result.Failed)
|
||||||
planBuilt(ctx, open, e.Manifest.Module, result.Commit, result.Failed, asked)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -703,19 +632,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -824,52 +740,3 @@ 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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -115,69 +115,3 @@ func TestACycleIsOneLastTierAndSaidSo(t *testing.T) {
|
|||||||
t.Fatalf("a cycle should be one tier of two, said: %v", tiers)
|
t.Fatalf("a cycle should be one tier of two, said: %v", tiers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// novox/hq 04-ISSUES/211: a merge moving the toolchain and a bundle compiled in it builds the
|
|
||||||
// bundle a tier after the toolchain, not beside it.
|
|
||||||
func TestABundleIsPlannedAfterTheToolchainItIsCompiledIn(t *testing.T) {
|
|
||||||
edges := []inventory.Edge{{From: "node-tools", To: "mesh-tools", Kind: inventory.EdgeStandsOn}}
|
|
||||||
p := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-tools", Commit: "abc"},
|
|
||||||
[]string{"mesh-tools", "node-tools"}, edges)
|
|
||||||
if len(p.Tiers) != 2 || p.Tiers[0][0] != "mesh-tools" || p.Tiers[1][0] != "node-tools" {
|
|
||||||
t.Fatalf("the toolchain, then the bundle: %v", p.Tiers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// novox/hq 04-ISSUES/214: a plan whose build outcome was recorded while no controller followed it —
|
|
||||||
// the controller rebuilding itself — settles from the build records instead of waiting for ever.
|
|
||||||
func TestAPlanSettlesAnAskedBuildFromTheRecords(t *testing.T) {
|
|
||||||
asked := time.Date(2026, 10, 3, 19, 20, 0, 0, time.UTC)
|
|
||||||
p := inventory.Plan{ID: "plan-1", Tiers: [][]string{{"mesh-controller", "builder"}, {"route-proxy"}},
|
|
||||||
Modules: map[string]*inventory.PlanModule{
|
|
||||||
"mesh-controller": {State: "asked", AskedAt: &asked},
|
|
||||||
"builder": {State: "asked", AskedAt: &asked},
|
|
||||||
}}
|
|
||||||
records := map[string][]inventory.Build{
|
|
||||||
// Newest first, as Builds answers: the build after the ask is the outcome.
|
|
||||||
"mesh-controller": {
|
|
||||||
{ID: "build-2", Commit: "2ebbb799", At: asked.Add(4 * time.Minute)},
|
|
||||||
{ID: "build-1", Commit: "06ea2168", At: asked.Add(-10 * time.Minute)},
|
|
||||||
},
|
|
||||||
// Only a build from before the ask: not this ask's outcome.
|
|
||||||
"builder": {{ID: "build-0", Commit: "06ea2168", At: asked.Add(-time.Hour)}},
|
|
||||||
}
|
|
||||||
if !settleFromRecords(&p, p.Tiers[0], records) {
|
|
||||||
t.Fatal("nothing settled, though the controller's build is recorded after the ask")
|
|
||||||
}
|
|
||||||
if s := p.Modules["mesh-controller"]; s.State != "built" || s.Commit != "2ebbb799" || s.BuiltAt == nil {
|
|
||||||
t.Errorf("the controller's ask is %+v, want built from 2ebbb799", s)
|
|
||||||
}
|
|
||||||
if s := p.Modules["builder"]; s.State != "asked" {
|
|
||||||
t.Errorf("an ask with no record after it was settled: %+v", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// novox/hq 04-ISSUES/219: a build recorded after the ask but asked before it — an earlier
|
|
||||||
// plan's late outcome — is not this ask's, built or failed.
|
|
||||||
r := inventory.Plan{ID: "plan-3", Tiers: [][]string{{"postgres"}},
|
|
||||||
Modules: map[string]*inventory.PlanModule{"postgres": {State: "asked", AskedAt: &asked}}}
|
|
||||||
late := map[string][]inventory.Build{"postgres": {
|
|
||||||
{ID: "build-old", Commit: "efff5415", Asked: asked.Add(-18 * time.Minute), At: asked.Add(12 * time.Minute)},
|
|
||||||
}}
|
|
||||||
if settleFromRecords(&r, r.Tiers[0], late) || r.Modules["postgres"].State != "asked" {
|
|
||||||
t.Errorf("an earlier ask's late outcome settled this ask: %+v", r.Modules["postgres"])
|
|
||||||
}
|
|
||||||
// Newest heard first: the earlier ask's late outcome, then this ask's own, heard before it.
|
|
||||||
late["postgres"] = append(late["postgres"], inventory.Build{ID: "build-mine", Commit: "4bcd5f73",
|
|
||||||
Asked: asked.Add(time.Second), At: asked.Add(5 * time.Minute)})
|
|
||||||
if !settleFromRecords(&r, r.Tiers[0], late) || r.Modules["postgres"].State != "built" ||
|
|
||||||
r.Modules["postgres"].Commit != "4bcd5f73" {
|
|
||||||
t.Errorf("this ask's own outcome, heard before the earlier ask's, did not settle it: %+v", r.Modules["postgres"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// A failure recorded after the ask fails the plan, as hearing it would have.
|
|
||||||
q := inventory.Plan{ID: "plan-2", Tiers: [][]string{{"x"}},
|
|
||||||
Modules: map[string]*inventory.PlanModule{"x": {State: "asked", AskedAt: &asked}}}
|
|
||||||
settleFromRecords(&q, q.Tiers[0], map[string][]inventory.Build{"x": {{ID: "b", Failed: "no", At: asked.Add(time.Minute)}}})
|
|
||||||
if q.State != inventory.PlanFailed || q.Modules["x"].State != "failed" {
|
|
||||||
t.Errorf("a recorded failure did not fail the plan: %+v %+v", q, q.Modules["x"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -346,9 +346,7 @@ func rolloutMint(ctx context.Context, again bool) error {
|
|||||||
}
|
}
|
||||||
machines++
|
machines++
|
||||||
|
|
||||||
case broker.KindModule, broker.KindNodeTools:
|
case broker.KindModule:
|
||||||
// The runtime is minted and delivered exactly as a module is (novox/hq ADR 0175): it is
|
|
||||||
// issued as the module it stands for, to that module's `broker` secret.
|
|
||||||
if p.Module == "mesh-controller" {
|
if p.Module == "mesh-controller" {
|
||||||
// The control plane is a module too, and its `broker` secret is the old bus's
|
// The control plane is a module too, and its `broker` secret is the old bus's
|
||||||
// credential it is still using while this runs. Writing the new bus's blob there
|
// credential it is still using while this runs. Writing the new bus's blob there
|
||||||
@@ -367,7 +365,7 @@ func rolloutMint(ctx context.Context, again bool) error {
|
|||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: p.Username(), Kind: busKindOf(p.Module), Node: p.Node, Module: p.Module})
|
password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: p.Username(), Kind: inventory.BusModule, Node: p.Node, Module: p.Module})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A node's own set failing to compose, and the mesh being unable to answer at all, are different
|
// A node's own set failing to compose, and the mesh being unable to answer at all, are different
|
||||||
// things, and only the first may be passed over when something is gathered across every machine
|
// things, and only the first may be passed over when something is gathered across every machine
|
||||||
// (novox/hq 04-ISSUES/152). These pin that distinction where the gatherers rely on it.
|
// (novox/hq 04-ISSUES/152). These pin that distinction where the three gatherers rely on it.
|
||||||
|
|
||||||
func TestASetThatDoesNotComposeIsMarkedAsTheNodesOwnProblem(t *testing.T) {
|
func TestASetThatDoesNotComposeIsMarkedAsTheNodesOwnProblem(t *testing.T) {
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
@@ -44,3 +45,55 @@ func TestAStoreThatCannotBeReadIsNotANodeThatDoesNotCompose(t *testing.T) {
|
|||||||
t.Fatalf("a question the mesh could not answer was read as a node that runs nothing: %v", err)
|
t.Fatalf("a question the mesh could not answer was read as a node that runs nothing: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOneIncoherentNodeDoesNotCostTheRestTheirNames(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
one, two := rivals()
|
||||||
|
register(t, open, one)
|
||||||
|
register(t, open, two)
|
||||||
|
for _, m := range []string{one.Module, two.Module} {
|
||||||
|
if _, err := open.inventory.Assign(t.Context(), "laptop", m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// laptop cannot compose. That is laptop's problem and nobody else's: the roster is still
|
||||||
|
// answerable, and anchor keeps whatever it serves.
|
||||||
|
if _, err := routeNamesInTheMesh(t.Context(), open); err != nil {
|
||||||
|
t.Fatalf("one node's broken set cost the whole mesh its roster: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARosterIsNeverReturnedWithNamesItCouldNotRead(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
|
||||||
|
stopped, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
names, err := routeNamesInTheMesh(stopped, open)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("a roster was composed from a store that could not be read: %v", names)
|
||||||
|
}
|
||||||
|
// The failure must be raised, not turned into an absence. A roster missing a machine's names
|
||||||
|
// is indistinguishable, on every machine that receives it, from the operator withdrawing them —
|
||||||
|
// and because the roster is part of every container's identity, it replaces all of them.
|
||||||
|
if names != nil {
|
||||||
|
t.Fatalf("a partial roster was returned beside the error: %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept so the reason survives the next person reading it: the message the gatherer raises must say
|
||||||
|
// which machine could not be read, or the operator is left with a mesh-wide failure and no name.
|
||||||
|
func TestTheRaisedFailureNamesTheMachineItCouldNotRead(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
stopped, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := routeNamesInTheMesh(stopped, open)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("no failure was raised")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "cannot be read") {
|
||||||
|
t.Fatalf("the failure does not say the mesh could not be read: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,10 +6,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/nats-io/nats.go/micro"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
@@ -50,32 +48,17 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
switch verb {
|
switch verb {
|
||||||
case "command":
|
|
||||||
// The generic verb: the command line as given, split as a shell would split it, with
|
|
||||||
// nothing added — the named verbs add flags a caller cannot reach; this one is the whole
|
|
||||||
// binary and says so in its description (novox/hq ADR 0154, 0175).
|
|
||||||
if err := need("command"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
argv, err := splitCommandLine(str("command"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(argv) == 0 {
|
|
||||||
return nil, errors.New("command names no command")
|
|
||||||
}
|
|
||||||
return argv, nil
|
|
||||||
case "status":
|
case "status":
|
||||||
return []string{"status", "--json"}, nil
|
return []string{"status", "--json"}, nil
|
||||||
case "nodes":
|
case "nodes":
|
||||||
return []string{"node", "list", "--json"}, nil
|
return []string{"node", "list"}, nil
|
||||||
case "node":
|
case "node":
|
||||||
if err := need("node"); err != nil {
|
if err := need("node"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return []string{"node", "show", str("node")}, nil
|
return []string{"node", "show", str("node")}, nil
|
||||||
case "modules":
|
case "modules":
|
||||||
return []string{"module", "list", "--json"}, nil
|
return []string{"module", "list"}, nil
|
||||||
case "seats":
|
case "seats":
|
||||||
return []string{"seats", "--json"}, nil
|
return []string{"seats", "--json"}, nil
|
||||||
case "builds":
|
case "builds":
|
||||||
@@ -113,9 +96,7 @@ 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
|
||||||
}
|
}
|
||||||
// Several modules comma-separated, judged as one act (novox/hq ADR 0207): the holders of
|
return []string{verb, str("node"), str("module")}, nil
|
||||||
// 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
|
||||||
@@ -148,25 +129,6 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
|||||||
// Half of either shape: the command says its usage, which names both shapes, and that is
|
// Half of either shape: the command says its usage, which names both shapes, and that is
|
||||||
// the answer the caller needs.
|
// the answer the caller needs.
|
||||||
return []string{"rotate"}, nil
|
return []string{"rotate"}, nil
|
||||||
case "settings":
|
|
||||||
// `settings set|clear` at a shell (novox/hq issue 198). The values travel as an argument
|
|
||||||
// because a tool has no file to hand the command; the command reads either.
|
|
||||||
if err := need("module"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
argv := []string{"settings", "set", str("module")}
|
|
||||||
switch {
|
|
||||||
case str("clear") == "true":
|
|
||||||
argv = []string{"settings", "clear", str("module")}
|
|
||||||
case str("values") != "":
|
|
||||||
argv = append(argv, str("values"))
|
|
||||||
}
|
|
||||||
// Neither values nor clear: the command says its usage, which names both, and that is the
|
|
||||||
// answer the caller needs — the same as `rotate` given half of either shape.
|
|
||||||
if n := str("node"); n != "" {
|
|
||||||
argv = append(argv, "--node", n)
|
|
||||||
}
|
|
||||||
return argv, nil
|
|
||||||
case "issue":
|
case "issue":
|
||||||
// The same act as `module issue` at a shell (novox/hq design 25 §4): the account is minted
|
// The same act as `module issue` at a shell (novox/hq design 25 §4): the account is minted
|
||||||
// into the mesh's records and delivered at the machine's next push, which is the caller's to
|
// into the mesh's records and delivered at the machine's next push, which is the caller's to
|
||||||
@@ -234,15 +196,13 @@ func runVerb(ctx context.Context, argv []string) (verbAnswer, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seatToolHandlers are the handlers for every verb the mesh-controller seat declares, from the
|
// seatToolHandlers are the handlers for every verb the mesh-controller seat declares, from the
|
||||||
// store's row, so a verb the row does not carry is not served. A verb it carries that this binary
|
// store's row, so a verb the row does not carry is not served and a verb it carries that this binary
|
||||||
// cannot run is named at start and answers the reason when called — never a refusal to serve, which
|
// cannot run is said at start rather than at the first call.
|
||||||
// would take the whole control plane down for one word (novox/hq ADR 0185).
|
func seatToolHandlers() (map[string]link.ToolHandler, error) {
|
||||||
func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
|
||||||
seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName)
|
seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName)
|
||||||
if !known {
|
if !known {
|
||||||
return nil, nil, fmt.Errorf("this mesh defines no %s seat", catalogue.ControllerSeatName)
|
return nil, fmt.Errorf("this mesh defines no %s seat", catalogue.ControllerSeatName)
|
||||||
}
|
}
|
||||||
var behind []string
|
|
||||||
handlers := map[string]link.ToolHandler{}
|
handlers := map[string]link.ToolHandler{}
|
||||||
for _, v := range seat.Serves {
|
for _, v := range seat.Serves {
|
||||||
verb := v.Name
|
verb := v.Name
|
||||||
@@ -253,27 +213,8 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := argvFor(verb, sampleArguments(v)); err != nil {
|
if _, err := argvFor(verb, sampleArguments(v)); err != nil {
|
||||||
// **A row ahead of this binary is not a reason to go silent.**
|
return nil, fmt.Errorf("the %s seat's row declares %q, which this control plane cannot run: %w",
|
||||||
//
|
catalogue.ControllerSeatName, verb, err)
|
||||||
// The row is the store's and a control plane follows it (novox/hq ADR 0154), so a verb
|
|
||||||
// this build does not know means the row was widened by a newer one — the ordinary
|
|
||||||
// state of a roll-out, and of a push that put an older control plane back. Refusing to
|
|
||||||
// serve at all made that transient fatal: on 2026-10-02 one unknown verb took the whole
|
|
||||||
// mesh off the bus for ten minutes, and the way back was a human running the binary by
|
|
||||||
// hand, because the thing that would have repaired it is the thing that was down
|
|
||||||
// (novox/hq 04-ISSUES/201, ADR 0185).
|
|
||||||
//
|
|
||||||
// So the verbs this binary knows are served, and this one answers the reason instead of
|
|
||||||
// nothing: a caller gets a sentence naming the fault, and everything else keeps working
|
|
||||||
// — including the push that replaces this binary with the one whose verb it is.
|
|
||||||
behind = append(behind, verb)
|
|
||||||
reason := err
|
|
||||||
handlers[verb] = func(context.Context, json.RawMessage) (any, error) {
|
|
||||||
return nil, fmt.Errorf("%s is in this mesh's %s row and the control plane running "+
|
|
||||||
"here cannot run it: %w. It is a verb of a newer build; this one is behind",
|
|
||||||
verb, catalogue.ControllerSeatName, reason)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
handlers[verb] = func(ctx context.Context, raw json.RawMessage) (any, error) {
|
handlers[verb] = func(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||||
args := map[string]any{}
|
args := map[string]any{}
|
||||||
@@ -289,7 +230,7 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
|||||||
return runVerb(ctx, argv)
|
return runVerb(ctx, argv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return handlers, behind, nil
|
return handlers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// seatTools is what `tools` answers: every seat with a protocol, and the tools each serves, from the
|
// seatTools is what `tools` answers: every seat with a protocol, and the tools each serves, from the
|
||||||
@@ -329,100 +270,3 @@ func sampleArguments(v catalogue.Verb) map[string]any {
|
|||||||
}
|
}
|
||||||
return sample
|
return sample
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitCommandLine splits a command line into words the way a POSIX shell does for the simple
|
|
||||||
// cases a controller command needs: spaces separate, single or double quotes group, a backslash
|
|
||||||
// escapes the next character inside double quotes or outside any. No expansion of anything.
|
|
||||||
func splitCommandLine(line string) ([]string, error) {
|
|
||||||
var words []string
|
|
||||||
var cur strings.Builder
|
|
||||||
inWord := false
|
|
||||||
quote := rune(0)
|
|
||||||
runes := []rune(line)
|
|
||||||
for i := 0; i < len(runes); i++ {
|
|
||||||
r := runes[i]
|
|
||||||
switch {
|
|
||||||
case quote == '\'':
|
|
||||||
if r == '\'' {
|
|
||||||
quote = 0
|
|
||||||
} else {
|
|
||||||
cur.WriteRune(r)
|
|
||||||
}
|
|
||||||
case quote == '"':
|
|
||||||
if r == '"' {
|
|
||||||
quote = 0
|
|
||||||
} else if r == '\\' && i+1 < len(runes) {
|
|
||||||
i++
|
|
||||||
cur.WriteRune(runes[i])
|
|
||||||
} else {
|
|
||||||
cur.WriteRune(r)
|
|
||||||
}
|
|
||||||
case r == '\'' || r == '"':
|
|
||||||
quote = r
|
|
||||||
inWord = true
|
|
||||||
case r == '\\' && i+1 < len(runes):
|
|
||||||
i++
|
|
||||||
cur.WriteRune(runes[i])
|
|
||||||
inWord = true
|
|
||||||
case r == ' ' || r == '\t' || r == '\n':
|
|
||||||
if inWord {
|
|
||||||
words = append(words, cur.String())
|
|
||||||
cur.Reset()
|
|
||||||
inWord = false
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
cur.WriteRune(r)
|
|
||||||
inWord = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if quote != 0 {
|
|
||||||
return nil, fmt.Errorf("command has an unclosed %c quote", quote)
|
|
||||||
}
|
|
||||||
if inWord {
|
|
||||||
words = append(words, cur.String())
|
|
||||||
}
|
|
||||||
return words, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// seatAnnouncement is what the controller says it serves on the bus (novox/hq ADR 0197): the
|
|
||||||
// mesh-controller seat, one endpoint per verb it answers, each with the seat's own description and
|
|
||||||
// argument schema — the same facts `tools` answers from the records, as NATS's services format.
|
|
||||||
func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info {
|
|
||||||
about := map[string]catalogue.Verb{}
|
|
||||||
for _, s := range catalogue.SeatsWithAProtocol() {
|
|
||||||
if s.Name == catalogue.ControllerSeatName {
|
|
||||||
for _, v := range s.Serves {
|
|
||||||
about[v.Name] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
verbs := make([]string, 0, len(handlers))
|
|
||||||
for verb := range handlers {
|
|
||||||
verbs = append(verbs, verb)
|
|
||||||
}
|
|
||||||
sort.Strings(verbs)
|
|
||||||
var endpoints []micro.EndpointInfo
|
|
||||||
for _, verb := range verbs {
|
|
||||||
schema, _ := json.Marshal(about[verb].Input)
|
|
||||||
// The same shape every tool runtime announces in (node-tools' announce package): the name is
|
|
||||||
// `<seat>__<verb>`, as the protocol's characters allow; the metadata is what identifies it.
|
|
||||||
endpoints = append(endpoints, micro.EndpointInfo{
|
|
||||||
Name: catalogue.ControllerSeatName + "__" + verb,
|
|
||||||
Subject: link.SeatToolSubject(catalogue.ControllerSeatName, verb),
|
|
||||||
QueueGroup: "seat." + catalogue.ControllerSeatName,
|
|
||||||
Metadata: map[string]string{
|
|
||||||
"kind": "seat", "module": catalogue.ControllerSeatName, "tool": verb,
|
|
||||||
"seat": catalogue.ControllerSeatName, "scope": "mesh", "interchangeable": "false",
|
|
||||||
"description": about[verb].Description, "schema": string(schema),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return micro.Info{
|
|
||||||
ServiceIdentity: micro.ServiceIdentity{
|
|
||||||
Name: catalogue.ControllerSeatName, ID: "controller", Version: "0.1.0",
|
|
||||||
Metadata: map[string]string{"seat": catalogue.ControllerSeatName, "scope": "mesh"},
|
|
||||||
},
|
|
||||||
Description: "the mesh's own verbs, answered by the holder of the mesh-controller seat",
|
|
||||||
Endpoints: endpoints,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -76,22 +73,6 @@ func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// `settings` is `settings set|clear` at a shell, with the values passed inline (novox/hq issue 198).
|
|
||||||
func TestSettingsSetsOrClearsALayer(t *testing.T) {
|
|
||||||
argv, err := argvFor("settings", map[string]any{"module": "dnsmasq", "values": `{"a":1}`, "node": "ace"})
|
|
||||||
if err != nil || strings.Join(argv, " ") != `settings set dnsmasq {"a":1} --node ace` {
|
|
||||||
t.Fatalf("set on a machine: %v %v", argv, err)
|
|
||||||
}
|
|
||||||
argv, _ = argvFor("settings", map[string]any{"module": "dnsmasq", "clear": "true"})
|
|
||||||
if strings.Join(argv, " ") != "settings clear dnsmasq" {
|
|
||||||
t.Fatalf("clear for the mesh: %v", argv)
|
|
||||||
}
|
|
||||||
argv, _ = argvFor("settings", map[string]any{"module": "dnsmasq"})
|
|
||||||
if strings.Join(argv, " ") != "settings set dnsmasq" {
|
|
||||||
t.Fatalf("a set with no values falls to the command's usage: %v", argv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `issue` is `module issue` at a shell: the module and the machine, and nothing that would push. A
|
// `issue` is `module issue` at a shell: the module and the machine, and nothing that would push. A
|
||||||
// module's bus account was mintable only from the controller's command line, so an agent working
|
// module's bus account was mintable only from the controller's command line, so an agent working
|
||||||
// through the tools could not finish a rollout that gave a module one (novox/hq issue 191).
|
// through the tools could not finish a rollout that gave a module one (novox/hq issue 191).
|
||||||
@@ -133,13 +114,10 @@ func TestActsDoNotBlockTheCall(t *testing.T) {
|
|||||||
|
|
||||||
// What `tools` answers is the seats' records, with each verb's schema.
|
// What `tools` answers is the seats' records, with each verb's schema.
|
||||||
func TestToolsAnswersTheSeatsRecords(t *testing.T) {
|
func TestToolsAnswersTheSeatsRecords(t *testing.T) {
|
||||||
handlers, behind, err := seatToolHandlers()
|
handlers, err := seatToolHandlers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(behind) != 0 {
|
|
||||||
t.Fatalf("this build cannot run %v of its own seat's verbs", behind)
|
|
||||||
}
|
|
||||||
if len(handlers) != len(catalogue.ControllerVerbs) {
|
if len(handlers) != len(catalogue.ControllerVerbs) {
|
||||||
t.Fatalf("%d handlers for %d verbs", len(handlers), len(catalogue.ControllerVerbs))
|
t.Fatalf("%d handlers for %d verbs", len(handlers), len(catalogue.ControllerVerbs))
|
||||||
}
|
}
|
||||||
@@ -177,122 +155,3 @@ func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) {
|
|||||||
t.Fatalf("stderr and stdout are both what the command said: %s", answer.Output)
|
t.Fatalf("stderr and stdout are both what the command said: %s", answer.Output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// `command` is the generic verb: the command line as given, split as a shell would, nothing added —
|
|
||||||
// so an operator's `node account g14 jochen` is one call through the console rather than a shell on
|
|
||||||
// the control node (novox/hq ADR 0154, ADR 0175).
|
|
||||||
func TestCommandRunsTheLineAsGiven(t *testing.T) {
|
|
||||||
argv, err := argvFor("command", map[string]any{"command": "node account g14 jochen"})
|
|
||||||
if err != nil || strings.Join(argv, " ") != "node account g14 jochen" {
|
|
||||||
t.Fatalf("a plain line: %v %v", argv, err)
|
|
||||||
}
|
|
||||||
argv, err = argvFor("command", map[string]any{"command": `settings set dnsmasq '{"a": "b c"}' --node ace`})
|
|
||||||
if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` {
|
|
||||||
t.Fatalf("a quoted word stays one word: %q %v", argv, err)
|
|
||||||
}
|
|
||||||
argv, err = argvFor("command", map[string]any{"command": `node add "the box" --adopted`})
|
|
||||||
if err != nil || len(argv) != 4 || argv[2] != "the box" {
|
|
||||||
t.Fatalf("double quotes group: %q %v", argv, err)
|
|
||||||
}
|
|
||||||
if _, err := argvFor("command", map[string]any{"command": " "}); err == nil {
|
|
||||||
t.Fatal("an empty line was accepted")
|
|
||||||
}
|
|
||||||
if _, err := argvFor("command", map[string]any{"command": `node "unclosed`}); err == nil {
|
|
||||||
t.Fatal("an unclosed quote was accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A verb in the row that this binary cannot run does not take the control plane off the bus: the
|
|
||||||
// rest are served, the unknown one answers the reason, and the start-up names it (novox/hq ADR
|
|
||||||
// 0185). One unknown word cost the mesh ten minutes of silence on 2026-10-02, recoverable only by
|
|
||||||
// a person running the binary by hand — the push that would have repaired it needs the control
|
|
||||||
// plane that was down.
|
|
||||||
func TestARowAheadOfThisBuildIsServedAnyway(t *testing.T) {
|
|
||||||
seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName)
|
|
||||||
if !known {
|
|
||||||
t.Fatal("no controller seat")
|
|
||||||
}
|
|
||||||
// The row as a newer control plane would have written it: every verb this build knows, and one
|
|
||||||
// it does not.
|
|
||||||
widened := seat
|
|
||||||
widened.Serves = append(append([]catalogue.Verb{}, seat.Serves...),
|
|
||||||
catalogue.Verb{Name: "teleport", Description: "a verb from a build that does not exist yet"})
|
|
||||||
rows := catalogue.DefaultSeats()
|
|
||||||
for i := range rows {
|
|
||||||
if rows[i].Name == catalogue.ControllerSeatName {
|
|
||||||
rows[i] = widened
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catalogue.UseSeats(rows)
|
|
||||||
t.Cleanup(func() { catalogue.UseSeats(catalogue.DefaultSeats()) })
|
|
||||||
|
|
||||||
handlers, behind, err := seatToolHandlers()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("a row with one unknown verb refused to serve at all: %v", err)
|
|
||||||
}
|
|
||||||
if len(behind) != 1 || behind[0] != "teleport" {
|
|
||||||
t.Fatalf("the verbs this build cannot run were reported as %v", behind)
|
|
||||||
}
|
|
||||||
if len(handlers) != len(widened.Serves) {
|
|
||||||
t.Fatalf("%d handlers for %d verbs in the row", len(handlers), len(widened.Serves))
|
|
||||||
}
|
|
||||||
for _, known := range []string{"status", "nodes", "push"} {
|
|
||||||
if handlers[known] == nil {
|
|
||||||
t.Errorf("%s is not served although this build knows it", known)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, err = handlers["teleport"](context.Background(), nil)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("the unknown verb answered as though it had run")
|
|
||||||
}
|
|
||||||
for _, want := range []string{"teleport", "cannot run it", "behind"} {
|
|
||||||
if !strings.Contains(err.Error(), want) {
|
|
||||||
t.Errorf("the answer does not say %q: %v", want, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// novox/hq ADR 0195: the console's discovery reads the machines and the modules; they answer as JSON,
|
|
||||||
// as status and seats do, so nothing parses a printed column.
|
|
||||||
func TestTheNodesAndModulesVerbsAnswerAsJSON(t *testing.T) {
|
|
||||||
for verb, want := range map[string]string{"nodes": "[node list --json]", "modules": "[module list --json]"} {
|
|
||||||
argv, err := argvFor(verb, map[string]any{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if fmt.Sprint(argv) != want {
|
|
||||||
t.Errorf("%s runs %v, want %s", verb, argv, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// novox/hq ADR 0197: the controller announces exactly the verbs it serves, each on the subject and
|
|
||||||
// queue it serves it on, with the seat's own description and schema, in NATS's services format.
|
|
||||||
func TestTheControllerAnnouncesTheVerbsItServes(t *testing.T) {
|
|
||||||
handlers, _, err := seatToolHandlers()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
info := seatAnnouncement(handlers)
|
|
||||||
if info.Name != catalogue.ControllerSeatName || info.ID == "" || info.Version == "" {
|
|
||||||
t.Fatalf("the service is not named for the seat: %+v", info.ServiceIdentity)
|
|
||||||
}
|
|
||||||
if len(info.Endpoints) != len(handlers) {
|
|
||||||
t.Fatalf("%d endpoints announced for %d verbs served", len(info.Endpoints), len(handlers))
|
|
||||||
}
|
|
||||||
for _, e := range info.Endpoints {
|
|
||||||
verb := e.Metadata["tool"]
|
|
||||||
if _, served := handlers[verb]; !served || e.Name != catalogue.ControllerSeatName+"__"+verb {
|
|
||||||
t.Errorf("%s (%s) is announced and not served under that name", e.Name, verb)
|
|
||||||
}
|
|
||||||
if e.Metadata["kind"] != "seat" || e.Metadata["seat"] != catalogue.ControllerSeatName {
|
|
||||||
t.Errorf("%s is not announced as the seat's verb: %v", e.Name, e.Metadata)
|
|
||||||
}
|
|
||||||
if e.Subject != link.SeatToolSubject(catalogue.ControllerSeatName, verb) || e.QueueGroup != "seat."+catalogue.ControllerSeatName {
|
|
||||||
t.Errorf("%s is announced on %s/%s, not where it is served", e.Name, e.Subject, e.QueueGroup)
|
|
||||||
}
|
|
||||||
if e.Metadata["description"] == "" || e.Metadata["schema"] == "" || e.Metadata["scope"] != "mesh" {
|
|
||||||
t.Errorf("%s is announced without its description, schema or scope: %v", e.Name, e.Metadata)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,12 +25,6 @@ type sendable struct {
|
|||||||
// Adoption is nil for a converged node, and then the body is byte for byte what it was before
|
// Adoption is nil for a converged node, and then the body is byte for byte what it was before
|
||||||
// adoption existed: an older host parses the envelope strictly and would refuse the key.
|
// adoption existed: an older host parses the envelope strictly and would refuse the key.
|
||||||
Adoption *adoptionEnvelope
|
Adoption *adoptionEnvelope
|
||||||
|
|
||||||
// Received and Mesh are not sent in the declaration. They are what this machine's memberships
|
|
||||||
// are issued with on the bus (novox/hq ADR 0167): each module's received contributions, from
|
|
||||||
// the same composition as its received files, and every machine's private-network address.
|
|
||||||
Received map[string]map[string][]catalogue.Contribution
|
|
||||||
Mesh []string
|
|
||||||
// LeftOut is every module of the machine's set left out of this declaration because a stored
|
// LeftOut is every module of the machine's set left out of this declaration because a stored
|
||||||
// setting cannot compose with its definition (novox/hq ADR 0163, rule 6), sorted. The host
|
// setting cannot compose with its definition (novox/hq ADR 0163, rule 6), sorted. The host
|
||||||
// keeps that module's held things and touches none of its containers; a machine is told
|
// keeps that module's held things and touches none of its containers; a machine is told
|
||||||
|
|||||||
@@ -227,28 +227,6 @@ func printStatus(asked answers) error {
|
|||||||
" not readable from a commit; that needs a version the host reports as ordered\n\n")
|
" not readable from a commit; that needs a version the host reports as ordered\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(asked.filtered) > 0 {
|
|
||||||
// A converged machine is filtered by the mesh alone, and the mesh says truthfully which
|
|
||||||
// (novox/hq ADR 0168). One that is not — a predecessor's chain still refusing, a found
|
|
||||||
// firewall in force again — is said here, and is not well.
|
|
||||||
machines := make([]string, 0, len(asked.filtered))
|
|
||||||
for name := range asked.filtered {
|
|
||||||
machines = append(machines, name)
|
|
||||||
}
|
|
||||||
sort.Strings(machines)
|
|
||||||
fmt.Printf("%d converged machine(s) are not filtered by the mesh alone:\n", len(machines))
|
|
||||||
for _, name := range machines {
|
|
||||||
f := asked.filtered[name]
|
|
||||||
if fw := f.FoundFirewall; fw != nil && fw.Active {
|
|
||||||
fmt.Printf(" %-12s the found firewall (%s) is in force again; the next apply retires it\n", name, fw.Kind)
|
|
||||||
}
|
|
||||||
for _, x := range f.Others() {
|
|
||||||
fmt.Printf(" %-12s %s (%s): %s\n", name, x.Where, x.Owner, x.Refuses)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("\n the mesh wrote none of these and removes none; `node show <node>` lists every filter with its owner\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(asked.untaken) > 0 {
|
if len(asked.untaken) > 0 {
|
||||||
// **Before the adopted line, and it breaks "all well".** An adopted machine is a state
|
// **Before the adopted line, and it breaks "all well".** An adopted machine is a state
|
||||||
// somebody chose and can leave alone; a module assigned to one and never taken is work
|
// somebody chose and can leave alone; a module assigned to one and never taken is work
|
||||||
@@ -282,22 +260,6 @@ 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".
|
||||||
@@ -395,27 +357,6 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return answers{}, err
|
return answers{}, err
|
||||||
}
|
}
|
||||||
// And which converged machines something other than the mesh filters (novox/hq ADR 0168), as
|
|
||||||
// each last reported — the account that was missing when a predecessor's chain refused what the
|
|
||||||
// mesh declared open for eleven hours (04-ISSUES/144, 145).
|
|
||||||
out.filtered, err = filteredMachines(ctx, inv, out.nodes)
|
|
||||||
if err != nil {
|
|
||||||
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
|
||||||
@@ -465,30 +406,6 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
|||||||
//
|
//
|
||||||
// A machine that reports no holds contributes nothing, so a converged mesh answers an empty map and
|
// A machine that reports no holds contributes nothing, so a converged mesh answers an empty map and
|
||||||
// the caller prints nothing.
|
// the caller prints nothing.
|
||||||
// filteredMachines is every converged machine not filtered by the mesh alone, with what it last said
|
|
||||||
// filters it (novox/hq ADR 0168). An adopted machine keeps its found firewall by design and is not
|
|
||||||
// counted; a machine that has not said is not said to be filtered by anything.
|
|
||||||
func filteredMachines(ctx context.Context, inv *inventory.Inventory, nodes []inventory.Node) (
|
|
||||||
map[string]inventory.Filtering, error) {
|
|
||||||
out := map[string]inventory.Filtering{}
|
|
||||||
for _, n := range nodes {
|
|
||||||
if n.Adopted {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
f, err := inv.FilteringOf(ctx, n.Name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("what filters %s cannot be read: %w", n.Name, err)
|
|
||||||
}
|
|
||||||
if len(f.Filters) == 0 && f.FoundFirewall == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !f.Alone() {
|
|
||||||
out[n.Name] = f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inventory.Node) (
|
func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inventory.Node) (
|
||||||
map[string]map[string]int, error) {
|
map[string]map[string]int, error) {
|
||||||
|
|
||||||
@@ -525,8 +442,7 @@ func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inven
|
|||||||
// read as success for the whole of the edge cut-over outage (novox/hq 04-ISSUES/125).
|
// read as success for the whole of the edge cut-over outage (novox/hq 04-ISSUES/125).
|
||||||
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.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
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
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,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -284,14 +283,6 @@ 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 {
|
||||||
@@ -315,13 +306,6 @@ 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",
|
||||||
@@ -361,24 +345,7 @@ func sourceIs(s inventory.Source, m link.SourceMoved) bool {
|
|||||||
if !sameRepository(s.Repository, m) {
|
if !sameRepository(s.Repository, m) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
ref := followedBranch(s.Ref)
|
return s.Ref == "" || s.Ref == m.Base
|
||||||
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
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The holder of the build seat follows the controller that defines its worker (novox/hq issue 206).
|
|
||||||
// On 2026-10-03 a plan put the build machine in tier 0 and the controller in tier 1; the new build
|
|
||||||
// machine could not bind the worker the old controller had defined, and nothing could build the
|
|
||||||
// controller that would have redefined it. The built-by edge from the controller to its build
|
|
||||||
// machine yields to that order: the controller is built by whichever build machine is running.
|
|
||||||
func TestTheBuildSeatsHolderFollowsTheControllerThatDefinesItsWorker(t *testing.T) {
|
|
||||||
edges := []inventory.Edge{
|
|
||||||
{From: "build-agent", To: "mesh-controller", Kind: inventory.EdgePackages},
|
|
||||||
{From: "build-agent", To: "mesh-controller", Kind: inventory.EdgeWorkerOf},
|
|
||||||
{From: "mesh-controller", To: "build-agent", Kind: inventory.EdgeBuiltBy},
|
|
||||||
{From: "route-proxy", To: "mesh-controller", Kind: inventory.EdgePackages},
|
|
||||||
{From: "route-proxy", To: "build-agent", Kind: inventory.EdgeBuiltBy},
|
|
||||||
}
|
|
||||||
set := reachableFrom([]string{"mesh-controller"}, edges)
|
|
||||||
if len(set) != 3 {
|
|
||||||
t.Fatalf("the controller, what packages it, and nothing more: %v", set)
|
|
||||||
}
|
|
||||||
tiers := tiersOf(set, edges)
|
|
||||||
pos := map[string]int{}
|
|
||||||
for i, tier := range tiers {
|
|
||||||
for _, m := range tier {
|
|
||||||
pos[m] = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if pos["mesh-controller"] != 0 {
|
|
||||||
t.Fatalf("the controller first, built by the build machine that is running: %v", tiers)
|
|
||||||
}
|
|
||||||
if pos["build-agent"] <= pos["mesh-controller"] {
|
|
||||||
t.Fatalf("the build machine after the controller that defines its worker: %v", tiers)
|
|
||||||
}
|
|
||||||
if pos["route-proxy"] <= pos["build-agent"] {
|
|
||||||
t.Fatalf("what the build machine builds comes after it: %v", tiers)
|
|
||||||
}
|
|
||||||
if hasCycle(tiers, edges) {
|
|
||||||
t.Fatalf("no cycle here: %v", tiers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,10 +10,7 @@
|
|||||||
# The client is copied from the vendor's own image rather than installed from a distribution:
|
# The client is copied from the vendor's own image rather than installed from a distribution:
|
||||||
# `apk add mc` on Alpine installs Midnight Commander, which is a different program with the same
|
# `apk add mc` on Alpine installs Midnight Commander, which is a different program with the same
|
||||||
# name, and the failure would be a provisioner that starts cleanly and cannot do anything.
|
# name, and the failure would be a provisioner that starts cleanly and cannot do anything.
|
||||||
# The Go it builds with is the one the manifest pins (build.on GO_BASE), passed by the Makefile and the
|
FROM golang:1.25-alpine AS build
|
||||||
# build machine alike; the default only serves a hand build, and matches go.mod.
|
|
||||||
ARG GO_BASE=golang:1.26-alpine
|
|
||||||
FROM ${GO_BASE} AS build
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
# Built here so a machine can be given it by the mesh rather than by somebody putting a binary on
|
# Built here so a machine can be given it by the mesh rather than by somebody putting a binary on
|
||||||
# it. Static and FROM scratch for the same reason the control plane's image is: it is fetched by
|
# it. Static and FROM scratch for the same reason the control plane's image is: it is fetched by
|
||||||
# digest and run on a machine, and everything in it is something a person would have to audit.
|
# digest and run on a machine, and everything in it is something a person would have to audit.
|
||||||
# The Go it builds with is the one the manifest pins (build.on GO_BASE), passed by the Makefile and the
|
FROM golang:1.25-alpine AS build
|
||||||
# build machine alike; the default only serves a hand build, and matches go.mod.
|
|
||||||
ARG GO_BASE=golang:1.26-alpine
|
|
||||||
FROM ${GO_BASE} AS build
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -2,10 +2,7 @@
|
|||||||
#
|
#
|
||||||
# FROM scratch, like the postgres one and unlike the bucket one: it speaks the store's own wire
|
# FROM scratch, like the postgres one and unlike the bucket one: it speaks the store's own wire
|
||||||
# protocol directly and needs no client in the image.
|
# protocol directly and needs no client in the image.
|
||||||
# The Go it builds with is the one the manifest pins (build.on GO_BASE), passed by the Makefile and the
|
FROM golang:1.25-alpine AS build
|
||||||
# build machine alike; the default only serves a hand build, and matches go.mod.
|
|
||||||
ARG GO_BASE=golang:1.26-alpine
|
|
||||||
FROM ${GO_BASE} AS build
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -2,10 +2,7 @@
|
|||||||
#
|
#
|
||||||
# Static and FROM scratch like the control plane's image, and for the same reason: it is fetched
|
# Static and FROM scratch like the control plane's image, and for the same reason: it is fetched
|
||||||
# by digest and run on a machine, so everything in it is something a person would have to audit.
|
# by digest and run on a machine, so everything in it is something a person would have to audit.
|
||||||
# The Go it builds with is the one the manifest pins (build.on GO_BASE), passed by the Makefile and the
|
FROM golang:1.25-alpine AS build
|
||||||
# build machine alike; the default only serves a hand build, and matches go.mod.
|
|
||||||
ARG GO_BASE=golang:1.26-alpine
|
|
||||||
FROM ${GO_BASE} AS build
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What the mesh issued this proxy, read on the bus (novox/hq ADR 0160, ADR 0167).
|
|
||||||
//
|
|
||||||
// **The proxy is told, not left to work it out.** Its membership carries the routes it is given —
|
|
||||||
// the same contributions its file is written from — and every machine's address on the private
|
|
||||||
// network, which is who may be served an internal name. Read once at connect and followed, so a
|
|
||||||
// route added or a machine joining reaches a running proxy without a restart.
|
|
||||||
|
|
||||||
// credential is the bus account the mesh delivered as this module's own secret named broker.
|
|
||||||
type credential struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Fingerprint string `json:"fingerprint"`
|
|
||||||
Node string `json:"node"`
|
|
||||||
Module string `json:"module"`
|
|
||||||
User string `json:"user"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// followMembership connects with the credential in path and applies every membership the mesh
|
|
||||||
// issues this proxy. It retries the first connection for as long as it takes: a proxy that started
|
|
||||||
// before the bus keeps serving the file, and takes the bus when it answers.
|
|
||||||
func followMembership(path string, held *table, fromBus *atomic.Bool) {
|
|
||||||
for {
|
|
||||||
err := followOnce(path, held, fromBus)
|
|
||||||
if err == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("cannot follow this proxy's membership, serving the file meanwhile: %v", err)
|
|
||||||
time.Sleep(30 * time.Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func followOnce(path string, held *table, fromBus *atomic.Bool) error {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var cred credential
|
|
||||||
if err := json.Unmarshal(raw, &cred); err != nil {
|
|
||||||
return fmt.Errorf("the broker credential is not one: %w", err)
|
|
||||||
}
|
|
||||||
if cred.Node == "" || cred.Module == "" {
|
|
||||||
return fmt.Errorf("the broker credential names no node or module, so it has no membership")
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := []nats.Option{
|
|
||||||
nats.Name(cred.Node + "." + cred.Module),
|
|
||||||
nats.UserInfo(cred.User, cred.Password),
|
|
||||||
// Its own inbox, and nothing wider: every principal is granted `_INBOX.<its user>.>` alone.
|
|
||||||
nats.CustomInboxPrefix("_INBOX." + cred.User),
|
|
||||||
// The bus being restarted is an upgrade, not a reason to stop following.
|
|
||||||
nats.MaxReconnects(-1),
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(cred.Fingerprint) != "" {
|
|
||||||
opts = append(opts, nats.Secure(broker.PinnedToFingerprint(cred.Fingerprint)))
|
|
||||||
}
|
|
||||||
conn, err := nats.Connect(cred.URL, opts...)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connecting to the bus at %s: %w", broker.BareAddress(cred.URL), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
subject := broker.MembershipSubject(cred.Node, cred.Module)
|
|
||||||
apply := func(body []byte) {
|
|
||||||
var issued broker.Membership
|
|
||||||
if err := json.Unmarshal(body, &issued); err != nil {
|
|
||||||
log.Printf("a membership arrived that is not one: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if took := applyMembership(issued, held); took && !fromBus.Swap(true) {
|
|
||||||
log.Printf("routes now come from this proxy's membership on %s", subject)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Followed first, read second: an issue landing between the two is applied, not missed.
|
|
||||||
if _, err := conn.Subscribe(subject, func(m *nats.Msg) { apply(m.Data) }); err != nil {
|
|
||||||
conn.Close()
|
|
||||||
return fmt.Errorf("cannot follow %s: %w", subject, err)
|
|
||||||
}
|
|
||||||
// The subject-addressed direct get: the one request this account may make of the stream.
|
|
||||||
got, err := conn.Request("$JS.API.DIRECT.GET."+broker.AssignmentsStream+"."+subject, nil, 5*time.Second)
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
log.Printf("cannot read the membership issued on %s yet (%v); following it", subject, err)
|
|
||||||
case got.Header.Get("Status") != "" || len(got.Data) == 0:
|
|
||||||
log.Printf("no membership issued on %s yet; serving the file until one is", subject)
|
|
||||||
default:
|
|
||||||
apply(got.Data)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyMembership serves what a membership says, and says whether it said anything about routes.
|
|
||||||
//
|
|
||||||
// A membership with no routes in it is one from a controller older than ADR 0167, and the file stays
|
|
||||||
// the source rather than every route being withdrawn because a field was absent.
|
|
||||||
func applyMembership(issued broker.Membership, held *table) bool {
|
|
||||||
raw, carries := issued.Receives["route"]
|
|
||||||
if !carries {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var contributions []contribution
|
|
||||||
if err := json.Unmarshal(raw, &contributions); err != nil {
|
|
||||||
log.Printf("the routes in this proxy's membership are not contributions, keeping what is served: %v", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
inside, err := sourcesOf(issued.Mesh)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("the mesh in this proxy's membership is unreadable, keeping what is served: %v", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
routes, public := routesOf(contributions)
|
|
||||||
held.set(routes, public)
|
|
||||||
held.setInside(inside)
|
|
||||||
log.Printf("serving %d route(s) from the membership, internal names to %d machine(s): %s",
|
|
||||||
len(routes), len(inside), strings.Join(held.names(), ", "))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/tls"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A backend behind the proxy learns the client used TLS and which name it asked for, so the addresses
|
|
||||||
// it writes into its own pages are the ones a client can use (2026-10-03: a forge's Go import tag
|
|
||||||
// named an http clone URL, and Go refused the module path).
|
|
||||||
func TestABackendIsToldTheRequestWasHTTPSAndForWhichName(t *testing.T) {
|
|
||||||
var proto, host, fwdHost, fwdFor string
|
|
||||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
proto, host, fwdHost, fwdFor = r.Header.Get("X-Forwarded-Proto"), r.Host, r.Header.Get("X-Forwarded-Host"), r.Header.Get("X-Forwarded-For")
|
|
||||||
}))
|
|
||||||
defer backend.Close()
|
|
||||||
where, _ := url.Parse(backend.URL)
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "https://git.example.org/novox/mesh-sdk/go?go-get=1", nil)
|
|
||||||
req.TLS = &tls.ConnectionState{}
|
|
||||||
req.Host = "git.example.org"
|
|
||||||
req.RemoteAddr = "192.0.2.7:51000"
|
|
||||||
towards(where).ServeHTTP(httptest.NewRecorder(), req)
|
|
||||||
if proto != "https" || fwdHost != "git.example.org" || host != "git.example.org" || fwdFor != "192.0.2.7" {
|
|
||||||
t.Errorf("the backend was told proto=%q host=%q forwarded-host=%q for=%q", proto, host, fwdHost, fwdFor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+30
-195
@@ -54,14 +54,12 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/netip"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/acme"
|
"golang.org/x/crypto/acme"
|
||||||
@@ -198,63 +196,6 @@ type table struct {
|
|||||||
// pass ACME's own validation (it has no public DNS to prove it against), so asking for it is
|
// pass ACME's own validation (it has no public DNS to prove it against), so asking for it is
|
||||||
// not merely pointless but the failing order onlyWhatTheMeshSaid exists to prevent.
|
// not merely pointless but the failing order onlyWhatTheMeshSaid exists to prevent.
|
||||||
public map[string]bool
|
public map[string]bool
|
||||||
// inside is where a request must come from to be served a name that is only internal: every
|
|
||||||
// machine's address on the private network, as the mesh issued it in this proxy's membership
|
|
||||||
// (novox/hq ADR 0167). Empty until it is issued, and then only the machine itself is inside.
|
|
||||||
inside sources
|
|
||||||
}
|
|
||||||
|
|
||||||
// sources is who may be served an internal name: the private network's addresses as the mesh
|
|
||||||
// issued them. The machine itself is always inside — anything on a machine may call anything on it
|
|
||||||
// (novox/hq ADR 0144) — so loopback needs no entry.
|
|
||||||
type sources []netip.Prefix
|
|
||||||
|
|
||||||
// sourcesOf reads the addresses the mesh issued, each a single address or a range. One that does
|
|
||||||
// not parse is an error, not an entry skipped: the proxy would otherwise serve internal names to
|
|
||||||
// fewer machines than the mesh said, and say nothing.
|
|
||||||
func sourcesOf(mesh []string) (sources, error) {
|
|
||||||
var out sources
|
|
||||||
for _, entry := range mesh {
|
|
||||||
entry = strings.TrimSpace(entry)
|
|
||||||
if prefix, err := netip.ParsePrefix(entry); err == nil {
|
|
||||||
out = append(out, prefix.Masked())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
addr, err := netip.ParseAddr(entry)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%q is not an address on the private network", entry)
|
|
||||||
}
|
|
||||||
addr = addr.Unmap()
|
|
||||||
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// holds says whether a request from this remote address came from the mesh or the machine itself.
|
|
||||||
//
|
|
||||||
// **By source, which the mesh's guard deliberately is not** — it names interfaces, because a source
|
|
||||||
// address can be claimed by whoever sends the packet. The proxy cannot see the interface a request
|
|
||||||
// arrived on, and here the claim does not carry: a connection needs its replies, and replies to a
|
|
||||||
// mesh address leave by the tunnel, never back to the claimant.
|
|
||||||
func (s sources) holds(remote string) bool {
|
|
||||||
host := remote
|
|
||||||
if h, _, err := net.SplitHostPort(remote); err == nil {
|
|
||||||
host = h
|
|
||||||
}
|
|
||||||
addr, err := netip.ParseAddr(host)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
addr = addr.Unmap()
|
|
||||||
if addr.IsLoopback() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, prefix := range s {
|
|
||||||
if prefix.Contains(addr) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *table) set(routes map[string][]rule, public map[string]bool) {
|
func (t *table) set(routes map[string][]rule, public map[string]bool) {
|
||||||
@@ -273,7 +214,7 @@ func (t *table) set(routes map[string][]rule, public map[string]bool) {
|
|||||||
log.Printf("route %s points at %q, which is not a URL: %v", host, r.target, err)
|
log.Printf("route %s points at %q, which is not a URL: %v", host, r.target, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
r.to = towards(where)
|
r.to = httputil.NewSingleHostReverseProxy(where)
|
||||||
if r.insecure {
|
if r.insecure {
|
||||||
r.to.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
|
r.to.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
|
||||||
}
|
}
|
||||||
@@ -373,41 +314,6 @@ func bareHost(host string) string {
|
|||||||
return strings.ToLower(host)
|
return strings.ToLower(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hiddenFrom says whether this host must look unrouted to a request from this address: it is
|
|
||||||
// only an internal name, and the request did not come from the private network.
|
|
||||||
//
|
|
||||||
// **The proxy is the only way in to a routed endpoint, so it is what makes `internal` true**
|
|
||||||
// (novox/hq ADR 0138, issue 191). It answers public names on the same listeners, so a request from
|
|
||||||
// anywhere can carry any Host header; a name being internal keeps nobody out unless this check does.
|
|
||||||
// Answered exactly as a name that was never routed, so an outsider learns nothing from asking.
|
|
||||||
func (t *table) hiddenFrom(host, remote string) bool {
|
|
||||||
if !t.eligibleForInternalACME(host) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
t.mu.RLock()
|
|
||||||
defer t.mu.RUnlock()
|
|
||||||
return !t.inside.holds(remote)
|
|
||||||
}
|
|
||||||
|
|
||||||
// setInside replaces who the mesh is, as the membership said.
|
|
||||||
func (t *table) setInside(inside sources) {
|
|
||||||
t.mu.Lock()
|
|
||||||
t.inside = inside
|
|
||||||
t.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// namesSeenFrom is what this proxy says it serves to a request from this address — every routed
|
|
||||||
// name, less the internal-only ones when the request came from outside.
|
|
||||||
func (t *table) namesSeenFrom(remote string) []string {
|
|
||||||
out := []string{}
|
|
||||||
for _, name := range t.names() {
|
|
||||||
if !t.hiddenFrom(name, remote) {
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *table) names() []string {
|
func (t *table) names() []string {
|
||||||
t.mu.RLock()
|
t.mu.RLock()
|
||||||
defer t.mu.RUnlock()
|
defer t.mu.RUnlock()
|
||||||
@@ -437,20 +343,7 @@ func run() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
held := newTable()
|
held := newTable()
|
||||||
// **The bus first, the file until it has spoken** (novox/hq ADR 0167). The membership carries
|
|
||||||
// the routes and who the mesh is; the file carries the routes alone, so while the proxy reads
|
|
||||||
// it an internal name is served to this machine and to nobody else — refused, never opened.
|
|
||||||
fromBus := &atomic.Bool{}
|
|
||||||
if credential := strings.TrimSpace(os.Getenv("MESH_BROKER_FILE")); credential != "" {
|
|
||||||
go followMembership(credential, held, fromBus)
|
|
||||||
} else {
|
|
||||||
log.Printf("MESH_BROKER_FILE is not set: routes come from %s alone, and a name that is only "+
|
|
||||||
"internal is served to this machine alone", path)
|
|
||||||
}
|
|
||||||
read := func() {
|
read := func() {
|
||||||
if fromBus.Load() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
routes, public, err := routesFrom(path)
|
routes, public, err := routesFrom(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Kept serving what it had. A file being rewritten is momentarily unreadable, and
|
// Kept serving what it had. A file being rewritten is momentarily unreadable, and
|
||||||
@@ -529,7 +422,19 @@ func run() error {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
tlsConfig := publicManager.TLSConfig()
|
tlsConfig := publicManager.TLSConfig()
|
||||||
tlsConfig.GetCertificate = certificateFor(held, tlsConfig.GetCertificate, internalManager)
|
if internalManager != nil {
|
||||||
|
// Dispatched by which authority may certify this name at all — the same question
|
||||||
|
// eligibleForInternalACME already answers, asked once more at handshake time rather than
|
||||||
|
// only when an order is placed, since a cached certificate is served here on every request
|
||||||
|
// and never goes through HostPolicy again.
|
||||||
|
fromPublic, fromInternal := tlsConfig.GetCertificate, internalManager.TLSConfig().GetCertificate
|
||||||
|
tlsConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
|
if held.eligibleForInternalACME(hello.ServerName) {
|
||||||
|
return fromInternal(hello)
|
||||||
|
}
|
||||||
|
return fromPublic(hello)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: secure,
|
Addr: secure,
|
||||||
@@ -687,33 +592,6 @@ func forThisAuthority(cache, directory string, root []byte) string {
|
|||||||
return filepath.Join(cache, hex.EncodeToString(sum[:])[:16])
|
return filepath.Join(cache, hex.EncodeToString(sum[:])[:16])
|
||||||
}
|
}
|
||||||
|
|
||||||
// certificateFor picks the certificate a handshake is answered with.
|
|
||||||
//
|
|
||||||
// Dispatched by which authority may certify this name at all — the same question
|
|
||||||
// eligibleForInternalACME already answers, asked once more at handshake time rather than only when
|
|
||||||
// an order is placed, since a cached certificate is served here on every request and never goes
|
|
||||||
// through HostPolicy again. And refused, exactly as an unrouted name is, to a client outside the
|
|
||||||
// private network asking for a name that is only internal: the certificate would name it.
|
|
||||||
func certificateFor(held *table, fromPublic func(*tls.ClientHelloInfo) (*tls.Certificate, error),
|
|
||||||
internalManager *autocert.Manager) func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
||||||
var fromInternal func(*tls.ClientHelloInfo) (*tls.Certificate, error)
|
|
||||||
if internalManager != nil {
|
|
||||||
fromInternal = internalManager.TLSConfig().GetCertificate
|
|
||||||
}
|
|
||||||
return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
||||||
if held.eligibleForInternalACME(hello.ServerName) {
|
|
||||||
if hello.Conn != nil && held.hiddenFrom(hello.ServerName, hello.Conn.RemoteAddr().String()) {
|
|
||||||
return nil, fmt.Errorf("no public route for %q in this mesh, so no certificate is asked for",
|
|
||||||
hello.ServerName)
|
|
||||||
}
|
|
||||||
if fromInternal != nil {
|
|
||||||
return fromInternal(hello)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fromPublic(hello)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTable is an empty routing table.
|
// newTable is an empty routing table.
|
||||||
func newTable() *table {
|
func newTable() *table {
|
||||||
return &table{to: map[string][]rule{}}
|
return &table{to: map[string][]rule{}}
|
||||||
@@ -722,9 +600,8 @@ func newTable() *table {
|
|||||||
// handler is the proxy itself, separated so it can be driven by a test without a listener.
|
// handler is the proxy itself, separated so it can be driven by a test without a listener.
|
||||||
func handler(held *table) http.Handler {
|
func handler(held *table) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
hidden := held.hiddenFrom(r.Host, r.RemoteAddr)
|
|
||||||
matched, known := held.find(r.Host, r.URL.Path)
|
matched, known := held.find(r.Host, r.URL.Path)
|
||||||
if hidden || !known {
|
if !known {
|
||||||
// **Named, not a bare 404.** A route that was withdrawn and a name that never existed
|
// **Named, not a bare 404.** A route that was withdrawn and a name that never existed
|
||||||
// are different things, and a proxy that says only "not found" makes an operator go
|
// are different things, and a proxy that says only "not found" makes an operator go
|
||||||
// and read the mesh to tell them apart. What it is serving is the answer to both.
|
// and read the mesh to tell them apart. What it is serving is the answer to both.
|
||||||
@@ -732,20 +609,15 @@ func handler(held *table) http.Handler {
|
|||||||
// And since a host may now be routed only on some paths, those are a third thing:
|
// And since a host may now be routed only on some paths, those are a third thing:
|
||||||
// saying "no route for this name" while listing that very name as served is a
|
// saying "no route for this name" while listing that very name as served is a
|
||||||
// contradiction an operator would have to disbelieve the proxy to get past.
|
// contradiction an operator would have to disbelieve the proxy to get past.
|
||||||
// **Said in the log as well as to the client.** A name this mesh does not serve, asked
|
|
||||||
// for from outside, is what a scanner does, and the machine's intrusion prevention reads
|
|
||||||
// this proxy's log for exactly that line (novox/hq ADR 0179): the address last, as the
|
|
||||||
// jail's filter expects it.
|
|
||||||
log.Printf("refused: no route for %q, asked from %s", r.Host, r.RemoteAddr)
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
if !hidden && held.routed(r.Host) {
|
if held.routed(r.Host) {
|
||||||
fmt.Fprintf(w, "%s is served here, but no route covers %q.\n",
|
fmt.Fprintf(w, "%s is served here, but no route covers %q.\n",
|
||||||
bareHost(r.Host), r.URL.Path)
|
bareHost(r.Host), r.URL.Path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "no route for %q in this mesh.\nserving: %s\n",
|
fmt.Fprintf(w, "no route for %q in this mesh.\nserving: %s\n",
|
||||||
r.Host, strings.Join(held.namesSeenFrom(r.RemoteAddr), ", "))
|
r.Host, strings.Join(held.names(), ", "))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,12 +716,6 @@ func boolByte(b bool) byte {
|
|||||||
// routesFrom reads what the mesh wrote and turns it into host → the rules for that host, and
|
// routesFrom reads what the mesh wrote and turns it into host → the rules for that host, and
|
||||||
// which of those hosts is a public name — the second is `name`, ACME-eligible; a host reached
|
// which of those hosts is a public name — the second is `name`, ACME-eligible; a host reached
|
||||||
// only through `internal-name` never appears there.
|
// only through `internal-name` never appears there.
|
||||||
//
|
|
||||||
// **A route may carry either name, or both** (novox/hq ADR 0138). How far an endpoint reaches
|
|
||||||
// decides which names the mesh composes, so an endpoint that reaches only the private network
|
|
||||||
// arrives with an `internal-name` and no `name`. That is a whole route, not a malformed one: it is
|
|
||||||
// served under its internal name and certified by the internal authority. Only a route with
|
|
||||||
// neither name has nothing to be served under (novox/hq issue 191).
|
|
||||||
func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -859,29 +725,17 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
|||||||
if err := json.Unmarshal(raw, &said); err != nil {
|
if err := json.Unmarshal(raw, &said); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
routes, public := routesOf(said.Given)
|
|
||||||
return routes, public, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// routesOf turns what the mesh gave into host → the rules for that host, and which hosts are public
|
|
||||||
// names — the same whether the contributions came in the file or in the membership.
|
|
||||||
func routesOf(contributions []contribution) (map[string][]rule, map[string]bool) {
|
|
||||||
out := map[string][]rule{}
|
out := map[string][]rule{}
|
||||||
public := map[string]bool{}
|
public := map[string]bool{}
|
||||||
for _, c := range contributions {
|
for _, c := range said.Given {
|
||||||
name, _ := c.Values["name"].(string)
|
name, _ := c.Values["name"].(string)
|
||||||
name = strings.TrimSpace(name)
|
if name == "" {
|
||||||
internal, _ := c.Values["internal-name"].(string)
|
|
||||||
internal = strings.TrimSpace(internal)
|
|
||||||
if name == "" && internal == "" {
|
|
||||||
log.Printf("%s on %s asked for a route and named nothing; skipped", c.From, c.Node)
|
log.Printf("%s on %s asked for a route and named nothing; skipped", c.From, c.Node)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// What the route is called in a log line: its public name when it has one.
|
host := strings.ToLower(name)
|
||||||
called := name
|
public[host] = true
|
||||||
if called == "" {
|
|
||||||
called = internal
|
|
||||||
}
|
|
||||||
|
|
||||||
made := rule{path: asPath(c.Values["path"])}
|
made := rule{path: asPath(c.Values["path"])}
|
||||||
if p, ok := asWhole(c.Values["priority"]); ok {
|
if p, ok := asWhole(c.Values["priority"]); ok {
|
||||||
@@ -898,7 +752,7 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
|
|||||||
if looksLikeACredential(named) {
|
if looksLikeACredential(named) {
|
||||||
log.Printf("%s on %s declared route %q with a credential in the declaration rather "+
|
log.Printf("%s on %s declared route %q with a credential in the declaration rather "+
|
||||||
"than the name of a secret; the whole route is refused (novox/hq ADR 0108)",
|
"than the name of a secret; the whole route is refused (novox/hq ADR 0108)",
|
||||||
c.From, c.Node, called)
|
c.From, c.Node, name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
users, err := usersFrom(named)
|
users, err := usersFrom(named)
|
||||||
@@ -916,7 +770,7 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
|
|||||||
port, ok := asPort(c.Values["port"])
|
port, ok := asPort(c.Values["port"])
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Printf("%s on %s asked for route %q and gave no usable port; skipped",
|
log.Printf("%s on %s asked for route %q and gave no usable port; skipped",
|
||||||
c.From, c.Node, called)
|
c.From, c.Node, name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Where the mesh says that machine is. Empty means it is this one — a workload beside
|
// Where the mesh says that machine is. Empty means it is this one — a workload beside
|
||||||
@@ -937,7 +791,7 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
|
|||||||
}
|
}
|
||||||
if scheme != "http" && scheme != "https" {
|
if scheme != "http" && scheme != "https" {
|
||||||
log.Printf("%s on %s asked for route %q with scheme %q, which is neither http "+
|
log.Printf("%s on %s asked for route %q with scheme %q, which is neither http "+
|
||||||
"nor https; skipped", c.From, c.Node, called, scheme)
|
"nor https; skipped", c.From, c.Node, name, scheme)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
made.insecure, _ = c.Values["insecure"].(bool)
|
made.insecure, _ = c.Values["insecure"].(bool)
|
||||||
@@ -948,7 +802,7 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
|
|||||||
bytes, whole := asWhole(asked)
|
bytes, whole := asWhole(asked)
|
||||||
if !whole || bytes <= 0 {
|
if !whole || bytes <= 0 {
|
||||||
log.Printf("%s on %s asked for route %q with a max-request-body of %v, which is "+
|
log.Printf("%s on %s asked for route %q with a max-request-body of %v, which is "+
|
||||||
"not a whole positive number of bytes; skipped", c.From, c.Node, called, asked)
|
"not a whole positive number of bytes; skipped", c.From, c.Node, name, asked)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
made.maxRequestBody = int64(bytes)
|
made.maxRequestBody = int64(bytes)
|
||||||
@@ -956,24 +810,19 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
|
|||||||
made.target = fmt.Sprintf("%s://%s:%d", scheme, at, port)
|
made.target = fmt.Sprintf("%s://%s:%d", scheme, at, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
if name != "" {
|
out[host] = append(out[host], made)
|
||||||
host := strings.ToLower(name)
|
|
||||||
out[host] = append(out[host], made)
|
|
||||||
public[host] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// The internal-network name, the same rule under a second host — a predecessor proxy
|
// The internal-network alias, the same rule under a second host — a predecessor proxy
|
||||||
// answered both for one route, as a convenience (reaching a service over the VPN without a
|
// answered both for one route, as a convenience (reaching a service over the VPN without a
|
||||||
// public TLS round trip), not as an access boundary; composing it here restores exactly
|
// public TLS round trip), not as an access boundary; composing it here restores exactly
|
||||||
// that, nothing more. Absent whenever the node composed no internal name (novox/hq ADR
|
// that, nothing more. Absent whenever the node composed no internal name (novox/hq ADR
|
||||||
// 0056's internalDomain half) — the same "nothing to join a label to" case the public name
|
// 0056's internalDomain half) — the same "nothing to join a label to" case the public name
|
||||||
// already has. And the only name, when the endpoint reaches no further than the private
|
// already has.
|
||||||
// network.
|
if internal, _ := c.Values["internal-name"].(string); strings.TrimSpace(internal) != "" {
|
||||||
if internal != "" {
|
|
||||||
out[strings.ToLower(internal)] = append(out[strings.ToLower(internal)], made)
|
out[strings.ToLower(internal)] = append(out[strings.ToLower(internal)], made)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, public
|
return out, public, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// asWhole is any whole number the mesh wrote, whatever its magnitude.
|
// asWhole is any whole number the mesh wrote, whatever its magnitude.
|
||||||
@@ -1060,17 +909,3 @@ func asPort(v any) (int, bool) {
|
|||||||
}
|
}
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// towards proxies to one backend and tells it what the client asked: **X-Forwarded-Proto, -Host and
|
|
||||||
// -For**, set from the request this proxy received. A backend that builds its own addresses — a forge
|
|
||||||
// writing its clone URL into a page, a login redirect — otherwise sees the plain HTTP hop from this
|
|
||||||
// proxy and writes `http://`, though every client reached it over TLS: Go refused the forge's module
|
|
||||||
// path for exactly that on 2026-10-03, its import tag naming an http clone URL.
|
|
||||||
// The standard library's NewSingleHostReverseProxy sets only X-Forwarded-For.
|
|
||||||
func towards(where *url.URL) *httputil.ReverseProxy {
|
|
||||||
return &httputil.ReverseProxy{Rewrite: func(pr *httputil.ProxyRequest) {
|
|
||||||
pr.SetURL(where)
|
|
||||||
pr.Out.Host = pr.In.Host
|
|
||||||
pr.SetXForwarded()
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/tls"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// behind is a workload the proxy can send to, and a table routing one public name and one
|
|
||||||
// internal-only name to it, with the mesh's machines as the membership would issue them.
|
|
||||||
func behind(t *testing.T, mesh ...string) *table {
|
|
||||||
t.Helper()
|
|
||||||
workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
io.WriteString(w, "the workload")
|
|
||||||
}))
|
|
||||||
t.Cleanup(workload.Close)
|
|
||||||
at, _ := url.Parse(workload.URL)
|
|
||||||
host, port, _ := net.SplitHostPort(at.Host)
|
|
||||||
|
|
||||||
routes, public, err := routesFrom(write(t, fmt.Sprintf(`{"given":[
|
|
||||||
{"from":"app","node":"anchor","at":%q,
|
|
||||||
"values":{"name":"app.example","internal-name":"app.anchor.internal","port":%s}},
|
|
||||||
{"from":"admin","node":"anchor","at":%q,
|
|
||||||
"values":{"internal-name":"admin.anchor.internal","port":%s}}
|
|
||||||
]}`, host, port, host, port)))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
held := newTable()
|
|
||||||
inside, err := sourcesOf(mesh)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
held.setInside(inside)
|
|
||||||
held.set(routes, public)
|
|
||||||
return held
|
|
||||||
}
|
|
||||||
|
|
||||||
// askFrom is what the proxy answers a request for host coming from remote.
|
|
||||||
func askFrom(held *table, host, remote string) (int, string) {
|
|
||||||
r := httptest.NewRequest(http.MethodGet, "http://"+host+"/", nil)
|
|
||||||
r.RemoteAddr = remote
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
handler(held).ServeHTTP(w, r)
|
|
||||||
return w.Code, w.Body.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// **An internal-only name is served to the private network and to nobody else** (novox/hq ADR
|
|
||||||
// 0138, issue 191). The proxy answers public names on the same listeners, so without this a name
|
|
||||||
// being internal kept nobody out: a request from the internet only had to carry it.
|
|
||||||
func TestAnInternalOnlyNameIsServedOnlyInsideThePrivateNetwork(t *testing.T) {
|
|
||||||
held := behind(t, "10.10.0.1", "10.10.0.7")
|
|
||||||
|
|
||||||
if code, body := askFrom(held, "admin.anchor.internal", "10.10.0.7:51000"); code != http.StatusOK ||
|
|
||||||
body != "the workload" {
|
|
||||||
t.Errorf("a request from the private network was not served: %d %q", code, body)
|
|
||||||
}
|
|
||||||
if code, body := askFrom(held, "admin.anchor.internal", "127.0.0.1:51000"); code != http.StatusOK {
|
|
||||||
t.Errorf("a request from the machine itself was not served: %d %q", code, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
code, body := askFrom(held, "admin.anchor.internal", "203.0.113.9:51000")
|
|
||||||
if code != http.StatusNotFound {
|
|
||||||
t.Fatalf("a request from outside the private network reached an internal-only name: %d %q",
|
|
||||||
code, body)
|
|
||||||
}
|
|
||||||
// Answered as a name never routed, and the list of what is served does not name it either —
|
|
||||||
// otherwise the refusal would tell an outsider exactly what to ask for from inside.
|
|
||||||
if strings.Contains(strings.SplitN(body, "\n", 2)[1], "admin.anchor.internal") {
|
|
||||||
t.Errorf("the refusal names the internal-only route to an outsider: %q", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "app.example") {
|
|
||||||
t.Errorf("the refusal stopped listing the public names: %q", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The internal name of a route that also has a public one is internal too: served inside, and to
|
|
||||||
// an outsider only under the public name. Nothing is lost — the outsider has the public name — and a
|
|
||||||
// name stays one thing whichever route it came from.
|
|
||||||
func TestAnInternalAliasOfAPublicRouteIsServedInsideOnly(t *testing.T) {
|
|
||||||
held := behind(t, "10.10.0.1", "10.10.0.7")
|
|
||||||
if code, body := askFrom(held, "app.anchor.internal", "10.10.0.7:51000"); code != http.StatusOK {
|
|
||||||
t.Errorf("the internal alias stopped answering the private network: %d %q", code, body)
|
|
||||||
}
|
|
||||||
if code, _ := askFrom(held, "app.anchor.internal", "203.0.113.9:51000"); code != http.StatusNotFound {
|
|
||||||
t.Errorf("the internal alias was served to an outsider: %d", code)
|
|
||||||
}
|
|
||||||
if code, _ := askFrom(held, "app.example", "203.0.113.9:51000"); code != http.StatusOK {
|
|
||||||
t.Errorf("the public name was refused to an outsider: %d", code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Before a membership has said who the mesh is, only the machine itself is inside — refused to
|
|
||||||
// everyone else, never served to everyone.
|
|
||||||
func TestUntilTheMeshIsIssuedAnInternalOnlyNameIsServedToTheMachineAlone(t *testing.T) {
|
|
||||||
held := behind(t)
|
|
||||||
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:51000"); code != http.StatusNotFound {
|
|
||||||
t.Errorf("an internal-only name was served with no private network said: %d", code)
|
|
||||||
}
|
|
||||||
if code, _ := askFrom(held, "admin.anchor.internal", "[::1]:51000"); code != http.StatusOK {
|
|
||||||
t.Errorf("an internal-only name was refused to the machine itself: %d", code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type from struct {
|
|
||||||
net.Conn
|
|
||||||
remote net.Addr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c from) RemoteAddr() net.Addr { return c.remote }
|
|
||||||
|
|
||||||
// The handshake refuses an internal-only name to an outsider too: the certificate would name it,
|
|
||||||
// and serving it would answer the question the routing refuses to.
|
|
||||||
func TestTheHandshakeRefusesAnInternalOnlyNameToAnOutsider(t *testing.T) {
|
|
||||||
held := behind(t, "10.10.0.1", "10.10.0.7")
|
|
||||||
served := &tls.Certificate{}
|
|
||||||
pick := certificateFor(held, func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return served, nil }, nil)
|
|
||||||
hello := func(name, remote string) *tls.ClientHelloInfo {
|
|
||||||
addr, _ := net.ResolveTCPAddr("tcp", remote)
|
|
||||||
return &tls.ClientHelloInfo{ServerName: name, Conn: from{remote: addr}}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := pick(hello("admin.anchor.internal", "203.0.113.9:443")); err == nil {
|
|
||||||
t.Error("an outsider was handed a certificate for an internal-only name")
|
|
||||||
}
|
|
||||||
if got, err := pick(hello("admin.anchor.internal", "10.10.0.7:443")); err != nil || got != served {
|
|
||||||
t.Errorf("a client on the private network was refused: %v", err)
|
|
||||||
}
|
|
||||||
if got, err := pick(hello("app.example", "203.0.113.9:443")); err != nil || got != served {
|
|
||||||
t.Errorf("a public name was refused to an outsider: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The mesh is issued as machines' addresses; a range is read as well. One that does not parse is
|
|
||||||
// refused rather than skipped, so a typo never quietly narrows or widens who is inside.
|
|
||||||
func TestTheMeshIsReadAsAddressesAndRanges(t *testing.T) {
|
|
||||||
if _, err := sourcesOf([]string{"10.10.0.1", "not-an-address"}); err == nil {
|
|
||||||
t.Error("an entry that is not an address was accepted")
|
|
||||||
}
|
|
||||||
inside, err := sourcesOf([]string{"10.10.0.1", "fd00::1", "10.20.0.0/24"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for remote, want := range map[string]bool{
|
|
||||||
"10.10.0.1:1": true,
|
|
||||||
"[::ffff:10.10.0.1]:1": true,
|
|
||||||
"[fd00::1]:1": true,
|
|
||||||
"10.20.0.200:1": true,
|
|
||||||
"10.10.0.2:1": false,
|
|
||||||
"192.168.1.10:1": false,
|
|
||||||
"not-an-address": false,
|
|
||||||
} {
|
|
||||||
if inside.holds(remote) != want {
|
|
||||||
t.Errorf("%s inside the mesh: got %v, want %v", remote, !want, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the mesh issues is what is served: the routes in the membership, internal names to the
|
|
||||||
// machines it names (novox/hq ADR 0167).
|
|
||||||
func TestAMembershipIsServedAsIssued(t *testing.T) {
|
|
||||||
held := newTable()
|
|
||||||
took := applyMembership(broker.Membership{
|
|
||||||
Receives: map[string]json.RawMessage{"route": json.RawMessage(`[
|
|
||||||
{"from":"admin","node":"anchor","at":"anchor.internal",
|
|
||||||
"values":{"internal-name":"admin.anchor.internal","port":8080}}]`)},
|
|
||||||
Mesh: []string{"10.10.0.7"},
|
|
||||||
}, held)
|
|
||||||
if !took {
|
|
||||||
t.Fatal("a membership carrying routes was not applied")
|
|
||||||
}
|
|
||||||
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code == http.StatusNotFound {
|
|
||||||
t.Error("a machine the membership names was refused the internal-only route")
|
|
||||||
}
|
|
||||||
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.9:1"); code != http.StatusNotFound {
|
|
||||||
t.Errorf("a machine the membership does not name was served the internal-only route: %d", code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A membership that says nothing about routes is one from a controller that does not issue them,
|
|
||||||
// and changes nothing: the file stays the source rather than every route being withdrawn.
|
|
||||||
func TestAMembershipWithoutRoutesLeavesTheFileServing(t *testing.T) {
|
|
||||||
held := behind(t, "10.10.0.7")
|
|
||||||
before := held.names()
|
|
||||||
if applyMembership(broker.Membership{Mesh: []string{"10.10.0.7"}}, held) {
|
|
||||||
t.Error("a membership without routes was taken as the source of routes")
|
|
||||||
}
|
|
||||||
if got := held.names(); strings.Join(got, ",") != strings.Join(before, ",") {
|
|
||||||
t.Errorf("a membership without routes changed what is served: %v, was %v", got, before)
|
|
||||||
}
|
|
||||||
if applyMembership(broker.Membership{
|
|
||||||
Receives: map[string]json.RawMessage{"route": json.RawMessage(`[]`)},
|
|
||||||
Mesh: []string{"not-an-address"},
|
|
||||||
}, held) {
|
|
||||||
t.Error("a membership whose mesh cannot be read was applied")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -90,50 +90,6 @@ func TestARouteWithAnInternalNameIsReachableUnderBoth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A route whose endpoint reaches only the private network carries an internal name and no public
|
|
||||||
// one (novox/hq ADR 0138), and is served under that name rather than skipped as naming nothing —
|
|
||||||
// skipping it left every internal-only module unreachable by name (novox/hq issue 191).
|
|
||||||
func TestARouteWithOnlyAnInternalNameIsServed(t *testing.T) {
|
|
||||||
routes, public, err := routesFrom(write(t, `{"given":[
|
|
||||||
{"from":"app","node":"anchor","at":"anchor.internal",
|
|
||||||
"values":{"internal-name":"App.Anchor.Internal","port":8443,"scheme":"https","insecure":true}}
|
|
||||||
]}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if targetOf(routes, "app.anchor.internal") != "https://anchor.internal:8443" {
|
|
||||||
t.Fatalf("the internal-only route is not served: %v", routes)
|
|
||||||
}
|
|
||||||
if len(routes) != 1 {
|
|
||||||
t.Errorf("an internal-only route made hosts it never named: %v", routes)
|
|
||||||
}
|
|
||||||
if len(public) != 0 {
|
|
||||||
t.Errorf("an internal-only route made a name eligible for a public certificate: %v", public)
|
|
||||||
}
|
|
||||||
|
|
||||||
held := newTable()
|
|
||||||
held.set(routes, public)
|
|
||||||
if err := onlyInternalNamesTheMeshSaid(held)(context.Background(), "app.anchor.internal"); err != nil {
|
|
||||||
t.Errorf("the internal authority refused the internal-only route's name: %v", err)
|
|
||||||
}
|
|
||||||
if err := onlyWhatTheMeshSaid(held)(context.Background(), "app.anchor.internal"); err == nil {
|
|
||||||
t.Error("a public certificate was ordered for an internal-only name")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A route with neither name has nothing to be served under, and is still skipped.
|
|
||||||
func TestARouteWithNeitherNameIsSkipped(t *testing.T) {
|
|
||||||
routes, public, err := routesFrom(write(t, `{"given":[
|
|
||||||
{"from":"app","node":"anchor","at":"anchor.internal","values":{"internal-name":" ","port":8080}}
|
|
||||||
]}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(routes) != 0 || len(public) != 0 {
|
|
||||||
t.Errorf("a route that named nothing was served: %v %v", routes, public)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A route with no internal-name composed gets no second host — the ordinary case, unchanged.
|
// A route with no internal-name composed gets no second host — the ordinary case, unchanged.
|
||||||
func TestARouteWithNoInternalNameGetsNoAlias(t *testing.T) {
|
func TestARouteWithNoInternalNameGetsNoAlias(t *testing.T) {
|
||||||
routes, _, err := routesFrom(write(t, `{"given":[
|
routes, _, err := routesFrom(write(t, `{"given":[
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -234,13 +234,3 @@ func admitsSubject(pattern, subject []string) bool {
|
|||||||
}
|
}
|
||||||
return len(pattern) == len(subject)
|
return len(pattern) == len(subject)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The two packages name the runtime module separately — the broker's types stay free of the
|
|
||||||
// catalogue's on purpose — so this is what holds them to one string. A rename that reached only one
|
|
||||||
// side would compose a runtime principal for a module nobody assigns, silently, and leave the one
|
|
||||||
// that is assigned with a module's own grants.
|
|
||||||
func TestTheBrokerAndTheCatalogueAgreeOnTheRuntimeModule(t *testing.T) {
|
|
||||||
if RuntimeModule != catalogue.RuntimeModule {
|
|
||||||
t.Fatalf("the broker calls the runtime %q and the catalogue %q", RuntimeModule, catalogue.RuntimeModule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+15
-16
@@ -144,18 +144,12 @@ func ConsumerFor(p Principal) (Consumer, bool) {
|
|||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// HolderConsumerFor is the worker a seat's holders share on that seat's work queue.
|
// HolderConsumerFor is the worker a seat's holder gets on that seat's work queue.
|
||||||
//
|
//
|
||||||
// **One worker for every holder, and each holder pulls one ask when it is idle** (novox/hq ADR
|
// **A queue group even though the seat guarantees one holder.** The seat is *authority* — who may
|
||||||
// 0190). The seat is *authority* — who may be the telegram sender — and the worker is *delivery*,
|
// be the telegram sender — and the queue group is *delivery*. Tie delivery to the seat and the
|
||||||
// kept separate so that relaxing one changes nothing about the other: a node-scoped seat has a
|
// day somebody allows two holders for throughput, every message is processed twice with nothing
|
||||||
// holder per machine, and all of them take from this one consumer, so the work is shared without
|
// reporting it. Kept separate, relaxing one changes nothing about the other.
|
||||||
// any holder knowing about the others. Pulled rather than pushed because a push consumer hands the
|
|
||||||
// next ask to whichever subscriber the server picks, busy or not, and a pulled one is asked for by
|
|
||||||
// a holder that has just become free. Which is also what ends the race issue 186 describes — asks
|
|
||||||
// delivered behind the one being worked, expiring unacknowledged and dropped after the fifth
|
|
||||||
// redelivery: nothing is delivered that nobody asked for. A long build keeps its own ask alive
|
|
||||||
// (stillWorking); the ack wait is for a holder that died.
|
|
||||||
func HolderConsumerFor(node, module string, seat DeclaredSeat) (Consumer, bool) {
|
func HolderConsumerFor(node, module string, seat DeclaredSeat) (Consumer, bool) {
|
||||||
if len(seat.Accepts) == 0 {
|
if len(seat.Accepts) == 0 {
|
||||||
return Consumer{}, false
|
return Consumer{}, false
|
||||||
@@ -164,13 +158,18 @@ func HolderConsumerFor(node, module string, seat DeclaredSeat) (Consumer, bool)
|
|||||||
Name: "SEAT_" + upperSnake(seat.Name) + "_worker",
|
Name: "SEAT_" + upperSnake(seat.Name) + "_worker",
|
||||||
Stream: seatStreamName(seat.Name),
|
Stream: seatStreamName(seat.Name),
|
||||||
Filters: []string{"mesh.seat." + seat.Name + ".accept.>"},
|
Filters: []string{"mesh.seat." + seat.Name + ".accept.>"},
|
||||||
|
Queue: "holders",
|
||||||
AckWaitSeconds: 60,
|
AckWaitSeconds: 60,
|
||||||
MaxDeliver: 5,
|
MaxDeliver: 5,
|
||||||
// As many in flight as there are holders working, which pulling bounds by itself: a holder
|
// **One in flight.** A holder works one ask at a time, so the server hands it one at a
|
||||||
// fetches one and fetches again only after it acknowledged. The server's default stands.
|
// time: with the default of many, every ask behind the one being worked was delivered,
|
||||||
Why: fmt.Sprintf("%s on %s holds %s; every holder pulls one ask at a time from this worker "+
|
// left unacknowledged for the length of the work, redelivered after the ack wait, and
|
||||||
"and acknowledges after the work is done, so a crash mid-work redelivers rather than "+
|
// after the fifth time dropped — on 2026-10-01 twenty-six of forty-three builds asked in
|
||||||
"loses and an idle holder is the one that takes the next ask", module, node, seat.Name),
|
// two minutes were never built, and the queue read as empty (novox/hq issue 186).
|
||||||
|
MaxAckPending: 1,
|
||||||
|
Why: fmt.Sprintf("%s on %s holds %s; it acknowledges after the work is done, so a "+
|
||||||
|
"crash mid-work redelivers rather than loses; one in flight, so a queue of asks is a "+
|
||||||
|
"queue and not a race against the ack wait", module, node, seat.Name),
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,20 +88,15 @@ func TestAModuleThatConsumesNothingGetsNoConsumer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The seat is authority and the worker is delivery (novox/hq ADR 0190): one worker per seat, shared
|
// The seat is authority and the queue group is delivery. Tie them together and the day somebody
|
||||||
// by every holder and pulled from, so a second holder takes the next ask rather than a copy of the
|
// allows two holders, every message is processed twice with nothing reporting it.
|
||||||
// same one — which is what a queue group used to guard, and what pulling one durable gives outright.
|
func TestAHoldersWorkerUsesAQueueGroupAnyway(t *testing.T) {
|
||||||
func TestAHoldersWorkerIsOneSharedByItsHolders(t *testing.T) {
|
|
||||||
c, ok := HolderConsumerFor("one", "telegram", telegramSeat())
|
c, ok := HolderConsumerFor("one", "telegram", telegramSeat())
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("the holder of a seat with inbound work got no worker")
|
t.Fatal("the holder of a seat with inbound work got no worker")
|
||||||
}
|
}
|
||||||
two, _ := HolderConsumerFor("two", "telegram", telegramSeat())
|
if c.Queue == "" {
|
||||||
if c.Name != two.Name || c.Stream != two.Stream {
|
t.Fatal("the worker is not in a queue group, so a second holder would double-process")
|
||||||
t.Fatal("two holders got two workers, so each would process every ask")
|
|
||||||
}
|
|
||||||
if c.Push || c.Queue != "" {
|
|
||||||
t.Fatal("the worker is pushed, so the server would hand an ask to a busy holder")
|
|
||||||
}
|
}
|
||||||
if c.Stream != "SEAT_TELEGRAM_SENDER" {
|
if c.Stream != "SEAT_TELEGRAM_SENDER" {
|
||||||
t.Fatalf("the worker reads %q, not the seat's own stream", c.Stream)
|
t.Fatalf("the worker reads %q, not the seat's own stream", c.Stream)
|
||||||
@@ -159,23 +154,15 @@ func TestANodesDeclarationConsumerIsWhatItsOwnGrantAllows(t *testing.T) {
|
|||||||
has(t, perms.Subscribe, c.Filters[0])
|
has(t, perms.Subscribe, c.Filters[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every holder of a seat shares one worker and pulls from it (novox/hq ADR 0190): no queue group
|
// A holder works one ask at a time, so the server hands it one at a time (novox/hq issue 186):
|
||||||
// and no delivery subject, because a push consumer hands the next ask to whichever subscriber the
|
// asks queued behind the one being worked wait in the stream rather than being delivered,
|
||||||
// server picks, busy or not; and no cap of one in flight, because pulling bounds the asks in flight
|
// left to expire and dropped after the fifth redelivery.
|
||||||
// by the holders that are free — which is what ended the race of issue 186, where asks delivered
|
func TestAHoldersWorkerTakesOneAskAtATime(t *testing.T) {
|
||||||
// behind the one being worked expired and were dropped.
|
c, found := HolderConsumerFor("anchor", "builder", DeclaredSeat{Name: "mesh-build-machine", Accepts: []string{"build"}})
|
||||||
func TestAHoldersWorkerIsPulledByEveryHolder(t *testing.T) {
|
|
||||||
c, found := HolderConsumerFor("anchor", "build-agent", DeclaredSeat{Name: "node-build-agent", Accepts: []string{"build"}})
|
|
||||||
if !found {
|
if !found {
|
||||||
t.Fatal("a seat that accepts work has no worker")
|
t.Fatal("a seat that accepts work has no worker")
|
||||||
}
|
}
|
||||||
if c.Queue != "" || c.Push {
|
if c.MaxAckPending != 1 {
|
||||||
t.Fatalf("the worker is pushed (queue %q, push %v); a holder pulls when it is free", c.Queue, c.Push)
|
t.Fatalf("the worker may have %d asks in flight; one, so a queue is a queue", c.MaxAckPending)
|
||||||
}
|
|
||||||
if c.MaxAckPending != 0 {
|
|
||||||
t.Fatalf("the worker caps asks in flight at %d; pulling bounds them by the holders working", c.MaxAckPending)
|
|
||||||
}
|
|
||||||
if c.Name != "SEAT_NODE_BUILD_AGENT_worker" || c.Stream != "SEAT_NODE_BUILD_AGENT" {
|
|
||||||
t.Fatalf("the worker is %s on %s; one per seat, shared by its holders", c.Name, c.Stream)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package broker
|
package broker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
@@ -13,7 +12,6 @@ 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
|
||||||
@@ -86,13 +84,6 @@ 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) {
|
||||||
@@ -223,51 +214,6 @@ func (j *JetStream) EnsureConsumer(c Consumer) error {
|
|||||||
|
|
||||||
switch have, err := j.js.ConsumerInfo(c.Stream, c.Name); {
|
switch have, err := j.js.ConsumerInfo(c.Stream, c.Name); {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
// **The controller owns the worker's shape, type included** (novox/hq issue 206). A holder
|
|
||||||
// built for a pull worker cannot bind a push one — `cannot pull subscribe to push based
|
|
||||||
// consumer` — and on 2026-10-03 the build machine rolled before the controller that would
|
|
||||||
// have redefined its worker, restarted on that for an hour, and nothing could build the
|
|
||||||
// controller that would have ended it. The server cannot change a consumer's type in place,
|
|
||||||
// so one of the wrong type is re-made: on a work queue nothing is lost, because what was
|
|
||||||
// acknowledged is gone from the stream and what was not is delivered again from the start.
|
|
||||||
// On any other stream a re-made consumer would replay what this one acknowledged (issue
|
|
||||||
// 156), so there it is said and left, and the person re-makes it knowing the cost.
|
|
||||||
if havePush, wantPush := have.Config.DeliverSubject != "", want.DeliverSubject != ""; havePush != wantPush {
|
|
||||||
shape := func(push bool) string {
|
|
||||||
if push {
|
|
||||||
return "push"
|
|
||||||
}
|
|
||||||
return "pull"
|
|
||||||
}
|
|
||||||
info, err := j.js.StreamInfo(c.Stream)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("asking about stream %s to re-make consumer %s: %w", c.Stream, c.Name, err)
|
|
||||||
}
|
|
||||||
if info.Config.Retention != nats.WorkQueuePolicy {
|
|
||||||
// **A stream that keeps its history is re-made from now on, never from the start.**
|
|
||||||
// Left for a hand, the hand re-makes it with the server's default — everything the
|
|
||||||
// stream holds — which on 2026-10-03 replayed every build ask since 1 October and
|
|
||||||
// re-registered nine modules from the past (novox/hq issue 207). What this consumer
|
|
||||||
// had not yet acknowledged is lost with it, and said: on a history stream that is
|
|
||||||
// the smaller cost, and the asks in flight are visible to whoever asked.
|
|
||||||
j.note("consumer %s on %s changes from %s to %s delivery on a stream that keeps its history: "+
|
|
||||||
"re-made to deliver from now on, so nothing this one acknowledged comes back (novox/hq issue "+
|
|
||||||
"207); %d ask(s) it had not acknowledged are not carried over and must be asked again",
|
|
||||||
c.Name, c.Stream, shape(havePush), shape(wantPush), have.NumPending+uint64(have.NumAckPending))
|
|
||||||
want.DeliverPolicy = nats.DeliverNewPolicy
|
|
||||||
} else {
|
|
||||||
j.note("consumer %s on %s changes from %s to %s delivery: re-made where it left off, nothing "+
|
|
||||||
"acknowledged comes back and nothing pending is lost (novox/hq issue 206); a holder bound to "+
|
|
||||||
"the old shape binds again", c.Name, c.Stream, shape(havePush), shape(wantPush))
|
|
||||||
}
|
|
||||||
if err := j.js.DeleteConsumer(c.Stream, c.Name); err != nil {
|
|
||||||
return fmt.Errorf("re-making consumer %s on %s as %s: %w", c.Name, c.Stream, shape(wantPush), err)
|
|
||||||
}
|
|
||||||
if _, err := j.js.AddConsumer(c.Stream, want); err != nil {
|
|
||||||
return fmt.Errorf("re-making consumer %s on %s as %s: %w", c.Name, c.Stream, shape(wantPush), err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Where an existing consumer starts is its history, not something an assertion may move:
|
// Where an existing consumer starts is its history, not something an assertion may move:
|
||||||
// the server refuses a changed deliver policy outright. Carried across, so asserting twice
|
// the server refuses a changed deliver policy outright. Carried across, so asserting twice
|
||||||
// is the no-op a restart depends on.
|
// is the no-op a restart depends on.
|
||||||
@@ -325,50 +271,3 @@ 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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package broker
|
package broker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -34,22 +33,6 @@ type Membership struct {
|
|||||||
Reaches map[string][]string `json:"reaches,omitempty"`
|
Reaches map[string][]string `json:"reaches,omitempty"`
|
||||||
// Tools is where this instance answers what it serves — the runtime's one verb of its own.
|
// Tools is where this instance answers what it serves — the runtime's one verb of its own.
|
||||||
Tools string `json:"tools"`
|
Tools string `json:"tools"`
|
||||||
// Receives is what this assignment is given for each requirement it receives, by requirement:
|
|
||||||
// the contributions of every module that asked for it, as the catalogue composed them (novox/hq
|
|
||||||
// ADR 0167). The same list its received file is written from, so the two cannot disagree; a
|
|
||||||
// requirement nobody contributed to is an empty list, never absent. Kept as JSON because the
|
|
||||||
// catalogue owns the shape of a contribution and the bus only carries it.
|
|
||||||
Receives map[string]json.RawMessage `json:"receives,omitempty"`
|
|
||||||
// Mesh is every machine's address on the private network — what a rule saying "from the mesh"
|
|
||||||
// resolves to in the packet filter, issued here from the same list (novox/hq ADR 0167). A
|
|
||||||
// module that must tell the mesh from the world, the route proxy serving an internal name, reads
|
|
||||||
// it here rather than keeping a definition of its own.
|
|
||||||
Mesh []string `json:"mesh,omitempty"`
|
|
||||||
// State is every bucket this module's code may reach, by the name it uses for each, and whether
|
|
||||||
// it may write it (novox/hq ADR 0201): the runtime answers a bundle's state verbs from this list
|
|
||||||
// and refuses, with the reason, what is not on it — the bus enforces only the union over every
|
|
||||||
// module on the machine.
|
|
||||||
State []StateIssued `json:"state,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Served is one address a tool is answered on.
|
// Served is one address a tool is answered on.
|
||||||
@@ -108,7 +91,6 @@ 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 {
|
||||||
|
|||||||
@@ -73,37 +73,3 @@ func TestAnAccountMayReadItsOwnMembershipAndNoOthers(t *testing.T) {
|
|||||||
has(t, perms.Publish, "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.anchor.postgres")
|
has(t, perms.Publish, "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.anchor.postgres")
|
||||||
hasNot(t, perms.Subscribe, "mesh.assignment.>")
|
hasNot(t, perms.Subscribe, "mesh.assignment.>")
|
||||||
}
|
}
|
||||||
|
|
||||||
// The runtime arriving on a machine changes nothing about what each module is issued (to-be 38 WP2):
|
|
||||||
// the memberships are composed as before and the runtime reads several of them. What the machine's
|
|
||||||
// user list gains is one runtime principal, and loses nothing but the runtime module's own.
|
|
||||||
func TestTheRuntimeArrivingLeavesEveryMembershipAsItWas(t *testing.T) {
|
|
||||||
filter := Seat{Name: "node-packet-filter", Scope: "node", Serves: []string{"rules", "reload"}}
|
|
||||||
three := []Declared{
|
|
||||||
{Module: "nftables", Holds: []Seat{filter}, Serves: []string{"firewall_rules"}},
|
|
||||||
{Module: "zsh", Serves: []string{"execute"}},
|
|
||||||
{Module: "systemd", Serves: []string{"units"}},
|
|
||||||
}
|
|
||||||
before := Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": three}}
|
|
||||||
after := Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{
|
|
||||||
"anchor": append(append([]Declared{}, three...), Declared{Module: RuntimeModule}),
|
|
||||||
}}
|
|
||||||
for _, d := range three {
|
|
||||||
was := MembershipFor("anchor", d, PlacementsOf(before, nil))
|
|
||||||
is := MembershipFor("anchor", d, PlacementsOf(after, nil))
|
|
||||||
if !reflect.DeepEqual(was, is) {
|
|
||||||
t.Errorf("%s's membership changed when the runtime arrived:\n%+v\n%+v", d.Module, was, is)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
users, err := Users(after)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
kinds := map[Kind]int{}
|
|
||||||
for _, p := range users {
|
|
||||||
kinds[p.Kind]++
|
|
||||||
}
|
|
||||||
if kinds[KindNodeTools] != 1 || kinds[KindModule] != 3 || kinds[KindNode] != 1 || kinds[KindController] != 1 {
|
|
||||||
t.Errorf("the machine's users are %v; one runtime, the three modules, the host and the controller", kinds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+10
-182
@@ -34,20 +34,8 @@ const (
|
|||||||
// authority is a list of tools and nothing else — not control, not declarations, not builds,
|
// authority is a list of tools and nothing else — not control, not declarations, not builds,
|
||||||
// and no ability to answer anything, because a person asks.
|
// and no ability to answer anything, because a person asks.
|
||||||
KindPerson Kind = "person"
|
KindPerson Kind = "person"
|
||||||
// KindNodeTools is a machine's tool runtime (novox/hq ADR 0175, to-be 38): one process per
|
|
||||||
// node, on the host side, serving every assigned module's tools and every held seat's verbs.
|
|
||||||
// Its authority is the union of what the modules it carries would each have had for their
|
|
||||||
// tools — and nothing of what they consume, because tools are what it runs, not reactions.
|
|
||||||
KindNodeTools Kind = "node-tools"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// RuntimeModule is the module that IS the node's tool runtime (novox/hq ADR 0175). Where it is
|
|
||||||
// assigned, the mesh composes one runtime principal for the machine in place of that module's own,
|
|
||||||
// and the per-module containers that served tools until then stop being the way tools reach a node.
|
|
||||||
// Mirrored in the catalogue package, which the agreement test holds to the same string; one
|
|
||||||
// constant, so a rename is one edit and the two packages cannot drift.
|
|
||||||
const RuntimeModule = "node-tools"
|
|
||||||
|
|
||||||
// Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it
|
// Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it
|
||||||
// emits (novox/hq ADR 0118, design 29 §5).
|
// emits (novox/hq ADR 0118, design 29 §5).
|
||||||
type Seat struct {
|
type Seat struct {
|
||||||
@@ -86,13 +74,6 @@ type Principal struct {
|
|||||||
// a namespace no such module owns. Every service started and the graph stayed empty.
|
// a namespace no such module owns. Every service started and the graph stayed empty.
|
||||||
Watches []Seat
|
Watches []Seat
|
||||||
|
|
||||||
// Carries are the modules whose tools this principal serves, for a KindNodeTools principal
|
|
||||||
// (novox/hq ADR 0175): every module assigned to its node, as each declares itself. Its
|
|
||||||
// serving authority is the union of theirs — each module's own tool namespace and each held
|
|
||||||
// seat's verbs on this node — derived from the same declarations the modules' own principals
|
|
||||||
// are, so the runtime can serve nothing a module could not have served for itself.
|
|
||||||
Carries []Declared
|
|
||||||
|
|
||||||
// Invokes are the tools this principal may call, as `<module>.<tool>`; a single `*` is every
|
// Invokes are the tools this principal may call, as `<module>.<tool>`; a single `*` is every
|
||||||
// tool. A person's whole authority (design 25 §7), and a module's only if its manifest says so
|
// tool. A person's whole authority (design 25 §7), and a module's only if its manifest says so
|
||||||
// (novox/hq ADR 0152) — the console's does, and nothing else's.
|
// (novox/hq ADR 0152) — the console's does, and nothing else's.
|
||||||
@@ -103,12 +84,6 @@ 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
|
||||||
@@ -116,13 +91,10 @@ type Principal struct {
|
|||||||
PasswordHash string
|
PasswordHash string
|
||||||
}
|
}
|
||||||
|
|
||||||
// seatsTheControllerAsks are the roles the mesh's own flows submit work to. Named rather than
|
// meshSeatsTheControllerUses are the roles the mesh's own flows submit work to. Named rather than
|
||||||
// derived from the seat set: the controller is not a module and declares no `uses`, so its side of a
|
// derived from the seat set: the controller is not a module and declares no `uses`, so its side of a
|
||||||
// seat has to be stated, and a list is what makes "which roles does the mesh itself talk to" answerable.
|
// seat has to be stated, and a list is what makes "which roles does the mesh itself talk to" answerable.
|
||||||
// Both build roles while the handover runs (novox/hq ADR 0190): the controller asks whichever has a
|
var meshSeatsTheControllerUses = []string{"mesh-build-machine"}
|
||||||
// holder, and the retired one has one until build-agent replaces the builder. The second entry
|
|
||||||
// goes with the retired seat row.
|
|
||||||
var seatsTheControllerAsks = []string{"node-build-agent", "mesh-build-machine"}
|
|
||||||
|
|
||||||
// enrolmentPrefix is the space every enrolling node's user and inbox live under, so the one place the
|
// enrolmentPrefix is the space every enrolling node's user and inbox live under, so the one place the
|
||||||
// controller may answer an enrolment is derived from the same constant the user is named from.
|
// controller may answer an enrolment is derived from the same constant the user is named from.
|
||||||
@@ -140,10 +112,7 @@ func (p Principal) Username() string {
|
|||||||
switch p.Kind {
|
switch p.Kind {
|
||||||
case KindPerson:
|
case KindPerson:
|
||||||
return "person." + p.Module
|
return "person." + p.Module
|
||||||
case KindModule, KindNodeTools:
|
case KindModule:
|
||||||
// The runtime is named exactly as the module it stands for would have been: the mesh
|
|
||||||
// issues its credential through the same path a module's takes (`module issue`), and
|
|
||||||
// that path knows the node and the module, not the kind.
|
|
||||||
return p.Node + "." + p.Module
|
return p.Node + "." + p.Module
|
||||||
case KindNode:
|
case KindNode:
|
||||||
return "node." + p.Node
|
return "node." + p.Node
|
||||||
@@ -217,9 +186,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
// Work the mesh's own flows submit to a role, and the outcomes they wait on (ADR 0121). A
|
// Work the mesh's own flows submit to a role, and the outcomes they wait on (ADR 0121). A
|
||||||
// build is the one today: the controller asks, and reads the answer from the seat's event
|
// build is the one today: the controller asks, and reads the answer from the seat's event
|
||||||
// like the catalogue does — which is why no holder needs to publish into anybody's inbox.
|
// like the catalogue does — which is why no holder needs to publish into anybody's inbox.
|
||||||
// A node-scoped seat's work subject carries no node (novox/hq ADR 0190): the ask goes to
|
for _, seat := range meshSeatsTheControllerUses {
|
||||||
// the role, and whichever machine holding it is idle takes it.
|
|
||||||
for _, seat := range seatsTheControllerAsks {
|
|
||||||
pub = append(pub, "mesh.seat."+seat+".accept.>")
|
pub = append(pub, "mesh.seat."+seat+".accept.>")
|
||||||
}
|
}
|
||||||
// **And what the mesh says it did** (novox/hq ADR 0134). The control plane states its own
|
// **And what the mesh says it did** (novox/hq ADR 0134). The control plane states its own
|
||||||
@@ -242,8 +209,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
// which this package mirrors rather than reads, and a verb the seat does not declare is a
|
// which this package mirrors rather than reads, and a verb the seat does not declare is a
|
||||||
// subject nothing publishes.
|
// subject nothing publishes.
|
||||||
sub = append(sub, "mesh.seat."+ControllerSeat+".tool.>")
|
sub = append(sub, "mesh.seat."+ControllerSeat+".tool.>")
|
||||||
// And says so (novox/hq ADR 0197): it answers discovery for the seat it serves.
|
|
||||||
sub = append(sub, announcing(ControllerSeat)...)
|
|
||||||
|
|
||||||
// The two events it reacts to, and its ack subject on the stream they arrive from
|
// The two events it reacts to, and its ack subject on the stream they arrive from
|
||||||
// (streams.go). **Each named, not a pattern**: `mesh.mod.*.event.>` would make the
|
// (streams.go). **Each named, not a pattern**: `mesh.mod.*.event.>` would make the
|
||||||
@@ -279,9 +244,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
return Permissions{}, err
|
return Permissions{}, err
|
||||||
}
|
}
|
||||||
pub = append(pub, invoked...)
|
pub = append(pub, invoked...)
|
||||||
// And may ask what answers (novox/hq ADR 0197): a question every service answers about
|
|
||||||
// itself, its replies to the asker's own inbox.
|
|
||||||
pub = append(pub, discovering()...)
|
|
||||||
|
|
||||||
case KindEnrolment:
|
case KindEnrolment:
|
||||||
// A leaked token is useless for anything but enrolling: it cannot read a declaration, hear
|
// A leaked token is useless for anything but enrolling: it cannot read a declaration, hear
|
||||||
@@ -338,15 +300,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
// away — no other principal may subscribe this namespace, and a caller's authority is
|
// away — no other principal may subscribe this namespace, and a caller's authority is
|
||||||
// still granted per tool, by name, on the publish side.
|
// still granted per tool, by name, on the publish side.
|
||||||
sub = append(sub, own+".tool.>")
|
sub = append(sub, own+".tool.>")
|
||||||
// It says what it serves (novox/hq ADR 0197): discovery for its own name and every seat it
|
|
||||||
// holds a verb of, answered by the runtime that serves them.
|
|
||||||
announced := []string{p.Module}
|
|
||||||
for _, s := range p.Holds {
|
|
||||||
if len(s.Serves) > 0 {
|
|
||||||
announced = append(announced, s.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sub = append(sub, announcing(announced...)...)
|
|
||||||
// Its own membership (ADR 0160): the one subject a runtime derives for itself, read
|
// Its own membership (ADR 0160): the one subject a runtime derives for itself, read
|
||||||
// directly from the stream and followed live. Nothing else's.
|
// directly from the stream and followed live. Nothing else's.
|
||||||
sub = append(sub, MembershipSubject(p.Node, p.Module))
|
sub = append(sub, MembershipSubject(p.Node, p.Module))
|
||||||
@@ -393,17 +346,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
|
|
||||||
// 3. Seats it holds: full participation.
|
// 3. Seats it holds: full participation.
|
||||||
for _, s := range p.Holds {
|
for _, s := range p.Holds {
|
||||||
// Taking work from the role's queue: the worker consumer every holder shares (asked
|
// Taking work from the role's queue: the worker consumer it binds (asked about,
|
||||||
// about, pulled from, acknowledged), on the seat's own stream (novox/hq ADR 0190). A
|
// delivered on, acknowledged), each on the seat's own stream. The first machine to
|
||||||
// holder pulls — asks the consumer for its next message, answered on its own inbox —
|
// take work over the new bus was refused the asking (2026-09-28).
|
||||||
// so what it needs is MSG.NEXT on that worker and nothing delivered to it. The first
|
|
||||||
// machine to take work over the new bus was refused the asking (2026-09-28).
|
|
||||||
worker := "SEAT_" + upperSnake(s.Name) + "_worker"
|
worker := "SEAT_" + upperSnake(s.Name) + "_worker"
|
||||||
stream := seatStreamName(s.Name)
|
stream := seatStreamName(s.Name)
|
||||||
pub = append(pub,
|
sub = append(sub, "_DELIVER."+worker, "_DELIVER."+worker+".>")
|
||||||
"$JS.API.CONSUMER.INFO."+stream+"."+worker,
|
pub = append(pub, "$JS.API.CONSUMER.INFO."+stream+"."+worker, "$JS.ACK."+stream+"."+worker+".>")
|
||||||
"$JS.API.CONSUMER.MSG.NEXT."+stream+"."+worker,
|
|
||||||
"$JS.ACK."+stream+"."+worker+".>")
|
|
||||||
for _, a := range s.Accepts {
|
for _, a := range s.Accepts {
|
||||||
sub = append(sub, seatSubject(s, "accept", a))
|
sub = append(sub, seatSubject(s, "accept", a))
|
||||||
}
|
}
|
||||||
@@ -426,86 +375,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
pub = append(pub, seatToolSubject(s, t, "*"))
|
pub = append(pub, seatToolSubject(s, t, "*"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Its state, and the state of others it reads (novox/hq ADR 0201): every one read and
|
|
||||||
// watched, its own written too.
|
|
||||||
pub = append(pub, stateGrants(p.Module, p.State, p.Reads)...)
|
|
||||||
|
|
||||||
case KindNodeTools:
|
|
||||||
// **One process serves what every module on the machine would have served for itself**
|
|
||||||
// (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that
|
|
||||||
// module's own principal has, for the same reason: the tools a module serves are what its
|
|
||||||
// code answers, and a list here would be a second copy of it. Each held seat's verbs on
|
|
||||||
// this node, as the holder's own principal would be granted them.
|
|
||||||
var serves []string
|
|
||||||
for _, d := range p.Carries {
|
|
||||||
if !safeSubject.MatchString(d.Module) {
|
|
||||||
return Permissions{}, fmt.Errorf(
|
|
||||||
"%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", d.Module)
|
|
||||||
}
|
|
||||||
serves = append(serves, d.Module)
|
|
||||||
for _, s := range d.Holds {
|
|
||||||
serves = append(serves, s.Name)
|
|
||||||
}
|
|
||||||
own := "mesh.mod." + d.Module
|
|
||||||
sub = append(sub, own+".tool.>")
|
|
||||||
// A tool that emits an event is the module's code and emits under the module's name
|
|
||||||
// (ADR 0042); the runtime carrying that code may publish what the module declared it
|
|
||||||
// emits, and nothing it did not.
|
|
||||||
for _, e := range d.Emits {
|
|
||||||
pub = append(pub, own+".event."+e)
|
|
||||||
}
|
|
||||||
for _, s := range d.Holds {
|
|
||||||
for _, t := range s.Serves {
|
|
||||||
sub = append(sub, seatToolSubject(s, t, p.Node))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Every assigned module's membership on this node (ADR 0160): one per module, read
|
|
||||||
// directly from the stream and followed live. This node's and no other's — the one token
|
|
||||||
// that varies is the module, so the pattern is the machine's own assignments.
|
|
||||||
sub = append(sub, "mesh.assignment."+p.Node+".*")
|
|
||||||
pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+p.Node+".*")
|
|
||||||
// And every tool on the mesh (ADR 0175, decision 5): any node may call any tool on any
|
|
||||||
// node, as the console already could — the runtime is the console's serving mode.
|
|
||||||
invoked, err := invokedSubjects([]string{"*"})
|
|
||||||
if err != nil {
|
|
||||||
return Permissions{}, err
|
|
||||||
}
|
|
||||||
pub = append(pub, invoked...)
|
|
||||||
// It says what it serves and may ask what answers (novox/hq ADR 0197): the runtime answers
|
|
||||||
// discovery for each module and seat it carries, and the console it is asks the bus.
|
|
||||||
// One service per runtime process, named for the runtime: the bus lets a principal answer each
|
|
||||||
// request once, so the runtime announces everything it carries under its own name.
|
|
||||||
sub = append(sub, announcing(append([]string{RuntimeModule}, serves...)...)...)
|
|
||||||
pub = append(pub, discovering()...)
|
|
||||||
// **And it consumes for the modules it carries** (novox/hq ADR 0198, which changes ADR 0175's
|
|
||||||
// "it consumes nothing"): a module's long-running code is a bundle this runtime launches, and
|
|
||||||
// the runtime is its bus — it reads the module's own durable consumer and acknowledges what
|
|
||||||
// the module's code took. Exactly the grants the module's own principal has for that consumer,
|
|
||||||
// on its name and no other's: asking about it, pulling from it, acknowledging it. The
|
|
||||||
// consumer is still the controller's to make, from the module's own principal.
|
|
||||||
for _, d := range p.Carries {
|
|
||||||
own := Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
|
|
||||||
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches}
|
|
||||||
if _, consumes := ConsumerFor(own); !consumes {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
stream, durable := consumerStream(own), consumerDurable(own)
|
|
||||||
pub = append(pub,
|
|
||||||
"$JS.API.CONSUMER.INFO."+stream+"."+durable,
|
|
||||||
"$JS.API.CONSUMER.MSG.NEXT."+stream+"."+durable,
|
|
||||||
"$JS.ACK."+stream+"."+durable+".>")
|
|
||||||
}
|
|
||||||
// **And it keeps and reads state for the modules it carries** (novox/hq ADR 0201): the union
|
|
||||||
// of what each may do with a bucket — an owner's write, a reader's read. That one module's code
|
|
||||||
// does not write another's bucket through it is the runtime's to keep, from the membership
|
|
||||||
// each assignment is issued, as it keeps each module's events under that module's own name.
|
|
||||||
for _, d := range p.Carries {
|
|
||||||
pub = append(pub, stateGrants(d.Module, stateNames(d.State), d.Reads)...)
|
|
||||||
}
|
|
||||||
sub = unique(sub)
|
|
||||||
pub = unique(pub)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.Kind == KindPerson {
|
if p.Kind == KindPerson {
|
||||||
@@ -513,11 +382,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
// consumer, because nothing is delivered to a person — they ask and are answered.
|
// consumer, because nothing is delivered to a person — they ask and are answered.
|
||||||
sub = append(sub, p.inbox())
|
sub = append(sub, p.inbox())
|
||||||
}
|
}
|
||||||
if p.Kind == KindNodeTools {
|
|
||||||
// Its reply space, so the answers to what its tools call come back to it. No ack subject
|
|
||||||
// for the same reason a person has none: nothing is delivered to it.
|
|
||||||
sub = append(sub, p.inbox())
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.Kind == KindModule || p.Kind == KindNode || p.Kind == KindController {
|
if p.Kind == KindModule || p.Kind == KindNode || p.Kind == KindController {
|
||||||
// Its own reply space, and nothing wider.
|
// Its own reply space, and nothing wider.
|
||||||
@@ -539,7 +403,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
// A module answers what it was asked — a tool call reaches it on its own namespace, so the
|
// A module answers what it was asked — a tool call reaches it on its own namespace, so the
|
||||||
// authority is bounded by having been asked — and so does the controller. A node and a
|
// authority is bounded by having been asked — and so does the controller. A node and a
|
||||||
// person are never asked anything, and are granted nothing here.
|
// person are never asked anything, and are granted nothing here.
|
||||||
AllowResponses: p.Kind == KindModule || p.Kind == KindController || p.Kind == KindNodeTools,
|
AllowResponses: p.Kind == KindModule || p.Kind == KindController,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,20 +625,6 @@ func ComposeAccounts(principals []Principal) (string, error) {
|
|||||||
return b.String(), nil
|
return b.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// unique is a sorted list with each subject once. Two carried modules holding seats with the same
|
|
||||||
// verb, or the runtime module itself carried beside the others, would otherwise write a grant twice
|
|
||||||
// — harmless to the server, and noise in a file that is read as the mesh's authority model.
|
|
||||||
func unique(values []string) []string {
|
|
||||||
sort.Strings(values)
|
|
||||||
out := values[:0]
|
|
||||||
for i, v := range values {
|
|
||||||
if i == 0 || v != values[i-1] {
|
|
||||||
out = append(out, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func quoted(values []string) string {
|
func quoted(values []string) string {
|
||||||
if len(values) == 0 {
|
if len(values) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -823,25 +673,3 @@ func invokedSubjects(invokes []string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// announcing is what a principal that serves tools subscribes to answer the NATS services
|
|
||||||
// protocol's discovery (novox/hq ADR 0197): the questions asked of every service, and those asked of
|
|
||||||
// each name it serves — its own and no other's, so it cannot answer for a service it is not.
|
|
||||||
func announcing(names ...string) []string {
|
|
||||||
out := []string{"$SRV.PING", "$SRV.INFO", "$SRV.STATS"}
|
|
||||||
for _, n := range names {
|
|
||||||
if !safeSubject.MatchString(n) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, verb := range []string{"PING", "INFO", "STATS"} {
|
|
||||||
out = append(out, "$SRV."+verb+"."+n, "$SRV."+verb+"."+n+".>")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// discovering is what a principal publishes to ask what answers (novox/hq ADR 0197): the services
|
|
||||||
// protocol's discovery requests, whose replies come to its own inbox.
|
|
||||||
func discovering() []string {
|
|
||||||
return []string{"$SRV.PING", "$SRV.PING.>", "$SRV.INFO", "$SRV.INFO.>"}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -233,9 +233,7 @@ func TestAPersonReachesNothingButTools(t *testing.T) {
|
|||||||
perms, _ := PermissionsFor(Principal{Kind: KindPerson, Module: "jo",
|
perms, _ := PermissionsFor(Principal{Kind: KindPerson, Module: "jo",
|
||||||
Invokes: []string{"*"}, PasswordHash: "x"})
|
Invokes: []string{"*"}, PasswordHash: "x"})
|
||||||
for _, p := range perms.Publish {
|
for _, p := range perms.Publish {
|
||||||
// A tool call, or asking what answers (novox/hq ADR 0197) — a question every service
|
if !strings.Contains(p, ".tool.") {
|
||||||
// answers about itself, which claims nothing and controls nothing.
|
|
||||||
if !strings.Contains(p, ".tool.") && !strings.HasPrefix(p, "$SRV.") {
|
|
||||||
t.Errorf("a person may publish %q, which is not a tool call", p)
|
t.Errorf("a person may publish %q, which is not a tool call", p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,109 +371,3 @@ func TestAModulePullsItsOwnConsumerAndNoOthers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The runtime's authority is the union of what the modules it carries would have been granted for
|
|
||||||
// their tools (novox/hq ADR 0175): every carried module's tool namespace, every held seat's verbs
|
|
||||||
// on this node, every module's membership on this node, and a call to anything. Nothing it
|
|
||||||
// consumes, because it reacts to nothing.
|
|
||||||
func TestTheRuntimeServesTheUnionAndConsumesForItsModules(t *testing.T) {
|
|
||||||
filter := Seat{Name: "node-packet-filter", Scope: "node", Serves: []string{"rules", "reload"}}
|
|
||||||
p := Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, Carries: []Declared{
|
|
||||||
{Module: "nftables", Holds: []Seat{filter}, Serves: []string{"firewall_rules"}},
|
|
||||||
{Module: "zsh", Emits: []string{"shell.opened"}, Consumes: []string{"shop.order.placed"}},
|
|
||||||
{Module: RuntimeModule},
|
|
||||||
}}
|
|
||||||
perms, err := PermissionsFor(p)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, want := range []string{
|
|
||||||
"mesh.mod.nftables.tool.>", "mesh.mod.zsh.tool.>", "mesh.mod." + RuntimeModule + ".tool.>",
|
|
||||||
"mesh.seat.node-packet-filter.tool.rules.anchor", "mesh.seat.node-packet-filter.tool.reload.anchor",
|
|
||||||
"mesh.assignment.anchor.*",
|
|
||||||
"_INBOX.anchor." + RuntimeModule + ".>",
|
|
||||||
} {
|
|
||||||
if !contains(perms.Subscribe, want) {
|
|
||||||
t.Errorf("the runtime may not subscribe %s: %v", want, perms.Subscribe)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, want := range []string{
|
|
||||||
"mesh.mod.*.tool.>", "mesh.seat.*.tool.>",
|
|
||||||
"$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.anchor.*",
|
|
||||||
"mesh.mod.zsh.event.shell.opened",
|
|
||||||
} {
|
|
||||||
if !contains(perms.Publish, want) {
|
|
||||||
t.Errorf("the runtime may not publish %s: %v", want, perms.Publish)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// It reads the consumer of every carried module that consumes — that module's, by its name, as
|
|
||||||
// the module's own principal could (novox/hq ADR 0198) — and of no module that consumes nothing.
|
|
||||||
for _, want := range []string{
|
|
||||||
"$JS.API.CONSUMER.INFO.EVENTS.anchor_zsh",
|
|
||||||
"$JS.API.CONSUMER.MSG.NEXT.EVENTS.anchor_zsh",
|
|
||||||
"$JS.ACK.EVENTS.anchor_zsh.>",
|
|
||||||
} {
|
|
||||||
if !contains(perms.Publish, want) {
|
|
||||||
t.Errorf("the runtime may not read zsh's consumer: %s missing from %v", want, perms.Publish)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, s := range perms.Publish {
|
|
||||||
if (strings.HasPrefix(s, "$JS.ACK.") || strings.Contains(s, "CONSUMER")) && !strings.Contains(s, "anchor_zsh") {
|
|
||||||
t.Errorf("the runtime was granted a consumer no carried module of it consumes on: %s", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// It pulls; nothing is pushed to it, and it subscribes no event subject directly.
|
|
||||||
for _, s := range perms.Subscribe {
|
|
||||||
if strings.Contains(s, ".event.") || strings.HasPrefix(s, "_DELIVER.") {
|
|
||||||
t.Errorf("the runtime was granted a delivery: %s", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !perms.AllowResponses {
|
|
||||||
t.Error("the runtime answers what it is asked, and may not reply")
|
|
||||||
}
|
|
||||||
if _, needed := ConsumerFor(p); needed {
|
|
||||||
t.Error("a consumer would be made for the runtime 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
|
|
||||||
// stand in both — the runtime answers discovery on `$SRV.INFO` and, as the console, asks it
|
|
||||||
// (novox/hq ADR 0197) — because subscribing and publishing are two different grants.
|
|
||||||
for _, list := range [][]string{perms.Subscribe, perms.Publish} {
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, s := range list {
|
|
||||||
if seen[s] {
|
|
||||||
t.Errorf("%s is granted twice", s)
|
|
||||||
}
|
|
||||||
seen[s] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(list []string, want string) bool {
|
|
||||||
for _, s := range list {
|
|
||||||
if s == want {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// A node-scoped seat's work is shared (novox/hq ADR 0190): its holder on any machine subscribes the
|
|
||||||
// seat's one work subject, with no node in it, so holders on several machines read one queue. The
|
|
||||||
// node token belongs to a seat's tools, which are asked of one machine (design 33 §4), not to its work.
|
|
||||||
func TestANodeSeatsWorkSubjectCarriesNoNode(t *testing.T) {
|
|
||||||
seat := Seat{Name: "node-build-agent", Scope: "node", Accepts: []string{"build"}, Serves: []string{"status"}}
|
|
||||||
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "anchor", Module: "build-agent", Holds: []Seat{seat}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
has(t, perms.Subscribe, "mesh.seat.node-build-agent.accept.build")
|
|
||||||
hasNot(t, perms.Subscribe, "mesh.seat.node-build-agent.accept.build.anchor")
|
|
||||||
// And its tools still carry the machine.
|
|
||||||
has(t, perms.Subscribe, "mesh.seat.node-build-agent.tool.status.anchor")
|
|
||||||
// The controller asks the role, not a machine.
|
|
||||||
controller, err := PermissionsFor(Principal{Kind: KindController})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
has(t, controller.Publish, "mesh.seat.node-build-agent.accept.>")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -217,15 +217,10 @@ var ControllerFollows = []string{
|
|||||||
// A build's outcome, which is the build-machine role's own event now (ADR 0121) rather than a
|
// A build's outcome, which is the build-machine role's own event now (ADR 0121) rather than a
|
||||||
// message on the control branch. Same three audiences, one publish: whoever asked, this, and the
|
// message on the control branch. Same three audiences, one publish: whoever asked, this, and the
|
||||||
// catalogue.
|
// catalogue.
|
||||||
seatEventSubject("node-build-agent", "built"),
|
seatEventSubject("mesh-build-machine", "built"),
|
||||||
// The forge's merges: what moved a source, so the mesh builds what that source produces
|
// The forge's merges: what moved a source, so the mesh builds what that source produces
|
||||||
// without anybody telling it (novox/hq 04-ISSUES/131). Appended, because the index is a name.
|
// without anybody telling it (novox/hq 04-ISSUES/131). Appended, because the index is a name.
|
||||||
moduleEventSubject("gitea", "pull.merged"),
|
moduleEventSubject("gitea", "pull.merged"),
|
||||||
// The retired build role's outcome too, while the handover runs (novox/hq ADR 0190): the one
|
|
||||||
// build machine keeps answering on its seat until build-agent replaces it, and the outcome that
|
|
||||||
// registers build-agent itself comes from there. Appended, for the same reason as above; goes
|
|
||||||
// with the retired seat row.
|
|
||||||
seatEventSubject("mesh-build-machine", "built"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// moduleEventSubject is where one module's event lands. The same derivation PermissionsFor uses, so
|
// moduleEventSubject is where one module's event lands. The same derivation PermissionsFor uses, so
|
||||||
|
|||||||
+6
-6
@@ -24,8 +24,8 @@ accounts {
|
|||||||
jetstream: enabled
|
jetstream: enabled
|
||||||
users = [
|
users = [
|
||||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.refused", "mesh.seat.node-build-agent.accept.>"] }
|
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.refused"] }
|
||||||
subscribe: { allow: ["$JS.API.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] }
|
subscribe: { allow: ["$JS.API.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
|
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
|
||||||
@@ -37,18 +37,18 @@ accounts {
|
|||||||
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.declare"] }
|
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.declare"] }
|
||||||
} }
|
} }
|
||||||
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
|
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
||||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.telegram", "$SRV.INFO.telegram.>", "$SRV.PING", "$SRV.PING.telegram", "$SRV.PING.telegram.>", "$SRV.STATS", "$SRV.STATS.telegram", "$SRV.STATS.telegram.>", "_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
subscribe: { allow: ["_DELIVER.SEAT_TELEGRAM_SENDER_worker", "_DELIVER.SEAT_TELEGRAM_SENDER_worker.>", "_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
|
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.EVENTS.two_audit.>", "$JS.API.CONSUMER.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.audit"] }
|
publish: { allow: ["$JS.ACK.EVENTS.two_audit.>", "$JS.API.CONSUMER.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.audit"] }
|
||||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.audit", "$SRV.INFO.audit.>", "$SRV.PING", "$SRV.PING.audit", "$SRV.PING.audit.>", "$SRV.STATS", "$SRV.STATS.audit", "$SRV.STATS.audit.>", "_INBOX.two.audit.>", "mesh.assignment.two.audit", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
|
subscribe: { allow: ["_INBOX.two.audit.>", "mesh.assignment.two.audit", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
{ user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: {
|
{ user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "$JS.API.CONSUMER.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.shop", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] }
|
publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "$JS.API.CONSUMER.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.shop", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] }
|
||||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.shop", "$SRV.INFO.shop.>", "$SRV.PING", "$SRV.PING.shop", "$SRV.PING.shop.>", "$SRV.STATS", "$SRV.STATS.shop", "$SRV.STATS.shop.>", "_INBOX.two.shop.>", "mesh.assignment.two.shop", "mesh.mod.shop.tool.>"] }
|
subscribe: { allow: ["_INBOX.two.shop.>", "mesh.assignment.two.shop", "mesh.mod.shop.tool.>"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -33,10 +33,6 @@ 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -66,32 +62,11 @@ func Users(r Records) ([]Principal, error) {
|
|||||||
|
|
||||||
for _, node := range sortedCopy(r.Nodes) {
|
for _, node := range sortedCopy(r.Nodes) {
|
||||||
out = append(out, Principal{Kind: KindNode, Node: node})
|
out = append(out, Principal{Kind: KindNode, Node: node})
|
||||||
// **Where the runtime is assigned, the machine gets one runtime principal in place of the
|
|
||||||
// runtime module's own** (novox/hq ADR 0175, to-be 38). It carries every module on the
|
|
||||||
// node: its serving grants are the union of theirs. Every other module keeps its own
|
|
||||||
// principal — a module still serving tools from its own container holds its own
|
|
||||||
// credential until it moves, and the two serve side by side in the meantime.
|
|
||||||
runtimeHere := false
|
|
||||||
for _, d := range r.Assigned[node] {
|
for _, d := range r.Assigned[node] {
|
||||||
if d.Module == RuntimeModule {
|
|
||||||
runtimeHere = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, d := range r.Assigned[node] {
|
|
||||||
if runtimeHere && d.Module == RuntimeModule {
|
|
||||||
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 {
|
|
||||||
out = append(out, Principal{
|
|
||||||
Kind: KindNodeTools, Node: node, Module: RuntimeModule,
|
|
||||||
Carries: append([]Declared(nil), r.Assigned[node]...),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,54 +245,3 @@ func TestAUserListIsComposedBeforeAnythingMovesOntoTheBus(t *testing.T) {
|
|||||||
t.Errorf("the composed list does not contain the machine running the bus")
|
t.Errorf("the composed list does not contain the machine running the bus")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Where the runtime module is assigned, the machine gets one runtime principal in place of the
|
|
||||||
// runtime module's own (novox/hq ADR 0175, to-be 38). Every other module keeps its own: a module
|
|
||||||
// still serving tools from its own container holds its own credential until it moves.
|
|
||||||
func TestTheRuntimeModuleBecomesTheMachinesRuntimePrincipal(t *testing.T) {
|
|
||||||
r := someRecords()
|
|
||||||
r.Assigned["one"] = append(r.Assigned["one"], Declared{Module: RuntimeModule})
|
|
||||||
users, err := Users(r)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var runtime *Principal
|
|
||||||
for i := range users {
|
|
||||||
p := &users[i]
|
|
||||||
if p.Node == "one" && p.Module == RuntimeModule {
|
|
||||||
if p.Kind == KindModule {
|
|
||||||
t.Fatalf("%s on one was composed as an ordinary module beside the runtime", RuntimeModule)
|
|
||||||
}
|
|
||||||
runtime = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if runtime == nil || runtime.Kind != KindNodeTools {
|
|
||||||
t.Fatalf("one runs %s and got no runtime principal: %v", RuntimeModule, namesOf(t, r))
|
|
||||||
}
|
|
||||||
if runtime.Username() != "one."+RuntimeModule {
|
|
||||||
t.Errorf("the runtime is named %q; `module issue` names it as the module it stands for", runtime.Username())
|
|
||||||
}
|
|
||||||
carried := map[string]bool{}
|
|
||||||
for _, d := range runtime.Carries {
|
|
||||||
carried[d.Module] = true
|
|
||||||
}
|
|
||||||
if !carried["telegram"] || !carried[RuntimeModule] {
|
|
||||||
t.Errorf("the runtime carries %v; it carries every module on its node", carried)
|
|
||||||
}
|
|
||||||
// And the other node, where the runtime is not assigned, is exactly as before.
|
|
||||||
for _, p := range users {
|
|
||||||
if p.Node == "two" && p.Kind == KindNodeTools {
|
|
||||||
t.Fatal("two runs no runtime and was given a runtime principal")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A module serving its own tools beside the runtime keeps its own principal.
|
|
||||||
found := false
|
|
||||||
for _, p := range users {
|
|
||||||
if p.Kind == KindModule && p.Node == "one" && p.Module == "telegram" {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Error("telegram lost its own principal when the runtime arrived on its node")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
package broker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A seat's worker that changed from push to pull delivery strands a holder built for the new shape
|
|
||||||
// (novox/hq issue 206): the server refuses a pull subscription on a push consumer, and the controller
|
|
||||||
// that would redefine it was the build that nobody could take. The controller owns the worker's
|
|
||||||
// shape, type included: on a work queue it re-makes one of the wrong type, losing nothing, and a
|
|
||||||
// pull subscription then binds and takes what was pending.
|
|
||||||
//
|
|
||||||
// docker run -d --rm --name t -p 14231:4222 nats:2.10-alpine -js
|
|
||||||
// MESH_TEST_NATS=nats://127.0.0.1:14231 go test ./internal/broker/ -run TestAWorker
|
|
||||||
func TestAWorkerOfTheWrongTypeIsRemadeOnAWorkQueueAndAPullThenBinds(t *testing.T) {
|
|
||||||
url := os.Getenv("MESH_TEST_NATS")
|
|
||||||
if url == "" {
|
|
||||||
t.Skip("MESH_TEST_NATS unset")
|
|
||||||
}
|
|
||||||
js, err := Dial(url)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer js.Close()
|
|
||||||
|
|
||||||
const stream, worker, filter = "SEAT_T_SHELF", "SEAT_T_SHELF_worker", "mesh.seat.t-shelf.accept.>"
|
|
||||||
_ = js.js.DeleteStream(stream)
|
|
||||||
if _, err := js.js.AddStream(&nats.StreamConfig{
|
|
||||||
Name: stream, Subjects: []string{filter}, Retention: nats.WorkQueuePolicy, Storage: nats.MemoryStorage,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer func() { _ = js.js.DeleteStream(stream) }()
|
|
||||||
|
|
||||||
// The worker as the previous controller defined it: push, in a queue group.
|
|
||||||
if _, err := js.js.AddConsumer(stream, &nats.ConsumerConfig{
|
|
||||||
Durable: worker, AckPolicy: nats.AckExplicitPolicy, AckWait: 60 * time.Second, MaxDeliver: 5,
|
|
||||||
FilterSubject: filter, DeliverSubject: "_DELIVER." + worker, DeliverGroup: "holders",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, body := range []string{"one", "two", "three"} {
|
|
||||||
if _, err := js.js.Publish("mesh.seat.t-shelf.accept.build", []byte(body)); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The old holder took and acknowledged the first ask, then went away.
|
|
||||||
old, err := js.js.QueueSubscribeSync(filter, "holders", nats.Bind(stream, worker))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m, err := old.NextMsg(twoSeconds)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if string(m.Data) != "one" {
|
|
||||||
t.Fatalf("the first ask is %q", m.Data)
|
|
||||||
}
|
|
||||||
if err := m.AckSync(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := old.Unsubscribe(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The new controller asserts the worker as the mesh derives it now: pull.
|
|
||||||
if err := js.EnsureConsumer(Consumer{
|
|
||||||
Name: worker, Stream: stream, Filters: []string{filter}, AckWaitSeconds: 60, MaxDeliver: 5,
|
|
||||||
Why: "the test's worker",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
have, err := js.js.ConsumerInfo(stream, worker)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if have.Config.DeliverSubject != "" || have.Config.DeliverGroup != "" {
|
|
||||||
t.Fatalf("the worker is still push: %+v", have.Config)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A holder built for the new shape binds, and takes exactly what the old one left.
|
|
||||||
sub, err := js.js.PullSubscribe(filter, worker, nats.Bind(stream, worker), nats.ManualAck())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("a pull subscription does not bind the re-made worker: %v", err)
|
|
||||||
}
|
|
||||||
got, err := sub.Fetch(3, nats.MaxWait(twoSeconds))
|
|
||||||
if err != nil && len(got) == 0 {
|
|
||||||
t.Fatalf("nothing pending was delivered: %v", err)
|
|
||||||
}
|
|
||||||
var bodies []string
|
|
||||||
for _, g := range got {
|
|
||||||
bodies = append(bodies, string(g.Data))
|
|
||||||
_ = g.Ack()
|
|
||||||
}
|
|
||||||
if len(bodies) != 2 || bodies[0] != "two" || bodies[1] != "three" {
|
|
||||||
t.Fatalf("the pending asks after the acknowledged one, in order: %v", bodies)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Asserted again, the pull worker is the no-op a restart depends on.
|
|
||||||
if err := js.EnsureConsumer(Consumer{
|
|
||||||
Name: worker, Stream: stream, Filters: []string{filter}, AckWaitSeconds: 60, MaxDeliver: 5,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// On a stream that keeps its history, a worker of the wrong type is re-made to deliver from now on:
|
|
||||||
// re-making it from the start would replay what it acknowledged (novox/hq issue 156), and leaving it
|
|
||||||
// for a hand re-made it exactly that way on 2026-10-03 (issue 207).
|
|
||||||
func TestAWorkerOfTheWrongTypeOnAHistoryStreamIsRemadeFromNowOn(t *testing.T) {
|
|
||||||
url := os.Getenv("MESH_TEST_NATS")
|
|
||||||
if url == "" {
|
|
||||||
t.Skip("MESH_TEST_NATS unset")
|
|
||||||
}
|
|
||||||
js, err := Dial(url)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer js.Close()
|
|
||||||
const stream, worker, filter = "EVENTS_T", "EVENTS_T_reader", "mesh.t.event.>"
|
|
||||||
_ = js.js.DeleteStream(stream)
|
|
||||||
if _, err := js.js.AddStream(&nats.StreamConfig{Name: stream, Subjects: []string{filter}, Storage: nats.MemoryStorage}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer func() { _ = js.js.DeleteStream(stream) }()
|
|
||||||
if _, err := js.js.AddConsumer(stream, &nats.ConsumerConfig{
|
|
||||||
Durable: worker, AckPolicy: nats.AckExplicitPolicy, AckWait: 60 * time.Second,
|
|
||||||
FilterSubject: filter, DeliverSubject: "_DELIVER." + worker,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// History the old consumer would have acknowledged long ago, and must not come back.
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
if _, err := js.js.Publish("mesh.t.event.old", []byte("old")); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := js.EnsureConsumer(Consumer{Name: worker, Stream: stream, Filters: []string{filter}, AckWaitSeconds: 60}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
have, err := js.js.ConsumerInfo(stream, worker)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if have.Config.DeliverSubject != "" {
|
|
||||||
t.Fatal("a history stream's consumer of the wrong type was left as it was")
|
|
||||||
}
|
|
||||||
if have.Config.DeliverPolicy != nats.DeliverNewPolicy || have.NumPending != 0 {
|
|
||||||
t.Fatalf("re-made consumer delivers %v with %d pending; it must deliver from now on with nothing of the past", have.Config.DeliverPolicy, have.NumPending)
|
|
||||||
}
|
|
||||||
// And what arrives from now on is delivered.
|
|
||||||
if _, err := js.js.Publish("mesh.t.event.new", []byte("new")); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
sub, err := js.js.PullSubscribe(filter, worker, nats.Bind(stream, worker))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got, err := sub.Fetch(1, nats.MaxWait(3*time.Second))
|
|
||||||
if err != nil || len(got) != 1 || string(got[0].Data) != "new" {
|
|
||||||
t.Fatalf("the re-made consumer delivered %v, %v; want the one new message", got, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-177
@@ -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, npmrc, seatBases, say)
|
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, seatBases, say)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
say("artifact", "%s FAILED: %v", a.Name, err)
|
say("artifact", "%s FAILED: %v", a.Name, err)
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
@@ -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, registry Npmrc, seats map[string]string,
|
held map[string]string, npmrc string, 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,28 +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 {
|
||||||
return catalogue.Built{}, fmt.Errorf("%s: compiling %s failed: %w", module, a.Name, err)
|
return catalogue.Built{}, fmt.Errorf("%s: compiling %s failed: %w", module, a.Name, err)
|
||||||
}
|
}
|
||||||
// **Every entrypoint the runtime may serve is executable** (novox/hq ADR 0193). The runtime
|
|
||||||
// knows no language; for one that runs through an interpreter the build writes the launcher.
|
|
||||||
launchers, err := writeLaunchers(compiled, chain, a)
|
|
||||||
if err != nil {
|
|
||||||
return catalogue.Built{}, fmt.Errorf("%s: writing %s's launchers failed: %w", module, a.Name, err)
|
|
||||||
}
|
|
||||||
if chain.Bundler != "" {
|
|
||||||
say("bundle", "bundling each entrypoint into one file")
|
|
||||||
if compiled, err = bundled(ctx, run, tree, chain, base, a, launchers); err != nil {
|
|
||||||
return catalogue.Built{}, fmt.Errorf("%s: bundling %s failed: %w", module, a.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
say("bundle", "compiled, packing")
|
say("bundle", "compiled, packing")
|
||||||
body, err := pack(compiled)
|
body, err := pack(compiled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -615,7 +598,7 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Built{}, err
|
return catalogue.Built{}, err
|
||||||
}
|
}
|
||||||
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest, Launchers: launchers}, nil
|
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest}, nil
|
||||||
|
|
||||||
case catalogue.ArtifactPackage:
|
case catalogue.ArtifactPackage:
|
||||||
// Built and published on a public base, to the mesh's package registry, by version
|
// Built and published on a public base, to the mesh's package registry, by version
|
||||||
@@ -985,26 +968,6 @@ 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 != "" && chain.Bundler == "" {
|
|
||||||
// **What the bundle runs with, from the image it was compiled in** (Toolchain.Dependencies).
|
|
||||||
// A second run in the same image rather than a shell wrapped around the compiler: the
|
|
||||||
// compile line stays a plain command a reader can run by hand, and the copy is one more
|
|
||||||
// plain command beside it. Refused by name when the image carries no such directory — an
|
|
||||||
// older toolchain image — because a bundle packed without its dependencies starts nowhere
|
|
||||||
// and says so three layers away from here.
|
|
||||||
copying := []string{
|
|
||||||
"run", "--rm",
|
|
||||||
"--volume", tree + ":" + within,
|
|
||||||
"--workdir", within,
|
|
||||||
base,
|
|
||||||
"sh", "-c",
|
|
||||||
`test -d "$1" || { echo "the toolchain image carries no $1: it predates the mesh shipping a bundle's dependencies, rebuild $2 first" >&2; exit 1; }; cp -a "$1/." "$3/"`,
|
|
||||||
"dependencies", chain.Dependencies, chain.Base, out,
|
|
||||||
}
|
|
||||||
if _, err := run(ctx, tree, "docker", copying...); err != nil {
|
|
||||||
return "", fmt.Errorf("copying the %s dependencies a bundle runs with: %w", chain.Language, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filepath.Join(tree, out), nil
|
return filepath.Join(tree, out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1182,9 +1145,6 @@ func readBy(manifest catalogue.Manifest) []catalogue.ArtifactContext {
|
|||||||
// binaryName is what a compiled bundle's executable is called: what the artifact says, or the name of
|
// binaryName is what a compiled bundle's executable is called: what the artifact says, or the name of
|
||||||
// the package it is built from, which is what a compiler would have chosen anyway.
|
// the package it is built from, which is what a compiler would have chosen anyway.
|
||||||
func binaryName(a catalogue.Artifact) string {
|
func binaryName(a catalogue.Artifact) string {
|
||||||
if name := catalogue.BinaryOf(a); name != "" {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
if name := strings.TrimSpace(a.Binary); name != "" {
|
if name := strings.TrimSpace(a.Binary); name != "" {
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
@@ -1193,137 +1153,3 @@ func binaryName(a catalogue.Artifact) string {
|
|||||||
}
|
}
|
||||||
return a.Name
|
return a.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// launcherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is
|
|
||||||
// started as index.serve.mjs (novox/hq ADR 0193). An ES module by its own extension, whatever the
|
|
||||||
// bundle's package.json says.
|
|
||||||
const launcherSuffix = ".serve.mjs"
|
|
||||||
|
|
||||||
// writeLaunchers writes, beside every entrypoint of a TypeScript bundle, an executable that
|
|
||||||
// imports the entrypoint and serves what it registered over MCP on stdio — through the bundle's
|
|
||||||
// own copy of the SDK, so registering and serving meet in one registry (novox/hq ADR 0193). Its
|
|
||||||
// answer is each entrypoint's launcher, by entrypoint, relative to the bundle's root; nothing for
|
|
||||||
// a language whose build is already executable.
|
|
||||||
func writeLaunchers(root string, chain Toolchain, a catalogue.Artifact) (map[string]string, error) {
|
|
||||||
if chain.Language != "typescript" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
out := map[string]string{}
|
|
||||||
for _, entry := range a.Entrypoints {
|
|
||||||
if !strings.HasSuffix(entry, ".js") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
launcher := strings.TrimSuffix(entry, ".js") + launcherSuffix
|
|
||||||
body := "#!/usr/bin/env node\n" +
|
|
||||||
"// Written by the mesh's builder (novox/hq ADR 0193): serve what " + entry + " registers,\n" +
|
|
||||||
"// over MCP on stdio, as the module the node's runtime names in MESH_SERVED_MODULE.\n" +
|
|
||||||
"import { serveRegisteredOverStdio } from \"@novox/mesh-sdk/stdio\";\n" +
|
|
||||||
"await import(\"./" + filepath.Base(entry) + "\");\n" +
|
|
||||||
"await serveRegisteredOverStdio();\n"
|
|
||||||
path := filepath.Join(root, filepath.FromSlash(launcher))
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// WriteFile honours the umask; the mode a machine unpacks is the one packed, so it is set.
|
|
||||||
if err := os.Chmod(path, 0o755); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out[entry] = launcher
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// bundledSuffix is where a bundle's one-file output is written, beside what the compiler wrote.
|
|
||||||
const bundledSuffix = ".bundled"
|
|
||||||
|
|
||||||
// bundled makes every entrypoint and every launcher of a compiled bundle ONE file, in the toolchain
|
|
||||||
// image's bundler, and answers the directory to pack (novox/hq ADR 0193).
|
|
||||||
//
|
|
||||||
// **What a launched bundle runs is what it imports, and nothing else.** Every served bundle is its
|
|
||||||
// own process, so it carries its own copy of the SDK and its own dependencies inlined — the
|
|
||||||
// toolchain's whole node_modules no longer travels in every bundle. An entrypoint a process runs by
|
|
||||||
// name (`node daemon/index.js`) is bundled in place under its own name; a launcher keeps its name
|
|
||||||
// and its first line, and stays executable. A package the bundler cannot inline is named by the
|
|
||||||
// artifact (`external`), kept as an import, and only then is the toolchain's runtime directory
|
|
||||||
// copied beside the files. CommonJS inlined into an ES module still finds `require`.
|
|
||||||
func bundled(ctx context.Context, run Runner, tree string, chain Toolchain, base string,
|
|
||||||
a catalogue.Artifact, launchers map[string]string) (string, error) {
|
|
||||||
const within = "/app/modules/module"
|
|
||||||
out, final := Out(a.Name), Out(a.Name)+bundledSuffix
|
|
||||||
if err := os.RemoveAll(filepath.Join(tree, final)); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Join(tree, final), 0o755); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
common := []string{"--bundle", "--platform=node", "--format=esm", "--target=node22",
|
|
||||||
"--outbase=" + out, "--outdir=" + final, "--log-level=warning",
|
|
||||||
"--banner:js=import { createRequire as __meshRequire } from 'node:module'; const require = __meshRequire(import.meta.url);"}
|
|
||||||
for _, x := range a.External {
|
|
||||||
common = append(common, "--external:"+x)
|
|
||||||
}
|
|
||||||
var plain []string
|
|
||||||
for _, e := range a.Entrypoints {
|
|
||||||
if strings.HasSuffix(e, ".js") {
|
|
||||||
plain = append(plain, out+"/"+e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var launch []string
|
|
||||||
for _, l := range sortedValues(launchers) {
|
|
||||||
launch = append(launch, out+"/"+l)
|
|
||||||
}
|
|
||||||
// Refused by name in an image that predates the bundler, as the dependencies copy is: a bundle
|
|
||||||
// packed without it would carry nothing it imports. Run as itself: npm installs esbuild's native
|
|
||||||
// binary in place of its script, which `node` cannot run.
|
|
||||||
guard := `test -x "$0" || { echo "the toolchain image carries no bundler at $0: it predates one-file bundles, rebuild mesh-tools first" >&2; exit 1; }; exec "$0" "$@"`
|
|
||||||
step := func(entries []string, extra ...string) error {
|
|
||||||
if len(entries) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
invocation := []string{"run", "--rm", "--volume", tree + ":" + within, "--workdir", within, base,
|
|
||||||
"sh", "-c", guard, chain.Bundler}
|
|
||||||
invocation = append(invocation, entries...)
|
|
||||||
invocation = append(invocation, common...)
|
|
||||||
invocation = append(invocation, extra...)
|
|
||||||
_, err := run(ctx, tree, "docker", invocation...)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := step(plain); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := step(launch, "--out-extension:.js=.mjs"); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
// Plain `.js` output is an ES module; said once, as the runtime directory used to say it.
|
|
||||||
if err := os.WriteFile(filepath.Join(tree, final, "package.json"), []byte(`{"type":"module","private":true}`+"\n"), 0o644); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
for _, l := range launchers {
|
|
||||||
path := filepath.Join(tree, final, filepath.FromSlash(l))
|
|
||||||
if _, err := os.Stat(path); err == nil {
|
|
||||||
if err := os.Chmod(path, 0o755); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(a.External) > 0 && chain.Dependencies != "" {
|
|
||||||
copying := []string{"run", "--rm", "--volume", tree + ":" + within, "--workdir", within, base,
|
|
||||||
"sh", "-c", `cp -a "$0/node_modules" "$1/"`, chain.Dependencies, final}
|
|
||||||
if _, err := run(ctx, tree, "docker", copying...); err != nil {
|
|
||||||
return "", fmt.Errorf("copying the packages %s keeps external: %w", a.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filepath.Join(tree, final), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortedValues(m map[string]string) []string {
|
|
||||||
out := make([]string, 0, len(m))
|
|
||||||
for _, v := range m {
|
|
||||||
out = append(out, v)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -82,65 +82,6 @@ func TestABundleIsCompiledAndPackedWithNoDockerfile(t *testing.T) {
|
|||||||
if !strings.HasPrefix(digest, "sha256:") {
|
if !strings.HasPrefix(digest, "sha256:") {
|
||||||
t.Fatalf("the bundle was not pinned: %v", got.Manifest.Resources[0])
|
t.Fatalf("the bundle was not pinned: %v", got.Manifest.Resources[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
// **One file per entrypoint and launcher, in the toolchain's bundler** (novox/hq ADR 0193). A
|
|
||||||
// second run in the same toolchain image bundles each into the artifact's bundled output, the SDK
|
|
||||||
// inlined, refusing by name in an image that predates the bundler; and the toolchain's
|
|
||||||
// node_modules is no longer copied into a bundle that keeps nothing external.
|
|
||||||
var bundling []string
|
|
||||||
for _, line := range r.ran {
|
|
||||||
if strings.HasPrefix(line, "docker run") && strings.Contains(line, "esbuild") {
|
|
||||||
bundling = append(bundling, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(bundling) != 2 {
|
|
||||||
t.Fatalf("want one bundling run for the entrypoints and one for the launchers:\n%s", strings.Join(r.ran, "\n"))
|
|
||||||
}
|
|
||||||
for _, want := range []string{"mesh-tools/build@sha256:", "predates one-file bundles", "--bundle", "--format=esm",
|
|
||||||
"--platform=node", "--outdir=" + Out("code") + ".bundled", Out("code") + "/index.js"} {
|
|
||||||
if !strings.Contains(bundling[0], want) {
|
|
||||||
t.Errorf("the entrypoints' bundling lacks %q: %s", want, bundling[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !strings.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A language whose bundle carries its own dependencies copies nothing in: a Go binary is static.
|
|
||||||
func TestOnlyALanguageWithARuntimeDirectoryCopiesDependenciesIn(t *testing.T) {
|
|
||||||
ts, _ := ToolchainFor("typescript")
|
|
||||||
if ts.Dependencies != "/app/runtime" {
|
|
||||||
t.Fatalf("typescript bundles run with %q", ts.Dependencies)
|
|
||||||
}
|
|
||||||
for _, language := range []string{"go", "python"} {
|
|
||||||
chain, _ := ToolchainFor(language)
|
|
||||||
if chain.Dependencies != "" {
|
|
||||||
t.Fatalf("%s copies %q into every bundle, and its bundles carry their own", language, chain.Dependencies)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// **Refused before anything is built, naming what to build first.** A base the mesh has not built
|
// **Refused before anything is built, naming what to build first.** A base the mesh has not built
|
||||||
@@ -204,13 +145,9 @@ func TestTwoBundlesInOneModuleArePackedSeparately(t *testing.T) {
|
|||||||
t.Fatalf("a module with two bundles did not build: %v", err)
|
t.Fatalf("a module with two bundles did not build: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compiled into two different places. Only the compile lines: the copy of each bundle's
|
// Compiled into two different places.
|
||||||
// dependencies names the same directory again, deliberately.
|
|
||||||
var outputs []string
|
var outputs []string
|
||||||
for _, line := range r.ran {
|
for _, line := range r.ran {
|
||||||
if !strings.Contains(line, "--outDir") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, part := range strings.Fields(line) {
|
for _, part := range strings.Fields(line) {
|
||||||
if strings.HasPrefix(part, ".mesh-build/") {
|
if strings.HasPrefix(part, ".mesh-build/") {
|
||||||
outputs = append(outputs, part)
|
outputs = append(outputs, part)
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
)
|
|
||||||
|
|
||||||
// novox/hq ADR 0193: every entrypoint the runtime may serve is executable, and the runtime knows no
|
|
||||||
// language — so a TypeScript bundle carries a launcher beside each entrypoint.
|
|
||||||
func TestATypeScriptBundleCarriesAnExecutableLauncherBesideEachEntrypoint(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
chain, err := ToolchainFor("typescript")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got, err := writeLaunchers(root, chain, catalogue.Artifact{Name: "tools", Kind: catalogue.ArtifactBundle,
|
|
||||||
Language: "typescript", Entrypoints: []string{"tools/index.js", "index.js"}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got["tools/index.js"] != "tools/index.serve.mjs" || got["index.js"] != "index.serve.mjs" {
|
|
||||||
t.Fatalf("launchers: %v", got)
|
|
||||||
}
|
|
||||||
path := filepath.Join(root, "tools", "index.serve.mjs")
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if info.Mode().Perm() != 0o755 {
|
|
||||||
t.Errorf("the launcher is %v, not executable 0755", info.Mode().Perm())
|
|
||||||
}
|
|
||||||
body, _ := os.ReadFile(path)
|
|
||||||
for _, want := range []string{"#!/usr/bin/env node\n", `from "@novox/mesh-sdk/stdio"`, `await import("./index.js")`, "serveRegisteredOverStdio()"} {
|
|
||||||
if !strings.Contains(string(body), want) {
|
|
||||||
t.Errorf("the launcher lacks %q:\n%s", want, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A compiled language's build is executable already: no launcher.
|
|
||||||
goChain, _ := ToolchainFor("go")
|
|
||||||
none, err := writeLaunchers(t.TempDir(), goChain, catalogue.Artifact{Name: "tools", Kind: catalogue.ArtifactBundle, Language: "go"})
|
|
||||||
if err != nil || len(none) != 0 {
|
|
||||||
t.Errorf("a Go bundle was given launchers: %v %v", none, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -200,23 +200,3 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -57,33 +57,6 @@ type Toolchain struct {
|
|||||||
// carrying its debug info. The mistake was believing a comment rather than reading the file it
|
// carrying its debug info. The mistake was believing a comment rather than reading the file it
|
||||||
// produced (novox/hq 04-ISSUES/161).
|
// produced (novox/hq 04-ISSUES/161).
|
||||||
LinkerFlags []string
|
LinkerFlags []string
|
||||||
// Dependencies is a directory inside the toolchain image whose contents a bundle in this
|
|
||||||
// language runs with, copied whole into the compiled output's root after the compile.
|
|
||||||
//
|
|
||||||
// **A bundle that compiles is not yet a bundle that runs.** The compiler resolves `import
|
|
||||||
// "nats"` from the toolchain image's own node_modules and the pack takes only what the compiler
|
|
||||||
// wrote, so what a machine unpacked could not find a single dependency — and no TypeScript bundle
|
|
||||||
// had ever run live to show it (novox/hq to-be 38 WP3). For TypeScript the directory holds 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,
|
|
||||||
// production-only node_modules the runtime itself ships with: the SDK's and the runtime's
|
|
||||||
// dependencies, and nothing module-specific (a module's own npm dependencies are installed into
|
|
||||||
// its own directory before the compile and inlined by the bundler: dependencies.go). Empty for a
|
|
||||||
// language whose bundle carries its own —
|
|
||||||
// a Go binary is static, a Python bundle is installed with its dependencies.
|
|
||||||
//
|
|
||||||
// A toolchain image without the directory fails the build by name rather than packing a bundle
|
|
||||||
// that starts nowhere: the image predates this and must be rebuilt first.
|
|
||||||
//
|
|
||||||
// *Since the bundler (below):* copied only for a bundle that names packages it keeps external,
|
|
||||||
// which cannot be inlined; a bundle with none carries no node_modules at all.
|
|
||||||
Dependencies string
|
|
||||||
// Bundler is the bundler inside the toolchain image that makes each compiled entrypoint and each
|
|
||||||
// launcher ONE self-contained file (novox/hq ADR 0193): every served bundle is its own process
|
|
||||||
// now, so each carries its own copy of what it imports — the SDK included — and nothing else.
|
|
||||||
// A bundle shrinks from the toolchain's whole node_modules to the code it runs. Empty for a
|
|
||||||
// language whose build is already one file.
|
|
||||||
Bundler string
|
|
||||||
// SystemStamp is the variable this language's linker fills with the artifact's declared system,
|
// 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).
|
||||||
//
|
//
|
||||||
@@ -134,22 +107,14 @@ var toolchains = []Toolchain{
|
|||||||
// symlinks to a launcher that requires its library relatively — and the base image's own
|
// symlinks to a launcher that requires its library relatively — and the base image's own
|
||||||
// assembly resolves them away, leaving a launcher whose relative require points nowhere.
|
// assembly resolves them away, leaving a launcher whose relative require points nowhere.
|
||||||
// Every module's hand-written Dockerfile had to know this. Now none of them does.
|
// Every module's hand-written Dockerfile had to know this. Now none of them does.
|
||||||
// **Rooted at the module, so an entrypoint lands where it is named.** Without a root the
|
|
||||||
// compiler takes the common directory of the files it is given: a module compiling only
|
|
||||||
// `tools/index.ts` had its output at `index.js`, and the entrypoint it declared —
|
|
||||||
// `tools/index.js`, "named as it will be found" — named a file the bundle did not
|
|
||||||
// contain. The runtime that loads bundles by their declared entrypoints (novox/hq ADR
|
|
||||||
// 0175) is what made this visible.
|
|
||||||
Compile: []string{
|
Compile: []string{
|
||||||
"node", "/app/node_modules/typescript/bin/tsc",
|
"node", "/app/node_modules/typescript/bin/tsc",
|
||||||
"--module", "NodeNext", "--moduleResolution", "NodeNext",
|
"--module", "NodeNext", "--moduleResolution", "NodeNext",
|
||||||
"--target", "ES2022", "--rootDir", ".",
|
"--target", "ES2022",
|
||||||
},
|
},
|
||||||
OutputFlag: "--outDir",
|
OutputFlag: "--outDir",
|
||||||
Unit: UnitSources,
|
Unit: UnitSources,
|
||||||
SourceExt: ".ts",
|
SourceExt: ".ts",
|
||||||
Dependencies: "/app/runtime",
|
|
||||||
Bundler: "/app/node_modules/esbuild/bin/esbuild",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Language: "go",
|
Language: "go",
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// novox/hq issue 213 (ADR 0188 §1, §3): a module's own Go service is a bundle the host runs as a
|
|
||||||
// process, not an image. Each test holds one thing that had to change in the composer for the
|
|
||||||
// controller to be declared that way.
|
|
||||||
|
|
||||||
const aServiceDigest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
|
||||||
|
|
||||||
// aServiceModule is the controller's shape in miniature: it answers tools of its own, its code is a
|
|
||||||
// Go bundle a process runs as an account it declares, its secrets belong to that account, it
|
|
||||||
// prepares its state, and its process replaces the container it used to run as.
|
|
||||||
func aServiceModule(t *testing.T) Manifest {
|
|
||||||
t.Helper()
|
|
||||||
raw := `{
|
|
||||||
"module": "svc", "version": "1", "tools": ["status"], "prepares": true,
|
|
||||||
"own-secrets": {"store": "${dir:state}/store"},
|
|
||||||
"secrets-owner": "svc",
|
|
||||||
"resources": [
|
|
||||||
{"id": "state", "type": "directory", "mode": "0700", "place": "mesh", "owner": "svc"},
|
|
||||||
{"id": "service", "type": "process", "name": "svc", "artifact": "code",
|
|
||||||
"run": ["./svc", "serve"], "user": "svc", "replaces": ["server"],
|
|
||||||
"env": {"SVC_STORE_FILE": "${dir:state}/store", "SVC_STORE_PORT": "${seat:mesh-store:5432}"}},
|
|
||||||
{"id": "account", "type": "user", "name": "svc", "shell": "/usr/bin/nologin", "home": "/var/lib/svc"}
|
|
||||||
],
|
|
||||||
"build": {"artifacts": [{"name": "code", "kind": "bundle", "language": "go", "system": "arch",
|
|
||||||
"from": "cmd/svc", "binary": "svc"}]}
|
|
||||||
}`
|
|
||||||
m, err := ParseManifest([]byte(raw))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("the service's manifest is refused: %v", err)
|
|
||||||
}
|
|
||||||
resolved, err := m.Resolve([]Built{{Name: "code", Kind: ArtifactBundle,
|
|
||||||
Reference: ArtifactStoreScheme + "svc/code@" + aServiceDigest, Digest: aServiceDigest}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
func composeTheService(t *testing.T, with Rendering) []map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
with.Needed = map[string]map[string]string{"svc": {"store": "sealed-store"}}
|
|
||||||
with.ArtifactStore = "anchor.internal:5100"
|
|
||||||
out, err := Resolution{Node: "anchor", Modules: []Manifest{aServiceModule(t)}}.Declaration(with)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("the service does not compose: %v", err)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func indexOf(out []map[string]any, id string) int {
|
|
||||||
for i, r := range out {
|
|
||||||
if r["id"] == id {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// A module that declares tools has every bundle served by the node's runtime unless it says
|
|
||||||
// otherwise — and the controller declares the verbs it answers as tools. Its service bundle is run
|
|
||||||
// by its own process; launched a second time by the runtime it would be a second controller
|
|
||||||
// pretending to be an MCP server.
|
|
||||||
func TestABundleItsOwnProcessRunsIsNotServedByTheRuntime(t *testing.T) {
|
|
||||||
m := aServiceModule(t)
|
|
||||||
if len(m.Bundles) != 1 {
|
|
||||||
t.Fatalf("the service's bundle was not kept: %+v", m.Bundles)
|
|
||||||
}
|
|
||||||
if loads := m.Bundles[0].Loads; len(loads) != 0 {
|
|
||||||
t.Fatalf("the runtime would launch the service's own bundle as tools: %v", loads)
|
|
||||||
}
|
|
||||||
// And a bundle no resource runs still is served, as a module declaring tools always had it.
|
|
||||||
tools := Manifest{Module: "t", Version: "1", Tools: []string{"x"},
|
|
||||||
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "go",
|
|
||||||
System: "arch", From: "cmd/t"}}}}
|
|
||||||
resolved, err := tools.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
|
||||||
Reference: ArtifactStoreScheme + "t/tools@" + aServiceDigest, Digest: aServiceDigest}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if loads := resolved.Bundles[0].Loads; len(loads) != 1 || loads[0] != "t" {
|
|
||||||
t.Fatalf("a tools bundle nothing runs is no longer served: %v", loads)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The account is created before anything is given to it. Its secrets are mesh-computed and so
|
|
||||||
// placed before the module's own resources; given to a user the machine did not have yet, they were
|
|
||||||
// refused on the first apply and the process started without them.
|
|
||||||
func TestAModulesAccountComesBeforeWhatBelongsToIt(t *testing.T) {
|
|
||||||
out := composeTheService(t, Rendering{})
|
|
||||||
account, secret := indexOf(out, "svc.account"), indexOf(out, "svc."+NeedID("store"))
|
|
||||||
if account < 0 || secret < 0 {
|
|
||||||
t.Fatalf("the account or the secret is missing: %v", out)
|
|
||||||
}
|
|
||||||
if account > secret {
|
|
||||||
t.Fatalf("the secret owned by svc is written before svc exists: account at %d, secret at %d",
|
|
||||||
account, secret)
|
|
||||||
}
|
|
||||||
if owner := out[secret]["owner"]; owner != "svc" {
|
|
||||||
t.Errorf("the secret belongs to %v, not the account its process runs as", owner)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The process is the module's program; its preparation is the same program asked to prepare, as a
|
|
||||||
// step before it — with the same account and environment, and handing nothing over.
|
|
||||||
func TestAProcessIsPreparedByItsOwnProgram(t *testing.T) {
|
|
||||||
out := composeTheService(t, Rendering{})
|
|
||||||
step, process := indexOf(out, "svc.service-prepare"), indexOf(out, "svc.service")
|
|
||||||
if step < 0 || process < 0 || step > process {
|
|
||||||
t.Fatalf("the preparation is not a step before the process (%d, %d): %v", step, process, out)
|
|
||||||
}
|
|
||||||
s := out[step]
|
|
||||||
if s["type"] != "process" || s["run-once"] != true || s["name"] != "svc-prepare" {
|
|
||||||
t.Errorf("the preparation is not a run-once process: %v", s)
|
|
||||||
}
|
|
||||||
if run, _ := json.Marshal(s["run"]); string(run) != `["./svc","prepare"]` {
|
|
||||||
t.Errorf("the preparation runs %s", run)
|
|
||||||
}
|
|
||||||
if s["user"] != "svc" || s["source"] != out[process]["source"] {
|
|
||||||
t.Errorf("the preparation does not run the same bundle as the same account: %v", s)
|
|
||||||
}
|
|
||||||
if env, _ := s["env"].(map[string]any); env["SVC_STORE_FILE"] == nil {
|
|
||||||
t.Errorf("the preparation is not given the process's environment: %v", s["env"])
|
|
||||||
}
|
|
||||||
if _, has := s["replaces"]; has {
|
|
||||||
t.Errorf("the preparation would hand over what the process replaces: %v", s)
|
|
||||||
}
|
|
||||||
if _, has := s["args"]; has {
|
|
||||||
t.Errorf("the preparation carries a container's args: %v", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the process replaces is named as the host recorded it, `<module>.<id>`; unprefixed, the host
|
|
||||||
// matches nothing and removes the container first, as before.
|
|
||||||
func TestWhatAProcessReplacesIsNamedAsTheHostRecordedIt(t *testing.T) {
|
|
||||||
out := composeTheService(t, Rendering{})
|
|
||||||
p := out[indexOf(out, "svc.service")]
|
|
||||||
if got, _ := json.Marshal(p["replaces"]); string(got) != `["svc.server"]` {
|
|
||||||
t.Fatalf("the process replaces %s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWhatReplacesMayNameIsRefusedNearItsAuthor(t *testing.T) {
|
|
||||||
for what, resource := range map[string]string{
|
|
||||||
"a container": `{"id":"c","type":"container","name":"c","image":"x@` + aServiceDigest + `","replaces":["old"]}`,
|
|
||||||
"a step": `{"id":"p","type":"process","name":"p","run":["./p"],"run-once":true,"replaces":["old"]}`,
|
|
||||||
"something declared": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["p"]}`,
|
|
||||||
"another module's": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["other.old"]}`,
|
|
||||||
"not a list": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":"old"}`,
|
|
||||||
} {
|
|
||||||
raw := `{"module":"m","version":"1","resources":[` + resource + `]}`
|
|
||||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "replace") {
|
|
||||||
t.Errorf("replaces on %s was accepted: %v", what, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ok := `{"module":"m","version":"1","resources":[{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["old"]}]}`
|
|
||||||
if _, err := ParseManifest([]byte(ok)); err != nil {
|
|
||||||
t.Errorf("a process replacing what its module no longer declares was refused: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -46,7 +46,7 @@ func boundUsed(content string) [][2]string {
|
|||||||
// Three facts the mesh states about any provision, plus whatever the provider said it serves. A
|
// 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, error) {
|
func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]string {
|
||||||
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,20 +54,12 @@ func knownFor(m Manifest, needs []Needed, node string) (map[string]map[string]st
|
|||||||
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": as,
|
"as": ConsumerIdentity(node, IdentitySource(m.Slug, m.Module)),
|
||||||
}
|
}
|
||||||
// What the provider derives for this consumer rather than for all of them
|
for key, value := range n.Serves {
|
||||||
// (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)
|
||||||
@@ -75,7 +67,7 @@ func knownFor(m Manifest, needs []Needed, node string) (map[string]map[string]st
|
|||||||
out[want] = values
|
out[want] = values
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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:
|
||||||
|
|||||||
+1
-181
@@ -2,7 +2,6 @@ package catalogue
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"path"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -29,9 +28,6 @@ type Built struct {
|
|||||||
Reference string
|
Reference string
|
||||||
// Digest is "sha256:<hex>", for an archive. An image reference already ends in one.
|
// Digest is "sha256:<hex>", for an archive. An image reference already ends in one.
|
||||||
Digest string
|
Digest string
|
||||||
// Launchers are, for a bundle in an interpreted language, the executable the build wrote beside
|
|
||||||
// each entrypoint, by entrypoint (novox/hq ADR 0193): what the node's runtime starts to serve it.
|
|
||||||
Launchers map[string]string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve fills a manifest's resources in from what was built.
|
// Resolve fills a manifest's resources in from what was built.
|
||||||
@@ -71,49 +67,6 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
|||||||
out := m
|
out := m
|
||||||
out.Build = nil
|
out.Build = nil
|
||||||
out.Resources = nil
|
out.Resources = nil
|
||||||
// What the build compiled, kept on the resolved manifest (novox/hq ADR 0175): a tools bundle is
|
|
||||||
// named by no resource of the module's own — the node's runtime loads it — so this is the only
|
|
||||||
// place the mesh would otherwise not have it. In artifact order, so two resolutions of one
|
|
||||||
// build compare equal.
|
|
||||||
out.Bundles = nil
|
|
||||||
if m.Build != nil {
|
|
||||||
run := runByAResource(m)
|
|
||||||
for _, a := range m.Build.Artifacts {
|
|
||||||
if a.Kind != ArtifactBundle {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
made := by[a.Name]
|
|
||||||
// What the runtime loads: what the artifact said, else every entrypoint of a module
|
|
||||||
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
|
|
||||||
//
|
|
||||||
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
|
|
||||||
// A process the host runs is the module's service, not its tools: the controller declares
|
|
||||||
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
|
|
||||||
// have been launched a second time by the node's runtime, as an MCP child it is not.
|
|
||||||
loads := append([]string(nil), a.Loads...)
|
|
||||||
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
|
|
||||||
loads = append([]string(nil), a.Entrypoints...)
|
|
||||||
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
|
|
||||||
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
|
|
||||||
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
|
|
||||||
if bin := BinaryOf(a); bin != "" {
|
|
||||||
loads = []string{bin}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// **Kept, never routed** (ADR 0155): the builder publishes to the store at the address
|
|
||||||
// it reached it by, and a manifest carrying that address names an installation —
|
|
||||||
// registration refused node-tools for exactly this on 2026-10-02. The build record
|
|
||||||
// already keeps the store-relative form; the resolved manifest keeps the same, and
|
|
||||||
// composition routes it through the store a machine reaches (Routed).
|
|
||||||
out.Bundles = append(out.Bundles, Bundle{
|
|
||||||
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
|
|
||||||
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
|
|
||||||
Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers),
|
|
||||||
Binary: BinaryOf(a),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
named, _ := r["artifact"].(string)
|
named, _ := r["artifact"].(string)
|
||||||
if named == "" {
|
if named == "" {
|
||||||
@@ -157,8 +110,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
|||||||
// The same on the wire: both are bytes fetched by digest and unpacked. They differ in
|
// The same on the wire: both are bytes fetched by digest and unpacked. They differ in
|
||||||
// how they were made — one packed as it stood, the other compiled first — and a
|
// how they were made — one packed as it stood, the other compiled first — and a
|
||||||
// machine has no reason to care which.
|
// machine has no reason to care which.
|
||||||
// Kept, not routed, for the reason the bundles above are (ADR 0155).
|
filled["source"] = artifact.Reference
|
||||||
filled["source"] = Recorded(artifact.Reference)
|
|
||||||
filled["digest"] = artifact.Digest
|
filled["digest"] = artifact.Digest
|
||||||
// **And `${version}`, so a resource can name a place that is this build's alone**
|
// **And `${version}`, so a resource can name a place that is this build's alone**
|
||||||
// (novox/hq ADR 0141, 04-ISSUES/142). A component is unpacked into a directory named
|
// (novox/hq ADR 0141, 04-ISSUES/142). A component is unpacked into a directory named
|
||||||
@@ -220,17 +172,6 @@ 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 {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is a %q and says what it is given (env). Only a bundle the node's runtime "+
|
|
||||||
"serves is given words (novox/hq ADR 0192); a container says its own environment",
|
|
||||||
module, a.Name, a.Kind))
|
|
||||||
}
|
|
||||||
if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
|
if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
|
||||||
// **Except for a language that compiles to a binary, where it names which one**
|
// **Except for a language that compiles to a binary, where it names which one**
|
||||||
// (novox/hq 04-ISSUES/142). A bundle in an interpreted language is the module's own
|
// (novox/hq 04-ISSUES/142). A bundle in an interpreted language is the module's own
|
||||||
@@ -250,26 +191,6 @@ func (b *Build) problems(module string) []string {
|
|||||||
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
|
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
|
||||||
"for it", module, a.Name))
|
"for it", module, a.Name))
|
||||||
}
|
}
|
||||||
problems = append(problems, bundleEnvProblems(module, a)...)
|
|
||||||
// What the runtime loads is among what was compiled (ADR 0175): a name here that is
|
|
||||||
// not an entrypoint is a file the bundle does not contain, and the runtime would
|
|
||||||
// fail to import it on every machine rather than here.
|
|
||||||
for _, load := range a.Loads {
|
|
||||||
found := false
|
|
||||||
for _, e := range a.Entrypoints {
|
|
||||||
found = found || e == load
|
|
||||||
}
|
|
||||||
// A bundle compiled to a binary is one executable: the runtime loads that or nothing
|
|
||||||
// (novox/hq ADR 0193).
|
|
||||||
if bin := BinaryOf(a); bin != "" {
|
|
||||||
found = load == bin
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q says the runtime loads %q, which is not among its entrypoints — "+
|
|
||||||
"what is loaded is compiled, so it is named there too", module, a.Name, load))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// **A system, for a language that compiles to a binary** (novox/hq ADR 0142). A binary
|
// **A system, for a language that compiles to a binary** (novox/hq ADR 0142). A binary
|
||||||
// is pinned to one operating system at link time so a host refuses to touch a machine
|
// is pinned to one operating system at link time so a host refuses to touch a machine
|
||||||
// it was not built for (novox/hq ADR 0005); an artifact that says nothing would be
|
// it was not built for (novox/hq ADR 0005); an artifact that says nothing would be
|
||||||
@@ -394,104 +315,3 @@ func versionOf(digest string) string {
|
|||||||
}
|
}
|
||||||
return hex
|
return hex
|
||||||
}
|
}
|
||||||
|
|
||||||
// bundleEnvWords are the words the runtime sets for itself; a bundle that named one would be
|
|
||||||
// telling the runtime what it is, which is the mesh's to say (novox/hq ADR 0192).
|
|
||||||
var bundleEnvWords = map[string]bool{
|
|
||||||
RuntimeToolModules: true, RuntimeBrokerFile: true, RuntimeOperatorAccount: true,
|
|
||||||
RuntimeOperatorHome: true, RuntimeToolEnv: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192):
|
|
||||||
// a value is a path or a constant written with the references a container's environment may use
|
|
||||||
// for a place or a port, and never a secret's content or another module's binding — a secret
|
|
||||||
// reaches a tool as a file whose path is named.
|
|
||||||
func bundleEnvProblems(module string, a Artifact) []string {
|
|
||||||
if len(a.Env) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
for _, word := range sortedKeys(a.Env) {
|
|
||||||
value := a.Env[word]
|
|
||||||
if bundleEnvWords[word] {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q gives itself %s, which the node's runtime sets for itself; a bundle is "+
|
|
||||||
"given its own words beside the runtime's, never in place of them (novox/hq ADR 0192)",
|
|
||||||
module, a.Name, word))
|
|
||||||
}
|
|
||||||
rest := ofPort.ReplaceAllString(dirRef.ReplaceAllString(value, ""), "")
|
|
||||||
if strings.Contains(rest, "${") {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q gives %s the value %q. A bundle's word is a path or a constant, written with "+
|
|
||||||
"${dir:…} and ${port:…} only; a secret reaches a tool as a file the mesh places, "+
|
|
||||||
"named by its path, never as its content (novox/hq ADR 0192)",
|
|
||||||
module, a.Name, word, value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyWords(in map[string]string) map[string]string {
|
|
||||||
if len(in) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make(map[string]string, len(in))
|
|
||||||
for k, v := range in {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// BinaryOf is what a bundle compiled to a binary is called once built: what the artifact names, else
|
|
||||||
// the package it is built from, else the artifact's own name (novox/hq 04-ISSUES/142). Empty for a
|
|
||||||
// language that does not compile to one. The builder writes the binary under this name, and the
|
|
||||||
// composer runs it by it, so both ask here.
|
|
||||||
func BinaryOf(a Artifact) string {
|
|
||||||
if !compilesToABinary(a.Language) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if name := strings.TrimSpace(a.Binary); name != "" {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
if from := strings.Trim(a.From, "./"); from != "" {
|
|
||||||
return path.Base(from)
|
|
||||||
}
|
|
||||||
return a.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
// runByAResource is the artifacts one of a module's own resources names — a process that runs it,
|
|
||||||
// a step, an archive that unpacks it — by name.
|
|
||||||
func runByAResource(m Manifest) map[string]bool {
|
|
||||||
named := map[string]bool{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if a, ok := r["artifact"].(string); ok && a != "" {
|
|
||||||
named[a] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return named
|
|
||||||
}
|
|
||||||
|
|
||||||
// undeliveredBundles says which of a module's bundles nothing would ever put on a machine (novox/hq
|
|
||||||
// 04-ISSUES/216). A bundle reaches a machine three ways: the node's runtime serves it (it says
|
|
||||||
// `loads`, or its module declares `tools`), a resource names it (a process, a step, an archive), or
|
|
||||||
// it is the runtime itself. One reached by none of them was built, recorded and pushed as success,
|
|
||||||
// and was simply absent — seven modules' tools went missing that way on 2026-10-03. Refused here,
|
|
||||||
// naming the field that would deliver it.
|
|
||||||
func undeliveredBundles(m Manifest) []string {
|
|
||||||
if m.Build == nil || m.Module == RuntimeModule {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
named := runByAResource(m)
|
|
||||||
var problems []string
|
|
||||||
for _, a := range m.Build.Artifacts {
|
|
||||||
if a.Kind != ArtifactBundle || named[a.Name] || len(a.Loads) > 0 || len(m.Tools) > 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: the bundle %q would be built and never reach a machine: nothing loads it, runs it or "+
|
|
||||||
"unpacks it. A tools bundle says `loads` (the entrypoints the node's runtime serves) or its "+
|
|
||||||
"module lists its `tools`; a daemon or a step is a resource naming it (novox/hq 04-ISSUES/216)",
|
|
||||||
m.Module, a.Name))
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,311 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -241,14 +241,9 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
|||||||
// Owner is kept beside the resources because a resource id cannot be split back into its module:
|
// Owner is kept beside the resources because a resource id cannot be split back into its module:
|
||||||
// a module's name may itself contain a dot. What the mesh adds of its own — an opening, the guard —
|
// a module's name may itself contain a dot. What the mesh adds of its own — an opening, the guard —
|
||||||
// has no owner.
|
// has no owner.
|
||||||
//
|
|
||||||
// Received is what each module on the machine is given for each requirement it receives — the same
|
|
||||||
// contributions its received file is written from, kept beside it so the mesh can also issue them
|
|
||||||
// on the bus in the module's membership (novox/hq ADR 0167). By module, then requirement.
|
|
||||||
type Composed struct {
|
type Composed struct {
|
||||||
Resources []map[string]any
|
Resources []map[string]any
|
||||||
Owner map[string]string
|
Owner map[string]string
|
||||||
Received map[string]map[string][]Contribution
|
|
||||||
// LeftOut is every module of this machine's set that was left out of its declaration, and
|
// LeftOut is every module of this machine's set that was left out of its declaration, and
|
||||||
// why (novox/hq ADR 0163, rule 6): a setting stored for it that its definition can no longer
|
// why (novox/hq ADR 0163, rule 6): a setting stored for it that its definition can no longer
|
||||||
// compose. Its held things are kept and its containers untouched — the machine is told so —
|
// compose. Its held things are kept and its containers untouched — the machine is told so —
|
||||||
@@ -272,9 +267,8 @@ func (r Resolution) LeftOut(settings SettingsBy, adopted bool) map[string]string
|
|||||||
// Compose is Declaration with the owner of every resource said.
|
// Compose is Declaration with the owner of every resource said.
|
||||||
func (r Resolution) Compose(with Rendering) (Composed, error) {
|
func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||||
owner := map[string]string{}
|
owner := map[string]string{}
|
||||||
received := map[string]map[string][]Contribution{}
|
|
||||||
leftOut := map[string]string{}
|
leftOut := map[string]string{}
|
||||||
resources, err := r.compose(with, owner, received, leftOut)
|
resources, err := r.compose(with, owner, leftOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Composed{}, err
|
return Composed{}, err
|
||||||
}
|
}
|
||||||
@@ -286,7 +280,7 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
|||||||
"sealed": with.BusMembership, "mode": "0600",
|
"sealed": with.BusMembership, "mode": "0600",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut}, nil
|
return Composed{Resources: resources, Owner: owner, LeftOut: leftOut}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
|
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
|
||||||
@@ -295,8 +289,7 @@ func BusMembershipID() string { return "bus-membership" }
|
|||||||
|
|
||||||
const BusMembershipPath = "/var/lib/mesh/membership-next.json"
|
const BusMembershipPath = "/var/lib/mesh/membership-next.json"
|
||||||
|
|
||||||
func (r Resolution) compose(with Rendering, owner map[string]string,
|
func (r Resolution) compose(with Rendering, owner map[string]string, leftOut map[string]string) ([]map[string]any, error) {
|
||||||
received map[string]map[string][]Contribution, leftOut map[string]string) ([]map[string]any, error) {
|
|
||||||
// **A setting is judged where it is stored, and an impossible one costs a module, not a
|
// **A setting is judged where it is stored, and an impossible one costs a module, not a
|
||||||
// machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes
|
// machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes
|
||||||
// this module uncomposable; it is left out of the declaration — its held things kept, its
|
// this module uncomposable; it is left out of the declaration — its held things kept, its
|
||||||
@@ -415,13 +408,6 @@ 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 {
|
||||||
@@ -515,27 +501,10 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"%s needs a secret called %q and none was made for it", m.Module, name)
|
"%s needs a secret called %q and none was made for it", m.Module, name)
|
||||||
}
|
}
|
||||||
// The runtime's credential belongs to the account the runtime runs as (novox/hq ADR 0175,
|
first = append(first, ownedBy(m.SecretsOwner, map[string]any{
|
||||||
// to-be 38 WP3): its process is composed `user: <account>` where the node has one, and a
|
|
||||||
// root-owned 0600 file is one that process cannot read. Composed here rather than said in
|
|
||||||
// the manifest, because a manifest cannot say ${machine:account} safely — a node with no
|
|
||||||
// account has nothing to resolve it to, and then the runtime runs as root and the file
|
|
||||||
// stays root's.
|
|
||||||
owner := m.SecretsOwner
|
|
||||||
if m.Module == RuntimeModule && r.Account != "" {
|
|
||||||
owner = r.Account
|
|
||||||
}
|
|
||||||
first = append(first, ownedBy(owner, map[string]any{
|
|
||||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed,
|
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
// This module's tools bundles, where the machine runs the node's tool runtime (novox/hq
|
|
||||||
// ADR 0175, to-be 38 WP2). Mesh-computed like everything above it, and before the module's
|
|
||||||
// own resources for the same reason: the runtime's process names the files inside these
|
|
||||||
// and is restarted when one changes, so they are on the machine before it is.
|
|
||||||
if r.runtimeHere() {
|
|
||||||
first = append(first, bundleArchives(m)...)
|
|
||||||
}
|
|
||||||
// Operator-owned paths this module is granted use of (novox/hq ADR 0051). Written before
|
// Operator-owned paths this module is granted use of (novox/hq ADR 0051). Written before
|
||||||
// the module's own resources, and so before the container that mounts them: the host must
|
// the module's own resources, and so before the container that mounts them: the host must
|
||||||
// find each present — refusing clearly if the operator has not provided it — before it
|
// find each present — refusing clearly if the operator has not provided it — before it
|
||||||
@@ -610,14 +579,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, ownedBy(r.provisionsAs(m), map[string]any{
|
first = append(first, 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) {
|
||||||
@@ -652,16 +621,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
as := ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module))
|
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)), own)
|
||||||
// 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
|
||||||
}
|
}
|
||||||
@@ -673,12 +633,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
first = append(first, file)
|
first = append(first, file)
|
||||||
if received[m.Module] == nil {
|
|
||||||
received[m.Module] = map[string][]Contribution{}
|
|
||||||
}
|
|
||||||
// Empty rather than absent when nobody contributed, for the reason the file is
|
|
||||||
// written empty: "nothing asked" and "never told" want different responses.
|
|
||||||
received[m.Module][to] = append([]Contribution{}, given[to]...)
|
|
||||||
}
|
}
|
||||||
if m.Keeps != "" && with.Kept != nil {
|
if m.Keeps != "" && with.Kept != nil {
|
||||||
file, err := keptFile(m.Keeps, with.Kept)
|
file, err := keptFile(m.Keeps, with.Kept)
|
||||||
@@ -709,15 +663,7 @@ 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
|
||||||
@@ -735,10 +681,7 @@ 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, err := knownFor(m, r.Needs, r.Node)
|
known := 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.
|
||||||
@@ -755,11 +698,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
}
|
}
|
||||||
local := *answered
|
local := *answered
|
||||||
local.For = m.Module
|
local.For = m.Module
|
||||||
here, err := knownFor(m, []Needed{local}, r.Node)
|
for provision, values := range knownFor(m, []Needed{local}, r.Node) {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for provision, values := range here {
|
|
||||||
known[provision] = values
|
known[provision] = values
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -784,17 +723,6 @@ 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)
|
||||||
@@ -896,13 +824,6 @@ 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.
|
||||||
@@ -921,30 +842,6 @@ 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
|
|
||||||
// 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
|
|
||||||
// credential the mesh had replaced, because its manifest restarted it on its
|
|
||||||
// environment file and nobody had thought to name the credential too. Composed here so
|
|
||||||
// no manifest has to say it, for a container or a daemon that names the secret's path.
|
|
||||||
if reads := secretsReadBy(copied, m); len(reads) > 0 {
|
|
||||||
copied["restart-on"] = withRestartOn(copied["restart-on"], reads)
|
|
||||||
}
|
|
||||||
// **A version prepares its state before it runs** (novox/hq ADR 0135). Derived from the
|
// **A version prepares its state before it runs** (novox/hq ADR 0135). Derived from the
|
||||||
// module's own resource rather than declared beside it: what prepares the state is the
|
// module's own resource rather than declared beside it: what prepares the state is the
|
||||||
// module's own code, so what it is given has to be what that code is given — and a
|
// module's own code, so what it is given has to be what that code is given — and a
|
||||||
@@ -975,41 +872,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
out = append(out, fact)
|
out = append(out, fact)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The node's tool runtime, last (novox/hq ADR 0175, to-be 38 WP2.3): one process loading every
|
|
||||||
// bundle delivered above and holding the credential sealed above, so both exist before it starts
|
|
||||||
// — the order written here is the order the machine applies.
|
|
||||||
if r.runtimeHere() {
|
|
||||||
process, err := r.runtimeProcess(with)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
owner[fmt.Sprint(process["id"])] = RuntimeModule
|
|
||||||
out = append(out, process)
|
|
||||||
// What each module's bundles are given is read as the account the runtime runs as.
|
|
||||||
words := map[string]map[string]string{}
|
|
||||||
for _, m := range r.Modules {
|
|
||||||
w, err := bundleWords(m, with)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
words[m.Module] = w
|
|
||||||
}
|
|
||||||
// And a file a module's words name is one the runtime is restarted for when it changes.
|
|
||||||
if named := givenTo(out, owner, words, r.Account); len(named) > 0 {
|
|
||||||
restarts, _ := process["restart-on"].([]any)
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, id := range restarts {
|
|
||||||
seen[fmt.Sprint(id)] = true
|
|
||||||
}
|
|
||||||
for _, id := range named {
|
|
||||||
if !seen[id] {
|
|
||||||
restarts = append(restarts, id)
|
|
||||||
seen[id] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
process["restart-on"] = restarts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if with.Adopted {
|
if with.Adopted {
|
||||||
// First, before anything a module declares: what the mesh needs reachable, then its guard.
|
// First, before anything a module declares: what the mesh needs reachable, then its guard.
|
||||||
// The order a machine applies is the order written here.
|
// The order a machine applies is the order written here.
|
||||||
@@ -1225,41 +1087,6 @@ 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.
|
||||||
@@ -1351,17 +1178,12 @@ 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, Derived: derived,
|
From: g.From, Node: g.Consumer, At: g.At, Values: g.Values,
|
||||||
// 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: as,
|
As: holderAs(ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)), g.Local),
|
||||||
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 {
|
||||||
@@ -2114,18 +1936,12 @@ func preparationTarget(m Manifest) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
// A container, or a process the host runs from a bundle the module built (novox/hq issue
|
if fmt.Sprint(r["type"]) != "container" || !ownArtifact(r, m.Module) {
|
||||||
// 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 ""
|
||||||
@@ -2139,10 +1955,7 @@ func ownArtifact(resource map[string]any, module string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
image, _ := resource["image"].(string)
|
image, _ := resource["image"].(string)
|
||||||
// A process or an archive carries what was built as its source (novox/hq issue 213).
|
return strings.HasPrefix(image, ArtifactStoreScheme+module+"/")
|
||||||
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
|
||||||
@@ -2164,19 +1977,7 @@ 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" {
|
step["args"] = []any{PreparationArgument}
|
||||||
// A process says its whole command: the program, then its arguments. The step is the same
|
|
||||||
// program asked to prepare (novox/hq issue 213). It replaces nothing — what the process
|
|
||||||
// replaces is handed over to the process, never to the step that runs before it — and a
|
|
||||||
// step is not restarted, it runs again when what it reads changed, which `restart-on` says.
|
|
||||||
run := stringsIn(from["run"])
|
|
||||||
if len(run) > 0 {
|
|
||||||
step["run"] = []any{run[0], PreparationArgument}
|
|
||||||
}
|
|
||||||
delete(step, "replaces")
|
|
||||||
} else {
|
|
||||||
step["args"] = []any{PreparationArgument}
|
|
||||||
}
|
|
||||||
delete(step, "ports")
|
delete(step, "ports")
|
||||||
delete(step, "ip")
|
delete(step, "ip")
|
||||||
delete(step, "schedule")
|
delete(step, "schedule")
|
||||||
@@ -2237,100 +2038,3 @@ func portOfEndpoint(values map[string]any, ports map[string]int) {
|
|||||||
values["port"] = port
|
values["port"] = port
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// secretsReadBy is the file resources of this module's own secrets that a container or a daemon reads
|
|
||||||
// — named in its volumes, its environment or its env-files by the secret's placed path — as
|
|
||||||
// restart-on ids. Nothing for other shapes, and nothing for a scheduled or run-once process, which
|
|
||||||
// the host refuses a restart-on for (it runs again anyway, and reads the file afresh).
|
|
||||||
func secretsReadBy(resource map[string]any, m Manifest) []string {
|
|
||||||
kind := fmt.Sprint(resource["type"])
|
|
||||||
if kind != "container" && kind != "process" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if resource["schedule"] != nil || resource["run-once"] == true {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var mentioned []string
|
|
||||||
for _, key := range []string{"volumes", "env", "env-file"} {
|
|
||||||
mentioned = append(mentioned, stringsIn(resource[key])...)
|
|
||||||
}
|
|
||||||
var out []string
|
|
||||||
for _, name := range sortedKeys(m.OwnSecrets) {
|
|
||||||
path := m.OwnSecrets[name].Path
|
|
||||||
if path == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, s := range mentioned {
|
|
||||||
// A volume is `source:destination[:mode]`; an env value or an env-file is the path itself.
|
|
||||||
if s == path || strings.HasPrefix(s, path+":") {
|
|
||||||
out = append(out, m.Module+"."+NeedID(name))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// stringsIn is every string in a list or a map's values; nothing for anything else.
|
|
||||||
func stringsIn(v any) []string {
|
|
||||||
switch x := v.(type) {
|
|
||||||
case []any:
|
|
||||||
var out []string
|
|
||||||
for _, item := range x {
|
|
||||||
if s, ok := item.(string); ok {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case []string:
|
|
||||||
return x
|
|
||||||
case map[string]any:
|
|
||||||
var out []string
|
|
||||||
for _, k := range sortedKeys(x) {
|
|
||||||
if s, ok := x[k].(string); ok {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case map[string]string:
|
|
||||||
var out []string
|
|
||||||
for _, k := range sortedKeys(x) {
|
|
||||||
out = append(out, x[k])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// withRestartOn is a resource's restart-on list with these ids added once each.
|
|
||||||
func withRestartOn(have any, add []string) []any {
|
|
||||||
var out []any
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, id := range reflectsRenamed("", have) {
|
|
||||||
s := fmt.Sprint(id)
|
|
||||||
if !seen[s] {
|
|
||||||
seen[s] = true
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, id := range add {
|
|
||||||
if !seen[id] {
|
|
||||||
seen[id] = true
|
|
||||||
out = append(out, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,343 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What a provider derives for each consumer, said once and delivered to both ends
|
|
||||||
// (novox/hq ADR 0201, issue 124).
|
|
||||||
//
|
|
||||||
// The failure these are written against: the object store's provisioner derived each consumer's
|
|
||||||
// bucket from the login the mesh minted, in its own code, and the mesh had no channel to tell the
|
|
||||||
// consumer which bucket that was — so all three consumers wrote the answer into their own
|
|
||||||
// definitions by hand. Two were right. One named a predecessor's bucket and would have
|
|
||||||
// authenticated successfully and been refused on every object. Each of them also named the
|
|
||||||
// machine the module happens to run on, which a definition may not do.
|
|
||||||
|
|
||||||
// store is an object store in the shape minio has: it serves a region and a port to everyone, and
|
|
||||||
// a bucket named for whoever is asking.
|
|
||||||
func store() Manifest {
|
|
||||||
return Manifest{
|
|
||||||
Module: "store", Version: "1",
|
|
||||||
Provides: FromAnywhere("s3-bucket"),
|
|
||||||
Listens: []Listening{{Port: 9000, Protocol: "tcp", From: FromMesh}},
|
|
||||||
Serves: map[string]map[string]any{"s3-bucket": {
|
|
||||||
"region": "eu-west",
|
|
||||||
"bucket": "${consumer:as:dns}",
|
|
||||||
}},
|
|
||||||
Receives: map[string]string{"s3-bucket": "/var/lib/store/grants/mesh.json"},
|
|
||||||
Grants: map[string]string{"s3-bucket": "/var/lib/store/grants"},
|
|
||||||
Resources: []map[string]any{{
|
|
||||||
"id": "server", "type": "container", "name": "store", "ports": []any{"9000"},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// files is a consumer that writes the bucket into its own configuration — which is the thing it
|
|
||||||
// could not do before, and had to transcribe.
|
|
||||||
func files() Manifest {
|
|
||||||
return Manifest{
|
|
||||||
Module: "files", Version: "1", Slug: "files",
|
|
||||||
Requires: []string{"s3-bucket"},
|
|
||||||
Binds: map[string]string{"s3-bucket": "/var/lib/files/store.json"},
|
|
||||||
Secrets: map[string]string{"s3-bucket": "/var/lib/files/store.secret"},
|
|
||||||
Resources: []map[string]any{{
|
|
||||||
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
|
||||||
"content": "BUCKET=${bound:s3-bucket:bucket}\nREGION=${bound:s3-bucket:region}\n",
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pics is a second consumer of the same provider on the same machine: two derivations, neither
|
|
||||||
// the other's.
|
|
||||||
func pics() Manifest {
|
|
||||||
return Manifest{
|
|
||||||
Module: "pics", Version: "1", Slug: "pics",
|
|
||||||
Requires: []string{"s3-bucket"},
|
|
||||||
Binds: map[string]string{"s3-bucket": "/var/lib/pics/store.json"},
|
|
||||||
Secrets: map[string]string{"s3-bucket": "/var/lib/pics/store.secret"},
|
|
||||||
Resources: []map[string]any{{
|
|
||||||
"id": "env", "type": "file", "path": "/var/lib/pics/env", "mode": "0600",
|
|
||||||
"content": "BUCKET=${bound:s3-bucket:bucket}\n",
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The three places the derived value lands must agree, because agreeing is the whole point: the
|
|
||||||
// consumer's own file, the binding it reads as JSON, and the provider's contributions entry.
|
|
||||||
func TestADerivedValueReachesBothEndsAndAgrees(t *testing.T) {
|
|
||||||
r, err := Resolve(shelf(store(), files()), []string{"store", "files"}, reachable(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
out, err := r.Declaration(Rendering{Grants: []Grant{{
|
|
||||||
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
|
||||||
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
|
||||||
}}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The mesh minted this identity for the consumer; the bucket is that identity as a DNS label.
|
|
||||||
// Derived here with the mesh's own function, so the test cannot agree with a wrong rule.
|
|
||||||
as := ConsumerIdentity("workstation", IdentitySource("files", "files"))
|
|
||||||
want := strings.ReplaceAll(as, "_", "-")
|
|
||||||
if want == as || !strings.Contains(as, "_") {
|
|
||||||
t.Fatalf("the mesh's identity %q has no separator to rewrite; this test proves nothing", as)
|
|
||||||
}
|
|
||||||
|
|
||||||
env := fileNamed(out, "files.env")
|
|
||||||
if env == nil {
|
|
||||||
t.Fatalf("the consumer was given no file: %v", out)
|
|
||||||
}
|
|
||||||
if got := env["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") {
|
|
||||||
t.Errorf("the consumer's own file was not told the bucket:\n%s\nwant BUCKET=%s", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
binding := fileNamed(out, "files.bound-s3-bucket")
|
|
||||||
if binding == nil {
|
|
||||||
t.Fatalf("the consumer was given no binding: %v", out)
|
|
||||||
}
|
|
||||||
var said struct {
|
|
||||||
Serves map[string]any `json:"serves"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if said.Serves["bucket"] != want {
|
|
||||||
t.Errorf("the binding says the bucket is %q, want %q", said.Serves["bucket"], want)
|
|
||||||
}
|
|
||||||
// And what is the same for everybody is still the same for everybody.
|
|
||||||
if said.Serves["region"] != "eu-west" {
|
|
||||||
t.Errorf("the binding lost what the provider serves to all: %v", said.Serves)
|
|
||||||
}
|
|
||||||
|
|
||||||
given := storeGrants(t, out)
|
|
||||||
if len(given) != 1 {
|
|
||||||
t.Fatalf("the provider was told about %d consumer(s): %v", len(given), given)
|
|
||||||
}
|
|
||||||
if given[0].Derived["bucket"] != want {
|
|
||||||
t.Errorf("the provider was told the bucket is %v, and the consumer was told %q — "+
|
|
||||||
"the two ends disagree, which is the whole failure", given[0].Derived["bucket"], want)
|
|
||||||
}
|
|
||||||
// Only the per-consumer half. The region is the same for everyone and is already in the
|
|
||||||
// provider's own definition; repeating it here would be a copy to go stale.
|
|
||||||
if _, carried := given[0].Derived["region"]; carried {
|
|
||||||
t.Errorf("the provider was handed back what it already says for everyone: %v", given[0].Derived)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two consumers of one provider on one machine get two buckets, and neither gets the other's.
|
|
||||||
func TestTwoConsumersOfOneProviderGetTheirOwnDerivation(t *testing.T) {
|
|
||||||
r, err := Resolve(shelf(store(), files(), pics()),
|
|
||||||
[]string{"store", "files", "pics"}, reachable(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
out, err := r.Declaration(Rendering{Grants: []Grant{
|
|
||||||
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
|
||||||
Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
|
||||||
{Provision: "s3-bucket", Consumer: "workstation", From: "pics", Slug: "pics",
|
|
||||||
Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
|
||||||
}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
forFiles := strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("files", "files")), "_", "-")
|
|
||||||
forPics := strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("pics", "pics")), "_", "-")
|
|
||||||
if forFiles == forPics {
|
|
||||||
t.Fatal("the two consumers were given the same identity; this test proves nothing")
|
|
||||||
}
|
|
||||||
if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+forFiles+"\n") {
|
|
||||||
t.Errorf("files was not given its own bucket:\n%s", got)
|
|
||||||
}
|
|
||||||
if got := fileNamed(out, "pics.env")["content"].(string); !strings.Contains(got, "BUCKET="+forPics+"\n") {
|
|
||||||
t.Errorf("pics was not given its own bucket:\n%s", got)
|
|
||||||
}
|
|
||||||
var buckets []any
|
|
||||||
for _, g := range storeGrants(t, out) {
|
|
||||||
buckets = append(buckets, g.Derived["bucket"])
|
|
||||||
}
|
|
||||||
if len(buckets) != 2 || buckets[0] == buckets[1] {
|
|
||||||
t.Errorf("the provider was told %v; it must be told one bucket per consumer", buckets)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An operator may still set what the provider serves, and the mesh still derives the rest: the
|
|
||||||
// setting is laid on first, then the consumer's half is filled.
|
|
||||||
func TestASettingComposesWithADerivedValue(t *testing.T) {
|
|
||||||
r, err := Resolve(shelf(store(), files()), []string{"store", "files"}, reachable(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
out, err := r.Declaration(Rendering{
|
|
||||||
Settings: SettingsBy{"store": {{From: "the operator",
|
|
||||||
Values: map[string]any{"bucket": "team-${consumer:as:dns}"}}}},
|
|
||||||
Grants: []Grant{{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
|
||||||
Values: map[string]any{}, Sealed: "c2VhbGVk"}},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := "team-" + strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("files", "files")), "_", "-")
|
|
||||||
if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") {
|
|
||||||
t.Errorf("the operator's prefix did not survive the derivation:\n%s\nwant BUCKET=%s", got, want)
|
|
||||||
}
|
|
||||||
if given := storeGrants(t, out); given[0].Derived["bucket"] != want {
|
|
||||||
t.Errorf("the provider was told %v, the consumer %q", given[0].Derived["bucket"], want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A fact or an alphabet the mesh does not have is refused where the definition is, not where a
|
|
||||||
// consumer happens to be resolved — and the refusal says what may be said instead.
|
|
||||||
func TestAServedValueNamingSomethingTheMeshDoesNotHaveIsRefused(t *testing.T) {
|
|
||||||
for _, c := range []struct{ value, says string }{
|
|
||||||
{"${consumer:node}", "as"},
|
|
||||||
{"${consumer:as:punycode}", "dns"},
|
|
||||||
} {
|
|
||||||
m := store()
|
|
||||||
m.Serves["s3-bucket"]["bucket"] = c.value
|
|
||||||
raw, err := json.Marshal(m)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, err = ParseManifest(raw)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("%s was accepted", c.value)
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), c.value) {
|
|
||||||
t.Errorf("the refusal of %s does not quote it: %v", c.value, err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), c.says) {
|
|
||||||
t.Errorf("the refusal of %s does not say what may be said (%q): %v", c.value, c.says, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `dns` is checked against an identity the mesh actually mints, not an invented string.
|
|
||||||
func TestTheDNSAlphabetIsTheMintedIdentityWithItsSeparatorRewritten(t *testing.T) {
|
|
||||||
as := ConsumerIdentity("anchor", IdentitySource("ncloud", "nextcloud"))
|
|
||||||
if err := CheckIdentity("anchor", IdentitySource("ncloud", "nextcloud")); err != nil {
|
|
||||||
t.Fatalf("the mesh would not mint this identity at all: %v", err)
|
|
||||||
}
|
|
||||||
label := asDNSLabel(as)
|
|
||||||
if strings.Contains(label, "_") {
|
|
||||||
t.Errorf("%q is not a DNS label", label)
|
|
||||||
}
|
|
||||||
if strings.ReplaceAll(label, "-", "_") != as {
|
|
||||||
t.Errorf("%q is not %q with its separator rewritten", label, as)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The check that would have caught the one wrong instance: a consumer that writes the derived
|
|
||||||
// value into its own definition instead of asking for it is refused, whether it transcribed the
|
|
||||||
// right answer or a predecessor's.
|
|
||||||
func TestAConsumerThatTranscribesWhatItsProviderDerivesIsRefused(t *testing.T) {
|
|
||||||
as := ConsumerIdentity("workstation", IdentitySource("files", "files"))
|
|
||||||
transcribed := strings.ReplaceAll(as, "_", "-")
|
|
||||||
|
|
||||||
m := files()
|
|
||||||
m.Resources = []map[string]any{{
|
|
||||||
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
|
||||||
// Exactly what the provider will create — correct today, and a copy of a rule that is
|
|
||||||
// not this module's.
|
|
||||||
"content": "BUCKET=" + transcribed + "\n",
|
|
||||||
}}
|
|
||||||
r, err := Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, err = r.Declaration(Rendering{Grants: []Grant{{
|
|
||||||
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
|
||||||
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
|
||||||
}}})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a definition holding its own copy of the provider's naming rule was accepted")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "${bound:s3-bucket:bucket}") {
|
|
||||||
t.Errorf("the refusal does not say what to write instead: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// And a constant the provider serves to everyone is not a transcription: repeating it is
|
|
||||||
// redundant, not wrong, and refusing it would be the mesh policing style.
|
|
||||||
m.Resources = []map[string]any{{
|
|
||||||
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
|
||||||
"content": "REGION=eu-west\n",
|
|
||||||
}}
|
|
||||||
r, err = Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := r.Declaration(Rendering{Grants: []Grant{{
|
|
||||||
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
|
||||||
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
|
||||||
}}}); err != nil {
|
|
||||||
t.Errorf("a value the provider serves to everyone was judged a transcription: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func storeGrants(t *testing.T, out []map[string]any) []Contribution {
|
|
||||||
t.Helper()
|
|
||||||
for _, r := range out {
|
|
||||||
if r["path"] != "/var/lib/store/grants/mesh.json" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var parsed struct {
|
|
||||||
Given []Contribution `json:"given"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return parsed.Given
|
|
||||||
}
|
|
||||||
t.Fatalf("the provider was given no contributions file: %v", out)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// A consumer that keeps SEVERAL holders of one provision is refused, rather than told one thing
|
|
||||||
// while its provider is told another.
|
|
||||||
//
|
|
||||||
// **This is issue 124's own failure, one case to the side.** The mesh gives each holder its own
|
|
||||||
// login — `mesh_node_mod_<local>` (ADR 0094) — and the provider derives from the login, so it
|
|
||||||
// would make one resource per holder. The consumer's side has no such dimension: there is one
|
|
||||||
// binding file per provision and one `${bound:<provision>:<key>}`, both derived from the
|
|
||||||
// un-suffixed identity. So the provider would create `…-mod-cold` and the consumer would be
|
|
||||||
// configured against `…-mod`: it would authenticate successfully and be refused on every object,
|
|
||||||
// which is exactly the fault this whole record exists to end.
|
|
||||||
//
|
|
||||||
// Refused, loudly, at the one place that can see both halves. Lifting it means giving the
|
|
||||||
// consumer's side a local dimension, which is a decision and not an omission.
|
|
||||||
func TestAConsumerWithSeveralHoldersOfADerivingProviderIsRefused(t *testing.T) {
|
|
||||||
m := files()
|
|
||||||
// Two holders of the one provision, the shape ADR 0094 gives a module that keeps several.
|
|
||||||
m.Secrets = nil
|
|
||||||
m.SecretsMany = map[string]map[string]string{"s3-bucket": {
|
|
||||||
"hot": "/var/lib/files/hot.secret",
|
|
||||||
"cold": "/var/lib/files/cold.secret",
|
|
||||||
}}
|
|
||||||
m.Resources = []map[string]any{{
|
|
||||||
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
|
||||||
"content": "BUCKET=${bound:s3-bucket:bucket}\n",
|
|
||||||
}}
|
|
||||||
r, err := Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, err = r.Declaration(Rendering{Grants: []Grant{
|
|
||||||
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
|
||||||
Local: "hot", Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
|
||||||
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
|
||||||
Local: "cold", Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
|
||||||
}})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a consumer with several holders of a deriving provider was accepted; " +
|
|
||||||
"its two ends would have disagreed in silence")
|
|
||||||
}
|
|
||||||
for _, want := range []string{"files", "s3-bucket", "bucket"} {
|
|
||||||
if !strings.Contains(err.Error(), want) {
|
|
||||||
t.Errorf("the refusal does not name %q: %v", want, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,558 +0,0 @@
|
|||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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"}
|
|
||||||
)
|
|
||||||
|
|
||||||
// contributionTargets is every name a contribution's `for` may take.
|
|
||||||
func contributionTargets() []string {
|
|
||||||
return append(append([]string(nil), knownShells...), sessionFiles...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
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:([^}]*)\}`)
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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, and the session's files %s",
|
|
||||||
m.Module, i+1, c.For, strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", ")))
|
|
||||||
}
|
|
||||||
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)...)
|
|
||||||
}
|
|
||||||
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 or the session's file one of %s, and the slot one of %s", m.Module, r["id"], c[0],
|
|
||||||
strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "),
|
|
||||||
strings.Join(knownSlots, ", ")))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seat := placerOf(target)
|
|
||||||
if m.ClaimsSeat(seat) || refusedFor[seat] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
refusedFor[seat] = true
|
|
||||||
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 := placeholderProblems(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()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if ofShell.MatchString(content) {
|
|
||||||
content = ofShell.ReplaceAllStringFunc(content, func(placeholder string) string {
|
|
||||||
shell, slot, _ := strings.Cut(ofShell.FindStringSubmatch(placeholder)[1], ":")
|
|
||||||
return shellCode(modules, shell, slot)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
resource["content"] = content
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,471 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -444,18 +444,6 @@ func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int,
|
|||||||
b.WriteString("\t\t# this machine's own guests reaching outward: not a port opened to anybody\n")
|
b.WriteString("\t\t# this machine's own guests reaching outward: not a port opened to anybody\n")
|
||||||
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } accept\n", inward))
|
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } accept\n", inward))
|
||||||
}
|
}
|
||||||
// **The mesh passing through, not arriving.** A machine the mesh routes through — the hub, for
|
|
||||||
// every path between machines that are not co-located (novox/hq ADR 0007) — relays a packet that
|
|
||||||
// came in on the tunnel and leaves on it again, addressed to another machine of the mesh. That is
|
|
||||||
// no port of this machine's: the machine it is for filters it against its own rules. Without
|
|
||||||
// this, the chain below judged a relayed packet by this machine's own published ports, so two
|
|
||||||
// machines behind the hub reached each other only on ports the hub happened to publish for itself
|
|
||||||
// (novox/hq issue 196). In and out on the tunnel both: a packet off the tunnel for this machine's
|
|
||||||
// own containers leaves by a bridge, and still meets the rules below.
|
|
||||||
if tunnel != "" {
|
|
||||||
b.WriteString("\t\t# the mesh passing through to another of its machines, which filters it itself\n")
|
|
||||||
b.WriteString(fmt.Sprintf("\t\tiifname %q oifname %q accept\n", tunnel, tunnel))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(rules) > 0 {
|
if len(rules) > 0 {
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|||||||
@@ -887,34 +887,3 @@ func TestAPublicPortNeedsNoGuestLine(t *testing.T) {
|
|||||||
t.Fatalf("a public port was given a guest line it does not need:\n%s", nft)
|
t.Fatalf("a public port was given a guest line it does not need:\n%s", nft)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// **The hub relays the mesh** (novox/hq ADR 0007, issue 196). Two machines that are not co-located
|
|
||||||
// reach each other through the hub, so the hub forwards a packet that arrives on the tunnel and
|
|
||||||
// leaves on it. The forward chain judged that packet by the hub's own published ports, and two
|
|
||||||
// machines behind the hub reached each other only on the ports the hub happened to publish.
|
|
||||||
//
|
|
||||||
// Measured: from one home machine to another through the hub, 17 of 55 ports answered, and they
|
|
||||||
// were exactly the hub's own; the SYN for the rest never left the hub.
|
|
||||||
func TestTheMeshPassingThroughIsRelayedNotJudgedAsThisMachines(t *testing.T) {
|
|
||||||
nft := AsNftables(nil, []string{"10.42.0.1", "10.42.0.2"}, false, nil, []string{"eth0"}, "mesh0")
|
|
||||||
relay := `iifname "mesh0" oifname "mesh0" accept`
|
|
||||||
if !strings.Contains(chainBody(t, nft, "forward"), relay) {
|
|
||||||
t.Errorf("the forward chain does not relay the mesh through this machine:\n%s", chainBody(t, nft, "forward"))
|
|
||||||
}
|
|
||||||
// Relaying is not receiving: nothing in the input chain opens because of it.
|
|
||||||
if strings.Contains(chainBody(t, nft, "input"), "oifname") {
|
|
||||||
t.Errorf("the input chain names an outgoing interface, which no packet for this machine has:\n%s",
|
|
||||||
chainBody(t, nft, "input"))
|
|
||||||
}
|
|
||||||
// And off the tunnel into this machine's own containers is still judged: the tunnel is not
|
|
||||||
// accepted wholesale, only in and out on it.
|
|
||||||
if strings.Contains(chainBody(t, nft, "forward"), `iifname "mesh0" accept`) {
|
|
||||||
t.Errorf("the forward chain accepts everything off the tunnel:\n%s", chainBody(t, nft, "forward"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// A machine with no tunnel relays nothing, and names no interface it does not have.
|
|
||||||
alone := AsNftables(nil, nil, false, nil, []string{"eth0"}, "")
|
|
||||||
if strings.Contains(alone, "oifname") {
|
|
||||||
t.Errorf("a machine with no tunnel was given a relay rule:\n%s", alone)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -171,7 +170,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 code, over the
|
// The forge is reached a third way that neither test above covers: by its own sidecar, 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
|
||||||
@@ -179,13 +178,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: "code", Kind: ArtifactBundle,
|
Name: "runtime", Kind: ArtifactImage,
|
||||||
Reference: ArtifactStoreScheme + "gitea/code/blobs/" + bundleDigest, Digest: bundleDigest,
|
Reference: "registry.example/gitea-runtime@sha256:" + strings.Repeat("a", 64),
|
||||||
}})
|
}})
|
||||||
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, theRuntime(t)}, Needs: []Needed{
|
r := Resolution{Node: "anchor", Modules: []Manifest{forge}, 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"},
|
||||||
@@ -195,8 +194,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{ArtifactStore: "anchor.internal:5101",
|
out, err := r.Declaration(Rendering{
|
||||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-broker"}},
|
Needed: map[string]map[string]string{"gitea": {"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 {
|
||||||
@@ -211,19 +210,14 @@ 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"])
|
||||||
}
|
}
|
||||||
// The forge's own code runs in the node's runtime (novox/hq ADR 0198), given its words there.
|
runtime := fileNamed(out, "gitea.runtime")
|
||||||
runtime := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
|
||||||
if runtime == nil {
|
if runtime == nil {
|
||||||
t.Fatalf("the node's runtime is not in the declaration: %v", ids(out))
|
t.Fatalf("the forge's sidecar is not in the declaration: %v", out)
|
||||||
}
|
}
|
||||||
env, _ := runtime["env"].(map[string]string)
|
env, _ := runtime["env"].(map[string]any)
|
||||||
var given map[string]map[string]string
|
if env["MESH_GITEA_URL"] != "http://127.0.0.1:2999" {
|
||||||
if err := json.Unmarshal([]byte(env[RuntimeToolEnv]), &given); err != nil {
|
t.Fatalf("the forge's sidecar dials %v while the machine publishes the forge on 2999 — "+
|
||||||
t.Fatalf("the runtime's %s is not JSON: %q", RuntimeToolEnv, env[RuntimeToolEnv])
|
"whatever reads it dials a dead port", env["MESH_GITEA_URL"])
|
||||||
}
|
|
||||||
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"])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,13 +229,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: "code", Kind: ArtifactBundle,
|
Name: "runtime", Kind: ArtifactImage,
|
||||||
Reference: ArtifactStoreScheme + "gitea/code/blobs/" + bundleDigest, Digest: bundleDigest,
|
Reference: "registry.example/gitea-runtime@sha256:" + strings.Repeat("a", 64),
|
||||||
}})
|
}})
|
||||||
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, theRuntime(t)}, Needs: []Needed{
|
r := Resolution{Node: "anchor", Modules: []Manifest{resolved}, 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"},
|
||||||
@@ -252,8 +246,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{ArtifactStore: "anchor.internal:5101",
|
out, err := r.Declaration(Rendering{
|
||||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-broker"}},
|
Needed: map[string]map[string]string{"gitea": {"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},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
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")},
|
|
||||||
}},
|
|
||||||
{Name: DisplaySessionSeat, Scope: ScopeNode, Decision: decided, 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
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -45,16 +43,8 @@ func jailsInto(modules []Manifest, j *Jailing) []map[string]any {
|
|||||||
|
|
||||||
out := make([]map[string]any, 0, len(jails)+1)
|
out := make([]map[string]any, 0, len(jails)+1)
|
||||||
for _, d := range jails {
|
for _, d := range jails {
|
||||||
// **The filter's digest rides in the jail file.** fail2ban is restarted when this file
|
fmt.Fprintf(&composed, "\n# from %s\n[%s]\nenabled = true\nfilter = %s\n%s\n",
|
||||||
// changes, and the filter is a file of its own: a module that changed only what a failure
|
d.module, d.jail.Name, d.jail.Name, strings.TrimRight(d.jail.Jail, "\n"))
|
||||||
// looks like rewrote the filter on disk and left the running jail on the old pattern, with
|
|
||||||
// nothing said (novox/hq issue 191's rollout found it on gitea's sshd). Naming the filter's
|
|
||||||
// digest here makes a changed pattern a changed jail file, so the restart the service
|
|
||||||
// already takes on it covers the filter too.
|
|
||||||
sum := sha256.Sum256([]byte(d.jail.Failregex))
|
|
||||||
fmt.Fprintf(&composed, "\n# from %s, filter %s\n[%s]\nenabled = true\nfilter = %s\n%s\n",
|
|
||||||
d.module, hex.EncodeToString(sum[:])[:12], d.jail.Name, d.jail.Name,
|
|
||||||
strings.TrimRight(d.jail.Jail, "\n"))
|
|
||||||
// The filter is a file of its own, named as the jail's filter= references it.
|
// The filter is a file of its own, named as the jail's filter= references it.
|
||||||
out = append(out, map[string]any{
|
out = append(out, map[string]any{
|
||||||
"id": "filter-" + d.jail.Name,
|
"id": "filter-" + d.jail.Name,
|
||||||
|
|||||||
@@ -44,29 +44,3 @@ func TestTheComposedJailFileIsWrittenEvenWhenEmpty(t *testing.T) {
|
|||||||
t.Fatalf("the empty composed jail file was not written alone: %v", files)
|
t.Fatalf("the empty composed jail file was not written alone: %v", files)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A changed pattern restarts fail2ban (novox/hq issue 191's rollout): the service restarts when the
|
|
||||||
// composed jail file changes, and the filter is a file of its own, so the jail file names the
|
|
||||||
// filter's digest. Changing only the failregex must change the jail file; the same pattern must not.
|
|
||||||
func TestAChangedFilterChangesTheJailFile(t *testing.T) {
|
|
||||||
jailFile := func(failregex string) string {
|
|
||||||
modules := []Manifest{
|
|
||||||
{Module: "fail2ban", Jailing: &Jailing{Into: "/etc/fail2ban/jail.d/mesh.conf", FilterInto: "/etc/fail2ban/filter.d"}},
|
|
||||||
{Module: "gitea", Jails: []Jail{{Name: "gitea", Failregex: failregex, Jail: "port = 222"}}},
|
|
||||||
}
|
|
||||||
for _, f := range jailsInto(modules, modules[0].Jailing) {
|
|
||||||
if f["id"] == ComposedJailsID() {
|
|
||||||
return f["content"].(string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Fatal("no composed jail file")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
before := jailFile("web login failed from <HOST>")
|
|
||||||
if again := jailFile("web login failed from <HOST>"); again != before {
|
|
||||||
t.Errorf("the same pattern composed a different jail file, which would restart fail2ban for nothing")
|
|
||||||
}
|
|
||||||
if after := jailFile("web login failed from <HOST>\n Invalid user .* from <HOST>"); after == before {
|
|
||||||
t.Errorf("a changed pattern left the jail file as it was, so fail2ban keeps the old filter:\n%s", after)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -102,11 +102,8 @@ func accountHomeOf(account, home string) string {
|
|||||||
func machineInto(resource map[string]any, facts map[string]string, module string) error {
|
func machineInto(resource map[string]any, facts map[string]string, module string) error {
|
||||||
// Content, and now the path and owner too: a module that writes into a person's home names it
|
// Content, and now the path and owner too: a module that writes into a person's home names it
|
||||||
// with ${machine:account-home} and ${machine:account}, which it cannot know until assigned
|
// with ${machine:account-home} and ${machine:account}, which it cannot know until assigned
|
||||||
// (novox/hq to-be 29), the same reason its content names ${machine:address}. And the name a
|
// (novox/hq to-be 29), the same reason its content names ${machine:address}.
|
||||||
// `user` shape sets the login shell of, and the user a user-scoped unit or a process runs as:
|
for _, field := range []string{"path", "owner", "content"} {
|
||||||
// the shell module makes the operator's account its holder's login shell, and the desktop's
|
|
||||||
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person.
|
|
||||||
for _, field := range []string{"path", "owner", "content", "name", "user"} {
|
|
||||||
s, ok := resource[field].(string)
|
s, ok := resource[field].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -147,17 +147,8 @@ 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"`
|
||||||
@@ -205,29 +196,27 @@ 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, reach}: %w", err)
|
return fmt.Errorf("a provided name is either a string or {name, scope, credential}: %w", err)
|
||||||
}
|
}
|
||||||
o.Name, o.Scope, o.Credential, o.Reach = full.Name, full.Scope, full.Credential, full.Reach
|
o.Name, o.Scope, o.Credential = full.Name, full.Scope, full.Credential
|
||||||
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 && o.Reach == "" {
|
if o.Scope == "" && o.Credential == nil {
|
||||||
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"`
|
||||||
Reach string `json:"reach,omitempty"`
|
}{o.Name, o.Scope, o.Credential})
|
||||||
}{o.Name, o.Scope, o.Credential, o.Reach})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manifest is everything a module says about itself.
|
// Manifest is everything a module says about itself.
|
||||||
@@ -336,15 +325,6 @@ 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.
|
||||||
@@ -528,17 +508,6 @@ 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"`
|
|
||||||
|
|
||||||
// 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
|
||||||
@@ -603,44 +572,6 @@ type Manifest struct {
|
|||||||
// a module that could ask for it could read every credential on the bus — and the claim on
|
// a module that could ask for it could read every credential on the bus — and the claim on
|
||||||
// `mesh-broker` is what authorises it, checked from this manifest alone.
|
// `mesh-broker` is what authorises it, checked from this manifest alone.
|
||||||
BusUsers string `json:"bus-users,omitempty"`
|
BusUsers string `json:"bus-users,omitempty"`
|
||||||
|
|
||||||
// Bundles are this module's compiled bundles as the build produced them: what each is called,
|
|
||||||
// where it is, what it hashes to, what language it is in and which files a tool runtime loads
|
|
||||||
// from it (novox/hq ADR 0175, to-be 38).
|
|
||||||
//
|
|
||||||
// **Derived, never written.** The manifest in a repository says `build.artifacts`; the manifest
|
|
||||||
// the mesh holds says what came out, the way a resource naming an artifact comes to name a
|
|
||||||
// digest. Kept here because a tools bundle is referenced by no resource of the module's own —
|
|
||||||
// the node's runtime loads it, and the runtime is composed by the mesh — so without this the
|
|
||||||
// resolved manifest would carry no trace of the one artifact the runtime needs. A repository
|
|
||||||
// manifest that writes this beside a build is refused: it would be stating the build's output
|
|
||||||
// by hand.
|
|
||||||
Bundles []Bundle `json:"bundles,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bundle is one compiled bundle after it exists, as the resolved manifest carries it.
|
|
||||||
type Bundle struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
// Source is where a machine fetches it, kept without the store's address like every reference
|
|
||||||
// the mesh records (artifacts.go); Digest is what it must hash to.
|
|
||||||
Source string `json:"source"`
|
|
||||||
Digest string `json:"digest"`
|
|
||||||
// Language is what it was compiled from, which is what says how it is run.
|
|
||||||
Language string `json:"language,omitempty"`
|
|
||||||
// Entrypoints are the compiled files it was built around, relative to its root.
|
|
||||||
Entrypoints []string `json:"entrypoints,omitempty"`
|
|
||||||
// Loads are the entrypoints a node's tool runtime imports from it: what the artifact said, or
|
|
||||||
// every entrypoint for a module declaring tools that said nothing. Empty for a bundle that is
|
|
||||||
// run rather than loaded.
|
|
||||||
Loads []string `json:"loads,omitempty"`
|
|
||||||
// Env is what the artifact said it is given (ADR 0192), as written; composed per machine.
|
|
||||||
Env map[string]string `json:"env,omitempty"`
|
|
||||||
// Launchers are the executables the build wrote beside its entrypoints, by entrypoint (novox/hq
|
|
||||||
// ADR 0193). A bundle built before them has none, and is served as it was built.
|
|
||||||
Launchers map[string]string `json:"launchers,omitempty"`
|
|
||||||
// Binary is the executable a bundle compiled to a binary is, at its root (novox/hq ADR 0193):
|
|
||||||
// what runs it, where an interpreted bundle names an interpreter and an entrypoint.
|
|
||||||
Binary string `json:"binary,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build says how to produce this module's artifacts from its source.
|
// Build says how to produce this module's artifacts from its source.
|
||||||
@@ -777,26 +708,6 @@ type Artifact struct {
|
|||||||
// somebody adds a helper. An empty list is a bundle that is run rather than loaded — a
|
// somebody adds a helper. An empty list is a bundle that is run rather than loaded — a
|
||||||
// provisioner or a step, named by whatever runs it.
|
// provisioner or a step, named by whatever runs it.
|
||||||
Entrypoints []string `json:"entrypoints,omitempty"`
|
Entrypoints []string `json:"entrypoints,omitempty"`
|
||||||
|
|
||||||
// Loads are the entrypoints of this bundle the node's tool runtime loads (novox/hq ADR 0175,
|
|
||||||
// to-be 38): the module's tool code, each file registering its tools as it is imported. A
|
|
||||||
// subset of Entrypoints, for a bundle that also carries things that are RUN — a daemon, a
|
|
||||||
// step, a report — and must not have them imported into the runtime.
|
|
||||||
//
|
|
||||||
// Absent means every entrypoint, for a module that declares `tools`: a bundle holding the
|
|
||||||
// module's tools and nothing else is the ordinary case and should not have to say the same
|
|
||||||
// list twice. A module declaring no tools has nothing the runtime loads, whatever it compiles.
|
|
||||||
Loads []string `json:"loads,omitempty"`
|
|
||||||
|
|
||||||
// External are packages a TypeScript bundle keeps as imports rather than inlining — a native
|
|
||||||
// addon, a package that reads its own files — and so carries the toolchain's node_modules for
|
|
||||||
// (novox/hq ADR 0193). Absent for nearly every bundle, which is then one file per entrypoint.
|
|
||||||
External []string `json:"external,omitempty"`
|
|
||||||
|
|
||||||
// Env is what a tools bundle is given on a machine (novox/hq ADR 0192): words and their values,
|
|
||||||
// paths and constants composed with ${dir:…} and ${port:…} exactly as a container's environment
|
|
||||||
// is, never a secret's content. The node's runtime hands it to this bundle and to no other.
|
|
||||||
Env map[string]string `json:"env,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kinds an artifact may be.
|
// Kinds an artifact may be.
|
||||||
@@ -1328,19 +1239,6 @@ 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.
|
||||||
@@ -1360,8 +1258,6 @@ 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) {
|
||||||
@@ -1424,7 +1320,6 @@ 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
|
||||||
@@ -1438,15 +1333,6 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
//
|
//
|
||||||
// Refused here because the alternative is a build that never returns, on a mesh new enough
|
// Refused here because the alternative is a build that never returns, on a mesh new enough
|
||||||
// that nobody is watching it yet.
|
// that nobody is watching it yet.
|
||||||
if m.Build != nil && len(m.Bundles) > 0 {
|
|
||||||
// The output of a build, written beside the build that produces it (ADR 0175). A resource
|
|
||||||
// naming a digest beside an `artifact` would be the same mistake, and is caught the same way:
|
|
||||||
// what the mesh derives, a repository does not state.
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s writes `bundles` beside its build. The mesh derives that from what the build "+
|
|
||||||
"produced; a manifest states `build.artifacts` and nothing about what came out",
|
|
||||||
m.Module))
|
|
||||||
}
|
|
||||||
if m.Build != nil && len(m.Build.Artifacts) > 0 {
|
if m.Build != nil && len(m.Build.Artifacts) > 0 {
|
||||||
for _, o := range m.Offers() {
|
for _, o := range m.Offers() {
|
||||||
if o != ArtifactStoreProvision {
|
if o != ArtifactStoreProvision {
|
||||||
@@ -1474,10 +1360,6 @@ 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(
|
||||||
@@ -1565,53 +1447,6 @@ 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
|
||||||
@@ -1619,7 +1454,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 or process running an artifact it built — "+
|
"%s says it prepares its state, and declares no container 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))
|
||||||
}
|
}
|
||||||
@@ -1672,13 +1507,6 @@ 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) {
|
||||||
@@ -1839,13 +1667,6 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
problems = append(problems, m.undeclaredMounts()...)
|
problems = append(problems, m.undeclaredMounts()...)
|
||||||
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()...)
|
|
||||||
// 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()...)
|
|
||||||
|
|
||||||
for i, r := range m.Resources {
|
for i, r := range m.Resources {
|
||||||
id, _ := r["id"].(string)
|
id, _ := r["id"].(string)
|
||||||
@@ -1948,46 +1769,6 @@ func (m Manifest) MachineSide(port int) (at int, mayAssign bool) {
|
|||||||
var facilitiesOf = map[string][]string{
|
var facilitiesOf = map[string][]string{
|
||||||
// Both spellings: /var/run is a link to /run on every machine the mesh runs on.
|
// Both spellings: /var/run is a link to /run on every machine the mesh runs on.
|
||||||
"container-runtime": {"/var/run/docker.sock", "/run/docker.sock"},
|
"container-runtime": {"/var/run/docker.sock", "/run/docker.sock"},
|
||||||
// The virtualisation daemon's socket, for the lab (novox/hq ADR 0172): it raises machines there.
|
|
||||||
"virtualisation": {"/var/lib/incus/unix.socket"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// jailProblems is every jail this module declares that the machine's intrusion prevention would
|
|
||||||
// refuse (novox/hq ADR 0179).
|
|
||||||
//
|
|
||||||
// **Because one bad pattern stops every jail, not its own.** fail2ban expands `<HOST>` into a named
|
|
||||||
// capture group, so a pattern naming it twice is a duplicate group name, and the daemon refuses the
|
|
||||||
// whole configuration and exits — the machine keeps no bans at all, for any jail, including the one
|
|
||||||
// watching its ssh. Caught live on the control node the day this was built, where a proxy's pattern
|
|
||||||
// matched two shapes of refusal in one line. A pattern matches one shape; several shapes are several
|
|
||||||
// patterns, one per line, as fail2ban's own filters are written.
|
|
||||||
func (m Manifest) jailProblems() []string {
|
|
||||||
var problems []string
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, j := range m.Jails {
|
|
||||||
switch {
|
|
||||||
case strings.TrimSpace(j.Name) == "":
|
|
||||||
problems = append(problems, m.Module+" declares a jail with no name")
|
|
||||||
case seen[j.Name]:
|
|
||||||
problems = append(problems, m.Module+" declares two jails called "+strconv.Quote(j.Name))
|
|
||||||
}
|
|
||||||
seen[j.Name] = true
|
|
||||||
if strings.TrimSpace(j.Failregex) == "" {
|
|
||||||
problems = append(problems, m.Module+"'s jail "+strconv.Quote(j.Name)+" says nothing a failed attempt looks like")
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(j.Failregex, "\n") {
|
|
||||||
if strings.TrimSpace(line) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if n := strings.Count(line, "<HOST>"); n > 1 {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's jail %s names <HOST> %d times in one pattern; "+
|
|
||||||
"fail2ban reads it as one capture group and refuses the whole configuration, so the machine "+
|
|
||||||
"keeps no bans at all — write one pattern per shape, each naming <HOST> once",
|
|
||||||
m.Module, strconv.Quote(j.Name), n))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// undeclaredMounts is every bind-mount source no declaration covers — see the check above.
|
// undeclaredMounts is every bind-mount source no declaration covers — see the check above.
|
||||||
@@ -2029,12 +1810,6 @@ func (m Manifest) undeclaredMounts() []string {
|
|||||||
claim(p)
|
claim(p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The file a filter module's rule set is written to is declared by `filtering.into`: the mesh
|
|
||||||
// writes it, the module loads it, and the module's runtime may read it back to reload the
|
|
||||||
// mesh's own table (novox/hq ADR 0170).
|
|
||||||
if m.Filtering != nil {
|
|
||||||
claim(m.Filtering.Into)
|
|
||||||
}
|
|
||||||
// Under a declared directory is declared: a module that says where its data lives has said so
|
// Under a declared directory is declared: a module that says where its data lives has said so
|
||||||
// for what it puts inside.
|
// for what it puts inside.
|
||||||
covers := func(path string) bool {
|
covers := func(path string) bool {
|
||||||
@@ -2216,71 +1991,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -98,13 +98,3 @@ func TestAMountOfABoundFactIsAccepted(t *testing.T) {
|
|||||||
t.Fatalf("a mount of the file the mesh writes a binding to was refused: %v", err)
|
t.Fatalf("a mount of the file the mesh writes a binding to was refused: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The file a filter module's rule set is written to is declared by `filtering.into` (novox/hq ADR
|
|
||||||
// 0169): the module's runtime mounts it to reload the mesh's own table, and nothing else declares it.
|
|
||||||
func TestAMountOfTheFilterFileIsDeclaredByFilteringInto(t *testing.T) {
|
|
||||||
_, err := ParseManifest([]byte(`{"module":"nftables","filtering":{"into":"/etc/nftables.conf"},` +
|
|
||||||
`"resources":[` + strings.Replace(aContainerMounting, "%s", "/etc/nftables.conf", 1) + `]}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("a filter module mounting its own filter file was refused: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Which machine serves each routed name (novox/hq ADR 0066, issue 178).
|
||||||
|
//
|
||||||
|
// A routed name is a label the mesh composed for a consumer's endpoint, and it is *served* by the
|
||||||
|
// provider that answers requests for it — the proxy the consumer's route reaches. The same name is
|
||||||
|
// composed into every labelled contribution the consumer makes, because a provider that must know
|
||||||
|
// the consumer's public name (an identity provider composing a redirect) is told it the same way
|
||||||
|
// (04-ISSUES/122). Attributing the name to whichever of those providers a map happened to yield
|
||||||
|
// last sent a public name to the identity provider's machine on one plan and to the proxy's on the
|
||||||
|
// next (forge issue 227), and the whole names region flipped with it.
|
||||||
|
//
|
||||||
|
// **The terminus serves the name.** Among the providers a name reaches, the one that serves it is
|
||||||
|
// the one that is not itself routed: a provider that contributes a labelled name of its own to some
|
||||||
|
// requirement is published through another provider, and is a consumer of names, not their end.
|
||||||
|
// Name-agnostic — nothing here knows what "route" means — and structural: it reads the graph the
|
||||||
|
// modules declared. Deterministic: names, requirements and nodes are walked in order, so two
|
||||||
|
// plans of one mesh yield one region.
|
||||||
|
|
||||||
|
// NamesServed is every routed name across the mesh and the node that serves it, from every node's
|
||||||
|
// resolution and settings. A name several termini claim goes to the first node in name order, so
|
||||||
|
// the answer is stable; a name nothing terminal claims is left out.
|
||||||
|
func NamesServed(plans map[string]Resolution, settings map[string]SettingsBy) (map[string]string, error) {
|
||||||
|
nodes := make([]string, 0, len(plans))
|
||||||
|
for n := range plans {
|
||||||
|
nodes = append(nodes, n)
|
||||||
|
}
|
||||||
|
sort.Strings(nodes)
|
||||||
|
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, node := range nodes {
|
||||||
|
plan := plans[node]
|
||||||
|
all, err := plan.contributions(settings[node], nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
requirements := make([]string, 0, len(all))
|
||||||
|
for to := range all {
|
||||||
|
requirements = append(requirements, to)
|
||||||
|
}
|
||||||
|
sort.Strings(requirements)
|
||||||
|
for _, to := range requirements {
|
||||||
|
for _, given := range all[to] {
|
||||||
|
if given.Node != "" {
|
||||||
|
// Said from another machine; that machine's own resolution carries it.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A routed name, and only that: a contribution the mesh composed a name for from a
|
||||||
|
// label it was given. A grant that happens to carry a `name` of its own — a database
|
||||||
|
// name — carries no label and is left alone.
|
||||||
|
if _, labelled := given.Values["label"]; !labelled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, _ := given.Values["name"].(string)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
serving := servingNodeOf(plan, to, given.From, node)
|
||||||
|
if !servesNames(plans[serving], to) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name = strings.ToLower(name)
|
||||||
|
if held, taken := out[name]; !taken || serving < held {
|
||||||
|
out[name] = serving
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// servingNodeOf is the node answering one consumer's requirement: whoever the plan needs it from,
|
||||||
|
// or this same node when the provider is beside the consumer.
|
||||||
|
func servingNodeOf(plan Resolution, requirement, consumer, self string) string {
|
||||||
|
for _, need := range plan.Needs {
|
||||||
|
if need.Name == requirement && need.For == consumer && need.From != "" {
|
||||||
|
return need.From
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
// servesNames says whether the module providing a requirement on a node is a terminus: it is not
|
||||||
|
// itself published under a labelled name through some other provider. A node whose plan is not
|
||||||
|
// known (it did not resolve) serves nothing.
|
||||||
|
func servesNames(plan Resolution, requirement string) bool {
|
||||||
|
for _, m := range plan.Modules {
|
||||||
|
if !offers(m, requirement) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return !contributesALabel(m)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func offers(m Manifest, requirement string) bool {
|
||||||
|
for _, o := range m.Offers() {
|
||||||
|
if o == requirement {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func contributesALabel(m Manifest) bool {
|
||||||
|
for _, values := range m.Contributes {
|
||||||
|
if _, labelled := values["label"]; labelled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, locals := range m.ContributesMany {
|
||||||
|
for _, values := range locals {
|
||||||
|
if _, labelled := values["label"]; labelled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The mesh of forge issue 227 (novox/hq issue 178): a dashboard on the home server contributes its
|
||||||
|
// label to the route its proxy serves AND to the identity provider on the control node, which must
|
||||||
|
// know the dashboard's public name to compose a redirect. Both contributions carry the composed
|
||||||
|
// name; only the proxy serves it.
|
||||||
|
func twoNodesOneName(t *testing.T) (map[string]Resolution, map[string]SettingsBy) {
|
||||||
|
t.Helper()
|
||||||
|
catalogue := shelf(
|
||||||
|
Manifest{Module: "route-adapter", Version: "1", Provides: Offers("route"),
|
||||||
|
Serves: map[string]map[string]any{"route": {}}, Receives: map[string]string{"route": "/etc/adapter/mesh.json"}},
|
||||||
|
Manifest{Module: "route-proxy", Version: "1", Provides: Offers("route"),
|
||||||
|
Serves: map[string]map[string]any{"route": {}}, Receives: map[string]string{"route": "/etc/proxy/mesh.json"}},
|
||||||
|
Manifest{Module: "keycloak", Version: "1", Provides: FromAnywhere("oidc-client"),
|
||||||
|
Serves: map[string]map[string]any{"oidc-client": {"token-path": "/token"}},
|
||||||
|
Receives: map[string]string{"oidc-client": "/etc/keycloak/clients.json"},
|
||||||
|
Listens: []Listening{{Port: 8080, From: FromMesh, Why: "the login page"}},
|
||||||
|
// Published through the proxy itself: the identity provider is routed, not a router.
|
||||||
|
Contributes: map[string]map[string]any{"route": {"label": "login", "endpoint": "web", "port": 8080}}},
|
||||||
|
Manifest{Module: "grafana", Version: "1",
|
||||||
|
Listens: []Listening{{Port: 3000, From: FromMesh, Why: "dashboards"}},
|
||||||
|
Contributes: map[string]map[string]any{
|
||||||
|
"route": {"label": "grafana", "endpoint": "web", "port": 3000},
|
||||||
|
"oidc-client": {"label": "grafana", "endpoint": "web", "port": 3000, "callback": "/login"},
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
home := withDomain("home.example")
|
||||||
|
home.Name, home.At = "home-server", "home-server.internal"
|
||||||
|
control := withDomain("control.example")
|
||||||
|
control.Name, control.At = "anchor", "anchor.internal"
|
||||||
|
|
||||||
|
onHome, err := Resolve(catalogue, []string{"grafana", "route-adapter"}, home, World{
|
||||||
|
Offered: map[string][]Provider{"oidc-client": {{Node: "anchor", At: "anchor.internal", Module: "keycloak"}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
onControl, err := Resolve(catalogue, []string{"keycloak", "route-proxy"}, control, World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return map[string]Resolution{"home-server": onHome, "anchor": onControl},
|
||||||
|
map[string]SettingsBy{"home-server": {}, "anchor": {}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestANameResolvesToTheNodeWhoseProxyServesIt(t *testing.T) {
|
||||||
|
plans, settings := twoNodesOneName(t)
|
||||||
|
// Many times, because the fault was map order: one plan said one node, the next the other.
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
served, err := NamesServed(plans, settings)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if served["grafana.home.example"] != "home-server" {
|
||||||
|
t.Fatalf("run %d: the dashboard's name is served by %q, and its proxy is on the home server: %v",
|
||||||
|
i, served["grafana.home.example"], served)
|
||||||
|
}
|
||||||
|
if served["login.control.example"] != "anchor" {
|
||||||
|
t.Fatalf("run %d: the identity provider's own name is served by its proxy on the control node: %v", i, served)
|
||||||
|
}
|
||||||
|
if _, leaked := served["grafana.control.example"]; leaked {
|
||||||
|
t.Fatalf("a name composed for the identity provider's benefit is not one it serves: %v", served)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module that is routed several times names each route (ADR 0094's sibling for contributes);
|
||||||
|
// every one of them is a name the mesh must resolve, and none reached the names region before.
|
||||||
|
func TestEveryRouteOfAModuleWithSeveralIsANameServed(t *testing.T) {
|
||||||
|
catalogue := shelf(
|
||||||
|
Manifest{Module: "route-proxy", Version: "1", Provides: Offers("route"),
|
||||||
|
Serves: map[string]map[string]any{"route": {}}, Receives: map[string]string{"route": "/etc/proxy/mesh.json"}},
|
||||||
|
Manifest{Module: "photos", Version: "1",
|
||||||
|
Listens: []Listening{{Port: 8102, From: FromMesh, Why: "web"}, {Port: 9102, From: FromMesh, Why: "api"}},
|
||||||
|
ContributesMany: map[string]map[string]map[string]any{"route": {
|
||||||
|
"site": {"label": "photos", "endpoint": "web", "port": 8102},
|
||||||
|
"api": {"label": "photos-api", "endpoint": "api", "port": 9102},
|
||||||
|
}}},
|
||||||
|
)
|
||||||
|
node := withDomain("control.example")
|
||||||
|
node.Name, node.At = "anchor", "anchor.internal"
|
||||||
|
plan, err := Resolve(catalogue, []string{"photos", "route-proxy"}, node, World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
served, err := NamesServed(map[string]Resolution{"anchor": plan}, map[string]SettingsBy{"anchor": {}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"photos.control.example", "photos-api.control.example"} {
|
||||||
|
if served[name] != "anchor" {
|
||||||
|
t.Fatalf("%s is not served by its proxy: %v", name, served)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A `user` shape and a user-scoped unit name the operator account the way a home file does
|
|
||||||
// (novox/hq ADR 0176, ADR 0177): with ${machine:account}, resolved when the module is assigned.
|
|
||||||
func TestAUserShapeAndAUserScopedUnitNameTheAccount(t *testing.T) {
|
|
||||||
facts := map[string]string{"account": "ops", "account-home": "/home/ops"}
|
|
||||||
login := map[string]any{"type": "user", "id": "login", "name": "${machine:account}", "shell": "/usr/bin/zsh"}
|
|
||||||
if err := machineInto(login, facts, "zsh"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if login["name"] != "ops" {
|
|
||||||
t.Fatalf("the user shape did not learn the account: %v", login["name"])
|
|
||||||
}
|
|
||||||
watcher := map[string]any{"type": "service", "id": "watcher", "unit": "i3-reload-watcher.service",
|
|
||||||
"scope": "user", "user": "${machine:account}"}
|
|
||||||
if err := machineInto(watcher, facts, "i3"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if watcher["user"] != "ops" {
|
|
||||||
t.Fatalf("the user-scoped unit did not learn the account: %v", watcher["user"])
|
|
||||||
}
|
|
||||||
// A machine with no operator account refuses rather than writing the literal.
|
|
||||||
err := machineInto(map[string]any{"type": "user", "id": "login", "name": "${machine:account}"},
|
|
||||||
map[string]string{"address": "10.0.0.1"}, "zsh")
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "${machine:account}") {
|
|
||||||
t.Fatalf("a user shape on a machine with no account was not refused by name: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The service manager is a seat of the mesh's own with the unit verbs as its contract (novox/hq
|
|
||||||
// ADR 0177): every verb described, with a schema, taking a scope.
|
|
||||||
func TestTheServiceManagerSeatServesTheUnitVerbs(t *testing.T) {
|
|
||||||
seat, ok := SeatNamed("node-service-manager")
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("node-service-manager is not a seat the mesh defines")
|
|
||||||
}
|
|
||||||
if seat.Scope != ScopeNode {
|
|
||||||
t.Fatalf("the service manager is a role each machine has once, and the seat is %s-scoped", seat.Scope)
|
|
||||||
}
|
|
||||||
want := []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal"}
|
|
||||||
var got []string
|
|
||||||
for _, v := range seat.Serves {
|
|
||||||
got = append(got, v.Name)
|
|
||||||
if v.Description == "" || v.Input == nil {
|
|
||||||
t.Fatalf("%s is promised without a description or a schema", v.Name)
|
|
||||||
}
|
|
||||||
props, _ := v.Input["properties"].(map[string]any)
|
|
||||||
if _, has := props["scope"]; !has {
|
|
||||||
t.Fatalf("%s takes no scope, and a user unit could not be asked for", v.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
||||||
t.Fatalf("the seat serves %v, not %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 or a process's `env`.
|
// file's content, or in a value of a container'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,12 +64,7 @@ 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 or a process's environment.
|
// file's content, and in a value of a container'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.
|
||||||
@@ -89,7 +84,7 @@ func portInto(resource map[string]any, module string, listens []Listening, with
|
|||||||
}
|
}
|
||||||
resource["content"] = filled
|
resource["content"] = filled
|
||||||
|
|
||||||
case "container", "process":
|
case "container":
|
||||||
env, ok := resource["env"].(map[string]any)
|
env, ok := resource["env"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
@@ -111,8 +106,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 %s %s sets %s to something that",
|
fmt.Sprintf("%s's container %s sets %s to something that",
|
||||||
module, resource["type"], resource["name"], key), module, listens, with)
|
module, resource["name"], key), module, listens, with)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user