Self-upgrade installer: SDK-by-version, mesh-controller rename, foundation adopted #26
+3
-3
@@ -22,13 +22,13 @@ COPY . .
|
||||
ARG VERSION=development
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||
-o /mesh-control ./cmd/mesh-control
|
||||
-o /mesh-controller ./cmd/mesh-controller
|
||||
|
||||
FROM scratch
|
||||
COPY --from=build /mesh-control /mesh-control
|
||||
COPY --from=build /mesh-controller /mesh-controller
|
||||
|
||||
# Numeric because there is no /etc/passwd to look a name up in. Nothing here needs to be root:
|
||||
# it opens outbound connections and writes nothing to its own filesystem.
|
||||
USER 65534:65534
|
||||
|
||||
ENTRYPOINT ["/mesh-control"]
|
||||
ENTRYPOINT ["/mesh-controller"]
|
||||
|
||||
@@ -12,20 +12,20 @@ LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
# touches a database anybody else is using. Override PG_PORT if this one is taken -- the first
|
||||
# port chosen was already serving something that had been up for six days.
|
||||
PG_PORT ?= 55532
|
||||
PG_CONTAINER ?= mesh-control-check
|
||||
PG_CONTAINER ?= mesh-controller-check
|
||||
PG_IMAGE ?= postgres:17-alpine
|
||||
export MESH_TEST_POSTGRES ?= postgres://postgres:check@127.0.0.1:$(PG_PORT)/postgres?sslmode=disable
|
||||
|
||||
.PHONY: build image check test vet fmt postgres postgres-stop clean
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags '$(LDFLAGS)' -o build/mesh-control ./cmd/mesh-control
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags '$(LDFLAGS)' -o build/mesh-controller ./cmd/mesh-controller
|
||||
|
||||
# Tagged 'development' as well as by version, because the lab places images by name and a
|
||||
# scenario naming a version would have to be edited on every build. The version tag is what a
|
||||
# real bundle pins.
|
||||
IMAGE ?= mesh-control:$(VERSION)
|
||||
DEV_TAG ?= mesh-control:development
|
||||
IMAGE ?= mesh-controller:$(VERSION)
|
||||
DEV_TAG ?= mesh-controller:development
|
||||
|
||||
image:
|
||||
docker build --build-arg VERSION=$(VERSION) -t $(IMAGE) -t $(DEV_TAG) .
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# mesh-control
|
||||
# mesh-controller
|
||||
|
||||
**Tier 2 of Novox Mesh — the control plane.** Everything that needs to know about more than one
|
||||
node.
|
||||
@@ -31,17 +31,17 @@ argument that is not settled there.
|
||||
| the interface every surface speaks to | not built; its shape is not decided |
|
||||
|
||||
```
|
||||
mesh-control migrate bring each context's schema up to date
|
||||
mesh-control node add <name> create a node record
|
||||
mesh-control node list the nodes this mesh knows about
|
||||
mesh-control token issue --node <name> a one-time right to join, for an existing record
|
||||
mesh-control token issue --new <name> create the record and issue for it
|
||||
mesh-control identity show this control plane's signing key
|
||||
mesh-control broker show where the broker is, and what to expect there
|
||||
mesh-control version what this binary is
|
||||
mesh-controller migrate bring each context's schema up to date
|
||||
mesh-controller node add <name> create a node record
|
||||
mesh-controller node list the nodes this mesh knows about
|
||||
mesh-controller token issue --node <name> a one-time right to join, for an existing record
|
||||
mesh-controller token issue --new <name> create the record and issue for it
|
||||
mesh-controller identity show this control plane's signing key
|
||||
mesh-controller broker show where the broker is, and what to expect there
|
||||
mesh-controller version what this binary is
|
||||
```
|
||||
|
||||
`migrate` is **step 3 of the substrate bootstrap** — the step the first node cannot get past, run
|
||||
`migrate` is **step 3 of the foundation bootstrap** — the step the first node cannot get past, run
|
||||
against a database raised moments earlier from the bundle the host carries.
|
||||
|
||||
### Tokens, and what they are missing
|
||||
@@ -176,7 +176,7 @@ without its neighbour checked out is a repository nobody can build.
|
||||
**What this mesh sends, read by the host that receives it:**
|
||||
|
||||
```
|
||||
mesh-control: ./build/mesh-control plan <node> --json > /tmp/d.json
|
||||
mesh-controller: ./build/mesh-controller plan <node> --json > /tmp/d.json
|
||||
mesh-host: MESH_EMITTED=/tmp/d.json go test ./internal/declaration/ -v
|
||||
```
|
||||
|
||||
@@ -184,7 +184,7 @@ mesh-host: MESH_EMITTED=/tmp/d.json go test ./internal/declaration/ -v
|
||||
|
||||
```
|
||||
mesh-host: MESH_ENROL_OUT=/tmp/enrol.json go test ./internal/link/
|
||||
mesh-control: MESH_ENROL=/tmp/enrol.json make check
|
||||
mesh-controller: MESH_ENROL=/tmp/enrol.json make check
|
||||
```
|
||||
|
||||
The second does more than compare shapes: it seals something to the key that arrived and opens it
|
||||
|
||||
+107
-10
@@ -32,8 +32,8 @@ import (
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
|
||||
"github.com/novox/mesh-control/internal/builder"
|
||||
"github.com/novox/mesh-control/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/builder"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// version is set at build time.
|
||||
@@ -55,6 +55,11 @@ is dialled except the broker.
|
||||
MESH_BROKER_FILE a file the mesh sealed to this machine holding the same
|
||||
MESH_REGISTRY host:port to publish artifacts to, when the mesh has not said
|
||||
MESH_BINDING a file the mesh wrote saying where the artifact store is
|
||||
MESH_PACKAGE_BINDING a file the mesh wrote saying where the package registry is
|
||||
MESH_NPM_TOKEN_FILE a file the mesh sealed holding the token for it
|
||||
MESH_NPM_REGISTRY a package registry URL, when the mesh has not said (a person, the bootstrap)
|
||||
MESH_NPM_TOKEN the token for it, likewise
|
||||
MESH_NPM_SCOPE the scope it answers for (default: @novox)
|
||||
MESH_WORKSPACE where to clone and build (default: a temporary directory)
|
||||
|
||||
It also builds one module and stops, which is how a mesh is raised — before there is a
|
||||
@@ -139,7 +144,7 @@ func run() error {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("building for the mesh, publishing to %s\n", registry)
|
||||
fmt.Fprintf(os.Stderr, "building for the mesh, publishing to %s\n", registry)
|
||||
publisher := builder.Registry{Address: registry, Run: builder.Command}
|
||||
|
||||
for {
|
||||
@@ -160,6 +165,12 @@ func run() error {
|
||||
func answer(ctx context.Context, channel *amqp.Channel, publisher builder.Publisher,
|
||||
on, workspace string, delivery amqp.Delivery) {
|
||||
|
||||
// **First thing, and to stdout.** A build request that arrives and produces no visible line
|
||||
// until it either finishes or fails is indistinguishable from one that never arrived — which
|
||||
// cost a long diagnosis against a running mesh, chasing "the handler never fired" when the
|
||||
// truth was only that the handler said nothing until the end.
|
||||
fmt.Fprintf(os.Stderr, "a build request arrived (%d bytes)\n", len(delivery.Body))
|
||||
|
||||
var request link.BuildRequest
|
||||
if err := json.Unmarshal(delivery.Body, &request); err != nil {
|
||||
// Unreadable. Acknowledged and dropped rather than requeued: a message this builder
|
||||
@@ -174,17 +185,27 @@ func answer(ctx context.Context, channel *amqp.Channel, publisher builder.Publis
|
||||
ID: request.ID, Repository: request.Repository, Path: request.Path,
|
||||
Ref: request.Ref, On: on,
|
||||
}
|
||||
fmt.Printf("building %s", request.Repository)
|
||||
fmt.Fprintf(os.Stderr, "building %s", request.Repository)
|
||||
if request.Path != "" {
|
||||
fmt.Printf(" at %s", request.Path)
|
||||
fmt.Fprintf(os.Stderr, " at %s", request.Path)
|
||||
}
|
||||
if request.Ref != "" {
|
||||
fmt.Printf(" at %s", request.Ref)
|
||||
fmt.Fprintf(os.Stderr, " at %s", request.Ref)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Fprintln(os.Stderr)
|
||||
|
||||
built, err := builder.Build(ctx, builder.Command, publisher,
|
||||
request.Repository, request.Path, request.Ref, workspace, request.Held)
|
||||
npmrc, err := packagesFrom()
|
||||
var built builder.Result
|
||||
if err == nil {
|
||||
// The package-registry credential is a build input, so it is resolved before the clone: a
|
||||
// build that could not have resolved its dependencies is refused in front of the reason,
|
||||
// not after a clone that then fails at npm ci.
|
||||
built, err = builder.Build(ctx, builder.Command, publisher,
|
||||
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
|
||||
func(step, message string) {
|
||||
fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message)
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
// A failure is a result. A build that fails and says nothing is indistinguishable from a
|
||||
// builder that is not running, and those want completely different responses.
|
||||
@@ -203,7 +224,7 @@ func answer(ctx context.Context, channel *amqp.Channel, publisher builder.Publis
|
||||
})
|
||||
}
|
||||
result.Against = built.Against
|
||||
fmt.Printf(" built %s from %s\n", built.Manifest.Module, short(built.Commit))
|
||||
fmt.Fprintf(os.Stderr, " built %s from %s\n", built.Manifest.Module, short(built.Commit))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +291,82 @@ func moduleOf(manifest json.RawMessage) string {
|
||||
return named.Module
|
||||
}
|
||||
|
||||
// packagesFrom is where a build resolves the mesh's own published packages — the SDK above all
|
||||
// (novox/hq ADR 0076, issue 053).
|
||||
//
|
||||
// Preferably from the mesh: a package-registry binding names the endpoint the way the artifact
|
||||
// store's binding does, and a sealed token file the credential the way the broker's does. The
|
||||
// environment variables remain for a builder run by a person, and for the bootstrap, where there is
|
||||
// no registry yet — there the result is disabled and a build that needs no mesh-published dependency
|
||||
// builds anyway.
|
||||
func packagesFrom() (builder.Npmrc, error) {
|
||||
scope := strings.TrimSpace(os.Getenv("MESH_NPM_SCOPE"))
|
||||
if scope == "" {
|
||||
scope = "@novox"
|
||||
}
|
||||
|
||||
registry := strings.TrimSpace(os.Getenv("MESH_NPM_REGISTRY"))
|
||||
var username string
|
||||
if path := strings.TrimSpace(os.Getenv("MESH_PACKAGE_BINDING")); path != "" {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return builder.Npmrc{}, fmt.Errorf("cannot read what the mesh said about the package registry: %w", err)
|
||||
}
|
||||
var told struct {
|
||||
From string `json:"from"`
|
||||
At string `json:"at"`
|
||||
As string `json:"as"`
|
||||
Serves map[string]any `json:"serves"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &told); err != nil {
|
||||
return builder.Npmrc{}, fmt.Errorf("%s is not a binding: %w", path, err)
|
||||
}
|
||||
if told.At == "" {
|
||||
return builder.Npmrc{}, fmt.Errorf(
|
||||
"%s says the package registry is on %q and gives no address for it", path, told.From)
|
||||
}
|
||||
// Composed from what the provider serves, so nothing here knows gitea's URL shape from
|
||||
// another registry's: it states its port, the path its registry answers on, and the scheme.
|
||||
scheme := "https"
|
||||
if s, ok := told.Serves["scheme"]; ok {
|
||||
scheme = fmt.Sprintf("%v", s)
|
||||
}
|
||||
port, ok := told.Serves["port"]
|
||||
if !ok {
|
||||
return builder.Npmrc{}, fmt.Errorf("%s says nothing about which port the package registry answers on", path)
|
||||
}
|
||||
npmPath, ok := told.Serves["npm-path"]
|
||||
if !ok {
|
||||
return builder.Npmrc{}, fmt.Errorf("%s says nothing about the path the package registry answers on", path)
|
||||
}
|
||||
registry = fmt.Sprintf("%s://%s:%v%v", scheme, told.At, port, npmPath)
|
||||
username = told.As
|
||||
}
|
||||
|
||||
// The credential the mesh sealed to this machine. The mesh authenticates the ordinary way — a
|
||||
// generated password the provider only applies (novox/hq ADR 0048) — so with a username this is
|
||||
// a password (basic auth); without one it is a bearer token a provider minted.
|
||||
secret := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN"))
|
||||
if path := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN_FILE")); path != "" {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return builder.Npmrc{}, fmt.Errorf("cannot read this builder's package-registry credential: %w", err)
|
||||
}
|
||||
secret = strings.TrimSpace(string(raw))
|
||||
}
|
||||
if u := strings.TrimSpace(os.Getenv("MESH_NPM_USER")); u != "" {
|
||||
username = u
|
||||
}
|
||||
|
||||
if registry == "" && secret == "" {
|
||||
return builder.Npmrc{}, nil
|
||||
}
|
||||
if username != "" {
|
||||
return builder.Npmrc{Scope: scope, Registry: registry, Username: username, Password: secret}, nil
|
||||
}
|
||||
return builder.Npmrc{Scope: scope, Registry: registry, Token: secret}, nil
|
||||
}
|
||||
|
||||
func short(commit string) string {
|
||||
if len(commit) > 8 {
|
||||
return commit[:8]
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-control/internal/builder"
|
||||
"github.com/novox/mesh-controller/internal/builder"
|
||||
)
|
||||
|
||||
// buildOnce is the builder doing one build and stopping, with no broker and no mesh.
|
||||
@@ -84,7 +84,12 @@ func buildOnce(ctx context.Context, args []string) error {
|
||||
}
|
||||
fmt.Fprintln(os.Stderr)
|
||||
|
||||
built, buildErr := builder.Build(ctx, builder.Command, publisher, repository, *path, *ref, where, bases)
|
||||
npmrc, err := packagesFrom()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
built, buildErr := builder.Build(ctx, builder.Command, publisher, repository, *path, *ref, where, bases, npmrc,
|
||||
func(step, message string) { fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message) })
|
||||
if buildErr != nil {
|
||||
return buildErr
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **The genesis path writes its result to stdout and nothing else there.** The installer captures
|
||||
// stdout alone and parses it as JSON; one stray log line makes that fail with "invalid character"
|
||||
// — exactly what a builder fmt.Printf caused, breaking a build that had worked. This keeps
|
||||
// diagnostics on stderr so a future print cannot repeat it silently.
|
||||
func TestBuilderDiagnosticsStayOffStdout(t *testing.T) {
|
||||
allowed := map[string]bool{
|
||||
"string(body)": true, // once.go: the result JSON, which IS stdout
|
||||
"version)": true, // --version
|
||||
`"stopping")`: true, // the loop.s shutdown line
|
||||
"usage)": true, // --help text, for a human
|
||||
}
|
||||
for _, file := range []string{"once.go", "main.go"} {
|
||||
src, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, raw := range strings.Split(string(src), "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if !strings.HasPrefix(line, "fmt.Printf(") &&
|
||||
!strings.HasPrefix(line, "fmt.Println(") &&
|
||||
!strings.HasPrefix(line, "fmt.Print(") {
|
||||
continue
|
||||
}
|
||||
ok := false
|
||||
for token := range allowed {
|
||||
if strings.Contains(line, token) {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
t.Errorf("%s:%d writes a diagnostic to stdout, which the installer parses as JSON:\n %s",
|
||||
file, i+1, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// The things the mesh can be asked to do, separated from how it was asked.
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// What an assignment says about the machines it was not about.
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// Refused and failed stay distinct all the way to the page.
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-control/internal/broker"
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-control/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// asking a build machine for a module, and what came back.
|
||||
@@ -72,6 +72,12 @@ func buildFrom(result link.BuildResult) inventory.Build {
|
||||
kept := inventory.Build{
|
||||
ID: result.ID, Repository: result.Repository, Ref: result.Ref,
|
||||
Commit: result.Commit, On: result.On, Failed: result.Failed,
|
||||
// **What the announcement carries, kept rather than discarded** (novox/hq 04-ISSUES/050).
|
||||
// The catalogue turns the manifest into requires/provides edges and `against` into build
|
||||
// edges, and it is not always listening when a build happens — on a fresh mesh it cannot
|
||||
// be, for exactly the modules it needs most. Keeping them is what makes a replay able to
|
||||
// rebuild the graph rather than a list of names.
|
||||
Path: result.Path, Manifest: result.Manifest, Against: result.Against,
|
||||
}
|
||||
for _, made := range result.Made {
|
||||
kept.Made = append(kept.Made, inventory.Artifact{
|
||||
@@ -1,9 +1,9 @@
|
||||
// Command mesh-control is the control plane: everything that needs to know about more than one
|
||||
// Command mesh-controller is the control plane: everything that needs to know about more than one
|
||||
// node (novox/hq ADR 0006).
|
||||
//
|
||||
// It runs as one process holding several contexts, each owning its own store. Today it holds one,
|
||||
// `inventory`, and does one thing with it — brings its schema up to date, which is step 3 of the
|
||||
// bootstrap in novox/hq 07-the-substrate and the step the first node cannot get past without.
|
||||
// bootstrap in novox/hq 07-the-foundation and the step the first node cannot get past without.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/novox/mesh-control/internal/identity"
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-control/internal/licences"
|
||||
"github.com/novox/mesh-control/internal/link"
|
||||
"github.com/novox/mesh-control/internal/store"
|
||||
"github.com/novox/mesh-controller/internal/identity"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/licences"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/store"
|
||||
)
|
||||
|
||||
// version is stamped at link time. Unset in a development build, and it says so rather than
|
||||
@@ -40,7 +40,7 @@ var held = []struct {
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "mesh-control: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "mesh-controller: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func run() error {
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprint(os.Stderr, `mesh-control — the control plane
|
||||
fmt.Fprint(os.Stderr, `mesh-controller — the control plane
|
||||
|
||||
migrate bring each context's schema up to date
|
||||
node add <name> create a node record
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-control/internal/licences"
|
||||
"github.com/novox/mesh-control/internal/overlay"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/licences"
|
||||
"github.com/novox/mesh-controller/internal/overlay"
|
||||
)
|
||||
|
||||
// A mesh a command can be run against.
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-control/internal/broker"
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-control/internal/overlay"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/overlay"
|
||||
)
|
||||
|
||||
// the catalogue: what exists, what is assigned, and how it is configured.
|
||||
@@ -36,8 +36,12 @@ import (
|
||||
func providedModules() []catalogue.Manifest {
|
||||
var out []catalogue.Manifest
|
||||
for _, raw := range []map[string]any{
|
||||
overlay.Manifest(), overlay.NamesManifest(), overlay.ResolverManifest(),
|
||||
overlay.DomainManifest(),
|
||||
// **Two used to be here and are gone**: one wrote the mesh's names into a hosts file, the
|
||||
// other wrote the same machines as wildcards for a resolver to read. Neither ran software
|
||||
// and neither could be swapped for anything, which is the test of whether a thing is a
|
||||
// module at all (novox/hq ADR 0040). They existed because computed output needed somewhere
|
||||
// to live, and now a module says where it wants it — `facts` in its own manifest.
|
||||
overlay.Manifest(), overlay.DomainManifest(),
|
||||
} {
|
||||
var m catalogue.Manifest
|
||||
b, _ := json.Marshal(raw)
|
||||
@@ -253,7 +257,7 @@ func moduleCommand(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The substrate owns the bus; make sure it exists before a module binds onto it.
|
||||
// The foundation owns the bus; make sure it exists before a module binds onto it.
|
||||
if err := management.EnsureEventExchanges(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -267,7 +271,7 @@ func moduleCommand(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A consumer's queue, with its dead-letter, is the substrate's to declare — its own account
|
||||
// A consumer's queue, with its dead-letter, is the foundation's to declare — its own account
|
||||
// may not (ADR 0043). Made now, so it exists before the module binds onto it.
|
||||
if len(m.Consumes) > 0 {
|
||||
if err := management.EnsureModuleQueue(ctx, *forNode, module); err != nil {
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-control/internal/overlay"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/overlay"
|
||||
)
|
||||
|
||||
// the private network: who is on it, where, and what they are called.
|
||||
@@ -231,12 +231,13 @@ func generators(ctx context.Context, open *stores) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Both generators see the same machines: the ones on the private network. Names for a machine
|
||||
// that is not on it would resolve to addresses it cannot reach, which is worse than no names.
|
||||
// **One generator now.** Two more used to sit beside it — the names and a resolver's zone
|
||||
// file — as modules that ran nothing. Both are facts a module asks for in its manifest
|
||||
// (`facts:` — catalogue.FactsInto), computed from the same machines this sees: the ones on the
|
||||
// private network, because a name for a machine not on it would resolve to an address nothing
|
||||
// can reach.
|
||||
return map[string]catalogue.Generator{
|
||||
overlay.Name: net,
|
||||
overlay.Names: overlay.NamesFor(net.Nodes()),
|
||||
overlay.Resolver: overlay.ResolverFor(net.Nodes()),
|
||||
overlay.Name: net,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// placementOf is what the mesh holds about where one node is.
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-control/internal/broker"
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-control/internal/link"
|
||||
"github.com/novox/mesh-control/internal/token"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/token"
|
||||
)
|
||||
|
||||
// what a machine is, and what it is allowed to be told.
|
||||
@@ -9,9 +9,12 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-control/internal/licences"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/licences"
|
||||
"net"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// working out what one machine should be.
|
||||
@@ -447,9 +450,22 @@ func declarationWith(ctx context.Context, open *stores, node string,
|
||||
names[name] = at
|
||||
}
|
||||
|
||||
// The ports the mesh itself needs open, which no module declares. Read from the broker this
|
||||
// control plane was told about rather than written down twice: the address a node is handed in
|
||||
// its token and the port its machine must accept on are the same fact.
|
||||
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 = append(foundation, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return plan.Declaration(catalogue.Rendering{
|
||||
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports,
|
||||
Certificate: certificate, Authority: authority, Mesh: private, Names: names})
|
||||
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
||||
Foundation: foundation})
|
||||
}
|
||||
|
||||
// routeNamesInTheMesh is every routed name and the address of the node that serves it (novox/hq
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-control/internal/broker"
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-control/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// reportUnhostable says which of a node's assigned modules the machine cannot run, once per push.
|
||||
@@ -92,6 +92,11 @@ func serve(ctx context.Context) error {
|
||||
if err := server.Follows(following{open}); err != nil {
|
||||
return err
|
||||
}
|
||||
// And a catalogue that has just started, asking for what it missed. The same type answers
|
||||
// both: what a build meant and what the builds were are two questions about one record.
|
||||
if err := server.Answers(following{open}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return server.Serve(ctx)
|
||||
}
|
||||
@@ -163,6 +168,11 @@ func pushCommand(ctx context.Context, args []string) error {
|
||||
// eventually watches.
|
||||
behind := set.Bool("behind", false,
|
||||
"only machines whose last declaration was refused or partly failed")
|
||||
// For a named node, wait until it reports applying exactly what it was sent, so `push <node>`
|
||||
// means "this node is now what it was told" — a command right after does not race the apply
|
||||
// (novox/hq ADR 0010). 0 waits for nothing, which is the old fire-and-forget.
|
||||
wait := set.Duration("wait", 0,
|
||||
"for a named node, how long to wait for it to report applying what it was sent (0: do not wait)")
|
||||
positionals, err := parseAround(set, args)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -287,6 +297,7 @@ func pushCommand(ctx context.Context, args []string) error {
|
||||
return declarationWith(ctx, open, node, plan, settings, gens, Allocating)
|
||||
})
|
||||
|
||||
sentDigest := map[string]string{}
|
||||
for _, s := range sending {
|
||||
body, err := json.Marshal(map[string]any{"declaration": 1, "resources": s.resources})
|
||||
if err != nil {
|
||||
@@ -301,15 +312,62 @@ func pushCommand(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := inv.RecordSent(ctx, record.ID, digestOf(body)); err != nil {
|
||||
digest := digestOf(body)
|
||||
if err := inv.RecordSent(ctx, record.ID, digest); err != nil {
|
||||
return err
|
||||
}
|
||||
sentDigest[s.node] = digest
|
||||
fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.resources))
|
||||
}
|
||||
fmt.Printf("\n%d node(s) told\n", len(sending))
|
||||
|
||||
// A named node is a request to make THAT node current now, so it waits for the node to say it
|
||||
// applied exactly this. A whole-mesh or --behind push does not wait: it is a sweep, and blocking
|
||||
// on the slowest machine would hold back the report on all the others.
|
||||
if *wait > 0 && len(args) == 1 {
|
||||
if err := waitForApplied(ctx, inv, args[0], sentDigest[args[0]], *wait); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return couldNotBeResolved(refusals, len(sending))
|
||||
}
|
||||
|
||||
// waitForApplied blocks until the node reports it applied exactly the declaration just sent, or the
|
||||
// wait runs out. A report of failure or refusal for that same declaration ends the wait at once —
|
||||
// there is nothing to wait for, and the reason is the node's own.
|
||||
func waitForApplied(ctx context.Context, inv *inventory.Inventory, node, digest string, wait time.Duration) error {
|
||||
if digest == "" {
|
||||
return nil // nothing was sent to this node
|
||||
}
|
||||
deadline := time.Now().Add(wait)
|
||||
for {
|
||||
doing, said, err := inv.DoingOf(ctx, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if said && doing.Declared == digest {
|
||||
switch doing.Outcome {
|
||||
case inventory.OutcomeApplied:
|
||||
fmt.Printf("%s applied it\n", node)
|
||||
return nil
|
||||
case inventory.OutcomeFailed:
|
||||
return fmt.Errorf("%s applied what it was sent but %d resource(s) failed", node, len(doing.Failed))
|
||||
case inventory.OutcomeRefused:
|
||||
return fmt.Errorf("%s refused what it was sent: %s", node, doing.Refused)
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("%s did not report applying what it was sent within %s "+
|
||||
"(it may still be converging; check `status`)", node, wait)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readyNode is one machine and the declaration it would be sent.
|
||||
type readyNode struct {
|
||||
node string
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// The shape something other than a person reads.
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-control/internal/overlay"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/overlay"
|
||||
)
|
||||
|
||||
// is anything broken, is anything not answering, is anything out of date.
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-control/internal/identity"
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-control/internal/licences"
|
||||
"github.com/novox/mesh-control/internal/store"
|
||||
"github.com/novox/mesh-controller/internal/identity"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/licences"
|
||||
"github.com/novox/mesh-controller/internal/store"
|
||||
)
|
||||
|
||||
// reaching each context's store, which no other context may touch.
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-control/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// following acts on what the catalogue announces.
|
||||
@@ -163,3 +163,34 @@ func sayUpgrade(module string, u inventory.Upgrade) string {
|
||||
return fmt.Sprintf("when %s moves, the machines running it are sent the new version one at "+
|
||||
"a time, stopping at the first that fails", module)
|
||||
}
|
||||
|
||||
// Announceable is every build this mesh recorded, in the shape the builder announces one.
|
||||
//
|
||||
// **The catalogue asks for this when it starts, and the answer is the graph's foundation**
|
||||
// (novox/hq 04-ISSUES/050). A durable queue keeps what arrived after it existed, so a running
|
||||
// catalogue misses nothing — but the modules built before it first ran were announced to a queue
|
||||
// that did not exist, and on a fresh mesh those are always the same three: the shared base, the
|
||||
// store the catalogue runs on, and the catalogue itself.
|
||||
func (f following) Announceable(ctx context.Context) ([]link.Announcement, error) {
|
||||
builds, err := f.open.inventory.Announceable(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]link.Announcement, 0, len(builds))
|
||||
for _, b := range builds {
|
||||
a := link.Announcement{
|
||||
Module: b.Module, Commit: b.Commit, Repository: b.Repository,
|
||||
Path: b.Path, Ref: b.Ref, Against: b.Against,
|
||||
}
|
||||
if len(b.Manifest) > 0 {
|
||||
a.Manifest = b.Manifest
|
||||
}
|
||||
for _, made := range b.Made {
|
||||
a.Made = append(a.Made, link.MadeArtifact{
|
||||
Name: made.Name, Kind: made.Kind, Reference: made.Reference,
|
||||
})
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"module": "registry",
|
||||
"module": "distribution",
|
||||
"version": "1",
|
||||
"provides": [
|
||||
{
|
||||
@@ -1,9 +1,6 @@
|
||||
{
|
||||
"module": "dnsmasq",
|
||||
"version": "1",
|
||||
"requires": [
|
||||
"resolver-data"
|
||||
],
|
||||
"provides": [
|
||||
"wildcard-resolution"
|
||||
],
|
||||
@@ -43,8 +40,11 @@
|
||||
"boot": "enabled",
|
||||
"restart-on": [
|
||||
"config",
|
||||
"mesh-resolver.nodes"
|
||||
"dnsmasq.fact-node-zones"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"facts": {
|
||||
"node-zones": "/etc/mesh-resolver/nodes.conf"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-control/internal/overlay"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/overlay"
|
||||
)
|
||||
|
||||
// The examples are manifests, so the thing to check is that the catalogue accepts them.
|
||||
@@ -64,13 +64,16 @@ func TestTheResolverReadsTheMeshsNamesAndFollowsThem(t *testing.T) {
|
||||
if config == nil || service == nil {
|
||||
t.Fatal("the module has no configuration or no service")
|
||||
}
|
||||
if !strings.Contains(config["content"].(string), overlay.ResolverPath) {
|
||||
t.Fatalf("it does not read what the mesh writes at %s", overlay.ResolverPath)
|
||||
// The zone file is a FACT the module asks for, at a path it chose. The mesh writes it there;
|
||||
// what reads it and how is this module's own business, which is the whole shape.
|
||||
const zones = "/etc/mesh-resolver/nodes.conf"
|
||||
if !strings.Contains(config["content"].(string), zones) {
|
||||
t.Fatalf("it does not read what the mesh writes at %s", zones)
|
||||
}
|
||||
|
||||
var follows bool
|
||||
for _, id := range service["restart-on"].([]any) {
|
||||
if id.(string) == overlay.Resolver+".nodes" {
|
||||
if id.(string) == "dnsmasq.fact-node-zones" {
|
||||
follows = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// The password comes from a file, because that is how the mesh delivers one.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/novox/mesh-control
|
||||
module github.com/novox/mesh-controller
|
||||
|
||||
go 1.25.0
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/broker"
|
||||
"github.com/novox/mesh-control/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The names are written twice, so a test keeps them agreeing.
|
||||
|
||||
@@ -68,7 +68,7 @@ const ExchangeName = "mesh"
|
||||
const BuildQueueName = "builds"
|
||||
|
||||
// The events bus (novox/hq ADR 0042): one topic exchange every event rides, a second for tool
|
||||
// RPC kept apart, and a dead-letter home for a poison event. The substrate owns these — a module's
|
||||
// RPC kept apart, and a dead-letter home for a poison event. The foundation owns these — a module's
|
||||
// account cannot declare them, only bind its own queue to the events one.
|
||||
const (
|
||||
EventsExchangeName = "mesh.events"
|
||||
@@ -151,7 +151,7 @@ func (m *Management) CreateModuleAccount(ctx context.Context, node, module, pass
|
||||
}
|
||||
|
||||
// EnsureModuleQueue declares a consuming module's queue with its dead-letter exchange, idempotently.
|
||||
// The substrate declares it because a scoped module account may not: the broker refuses a queue with
|
||||
// The foundation declares it because a scoped module account may not: the broker refuses a queue with
|
||||
// a dead-letter exchange to a non-administrator (novox/hq ADR 0043), so a consumer passively checks
|
||||
// the queue the mesh made rather than declaring its own.
|
||||
func (m *Management) EnsureModuleQueue(ctx context.Context, node, module string) error {
|
||||
@@ -166,7 +166,7 @@ func (m *Management) EnsureModuleQueue(ctx context.Context, node, module string)
|
||||
}
|
||||
|
||||
// EnsureEventExchanges declares the bus's exchanges and the dead-letter home, idempotently. The
|
||||
// substrate owns them (a module's account may not declare an exchange), and a dead-letter exchange
|
||||
// foundation owns them (a module's account may not declare an exchange), and a dead-letter exchange
|
||||
// with no queue behind it drops what it receives — so a durable queue bound to `#` retains a poison
|
||||
// event for inspection, which is the whole reason the trail exists.
|
||||
func (m *Management) EnsureEventExchanges(ctx context.Context) error {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
)
|
||||
|
||||
// The audit logger consumes everything and emits nothing. Its account must let it declare and read
|
||||
|
||||
+312
-4
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -14,8 +15,9 @@ import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// Turning a repository into artifacts the mesh can pin.
|
||||
@@ -67,7 +69,10 @@ type Result struct {
|
||||
// archive failed would otherwise leave half of itself in the store under a digest the mesh never
|
||||
// records — reachable, unreferenced, and indistinguishable from something in use.
|
||||
func Build(ctx context.Context, run Runner, publish Publisher,
|
||||
repository, path, ref, workspace string, held map[string]string) (Result, error) {
|
||||
repository, path, ref, workspace string, held map[string]string, npmrc Npmrc, log Log) (Result, error) {
|
||||
|
||||
say := logging(log)
|
||||
say("clone", "%s%s at %s", repository, describePath(path), refOrHead(ref))
|
||||
|
||||
// Made rather than required. A builder that fails because the directory it was told to work
|
||||
// in does not exist is a builder that needs a setup step nobody documented.
|
||||
@@ -82,8 +87,10 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
||||
// that reuses a working tree can succeed because of something a previous build left behind,
|
||||
// and that is a build nobody can reproduce.
|
||||
if _, err := run(ctx, workspace, "git", "clone", "--quiet", repository, tree); err != nil {
|
||||
say("clone", "FAILED: %v", err)
|
||||
return Result{}, fmt.Errorf("cannot clone %s: %w", repository, err)
|
||||
}
|
||||
say("clone", "done")
|
||||
if ref != "" {
|
||||
if _, err := run(ctx, tree, "git", "checkout", "--quiet", ref); err != nil {
|
||||
return Result{}, fmt.Errorf("%s has no %s: %w", repository, ref, err)
|
||||
@@ -94,6 +101,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
||||
return Result{}, err
|
||||
}
|
||||
commit = strings.TrimSpace(commit)
|
||||
say("commit", "%s", short(commit))
|
||||
|
||||
// A module is a repository and a path within it (novox/hq ADR 0069). The ordinary case is an
|
||||
// empty path, meaning the repository's root; a repository holding several modules names each
|
||||
@@ -112,8 +120,29 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
||||
}
|
||||
manifest, err := catalogue.ParseManifest(raw)
|
||||
if err != nil {
|
||||
say("manifest", "INVALID: %v", err)
|
||||
return Result{}, err
|
||||
}
|
||||
say("manifest", "%s v%s — %d artifact(s)", manifest.Module, manifest.Version, artifactCount(manifest))
|
||||
|
||||
// A build-time credential, written into the build context as .npmrc, but ONLY for a module that
|
||||
// asks for it: a `package` artifact (which publishes), or an image whose Dockerfile COPYs .npmrc.
|
||||
// Writing it into every context would put a per-run credential in `COPY . .` of modules that
|
||||
// never resolve a mesh package — making their image non-deterministic (a needless rollout every
|
||||
// build) and leaking the credential into a build stage. Absent entirely with no registry, which
|
||||
// is the bootstrap case (novox/hq ADR 0076).
|
||||
var npmrcPath string
|
||||
if npmrc.Enabled() && manifest.Build != nil && wantsPackages(manifest, within) {
|
||||
content, err := npmrc.File()
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
npmrcPath = filepath.Join(within, ".npmrc")
|
||||
if err := os.WriteFile(npmrcPath, []byte(content), 0o600); err != nil {
|
||||
return Result{}, fmt.Errorf("cannot write the package-registry credential for the build: %w", err)
|
||||
}
|
||||
say("packages", "resolving %s from the mesh's package registry", npmrc.Scope)
|
||||
}
|
||||
|
||||
var built []catalogue.Built
|
||||
if manifest.Build != nil {
|
||||
@@ -122,29 +151,86 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
||||
// fix it rather than inside a build that stops on its own first line.
|
||||
args, err := standingOn(manifest, held)
|
||||
if err != nil {
|
||||
say("bases", "UNMET: %v", err)
|
||||
return Result{}, err
|
||||
}
|
||||
if len(args) > 0 {
|
||||
say("bases", "%d resolved from what the mesh holds", len(args)/2)
|
||||
}
|
||||
artifacts := append([]catalogue.Artifact{}, manifest.Build.Artifacts...)
|
||||
// Ordered, so two builds of one commit do the same work in the same sequence and their
|
||||
// logs can be compared.
|
||||
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
|
||||
for _, a := range artifacts {
|
||||
made, err := one(ctx, run, publish, manifest.Module, within, commit, a, args)
|
||||
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
|
||||
made, err := one(ctx, run, publish, manifest.Module, within, commit, a, args, held, npmrcPath, say)
|
||||
if err != nil {
|
||||
say("artifact", "%s FAILED: %v", a.Name, err)
|
||||
return Result{}, err
|
||||
}
|
||||
say("artifact", "%s done — %s", a.Name, describeMade(made))
|
||||
built = append(built, made)
|
||||
}
|
||||
}
|
||||
|
||||
resolved, err := manifest.Resolve(built)
|
||||
if err != nil {
|
||||
say("resolve", "FAILED: %v", err)
|
||||
return Result{}, err
|
||||
}
|
||||
say("done", "%s at %s — %d artifact(s) pinned", manifest.Module, short(commit), len(built))
|
||||
return Result{Manifest: resolved, Commit: commit, Built: built,
|
||||
Against: against(within, manifest)}, nil
|
||||
}
|
||||
|
||||
// Log is where a build says what it is doing, step by step. Nil is silent — the tests pass none,
|
||||
// and a build with nowhere to speak must still build.
|
||||
type Log func(step, message string)
|
||||
|
||||
func logging(log Log) func(step, format string, args ...any) {
|
||||
if log == nil {
|
||||
return func(string, string, ...any) {}
|
||||
}
|
||||
return func(step, format string, args ...any) {
|
||||
log(step, fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
func describePath(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
return " at " + path
|
||||
}
|
||||
|
||||
func refOrHead(ref string) string {
|
||||
if ref == "" {
|
||||
return "HEAD"
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
func artifactCount(m catalogue.Manifest) int {
|
||||
if m.Build == nil {
|
||||
return 0
|
||||
}
|
||||
return len(m.Build.Artifacts)
|
||||
}
|
||||
|
||||
func langSuffix(a catalogue.Artifact) string {
|
||||
if a.Language != "" {
|
||||
return ", " + a.Language
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func describeMade(made catalogue.Built) string {
|
||||
if made.Digest != "" {
|
||||
return made.Kind + " " + short(strings.TrimPrefix(made.Digest, "sha256:"))
|
||||
}
|
||||
return made.Kind + " " + made.Reference
|
||||
}
|
||||
|
||||
// inside resolves a module's path within a clone, and refuses one that leaves it.
|
||||
//
|
||||
// **A build reads only its own tree.** A path of `../../etc` would otherwise make a build read —
|
||||
@@ -214,17 +300,38 @@ func against(within string, manifest catalogue.Manifest) []string {
|
||||
// setting somebody has to find.
|
||||
const ManifestName = "module.json"
|
||||
|
||||
// wantsPackages reports whether this module's build resolves anything from the mesh's package
|
||||
// registry, so the credential is written into its context only then. A package artifact always
|
||||
// does; an image does when its Dockerfile names .npmrc — the file it would COPY to authenticate.
|
||||
func wantsPackages(manifest catalogue.Manifest, within string) bool {
|
||||
for _, a := range manifest.Build.Artifacts {
|
||||
switch a.Kind {
|
||||
case catalogue.ArtifactPackage:
|
||||
return true
|
||||
case catalogue.ArtifactImage:
|
||||
raw, err := os.ReadFile(filepath.Join(within, a.From))
|
||||
if err == nil && strings.Contains(string(raw), ".npmrc") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func one(ctx context.Context, run Runner, publish Publisher,
|
||||
module, tree, commit string, a catalogue.Artifact, args []string) (catalogue.Built, error) {
|
||||
module, tree, commit string, a catalogue.Artifact, args []string,
|
||||
held map[string]string, npmrc string, say func(step, format string, args ...any)) (catalogue.Built, error) {
|
||||
|
||||
switch a.Kind {
|
||||
case catalogue.ArtifactUpstream:
|
||||
// Mirrored, not built. Pulled by the reference the module names and pushed under a name
|
||||
// of the mesh's own, so what a machine fetches is pinned by a digest this registry
|
||||
// assigned rather than by a tag somebody else can move.
|
||||
say("mirror", "pulling %s", a.From)
|
||||
if _, err := run(ctx, tree, "docker", "pull", a.From); err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: cannot fetch %s: %w", module, a.From, err)
|
||||
}
|
||||
say("mirror", "publishing under the mesh's own name")
|
||||
reference, err := publish.PublishImage(ctx, a.From, module+"/"+a.Name)
|
||||
if err != nil {
|
||||
return catalogue.Built{}, err
|
||||
@@ -242,16 +349,80 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
||||
if a.Target != "" {
|
||||
invocation = append(invocation, "--target", a.Target)
|
||||
}
|
||||
if npmrc != "" {
|
||||
// Host network for the build, so a RUN reaching the package registry finds it where the
|
||||
// binding says it is — the machine's own loopback, where the registry answers. The
|
||||
// credential itself is in the context as .npmrc, COPY'd by a stage that is not published;
|
||||
// buildkit is not required, because this machine's docker may not carry buildx.
|
||||
invocation = append(invocation, "--network", "host")
|
||||
}
|
||||
invocation = append(invocation, ".")
|
||||
say("image", "docker build -f %s", a.From)
|
||||
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: building %s failed: %w", module, a.Name, err)
|
||||
}
|
||||
say("image", "built, publishing")
|
||||
reference, err := publish.PublishImage(ctx, local, module+"/"+a.Name)
|
||||
if err != nil {
|
||||
return catalogue.Built{}, err
|
||||
}
|
||||
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil
|
||||
|
||||
case catalogue.ArtifactBundle:
|
||||
// **The one recipe that both builds and packs.** Everything else either produces an image
|
||||
// or packs what is already there; this compiles the module's own code first, in a
|
||||
// toolchain the mesh chose from what the module said it was written in, and packs the
|
||||
// result.
|
||||
//
|
||||
// The compiler runs in a container rather than on the build machine, for the reason every
|
||||
// other build does: what a build needs installed is the toolchain's business, and a build
|
||||
// machine that accumulated one toolchain per language would be a machine nobody could
|
||||
// reproduce.
|
||||
chain, err := ToolchainFor(a.Language)
|
||||
if err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: %s: %w", module, a.Name, err)
|
||||
}
|
||||
base, ok := held[chain.Base+"/"+chain.Artifact]
|
||||
if !ok {
|
||||
// Named, not pinned: the mesh answers with the copy it holds. Refused before anything
|
||||
// is built, saying which module has to exist first, rather than failing inside a
|
||||
// compile with a message about an image (novox/hq 04-ISSUES/044).
|
||||
return catalogue.Built{}, fmt.Errorf(
|
||||
"%s: %s is written in %s, which is compiled by %s's %q artifact, and this mesh "+
|
||||
"holds no copy of it. Build %s first",
|
||||
module, a.Name, chain.Language, chain.Base, chain.Artifact, chain.Base)
|
||||
}
|
||||
say("bundle", "compiling %s in %s's toolchain", a.Language, chain.Base)
|
||||
compiled, err := compile(ctx, run, tree, chain, base, a)
|
||||
if err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: compiling %s failed: %w", module, a.Name, err)
|
||||
}
|
||||
say("bundle", "compiled, packing")
|
||||
body, err := pack(compiled)
|
||||
if err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err)
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
digest := "sha256:" + hex.EncodeToString(sum[:])
|
||||
where, err := publish.PublishArchive(ctx, module+"/"+a.Name, body, digest)
|
||||
if err != nil {
|
||||
return catalogue.Built{}, err
|
||||
}
|
||||
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest}, nil
|
||||
|
||||
case catalogue.ArtifactPackage:
|
||||
// Built and published on a public base, to the mesh's package registry, by version
|
||||
// (novox/hq ADR 0076). Not an image, not an archive: nothing on a machine references it, so
|
||||
// there is no Publisher call — the container itself publishes, with the credential the
|
||||
// build was handed.
|
||||
say("package", "building and publishing %s (%s)", a.Name, a.Language)
|
||||
reference, err := publishPackage(ctx, run, module, tree, a, npmrc, say)
|
||||
if err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: publishing %s failed: %w", module, a.Name, err)
|
||||
}
|
||||
say("package", "published %s", reference)
|
||||
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil
|
||||
|
||||
case catalogue.ArtifactArchive:
|
||||
body, err := pack(filepath.Join(tree, a.From))
|
||||
if err != nil {
|
||||
@@ -360,16 +531,31 @@ func short(commit string) string {
|
||||
|
||||
// Command is a Runner that actually runs things.
|
||||
func Command(ctx context.Context, dir, name string, args ...string) (string, error) {
|
||||
// **Every command is echoed before it runs**, with where. On a build that hangs, the last line
|
||||
// is exactly the command it is inside — which is the difference between "the builder did
|
||||
// nothing" and "git clone is waiting on a network that will not answer". Silent on success is
|
||||
// what made an empty workspace unreadable.
|
||||
started := timeNow()
|
||||
fmt.Fprintf(os.Stderr, " $ (%s) %s %s\n", short(filepath.Base(dir)), name, strings.Join(args, " "))
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, " ! %s %s failed after %s\n", name, args[0], since(started))
|
||||
return string(out), fmt.Errorf("%s %s: %w\n%s",
|
||||
name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " ✓ %s %s (%s)\n", name, firstArg(args), since(started))
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func firstArg(args []string) string {
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
return args[0]
|
||||
}
|
||||
|
||||
var _ io.Writer = (*stringWriter)(nil)
|
||||
|
||||
// standingOn turns the bases a module named into build arguments for what this mesh holds.
|
||||
@@ -406,3 +592,125 @@ func standingOn(manifest catalogue.Manifest, held map[string]string) ([]string,
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
// compile runs a module's own code through its toolchain, and says where the result is.
|
||||
//
|
||||
// **In the module's own directory, under the path the toolchain expects.** A module is compiled
|
||||
// where its dependencies resolve upward into the base's own library directory, so what it is
|
||||
// compiled against is exactly what it will run against — the reason every hand-written Dockerfile
|
||||
// had to choose a working directory carefully, and the reason none of them has to now.
|
||||
// publishPackage builds a `package` artifact on a public base image and publishes it to the mesh's
|
||||
// package registry by version. The credential arrives as an .npmrc file the build was handed
|
||||
// (novox/hq ADR 0076); it is mounted read-only into the container rather than baked, because a
|
||||
// package build produces no image to leak it into. The reference returned is name@version, read from
|
||||
// the module's own package.json — the same two fields npm publishes under.
|
||||
func publishPackage(ctx context.Context, run Runner, module, dir string, a catalogue.Artifact,
|
||||
npmrc string, say func(step, format string, args ...any)) (string, error) {
|
||||
|
||||
recipe, ok := packageRecipes[a.Language]
|
||||
if !ok {
|
||||
return "", fmt.Errorf(
|
||||
"a package written in %q cannot be built: no public toolchain is known for it", a.Language)
|
||||
}
|
||||
if npmrc == "" {
|
||||
// A package with nowhere to be published is not built. Said here rather than failing inside
|
||||
// npm publish with a message about a registry that is simply absent.
|
||||
return "", fmt.Errorf(
|
||||
"%s is a package and this build was given no package registry to publish it to", a.Name)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "package.json"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("a package is published by name and version, and %s has no package.json: %w", module, err)
|
||||
}
|
||||
var pkg struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &pkg); err != nil {
|
||||
return "", fmt.Errorf("%s's package.json is not readable: %w", module, err)
|
||||
}
|
||||
if pkg.Name == "" || pkg.Version == "" {
|
||||
return "", fmt.Errorf("%s's package.json names no %s to publish under",
|
||||
module, either(pkg.Name == "", "name", "version"))
|
||||
}
|
||||
|
||||
const within = "/app/module"
|
||||
invocation := []string{
|
||||
"run", "--rm",
|
||||
// Host network, so the publish reaches the registry at the address the binding names.
|
||||
"--network", "host",
|
||||
"--volume", dir + ":" + within,
|
||||
// Read-only, so a build cannot alter the credential, and at /root where npm reads it.
|
||||
"--volume", npmrc + ":/root/.npmrc:ro",
|
||||
"--workdir", within,
|
||||
recipe.Base,
|
||||
"sh", "-c", recipe.Script,
|
||||
}
|
||||
if _, err := run(ctx, dir, "docker", invocation...); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pkg.Name + "@" + pkg.Version, nil
|
||||
}
|
||||
|
||||
// either names whichever of two fields is the missing one, for a message that says which.
|
||||
func either(first bool, a, b string) string {
|
||||
if first {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func compile(ctx context.Context, run Runner, tree string, chain Toolchain,
|
||||
base string, a catalogue.Artifact) (string, error) {
|
||||
|
||||
// Where inside the toolchain the module's source is mounted, and where its output lands. Fixed
|
||||
// rather than configurable: a module that could move this would be describing its own build.
|
||||
const within = "/app/modules/module"
|
||||
|
||||
// **Its own output directory, because a module may be several languages at once.** One module
|
||||
// is one piece of software and can still carry a daemon in one language, tools in another and
|
||||
// a package in a third (ADR 0040). Compiling them all into one place would have them overwrite
|
||||
// each other and then be packed together, so each bundle compiles and packs alone.
|
||||
out := Out(a.Name)
|
||||
|
||||
invocation := []string{
|
||||
"run", "--rm",
|
||||
"--volume", tree + ":" + within,
|
||||
"--workdir", within,
|
||||
base,
|
||||
}
|
||||
invocation = append(invocation, chain.Compile...)
|
||||
if chain.OutputFlag != "" {
|
||||
invocation = append(invocation, chain.OutputFlag, out)
|
||||
}
|
||||
// What to compile. Named by the module rather than discovered, so adding a file does not
|
||||
// silently change what a build produces.
|
||||
if len(a.Entrypoints) > 0 {
|
||||
invocation = append(invocation, sourcesFor(a.Entrypoints, out)...)
|
||||
}
|
||||
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(tree, out), nil
|
||||
}
|
||||
|
||||
// sourcesFor turns compiled entrypoints back into what to compile.
|
||||
//
|
||||
// A module names what a tool host should LOAD — compiled paths under the bundle's root — because
|
||||
// that is the thing anything else needs to know. What to compile is the same list with the
|
||||
// language's own extension, which is the toolchain's business rather than the module's.
|
||||
func sourcesFor(entrypoints []string, out string) []string {
|
||||
sources := make([]string, 0, len(entrypoints))
|
||||
for _, e := range entrypoints {
|
||||
// An entrypoint is named as it will be FOUND — a path inside the unpacked bundle — so the
|
||||
// source is the same path with the output directory taken off the front and the language's
|
||||
// own extension on the end.
|
||||
at := strings.TrimPrefix(strings.TrimPrefix(e, out), "/")
|
||||
sources = append(sources, strings.TrimSuffix(at, filepath.Ext(at))+".ts")
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func timeNow() time.Time { return time.Now() }
|
||||
func since(t time.Time) string { return time.Since(t).Round(time.Millisecond).String() }
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// A repository becoming artifacts the mesh can pin.
|
||||
@@ -108,7 +108,7 @@ func TestABuildProducesAManifestThePinsAreIn(t *testing.T) {
|
||||
r, workspace := aRepository(t, withBoth, map[string]string{
|
||||
"Dockerfile": "FROM scratch", "files/theme.conf": "dark",
|
||||
})
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace, nil)
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace, nil, Npmrc{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func TestTwoBuildsOfOneCommitProduceOneDigest(t *testing.T) {
|
||||
})
|
||||
// A year apart, so a packer carrying timestamps cannot accidentally agree.
|
||||
r.stamped = time.Date(2020+i, time.March, 3, 4, 5, 6, 0, time.UTC)
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil)
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func TestNothingIsPublishedUntilEverythingIsBuilt(t *testing.T) {
|
||||
// unreferenced, and indistinguishable from something in use.
|
||||
r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch"})
|
||||
// `files` is missing, so packing the archive fails — after the image would have been pushed.
|
||||
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil)
|
||||
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a build with a missing input succeeded")
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func TestNothingIsPublishedUntilEverythingIsBuilt(t *testing.T) {
|
||||
func TestARepositoryWithNoManifestSaysSo(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
r := &recorded{contents: map[string]string{"README.md": "nothing to see"}}
|
||||
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil)
|
||||
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a repository with nothing saying what it is was built")
|
||||
}
|
||||
@@ -179,7 +179,7 @@ func TestAModuleThatBuildsNothingStillProducesAManifest(t *testing.T) {
|
||||
// Most of what a person installs is configuration.
|
||||
r, workspace := aRepository(t, `{"module":"shell","version":"1","resources":[
|
||||
{"id":"rc","type":"file","path":"/etc/zsh/zshrc","content":"setopt"}]}`, nil)
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/shell.git", "", "", workspace, nil)
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/shell.git", "", "", workspace, nil, Npmrc{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -209,7 +209,7 @@ func TestTheTreeIsFreshEveryTime(t *testing.T) {
|
||||
if err := os.WriteFile(leftover, []byte("stale"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil); err != nil {
|
||||
if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(leftover); err == nil {
|
||||
@@ -222,7 +222,7 @@ func TestABuildThatCannotPushFails(t *testing.T) {
|
||||
"Dockerfile": "FROM scratch", "files/a": "b",
|
||||
})
|
||||
r.failPush = true
|
||||
if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil); err == nil {
|
||||
if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, nil); err == nil {
|
||||
t.Fatal("a build that could publish nothing reported success")
|
||||
}
|
||||
}
|
||||
@@ -236,7 +236,7 @@ func TestAnUpstreamImageIsMirroredRatherThanBuilt(t *testing.T) {
|
||||
"resources":[{"id":"db","type":"container","name":"mesh-postgres","artifact":"store"}]}`
|
||||
|
||||
r, workspace := aRepository(t, mirrors, nil)
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/postgres.git", "", "", workspace, nil)
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/postgres.git", "", "", workspace, nil, Npmrc{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -302,7 +302,7 @@ func TestAModuleIsBuiltFromItsPathWithinTheRepository(t *testing.T) {
|
||||
"modules/other/" + ManifestName: `{"module":"other","version":"1"}`,
|
||||
}}
|
||||
got, err := Build(context.Background(), r.run, r,
|
||||
"https://forge.invalid/catalogue.git", "modules/shell", "", t.TempDir(), nil)
|
||||
"https://forge.invalid/catalogue.git", "modules/shell", "", t.TempDir(), nil, Npmrc{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -321,7 +321,7 @@ func TestAPathThatLeavesTheRepositoryIsRefused(t *testing.T) {
|
||||
for _, escaping := range []string{"../../etc", "/etc"} {
|
||||
r := &recorded{contents: map[string]string{ManifestName: withBoth}}
|
||||
_, err := Build(context.Background(), r.run, r,
|
||||
"https://forge.invalid/x.git", escaping, "", t.TempDir(), nil)
|
||||
"https://forge.invalid/x.git", escaping, "", t.TempDir(), nil, Npmrc{}, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("%q was accepted as a module's path", escaping)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const aBundle = `{"module":"greeter","version":"1",
|
||||
"build":{"artifacts":[
|
||||
{"name":"code","kind":"bundle","language":"typescript","entrypoints":["index.js"]}]},
|
||||
"resources":[
|
||||
{"id":"files","type":"archive","path":"/opt/greeter","artifact":"code"}]}`
|
||||
|
||||
// compiling is a runner that behaves like a toolchain: when asked to compile, it leaves output
|
||||
// where the toolchain says output lands. Without this the pack step has nothing to pack, and the
|
||||
// test would be asserting on a failure rather than on a build.
|
||||
type compiling struct{ *recorded }
|
||||
|
||||
func (c compiling) run(ctx context.Context, dir, name string, args ...string) (string, error) {
|
||||
out, err := c.recorded.run(ctx, dir, name, args...)
|
||||
if name != "docker" || len(args) == 0 || args[0] != "run" {
|
||||
return out, err
|
||||
}
|
||||
// **Writes where it was TOLD to**, rather than to a fixed directory. A fake that always wrote
|
||||
// to one place would pass whether or not the builder gave each artifact its own — which is the
|
||||
// thing being tested.
|
||||
where := ""
|
||||
for i, a := range args {
|
||||
if a == "--outDir" && i+1 < len(args) {
|
||||
where = args[i+1]
|
||||
}
|
||||
}
|
||||
if where == "" {
|
||||
return out, err
|
||||
}
|
||||
made := filepath.Join(dir, where)
|
||||
if err := os.MkdirAll(made, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(made, "index.js"), []byte("console.log(1)"), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
// **A module says what it is written in, and needs no Dockerfile.** This is the whole point of the
|
||||
// bundle recipe: the same module previously needed a hand-written recipe repeating an incantation
|
||||
// that is easy to get wrong in ways that fail somewhere else.
|
||||
func TestABundleIsCompiledAndPackedWithNoDockerfile(t *testing.T) {
|
||||
r, workspace := aRepository(t, aBundle, 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)}
|
||||
|
||||
got, err := Build(context.Background(), compiling{r}.run, r,
|
||||
"https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("a module with a language and no Dockerfile did not build: %v", err)
|
||||
}
|
||||
|
||||
// Compiled in the toolchain the mesh chose, not in one the module named.
|
||||
var compiled string
|
||||
for _, line := range r.ran {
|
||||
if strings.HasPrefix(line, "docker run") {
|
||||
compiled = line
|
||||
}
|
||||
}
|
||||
if compiled == "" {
|
||||
t.Fatalf("nothing was compiled:\n%s", strings.Join(r.ran, "\n"))
|
||||
}
|
||||
if !strings.Contains(compiled, "mesh-tools/build@sha256:") {
|
||||
t.Fatalf("the compile did not run in the mesh's own toolchain: %s", compiled)
|
||||
}
|
||||
if strings.Contains(strings.Join(r.ran, "\n"), "docker build") {
|
||||
t.Fatalf("a bundle invoked a Dockerfile build, which is the thing it exists to avoid:\n%s",
|
||||
strings.Join(r.ran, "\n"))
|
||||
}
|
||||
|
||||
// And pinned by a digest of what came out, like any other artifact.
|
||||
digest, _ := got.Manifest.Resources[0]["digest"].(string)
|
||||
if !strings.HasPrefix(digest, "sha256:") {
|
||||
t.Fatalf("the bundle was not pinned: %v", got.Manifest.Resources[0])
|
||||
}
|
||||
}
|
||||
|
||||
// **Refused before anything is built, naming what to build first.** A base the mesh has not built
|
||||
// is not a compile that fails on its first line — it is a question somebody can answer, and saying
|
||||
// it early is the difference between a fixable message and one about a missing image.
|
||||
func TestABundleWhoseToolchainIsNotHeldIsRefusedFirst(t *testing.T) {
|
||||
r, workspace := aRepository(t, aBundle, map[string]string{"index.ts": "console.log(1)"})
|
||||
|
||||
_, err := Build(context.Background(), compiling{r}.run, r,
|
||||
"https://forge.invalid/greeter.git", "", "", workspace, nil, Npmrc{}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a bundle was built with no toolchain to compile it in")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mesh-tools") {
|
||||
t.Fatalf("the refusal does not name what has to be built first: %v", err)
|
||||
}
|
||||
for _, line := range r.ran {
|
||||
if strings.HasPrefix(line, "docker run") {
|
||||
t.Fatalf("a compile was attempted before the refusal: %s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A language the mesh does not build is refused the same way, and names what it can build.
|
||||
func TestABundleInAnUnknownLanguageIsRefused(t *testing.T) {
|
||||
manifest := strings.Replace(aBundle, `"language":"typescript"`, `"language":"cobol"`, 1)
|
||||
r, workspace := aRepository(t, manifest, map[string]string{"index.ts": "x"})
|
||||
|
||||
_, err := Build(context.Background(), compiling{r}.run, r,
|
||||
"https://forge.invalid/greeter.git", "", "", workspace,
|
||||
map[string]string{"mesh-tools/build": "registry.invalid/x@sha256:" + strings.Repeat("c", 64)}, Npmrc{}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a language nothing can compile was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "typescript") {
|
||||
t.Fatalf("the refusal does not say what would have worked: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// **One module, two bundles, and neither packs the other.**
|
||||
//
|
||||
// The case that matters for real modules: a module is one piece of software and may still carry a
|
||||
// daemon in one language and tools in another (ADR 0040). An earlier version of this compiled
|
||||
// every bundle into the toolchain's single output directory, so two of them would overwrite each
|
||||
// other and then be packed together — one artifact containing both, twice.
|
||||
func TestTwoBundlesInOneModuleArePackedSeparately(t *testing.T) {
|
||||
const two = `{"module":"greeter","version":"1",
|
||||
"build":{"artifacts":[
|
||||
{"name":"daemon","kind":"bundle","language":"typescript","entrypoints":["index.js"]},
|
||||
{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["index.js"]}]},
|
||||
"resources":[
|
||||
{"id":"a","type":"archive","path":"/opt/greeter/daemon","artifact":"daemon"},
|
||||
{"id":"b","type":"archive","path":"/opt/greeter/tools","artifact":"tools"}]}`
|
||||
|
||||
r, workspace := aRepository(t, two, 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)}
|
||||
|
||||
got, err := Build(context.Background(), compiling{r}.run, r,
|
||||
"https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("a module with two bundles did not build: %v", err)
|
||||
}
|
||||
|
||||
// Compiled into two different places.
|
||||
var outputs []string
|
||||
for _, line := range r.ran {
|
||||
for _, part := range strings.Fields(line) {
|
||||
if strings.HasPrefix(part, ".mesh-build/") {
|
||||
outputs = append(outputs, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(outputs) != 2 || outputs[0] == outputs[1] {
|
||||
t.Fatalf("two bundles did not get their own output directories: %v", outputs)
|
||||
}
|
||||
|
||||
// And published as two artifacts, each with its own digest.
|
||||
if len(r.archives) != 2 {
|
||||
t.Fatalf("expected two archives published, got %v", r.archives)
|
||||
}
|
||||
first, _ := got.Manifest.Resources[0]["digest"].(string)
|
||||
second, _ := got.Manifest.Resources[1]["digest"].(string)
|
||||
if first == "" || second == "" {
|
||||
t.Fatalf("a bundle was not pinned: %v", got.Manifest.Resources)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Npmrc is what a build needs to resolve the mesh's own shared library — and any other package the
|
||||
// mesh publishes — from the mesh's package registry rather than from a git URL (novox/hq ADR 0076,
|
||||
// issue 053).
|
||||
//
|
||||
// It is a build-time credential, not a runtime one. A module compiled inside the toolchain image
|
||||
// resolves the SDK there, once, when that image is built; the running container never speaks to the
|
||||
// package registry. So this is given to the *builder*, the way the artifact store is
|
||||
// (`whereToPublish`), and reaches a build as a secret rather than a layer — see Secret.
|
||||
//
|
||||
// The registry's exact URL shape is the provider's business, not the builder's: it arrives whole,
|
||||
// either from the binding the mesh writes (a package-registry provider's `serves` facts) or from the
|
||||
// environment when a person runs a build by hand. Nothing here knows gitea from verdaccio.
|
||||
type Npmrc struct {
|
||||
// Scope is the npm scope the registry answers for, e.g. "@novox". A build resolves only this
|
||||
// scope from the mesh; everything else resolves the ordinary way, so a mesh with no internet
|
||||
// still cannot pull the public registry's version of a name the mesh also publishes.
|
||||
Scope string
|
||||
// Registry is the full base URL a client uses for this scope, e.g.
|
||||
// "https://<forge>/api/packages/<owner>/npm/". Trailing slash tolerated either way.
|
||||
Registry string
|
||||
// Token authenticates to the registry as a bearer token, when a provider mints one. Left empty
|
||||
// when the mesh authenticates the ordinary way it authenticates everything — a generated
|
||||
// password it applies and seals — for which see Username and Password.
|
||||
Token string
|
||||
// Username and Password authenticate by basic auth, which is what gitea and verdaccio both
|
||||
// accept and what lets the credential be a mesh-generated password the provider's provisioner
|
||||
// applies and the mesh seals to the consumer — the same shape a database password takes. The
|
||||
// username is the consumer's mesh identity. Ignored when Token is set.
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// Enabled reports whether there is a registry to resolve against at all. A bootstrap build that
|
||||
// runs before any package registry exists has none, and must still build whatever needs no
|
||||
// mesh-published dependency.
|
||||
func (n Npmrc) Enabled() bool {
|
||||
return strings.TrimSpace(n.Scope) != "" && strings.TrimSpace(n.Registry) != ""
|
||||
}
|
||||
|
||||
// File renders the .npmrc a build mounts. Two lines: which registry answers for the scope, and the
|
||||
// token to present to it. The auth line is keyed by the registry URL with its scheme removed, which
|
||||
// is how npm matches a stored credential to a request.
|
||||
//
|
||||
// It returns an error rather than a malformed file, because an .npmrc that npm parses but points
|
||||
// nowhere fails much later, inside a build, as a package that cannot be found.
|
||||
func (n Npmrc) File() (string, error) {
|
||||
scope := strings.TrimSpace(n.Scope)
|
||||
if !strings.HasPrefix(scope, "@") {
|
||||
return "", fmt.Errorf("a package-registry scope is written with its leading @, not %q", scope)
|
||||
}
|
||||
reg := strings.TrimSpace(n.Registry)
|
||||
if !strings.HasPrefix(reg, "http://") && !strings.HasPrefix(reg, "https://") {
|
||||
return "", fmt.Errorf("a package registry is reached over http(s), and %q is neither", reg)
|
||||
}
|
||||
if !strings.HasSuffix(reg, "/") {
|
||||
// npm's per-scope registry key is matched by prefix, and the auth key below is derived from
|
||||
// it; a missing trailing slash makes the two disagree and the token is never sent.
|
||||
reg += "/"
|
||||
}
|
||||
parsed, err := url.Parse(reg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%q is not a usable registry URL: %w", reg, err)
|
||||
}
|
||||
// The auth key is the URL without its scheme, e.g. "//host/api/packages/owner/npm/".
|
||||
authKey := "//" + parsed.Host + parsed.EscapedPath()
|
||||
|
||||
var auth string
|
||||
switch {
|
||||
case strings.TrimSpace(n.Token) != "":
|
||||
auth = fmt.Sprintf("%s:_authToken=%s\n", authKey, strings.TrimSpace(n.Token))
|
||||
case strings.TrimSpace(n.Username) != "" && n.Password != "":
|
||||
// npm reads the password base64-encoded, and always-auth so it presents the credential to
|
||||
// reads as well as writes — a private registry answers neither without it.
|
||||
enc := base64.StdEncoding.EncodeToString([]byte(n.Password))
|
||||
auth = fmt.Sprintf("%s:username=%s\n%s:_password=%s\n%s:always-auth=true\n",
|
||||
authKey, strings.TrimSpace(n.Username), authKey, enc, authKey)
|
||||
default:
|
||||
return "", fmt.Errorf(
|
||||
"the package registry at %s was given neither a token nor a username and password", reg)
|
||||
}
|
||||
return fmt.Sprintf("%s:registry=%s\n%s", scope, reg, auth), nil
|
||||
}
|
||||
|
||||
// packageRecipe is how a `package` artifact is built and published: on a PUBLIC base image, never
|
||||
// the mesh toolchain, because the toolchain is built from the package this produces (the SDK). The
|
||||
// script builds the module, then publishes it to the mesh's package registry unless that exact
|
||||
// version is already there — so a re-run of genesis, which must be safe, does not fail on a version
|
||||
// it published a moment ago.
|
||||
type packageRecipe struct {
|
||||
Base string
|
||||
Script string
|
||||
}
|
||||
|
||||
var packageRecipes = map[string]packageRecipe{
|
||||
"typescript": {
|
||||
Base: "node:22-bookworm-slim",
|
||||
Script: `set -e
|
||||
npm install --no-audit --no-fund
|
||||
npm run build
|
||||
name="$(node -p "require('./package.json').name")"
|
||||
ver="$(node -p "require('./package.json').version")"
|
||||
if npm view "$name@$ver" version >/dev/null 2>&1; then
|
||||
echo "mesh-builder: $name@$ver is already published, leaving it"
|
||||
else
|
||||
npm publish
|
||||
fi`,
|
||||
},
|
||||
}
|
||||
|
||||
// PackageLanguages is the languages a package artifact can be written in, for a manifest check that
|
||||
// wants to refuse one it cannot build before a build starts.
|
||||
func PackageLanguages() []string {
|
||||
out := make([]string, 0, len(packageRecipes))
|
||||
for l := range packageRecipes {
|
||||
out = append(out, l)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNpmrcRendersRegistryAndTokenForTheScope(t *testing.T) {
|
||||
n := Npmrc{
|
||||
Scope: "@novox",
|
||||
Registry: "https://forge.invalid/api/packages/novox/npm/",
|
||||
Token: "a-token",
|
||||
}
|
||||
got, err := n.File()
|
||||
if err != nil {
|
||||
t.Fatalf("a complete credential did not render: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "@novox:registry=https://forge.invalid/api/packages/novox/npm/") {
|
||||
t.Fatalf("the scope's registry line is missing:\n%s", got)
|
||||
}
|
||||
// The auth line is keyed by the URL without its scheme, or npm never sends the token.
|
||||
if !strings.Contains(got, "//forge.invalid/api/packages/novox/npm/:_authToken=a-token") {
|
||||
t.Fatalf("the auth line does not match the registry key:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNpmrcAddsATrailingSlashSoTheAuthKeyMatches(t *testing.T) {
|
||||
n := Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm", Token: "t"}
|
||||
got, err := n.File()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(got, "registry=https://forge.invalid/api/packages/novox/npm/\n") {
|
||||
t.Fatalf("a missing trailing slash was not normalised:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNpmrcRefusesTheHalfConfigured(t *testing.T) {
|
||||
cases := map[string]Npmrc{
|
||||
"scope without @": {Scope: "novox", Registry: "https://x.invalid/", Token: "t"},
|
||||
"registry not http": {Scope: "@novox", Registry: "ftp://x.invalid/", Token: "t"},
|
||||
"no token": {Scope: "@novox", Registry: "https://x.invalid/", Token: ""},
|
||||
}
|
||||
for name, n := range cases {
|
||||
if _, err := n.File(); err == nil {
|
||||
t.Fatalf("%s rendered an .npmrc rather than refusing", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNpmrcDisabledUntilThereIsARegistry(t *testing.T) {
|
||||
if (Npmrc{}).Enabled() {
|
||||
t.Fatal("an empty credential reported itself usable")
|
||||
}
|
||||
if (Npmrc{Scope: "@novox"}).Enabled() {
|
||||
t.Fatal("a scope with no registry reported itself usable")
|
||||
}
|
||||
if !(Npmrc{Scope: "@novox", Registry: "https://x.invalid/"}).Enabled() {
|
||||
t.Fatal("a scope and a registry did not count as usable")
|
||||
}
|
||||
}
|
||||
|
||||
// The credential reaches an image build as an .npmrc inside the build context — for a Dockerfile to
|
||||
// COPY in a stage it does not publish — and the build runs on the host network so a RUN resolving the
|
||||
// registry reaches it where the binding says (novox/hq ADR 0076). Not a buildkit secret, because this
|
||||
// machine's docker may carry no buildx.
|
||||
func TestAnImageBuildGetsTheCredentialInTheContextAndHostNetwork(t *testing.T) {
|
||||
r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch\nCOPY .npmrc ./", "files/x": "y"})
|
||||
n := Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm/", Token: "t"}
|
||||
if _, err := Build(context.Background(), r.run, r,
|
||||
"https://forge.invalid/meshboard.git", "", "", workspace, nil, n, nil); err != nil {
|
||||
t.Fatalf("the build failed: %v", err)
|
||||
}
|
||||
|
||||
var build string
|
||||
for _, line := range r.ran {
|
||||
if strings.HasPrefix(line, "docker build") {
|
||||
build = line
|
||||
}
|
||||
}
|
||||
if build == "" {
|
||||
t.Fatal("no docker build ran")
|
||||
}
|
||||
if strings.Contains(build, "--secret") {
|
||||
t.Fatalf("the build used a buildkit secret, which this path avoids: %s", build)
|
||||
}
|
||||
if !strings.Contains(build, "--network host") {
|
||||
t.Fatalf("the build was not given the host network to reach the registry: %s", build)
|
||||
}
|
||||
// The .npmrc is written into the build context (the source tree), where a Dockerfile COPYs it.
|
||||
tree := filepath.Join(workspace, "source")
|
||||
npmrc := filepath.Join(tree, ".npmrc")
|
||||
if _, err := os.Stat(npmrc); err != nil {
|
||||
t.Fatalf("the credential was not written into the build context: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnImageBuildWithoutACredentialGetsNoHostNetwork(t *testing.T) {
|
||||
r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch", "files/x": "y"})
|
||||
if _, err := Build(context.Background(), r.run, r,
|
||||
"https://forge.invalid/meshboard.git", "", "", workspace, nil, Npmrc{}, nil); err != nil {
|
||||
t.Fatalf("the build failed: %v", err)
|
||||
}
|
||||
for _, line := range r.ran {
|
||||
if strings.HasPrefix(line, "docker build") && (strings.Contains(line, "--secret") || strings.Contains(line, "--network host")) {
|
||||
t.Fatalf("a build with no credential was still given build-network or a secret: %s", line)
|
||||
}
|
||||
}
|
||||
tree := filepath.Join(workspace, "source")
|
||||
if _, err := os.Stat(filepath.Join(tree, ".npmrc")); err == nil {
|
||||
t.Fatal("an .npmrc was written into a build that has no credential")
|
||||
}
|
||||
}
|
||||
|
||||
const aPackage = `{"module":"mesh-sdk","version":"1",
|
||||
"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]},
|
||||
"resources":[]}`
|
||||
|
||||
// A package is compiled on a public base and published to the mesh's package registry by version,
|
||||
// with nothing pushed to the artifact store and the credential mounted, not baked (novox/hq ADR 0076).
|
||||
func TestAPackageIsBuiltOnAPublicBaseAndPublishedByVersion(t *testing.T) {
|
||||
r, workspace := aRepository(t, aPackage, map[string]string{
|
||||
"package.json": `{"name":"@novox/mesh-sdk","version":"0.1.0"}`,
|
||||
})
|
||||
n := Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm/", Token: "t"}
|
||||
got, err := Build(context.Background(), r.run, r,
|
||||
"https://forge.invalid/mesh-sdk.git", "", "", workspace, nil, n, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("the package did not build: %v", err)
|
||||
}
|
||||
if len(got.Built) != 1 || got.Built[0].Reference != "@novox/mesh-sdk@0.1.0" {
|
||||
t.Fatalf("a package is published by name and version, got %+v", got.Built)
|
||||
}
|
||||
if len(r.images) != 0 || len(r.archives) != 0 {
|
||||
t.Fatal("a package was pushed to the artifact store, which is not where packages live")
|
||||
}
|
||||
var ran string
|
||||
for _, line := range r.ran {
|
||||
if strings.HasPrefix(line, "docker run") {
|
||||
ran = line
|
||||
}
|
||||
}
|
||||
if ran == "" {
|
||||
t.Fatal("nothing ran to build the package")
|
||||
}
|
||||
if !strings.Contains(ran, "node:22-bookworm-slim") {
|
||||
t.Fatalf("a package was not built on a public base: %s", ran)
|
||||
}
|
||||
if !strings.Contains(ran, ":/root/.npmrc:ro") {
|
||||
t.Fatalf("the credential was not mounted read-only for the publish: %s", ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPackageWithNoRegistryIsRefused(t *testing.T) {
|
||||
r, workspace := aRepository(t, aPackage, map[string]string{
|
||||
"package.json": `{"name":"@novox/mesh-sdk","version":"0.1.0"}`,
|
||||
})
|
||||
_, err := Build(context.Background(), r.run, r,
|
||||
"https://forge.invalid/mesh-sdk.git", "", "", workspace, nil, Npmrc{}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a package built with no registry to publish to, silently")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNpmrcRendersBasicAuthWhenGivenAUserAndPassword(t *testing.T) {
|
||||
n := Npmrc{
|
||||
Scope: "@novox",
|
||||
Registry: "http://forge.invalid:3000/api/packages/novox/npm/",
|
||||
Username: "mesh_anchor_builder",
|
||||
Password: "s3cret",
|
||||
}
|
||||
got, err := n.File()
|
||||
if err != nil {
|
||||
t.Fatalf("basic-auth credential did not render: %v", err)
|
||||
}
|
||||
key := "//forge.invalid:3000/api/packages/novox/npm/"
|
||||
if !strings.Contains(got, key+":username=mesh_anchor_builder\n") {
|
||||
t.Fatalf("username line missing:\n%s", got)
|
||||
}
|
||||
// npm reads the password base64-encoded.
|
||||
if !strings.Contains(got, key+":_password=czNjcmV0\n") {
|
||||
t.Fatalf("base64 password line missing or wrong:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, key+":always-auth=true\n") {
|
||||
t.Fatalf("always-auth missing, so reads would go unauthenticated:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "_authToken") {
|
||||
t.Fatalf("a token line was rendered for a basic-auth credential:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNpmrcRefusesWhenGivenNeitherTokenNorPassword(t *testing.T) {
|
||||
n := Npmrc{Scope: "@novox", Registry: "http://x.invalid/npm/", Username: "u"}
|
||||
if _, err := n.File(); err == nil {
|
||||
t.Fatal("a username with no password rendered an .npmrc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnImageThatDoesNotAskForTheCredentialDoesNotGetIt(t *testing.T) {
|
||||
// A Dockerfile with no .npmrc reference (like the control plane's) must build clean: no .npmrc
|
||||
// in its context, no host network — so its image stays deterministic and the credential does not
|
||||
// leak into a build that never resolves a mesh package.
|
||||
r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch\nCOPY . .", "files/x": "y"})
|
||||
n := Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm/", Token: "t"}
|
||||
if _, err := Build(context.Background(), r.run, r,
|
||||
"https://forge.invalid/meshboard.git", "", "", workspace, nil, n, nil); err != nil {
|
||||
t.Fatalf("the build failed: %v", err)
|
||||
}
|
||||
for _, line := range r.ran {
|
||||
if strings.HasPrefix(line, "docker build") && strings.Contains(line, "--network host") {
|
||||
t.Fatalf("a build that does not ask for the credential got the host network: %s", line)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(workspace, "source", ".npmrc")); err == nil {
|
||||
t.Fatal("an .npmrc was written into a build that does not reference it")
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// A module naming a base the mesh has not built is refused, and the refusal names what is missing.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What a language implies, so a module does not have to say it.
|
||||
//
|
||||
// **A module says what it is written in; this says what that means.** The alternative is what the
|
||||
// mesh had: every module carrying a Dockerfile that repeated the same incantation, and most of the
|
||||
// catalogue never converted because the incantation is easy to get wrong in ways that fail
|
||||
// somewhere else (novox/hq 03-DESIGN/01-to-be/18-building-a-module.md).
|
||||
//
|
||||
// A toolchain is deliberately not configurable by the module. Anything a module could override
|
||||
// here it would be writing a Dockerfile to override, and then this bought nothing.
|
||||
|
||||
// Toolchain is how one language is compiled into a bundle.
|
||||
type Toolchain struct {
|
||||
// Language is what a module declares to select this.
|
||||
Language string
|
||||
// Base is the module whose artifact provides the compiler, named rather than pinned: the mesh
|
||||
// answers with the copy it holds, so a recipe never names one particular build of it
|
||||
// (novox/hq 04-ISSUES/044).
|
||||
Base string
|
||||
// Artifact is which of that module's artifacts is the compiling one.
|
||||
Artifact string
|
||||
// Compile is what runs inside it, relative to the module's own directory.
|
||||
//
|
||||
// The output directory is appended by the builder, per artifact, because one module may
|
||||
// declare several bundles — a daemon in one language, tools in another, a package in a third —
|
||||
// and a toolchain with one fixed output would have them overwrite each other and then be
|
||||
// packed together.
|
||||
Compile []string
|
||||
// OutputFlag is how this compiler is told where to put its output.
|
||||
OutputFlag string
|
||||
}
|
||||
|
||||
// Out is where one artifact's compiled output lands, inside the module's own directory.
|
||||
//
|
||||
// **Per artifact, never per toolchain.** A module is one piece of software and may still be
|
||||
// written in several languages — a daemon in one, a tool in another, a package in a third (ADR
|
||||
// 0040). Each bundle is compiled and packed alone, so what a machine unpacks is that artifact and
|
||||
// nothing else.
|
||||
//
|
||||
// Under a directory named for the build rather than beside the source, so a pack never sweeps up
|
||||
// the module's own working files, and two builds of one commit see the same tree.
|
||||
func Out(artifact string) string { return ".mesh-build/" + artifact }
|
||||
|
||||
// toolchains is every language the mesh can build.
|
||||
//
|
||||
// **A closed list, and adding to it is a decision rather than a configuration.** Every language is
|
||||
// permanent: it needs an SDK carrying the broker client, sealed-credential reading, the event
|
||||
// envelope and tool serving, and the contracts every module shares change rarely and cascade when
|
||||
// they do (novox/hq ADR 0039). A mesh whose languages disagree about the envelope fails by ignoring
|
||||
// messages rather than by failing to compile, so a new entry here is a commitment to keeping N
|
||||
// implementations of one contract in step.
|
||||
var toolchains = []Toolchain{
|
||||
{
|
||||
Language: "typescript",
|
||||
Base: "mesh-tools",
|
||||
Artifact: "build",
|
||||
// Invoked by its real path rather than through node_modules/.bin, whose entries are
|
||||
// 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.
|
||||
// Every module's hand-written Dockerfile had to know this. Now none of them does.
|
||||
Compile: []string{
|
||||
"node", "/app/node_modules/typescript/bin/tsc",
|
||||
"--module", "NodeNext", "--moduleResolution", "NodeNext",
|
||||
"--target", "ES2022",
|
||||
},
|
||||
OutputFlag: "--outDir",
|
||||
},
|
||||
{
|
||||
Language: "python",
|
||||
Base: "mesh-tools-python",
|
||||
Artifact: "build",
|
||||
// Nothing to compile: what a bundle needs is the module's own code and its dependencies
|
||||
// resolved, so the "compile" is an install into the output directory. Named here rather
|
||||
// than left implicit because a reader comparing two toolchains should be able to see what
|
||||
// each actually does.
|
||||
Compile: []string{"python", "-m", "pip", "install", "--no-compile", "--target"},
|
||||
OutputFlag: "",
|
||||
},
|
||||
}
|
||||
|
||||
// ToolchainFor is what builds this language, or says what it can build.
|
||||
func ToolchainFor(language string) (Toolchain, error) {
|
||||
want := strings.ToLower(strings.TrimSpace(language))
|
||||
if want == "" {
|
||||
return Toolchain{}, fmt.Errorf(
|
||||
"a bundle must say what language it is written in: the mesh chooses the compiler, and "+
|
||||
"it cannot choose one for a module that has not said. It can build %s", spoken())
|
||||
}
|
||||
for _, t := range toolchains {
|
||||
if t.Language == want {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return Toolchain{}, fmt.Errorf(
|
||||
"%q is not a language this mesh builds. It can build %s — and adding one is a decision "+
|
||||
"rather than a setting, because every language is another implementation of the "+
|
||||
"contracts every module shares", language, spoken())
|
||||
}
|
||||
|
||||
// spoken lists the languages, so a refusal says what would have worked.
|
||||
func spoken() string {
|
||||
names := make([]string, 0, len(toolchains))
|
||||
for _, t := range toolchains {
|
||||
names = append(names, t.Language)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// Languages is every language the mesh can build, for anything that wants to say so.
|
||||
func Languages() []string {
|
||||
names := make([]string, 0, len(toolchains))
|
||||
for _, t := range toolchains {
|
||||
names = append(names, t.Language)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A language the mesh builds resolves to the toolchain that builds it.
|
||||
func TestADeclaredLanguageSelectsItsToolchain(t *testing.T) {
|
||||
chain, err := ToolchainFor("typescript")
|
||||
if err != nil {
|
||||
t.Fatalf("typescript is not buildable: %v", err)
|
||||
}
|
||||
if chain.Base == "" || chain.Artifact == "" {
|
||||
t.Fatalf("a toolchain names no base to compile in: %+v", chain)
|
||||
}
|
||||
if len(chain.Compile) == 0 {
|
||||
t.Fatalf("a toolchain says nothing about how to compile: %+v", chain)
|
||||
}
|
||||
}
|
||||
|
||||
// Case and stray whitespace are a module author's slip, not a different language.
|
||||
func TestALanguageIsMatchedLoosely(t *testing.T) {
|
||||
for _, said := range []string{"TypeScript", " typescript ", "TYPESCRIPT"} {
|
||||
if _, err := ToolchainFor(said); err != nil {
|
||||
t.Fatalf("%q was refused: %v", said, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **A refusal says what would have worked.** A module author who names a language the mesh does
|
||||
// not build is one word away from a language it does, and a bare "unsupported" makes them go
|
||||
// looking for a list that exists in one place in the source.
|
||||
func TestAnUnknownLanguageSaysWhatIsBuildable(t *testing.T) {
|
||||
_, err := ToolchainFor("cobol")
|
||||
if err == nil {
|
||||
t.Fatal("a language nothing can build was accepted")
|
||||
}
|
||||
for _, want := range Languages() {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("the refusal does not mention %q, which would have worked: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And saying nothing is its own message: the mesh chooses the compiler, so a bundle that names no
|
||||
// language has not asked for anything in particular — which is a mistake rather than a default.
|
||||
func TestABundleMustSayWhatItIsWrittenIn(t *testing.T) {
|
||||
_, err := ToolchainFor("")
|
||||
if err == nil {
|
||||
t.Fatal("a bundle with no language was accepted, so the mesh guessed a compiler")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "must say") {
|
||||
t.Fatalf("the refusal does not say a language is required: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// **One module, several languages, and each bundle packed alone.**
|
||||
//
|
||||
// A module is one piece of software (ADR 0040) and may still carry a daemon in one language, tools
|
||||
// in another and a package in a third. Compiling them into one output directory would have them
|
||||
// overwrite each other and then be packed together, so output is a property of the artifact rather
|
||||
// than of the toolchain.
|
||||
func TestTwoBundlesInOneModuleDoNotShareAnOutputDirectory(t *testing.T) {
|
||||
first, second := Out("daemon"), Out("tools")
|
||||
if first == second {
|
||||
t.Fatalf("two artifacts compile into the same place (%q), so one would overwrite the "+
|
||||
"other and both would be packed together", first)
|
||||
}
|
||||
for _, out := range []string{first, second} {
|
||||
if strings.HasPrefix(out, "/") || strings.Contains(out, "..") {
|
||||
t.Fatalf("%q leaves the module's own directory", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the mesh can say what it builds, which is what a refusal quotes.
|
||||
func TestTheMeshSaysWhichLanguagesItBuilds(t *testing.T) {
|
||||
spoken := Languages()
|
||||
if len(spoken) < 2 {
|
||||
t.Fatalf("only %v — this test exists to keep the multi-language path real rather than "+
|
||||
"theoretical", spoken)
|
||||
}
|
||||
for _, language := range spoken {
|
||||
if _, err := ToolchainFor(language); err != nil {
|
||||
t.Fatalf("%q is listed as buildable and has no toolchain: %v", language, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func TestAModuleThatDoesNotProvideTheStoreStillBuilds(t *testing.T) {
|
||||
|
||||
// A mapping that names an address still names the port, and the machine side is still the middle.
|
||||
//
|
||||
// The substrate bundle writes "127.0.0.1:5432:5432" today, so the shape is not hypothetical.
|
||||
// The foundation bundle writes "127.0.0.1:5432:5432" today, so the shape is not hypothetical.
|
||||
// Found in review: the first cut split on the first colon, read "127.0.0.1" as the machine port,
|
||||
// failed to parse it, and silently skipped the mapping — which put the filter back on the
|
||||
// declared port, the exact fault MachineSide was written to end.
|
||||
|
||||
@@ -86,14 +86,24 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
}
|
||||
delete(filled, "artifact")
|
||||
switch artifact.Kind {
|
||||
case ArtifactPackage:
|
||||
// A package is not a resource on any machine; it is consumed by other builds. A
|
||||
// resource that names one is a manifest error, named here rather than shipped.
|
||||
return Manifest{}, fmt.Errorf(
|
||||
"%s: %v uses %q, which is a package — a build input, not a resource a machine runs",
|
||||
m.Module, r["id"], named)
|
||||
case ArtifactImage, ArtifactUpstream:
|
||||
filled["image"] = artifact.Reference
|
||||
case ArtifactArchive:
|
||||
case ArtifactArchive, ArtifactBundle:
|
||||
// The same on the wire: both are bytes fetched by digest and unpacked. They differ in
|
||||
// how they were made — one packed as it stood, the other compiled first — and a
|
||||
// machine has no reason to care which.
|
||||
filled["source"] = artifact.Reference
|
||||
filled["digest"] = artifact.Digest
|
||||
default:
|
||||
return Manifest{}, fmt.Errorf("%s: %q is a %q, and an artifact is %q, %q or %q",
|
||||
m.Module, named, artifact.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream)
|
||||
return Manifest{}, fmt.Errorf("%s: %q is a %q, and an artifact is %q, %q, %q or %q",
|
||||
m.Module, named, artifact.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream,
|
||||
ArtifactBundle)
|
||||
}
|
||||
out.Resources = append(out.Resources, filled)
|
||||
}
|
||||
@@ -119,15 +129,40 @@ func (b *Build) problems(module string) []string {
|
||||
}
|
||||
seen[a.Name] = true
|
||||
switch a.Kind {
|
||||
case ArtifactImage, ArtifactArchive, ArtifactUpstream:
|
||||
case ArtifactImage, ArtifactArchive, ArtifactUpstream, ArtifactBundle, ArtifactPackage:
|
||||
default:
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q is a %q, and an artifact is %q, %q or %q",
|
||||
module, a.Name, a.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream))
|
||||
"%s: %q is a %q, and an artifact is %q, %q, %q or %q",
|
||||
module, a.Name, a.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream,
|
||||
ArtifactBundle+", "+ArtifactPackage))
|
||||
}
|
||||
if a.From == "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q says nothing about what it is built from", module, a.Name))
|
||||
// **A bundle is built from the module itself, so it says a language instead.** Everything
|
||||
// else names what it is built from: a Dockerfile, a directory, somebody else's reference.
|
||||
// A bundle's source is the module's own directory by definition, and what it needs to say
|
||||
// is which compiler — because the mesh chooses that, and cannot choose for a module that
|
||||
// has not said.
|
||||
if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
|
||||
if a.From != "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q is a bundle and names what it is built from (%q). A bundle is built "+
|
||||
"from the module's own directory; what it says is the language",
|
||||
module, a.Name, a.From))
|
||||
}
|
||||
if strings.TrimSpace(a.Language) == "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
|
||||
"for it", module, a.Name))
|
||||
}
|
||||
} else {
|
||||
if a.From == "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q says nothing about what it is built from", module, a.Name))
|
||||
}
|
||||
if a.Language != "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q is a %q and names a language. Only a bundle is compiled by the mesh; "+
|
||||
"everything else brings its own recipe", module, a.Name, a.Kind))
|
||||
}
|
||||
}
|
||||
// An upstream image is named, not read from the repository, so the path rule does not
|
||||
// apply to it — and applying it anyway would refuse every reference with a registry host
|
||||
|
||||
@@ -142,3 +142,41 @@ func TestAnArtifactOfAnUnknownKindIsRefused(t *testing.T) {
|
||||
t.Fatal("an artifact of an unknown kind was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPackageParsesLikeABundleAndNeedsALanguage(t *testing.T) {
|
||||
// The SDK's shape: a package built from the module's own directory, naming a language.
|
||||
m, err := ParseManifest([]byte(`{"module":"mesh-sdk","version":"1","slug":"sdk",
|
||||
"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]},
|
||||
"resources":[]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("the SDK's package manifest did not parse: %v", err)
|
||||
}
|
||||
if m.Build.Artifacts[0].Kind != ArtifactPackage {
|
||||
t.Fatalf("expected a package artifact, got %q", m.Build.Artifacts[0].Kind)
|
||||
}
|
||||
|
||||
// A package that names what it is built from is refused, exactly as a bundle is: it is built
|
||||
// from the module's own directory.
|
||||
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
||||
{"name":"lib","kind":"package","language":"typescript","from":"Dockerfile"}]}}`)); err == nil {
|
||||
t.Fatal("a package naming a source was accepted")
|
||||
}
|
||||
// A package with no language cannot choose a toolchain.
|
||||
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
||||
{"name":"lib","kind":"package"}]}}`)); err == nil {
|
||||
t.Fatal("a package with no language was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAResourceNamingAPackageIsRefused(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"a","version":"1","slug":"a",
|
||||
"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]},
|
||||
"resources":[{"id":"svc","type":"container","name":"a","artifact":"lib"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
_, err = m.Resolve([]Built{{Name: "lib", Kind: ArtifactPackage, Reference: "@novox/a@1.0.0"}})
|
||||
if err == nil {
|
||||
t.Fatal("a resource backed by a package was accepted; a package is not a resource")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func routeProxy() Manifest {
|
||||
// A co-located provider's served VALUES reach its consumer, not just its served keys.
|
||||
//
|
||||
// The mesh walks a provider on ANOTHER machine and settles what it serves with that node's settings
|
||||
// layers before offering it (cmd/mesh-control plan.go, theRestOfTheMesh). A provider on the
|
||||
// layers before offering it (cmd/mesh-controller plan.go, theRestOfTheMesh). A provider on the
|
||||
// consumer's own machine was never settled at all: resolve.go's servedHere and declaration.go's
|
||||
// here() both read the manifest and stop there. So a served value the operator supplied — the one
|
||||
// kind of value a manifest cannot carry, because it is different on every mesh — arrived as the
|
||||
|
||||
@@ -89,6 +89,12 @@ type Rendering struct {
|
||||
// a fact about the mesh, and resolution answers questions about one machine.
|
||||
Mesh []string
|
||||
|
||||
// Foundation is the ports the mesh itself needs reachable on every machine, which no module
|
||||
// declares because the foundation is not a module (novox/hq 04-ISSUES/051 and 052). The broker
|
||||
// is the one that matters: a machine dials it to enrol, and a firewall derived only from
|
||||
// modules closes it.
|
||||
Foundation []int
|
||||
|
||||
// Names is every machine's internal name and its address, for containers to be given.
|
||||
//
|
||||
// **A container does not inherit the machine's names**, so every internal name the mesh wrote
|
||||
@@ -207,7 +213,7 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtering := AsNftables(rules, with.Mesh, r.PublicDomain != "")
|
||||
filtering := AsNftables(rules, with.Mesh, r.PublicDomain != "", with.Foundation)
|
||||
|
||||
var out []map[string]any
|
||||
for _, m := range r.Modules {
|
||||
@@ -450,6 +456,12 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
||||
// assigned to. Beside the bound values because it is the same kind of fact — the
|
||||
// mesh's own, held in the clear — and because a module that must name itself to
|
||||
// something else has no binding to learn it from (novox/hq ADR 0066).
|
||||
// Which port this machine gave it, for a module that binds directly rather than
|
||||
// through a runtime that can remap (ADR 0038). Applied before the machine's facts so
|
||||
// a refusal names the port rather than whatever came after it.
|
||||
if err := portInto(copied, m.Module, m.Listens, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := machineInto(copied, thisMachine, m.Module); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -486,6 +498,19 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
||||
}
|
||||
out = append(out, copied)
|
||||
}
|
||||
|
||||
// **What only the mesh knows, where this module asked for it.** The graph is the control
|
||||
// plane's; making a name resolve is the module's software. Emitted as ordinary files under
|
||||
// this module's name, so they are applied, reported and removed exactly as anything else
|
||||
// it declares.
|
||||
given, err := FactsInto(m, r, with.Names)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, fact := range given {
|
||||
fact["id"] = m.Module + "." + fmt.Sprint(fact["id"])
|
||||
out = append(out, fact)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -796,7 +821,7 @@ func here(r Resolution, requirement string, with Rendering) (*Needed, error) {
|
||||
//
|
||||
// **The one derivation, so the two arrangements cannot disagree.** For a provider elsewhere the
|
||||
// control plane walks that node, reads its manifest with that machine's port assignments, and
|
||||
// settles the result with that node's settings layers before offering it (cmd/mesh-control plan.go,
|
||||
// settles the result with that node's settings layers before offering it (cmd/mesh-controller plan.go,
|
||||
// theRestOfTheMesh). A provider on the consumer's own machine never passes through that walk, so
|
||||
// every step of it has to be repeated here — and each step that was not repeated was a promise the
|
||||
// co-located arrangement quietly broke: first the port (novox/hq 04-ISSUES/038), then the settled
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What only the mesh knows, written where a module asks for it.
|
||||
//
|
||||
// **The graph is the control plane's; using it is the module's.** The mesh knows which machines
|
||||
// exist, what they are called, and where they are. Turning that into a name that resolves is
|
||||
// somebody's software, and which software is a choice the mesh should not be making.
|
||||
//
|
||||
// This replaced three modules — names, a resolver's data, and the private network's own
|
||||
// configuration — that existed only because computed output needed somewhere to live. They ran no
|
||||
// software and could not be swapped for anything, which is the test of whether something is a
|
||||
// module at all (novox/hq ADR 0040).
|
||||
|
||||
const (
|
||||
// FactNodeNames is every machine's name and address, as a hosts file.
|
||||
//
|
||||
// Exact names only: `homer` and `homer.internal` resolve to homer. Anything *under* a machine
|
||||
// is a wildcard, which a hosts file cannot express — that is FactNodeZones.
|
||||
FactNodeNames = "node-names"
|
||||
|
||||
// FactNodeZones is every machine as a wildcard: `*.homer.internal` is homer.
|
||||
//
|
||||
// Written in the form a resolver reads. A machine's own name and everything under it are one
|
||||
// fact — if homer is at an address, so is anything homer serves.
|
||||
FactNodeZones = "node-zones"
|
||||
)
|
||||
|
||||
// facts is every fact the mesh computes, and what writes it.
|
||||
//
|
||||
// **A closed list.** A module asking for a fact the mesh does not have is asking for a file nobody
|
||||
// will write, and finding that out on a machine — as a daemon that starts, reads nothing, and
|
||||
// answers no queries — is worse than being told where the manifest is.
|
||||
var facts = map[string]func(Resolution, map[string]string) string{
|
||||
FactNodeNames: nodeNames,
|
||||
FactNodeZones: nodeZones,
|
||||
}
|
||||
|
||||
// FactsInto renders the facts a module asked for, as files it will be given.
|
||||
//
|
||||
// The module owns everything after the file exists: loading it, restarting on it, what a resolver
|
||||
// does with it. This only puts it there.
|
||||
func FactsInto(m Manifest, r Resolution, addresses map[string]string) ([]map[string]any, error) {
|
||||
if len(m.Facts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
names := make([]string, 0, len(m.Facts))
|
||||
for name := range m.Facts {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
out := make([]map[string]any, 0, len(names))
|
||||
for _, name := range names {
|
||||
write, known := facts[name]
|
||||
if !known {
|
||||
return nil, fmt.Errorf(
|
||||
"%s asks the mesh for %q, which it does not compute. It has %s",
|
||||
m.Module, name, spokenFacts())
|
||||
}
|
||||
path := m.Facts[name]
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return nil, fmt.Errorf(
|
||||
"%s asks for %q at %q, which is not an absolute path", m.Module, name, path)
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": "fact-" + name, "type": "file", "path": path, "mode": "0644",
|
||||
"content": write(r, addresses),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// spokenFacts lists them, so a refusal says what would have worked.
|
||||
func spokenFacts() string {
|
||||
names := make([]string, 0, len(facts))
|
||||
for name := range facts {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// nodeNames is every machine's name and address, as a hosts file.
|
||||
//
|
||||
// **A machine with no address is left out.** The mesh has a record for it — somebody added it —
|
||||
// and does not yet know where it is, which is the ordinary state between adding a machine and it
|
||||
// joining. Writing the name anyway would give a name that resolves to nothing, and a connection to
|
||||
// that hangs; leaving it out fails at once and says the name is unknown.
|
||||
func nodeNames(r Resolution, addresses map[string]string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n")
|
||||
b.WriteString("# joins or leaves, and an edit would survive until then and vanish.\n\n")
|
||||
// The floor every Linux expects, and which removing would break things that have nothing to do
|
||||
// with the mesh.
|
||||
b.WriteString("127.0.0.1\tlocalhost\n")
|
||||
b.WriteString("::1\t\tlocalhost ip6-localhost ip6-loopback\n")
|
||||
if r.Node != "" {
|
||||
fmt.Fprintf(&b, "127.0.1.1\t%s\n", r.Node)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
for _, name := range sortedNames(addresses) {
|
||||
at := addresses[name]
|
||||
// Its mesh name resolves to its address on the private network rather than to loopback,
|
||||
// so a service binding the name it was given stays reachable from everywhere else.
|
||||
fmt.Fprintf(&b, "%s\t%s.internal\t%s", at, name, name)
|
||||
if name == r.Node {
|
||||
b.WriteString("\t# this machine")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// nodeZones is every machine as a wildcard, in the form a resolver reads.
|
||||
//
|
||||
// `*.homer.internal` is homer, which is the whole rule: if homer is at an address, so is anything
|
||||
// homer serves. A module wanting this runs the resolver; the mesh only says what is true.
|
||||
func nodeZones(_ Resolution, addresses map[string]string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n")
|
||||
b.WriteString("# joins or leaves, and an edit would survive until then and vanish.\n\n")
|
||||
for _, name := range sortedNames(addresses) {
|
||||
fmt.Fprintf(&b, "address=/%s.internal/%s\n", name, addresses[name])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func sortedNames(addresses map[string]string) []string {
|
||||
out := make([]string, 0, len(addresses))
|
||||
for name, at := range addresses {
|
||||
// See nodeNames: a machine the mesh cannot place is left out rather than named at nothing.
|
||||
if at == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var threeMachines = map[string]string{"homer": "10.42.0.1", "marge": "10.42.0.2", "bart": ""}
|
||||
|
||||
// **`*.homer.internal` is homer. That is the whole rule.**
|
||||
func TestEveryMachineIsAWildcardUnderItsOwnName(t *testing.T) {
|
||||
out := nodeZones(Resolution{Node: "homer"}, threeMachines)
|
||||
for _, want := range []string{
|
||||
"address=/homer.internal/10.42.0.1",
|
||||
"address=/marge.internal/10.42.0.2",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A machine the mesh has a record for and cannot place is left out of both.
|
||||
//
|
||||
// **Not an oversight — the alternative is worse.** A name written with no address resolves to
|
||||
// nothing, and a connection to that hangs. Leaving it out fails at once and says the name is
|
||||
// unknown, which is a thing somebody can act on.
|
||||
func TestAMachineWithNoAddressIsNotNamed(t *testing.T) {
|
||||
for _, out := range []string{
|
||||
nodeNames(Resolution{Node: "homer"}, threeMachines),
|
||||
nodeZones(Resolution{Node: "homer"}, threeMachines),
|
||||
} {
|
||||
if strings.Contains(out, "bart") {
|
||||
t.Fatalf("a machine with no address was named, so its name resolves to nothing:\n%s", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A machine's own mesh name points at its address on the private network, not at loopback — or a
|
||||
// service binding the name it was given is unreachable from everywhere else.
|
||||
func TestAMachinesOwnNameIsItsMeshAddress(t *testing.T) {
|
||||
out := nodeNames(Resolution{Node: "homer"}, threeMachines)
|
||||
var line string
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
if strings.Contains(l, "homer.internal") {
|
||||
line = l
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(line, "10.42.0.1") {
|
||||
t.Fatalf("a machine's own mesh name is not its mesh address: %q", line)
|
||||
}
|
||||
// And the loopback floor is still there, or things with nothing to do with the mesh break.
|
||||
if !strings.Contains(out, "127.0.0.1\tlocalhost") {
|
||||
t.Fatalf("the loopback floor was removed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A module says where it wants a fact, and is given a file.
|
||||
func TestAModuleIsGivenTheFactsItAskedFor(t *testing.T) {
|
||||
m := Manifest{Module: "dnsmasq", Facts: map[string]string{FactNodeZones: "/etc/mesh/zones.conf"}}
|
||||
given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(given) != 1 {
|
||||
t.Fatalf("expected one file, got %d", len(given))
|
||||
}
|
||||
if given[0]["path"] != "/etc/mesh/zones.conf" || given[0]["type"] != "file" {
|
||||
t.Fatalf("not written where it was asked for: %v", given[0])
|
||||
}
|
||||
if !strings.Contains(given[0]["content"].(string), "homer.internal") {
|
||||
t.Fatalf("the file does not hold the fact: %v", given[0]["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// **Asking for a fact the mesh does not have is refused here, not on a machine.** A daemon that
|
||||
// starts, reads a file nobody wrote, and answers no queries is a much worse way to find out.
|
||||
func TestAskingForAFactTheMeshDoesNotHaveIsRefused(t *testing.T) {
|
||||
m := Manifest{Module: "dnsmasq", Facts: map[string]string{"the-weather": "/etc/weather"}}
|
||||
_, err := FactsInto(m, Resolution{}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a module asked for something nobody computes and was given nothing, silently")
|
||||
}
|
||||
for _, known := range []string{FactNodeNames, FactNodeZones} {
|
||||
if !strings.Contains(err.Error(), known) {
|
||||
t.Fatalf("the refusal does not say what would have worked: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And a relative path is refused, or a module decides where the mesh writes on a machine.
|
||||
func TestAFactMustBeAskedForAtAnAbsolutePath(t *testing.T) {
|
||||
m := Manifest{Module: "dnsmasq", Facts: map[string]string{FactNodeNames: "etc/hosts"}}
|
||||
if _, err := FactsInto(m, Resolution{}, nil); err == nil {
|
||||
t.Fatal("a relative path was accepted")
|
||||
}
|
||||
}
|
||||
@@ -216,7 +216,20 @@ const SSHPort = 22
|
||||
//
|
||||
// `outward` says this machine is reachable from outside the mesh, which is the only thing that
|
||||
// decides whether ssh is answered there as well as on the private network.
|
||||
func AsNftables(rules []Rule, mesh []string, outward bool) string {
|
||||
//
|
||||
// `foundation` is the ports the MESH ITSELF needs reachable, which no module declares.
|
||||
//
|
||||
// **Everything else in this file is derived from what modules say they listen on, and the
|
||||
// foundation is not a module** (novox/hq 04-ISSUES/051). So the broker — the port every machine
|
||||
// dials to enrol and to receive every declaration it is ever sent — was absent from the ruleset,
|
||||
// and nothing noticed: a mesh of one never dials its own broker across the network. The first
|
||||
// machine to join a firewalled anchor is refused by the packet filter during enrolment, before
|
||||
// the mesh can report anything about it.
|
||||
//
|
||||
// It is a floor for the same reason ssh is. A machine nobody can reach is a machine nobody can
|
||||
// repair; a machine the mesh cannot reach is a machine the mesh cannot manage. Neither is a thing
|
||||
// any module asks for, and neither may be derived away.
|
||||
func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Computed by the mesh from what is assigned to this node.\n")
|
||||
b.WriteString("# Edits are lost on the next declaration; change a module's listens instead.\n\n")
|
||||
@@ -279,6 +292,18 @@ func AsNftables(rules []Rule, mesh []string, outward bool) string {
|
||||
b.WriteString(fmt.Sprintf("\t\ttcp dport %d accept\n", SSHPort))
|
||||
}
|
||||
|
||||
// The mesh's own ports, from anywhere.
|
||||
//
|
||||
// Not narrowed to the private network, because the machines that need this most are the ones
|
||||
// not on it yet: a node enrols BEFORE it has an address here, over the ordinary network, and a
|
||||
// rule allowing only the private network would close the door being knocked on.
|
||||
if len(foundation) > 0 {
|
||||
b.WriteString("\n\t\t# the mesh's own — never derived, never closed\n")
|
||||
for _, port := range foundation {
|
||||
b.WriteString(fmt.Sprintf("\t\ttcp dport %d accept\n", port))
|
||||
}
|
||||
}
|
||||
|
||||
if len(rules) > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The mesh's own ports survive a ruleset derived from modules that do not mention them.
|
||||
//
|
||||
// **The firewall is computed from what modules declare they listen on, and the foundation is not a
|
||||
// module** (novox/hq 04-ISSUES/052). So the broker's port — the one every machine dials to enrol
|
||||
// and to receive every declaration it is ever sent — was absent from every ruleset the mesh ever
|
||||
// generated, and nothing caught it: a mesh of one never dials its own broker across the network,
|
||||
// so the ruleset looks complete right up until a second machine tries to join and is refused by
|
||||
// the packet filter, during enrolment, before the mesh can report anything about it.
|
||||
func TestTheBrokersPortIsOpenedThoughNoModuleDeclaresIt(t *testing.T) {
|
||||
const brokerPort = 5671
|
||||
|
||||
// A machine on the private network, with one ordinary module rule, and nothing that mentions
|
||||
// the broker — which is every machine.
|
||||
rules := []Rule{{Port: 8080, From: FromMesh, Because: []string{"some-module"}}}
|
||||
out := AsNftables(rules, []string{"10.42.0.1"}, false, []int{brokerPort})
|
||||
|
||||
if !strings.Contains(out, "tcp dport 5671 accept") {
|
||||
t.Fatalf("the broker's port is not opened, so no machine could enrol:\n%s", out)
|
||||
}
|
||||
|
||||
// From anywhere, deliberately: a node enrols BEFORE it has an address on the private network,
|
||||
// so a rule narrowed to that network would close the door being knocked on.
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.Contains(line, "5671") && strings.Contains(line, "saddr") {
|
||||
t.Fatalf("the broker's port is narrowed to the private network, which a machine that "+
|
||||
"has not yet enrolled is not on:\n%s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And a mesh that was never told about a broker still gets a ruleset, rather than an empty one or
|
||||
// a panic. A control plane in that state cannot issue tokens either, which is where it surfaces.
|
||||
func TestNoBrokerMeansNoFoundationRuleRatherThanNoRuleset(t *testing.T) {
|
||||
out := AsNftables(nil, []string{"10.42.0.1"}, false, nil)
|
||||
if !strings.Contains(out, "table inet mesh") {
|
||||
t.Fatalf("no ruleset at all:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "never derived, never closed\n\t\ttcp dport") {
|
||||
t.Fatalf("a foundation rule was written for a mesh with no broker:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func TestTwoModulesWantingOnePortAreBothNamed(t *testing.T) {
|
||||
t.Fatalf("a module that wanted this port open is not named: %+v", rules[0])
|
||||
}
|
||||
// The consequence, which is the reason this matters: removing web must not read as closing 443.
|
||||
nft := AsNftables(rules, nil, false)
|
||||
nft := AsNftables(rules, nil, false, nil)
|
||||
if !strings.Contains(nft, "web") || !strings.Contains(nft, "board") {
|
||||
t.Fatalf("the rendered rule set does not name both sources:\n%s", nft)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func TestAPortOpenToEveryoneIsNotAlsoRestrictedToTheMesh(t *testing.T) {
|
||||
func TestWhatNoModuleDeclaredIsClosed(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||
}}, nil), []string{"198.51.100.2"}, false)
|
||||
}}, nil), []string{"198.51.100.2"}, false, nil)
|
||||
// Naming the chain, not just the policy: the forward chain drops too, and an assertion on
|
||||
// "policy drop" alone passes while the input chain accepts everything. It did, once, here.
|
||||
if !strings.Contains(nft, "type filter hook input priority filter; policy drop;") {
|
||||
@@ -134,7 +134,7 @@ func TestWhatNoModuleDeclaredIsClosed(t *testing.T) {
|
||||
// `flush ruleset` would do the first and not the second: it empties every table on the machine,
|
||||
// including the ones the container runtime writes for its bridges.
|
||||
func TestReloadingReplacesOnlyTheMeshsOwnRules(t *testing.T) {
|
||||
nft := AsNftables(nil, nil, false)
|
||||
nft := AsNftables(nil, nil, false, nil)
|
||||
if strings.Contains(nft, "flush ruleset") {
|
||||
t.Fatalf("loading the rule set empties every table on the machine:\n%s", nft)
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func TestReloadingReplacesOnlyTheMeshsOwnRules(t *testing.T) {
|
||||
// So the chain exists and denies by default, and the runtime's own networks are allowed explicitly
|
||||
// — which is how the system being replaced has been doing it on these machines for months.
|
||||
func TestWhatIsForwardedIsGovernedToo(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false)
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil)
|
||||
if !strings.Contains(nft, "hook forward priority filter; policy drop") {
|
||||
t.Fatalf("forwarded traffic is not governed, so container ports are open:\n%s", nft)
|
||||
}
|
||||
@@ -168,7 +168,7 @@ func TestWhatIsForwardedIsGovernedToo(t *testing.T) {
|
||||
|
||||
// And containers keep working, which is the whole reason the chain was left out before.
|
||||
func TestTheRuntimesOwnNetworksKeepWorking(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false)
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil)
|
||||
for _, network := range []string{"172.16.0.0/12", "192.168.128.0/17"} {
|
||||
if !strings.Contains(nft, "ip saddr "+network+" accept") {
|
||||
t.Fatalf("%s is not allowed, so denying by default stops every container:\n%s", network, nft)
|
||||
@@ -183,7 +183,7 @@ func TestTheRuntimesOwnNetworksKeepWorking(t *testing.T) {
|
||||
func TestAPublishedPortIsMatchedByWhatWasAskedFor(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "web", Listens: []Listening{{Port: 8080, From: FromEverywhere}}},
|
||||
}}, nil), []string{"198.51.100.2"}, false)
|
||||
}}, nil), []string{"198.51.100.2"}, false, nil)
|
||||
if !strings.Contains(nft, "ct original proto-dst 8080 accept") {
|
||||
t.Fatalf("the forwarded rule does not match the port a client asked for:\n%s", nft)
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func TestAPublishedPortIsMatchedByWhatWasAskedFor(t *testing.T) {
|
||||
func TestAMeshScopedPortIsMeshScopedWhenForwarded(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
}}, nil), []string{"198.51.100.2"}, false)
|
||||
}}, nil), []string{"198.51.100.2"}, false, nil)
|
||||
if !strings.Contains(nft, "ip saddr { 198.51.100.2 } ct original proto-dst 5432 accept") {
|
||||
t.Fatalf("a mesh-only port is reachable from anywhere once forwarded:\n%s", nft)
|
||||
}
|
||||
@@ -203,7 +203,7 @@ func TestAMeshScopedPortIsMeshScopedWhenForwarded(t *testing.T) {
|
||||
func TestFromTheMeshIsTheNodesTheMeshKnows(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
}}, nil), []string{"198.51.100.2", "198.51.100.3"}, false)
|
||||
}}, nil), []string{"198.51.100.2", "198.51.100.3"}, false, nil)
|
||||
if !strings.Contains(nft, "ip saddr { 198.51.100.2, 198.51.100.3 } tcp dport 5432 accept") {
|
||||
t.Fatalf("a mesh-scoped port was not restricted to the mesh's addresses:\n%s", nft)
|
||||
}
|
||||
@@ -213,7 +213,7 @@ func TestFromTheMeshIsTheNodesTheMeshKnows(t *testing.T) {
|
||||
func TestAMeshPortOnANodeWithNoMeshIsClosedAndSaysSo(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
}}, nil), nil, false)
|
||||
}}, nil), nil, false, nil)
|
||||
if strings.Contains(nft, "dport 5432 accept") {
|
||||
t.Fatalf("a port meant for the mesh was opened to everything:\n%s", nft)
|
||||
}
|
||||
@@ -226,7 +226,7 @@ func TestAMeshPortOnANodeWithNoMeshIsClosedAndSaysSo(t *testing.T) {
|
||||
func TestAMachineScopedPortIsNotOpened(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "cache", Listens: []Listening{{Port: 6379, From: FromMachine}}},
|
||||
}}, nil), []string{"198.51.100.2"}, false)
|
||||
}}, nil), []string{"198.51.100.2"}, false, nil)
|
||||
if strings.Contains(nft, "dport 6379 accept") {
|
||||
t.Fatalf("a port for this machine only was opened to the network:\n%s", nft)
|
||||
}
|
||||
@@ -268,7 +268,7 @@ func TestAskingForTheRuleSetWithNowhereToPutItIsRefused(t *testing.T) {
|
||||
func TestAMeshOnBothAddressFamiliesRendersBoth(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
}}, nil), []string{"198.51.100.2", "2001:db8::2"}, false)
|
||||
}}, nil), []string{"198.51.100.2", "2001:db8::2"}, false, nil)
|
||||
if !strings.Contains(nft, "ip saddr { 198.51.100.2 } tcp dport 5432 accept") {
|
||||
t.Fatalf("the machines with v4 addresses were dropped:\n%s", nft)
|
||||
}
|
||||
@@ -672,7 +672,7 @@ func TestExposureRefusesAPortNotListenedOnAndABadSource(t *testing.T) {
|
||||
// loading the rules lives on conntrack until it drops, and then the machine is reached from a
|
||||
// rescue console (novox/hq issue 047).
|
||||
func TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2", "198.51.100.3"}, false)
|
||||
nft := AsNftables(nil, []string{"198.51.100.2", "198.51.100.3"}, false, nil)
|
||||
if !strings.Contains(nft, "ip saddr { 198.51.100.2, 198.51.100.3 } tcp dport 22 accept") {
|
||||
t.Fatalf("ssh is not open to the mesh, so a machine can lock everyone out:\n%s", nft)
|
||||
}
|
||||
@@ -685,7 +685,7 @@ func TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned(t *testing.T) {
|
||||
// And from outside as well, on a machine that faces outward — because that is the way in when the
|
||||
// private network is the thing that broke.
|
||||
func TestSSHIsOpenFromOutsideOnAMachineThatFacesIt(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, true)
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, true, nil)
|
||||
if !strings.Contains(nft, "\t\ttcp dport 22 accept") {
|
||||
t.Fatalf("a machine reachable from outside does not answer ssh there:\n%s", nft)
|
||||
}
|
||||
@@ -697,7 +697,7 @@ func TestSSHIsOpenFromOutsideOnAMachineThatFacesIt(t *testing.T) {
|
||||
// to narrow the rule to, so narrowing it shuts the port entirely — on the first machine anybody
|
||||
// adopts, reached over the network, closed by the act of adopting it.
|
||||
func TestSSHIsNeverLeftWithoutARule(t *testing.T) {
|
||||
nft := AsNftables(nil, nil, false)
|
||||
nft := AsNftables(nil, nil, false, nil)
|
||||
if !strings.Contains(nft, "tcp dport 22 accept") {
|
||||
t.Fatalf("a machine with no mesh addresses has no ssh rule, so adopting it locks it:\n%s", nft)
|
||||
}
|
||||
|
||||
@@ -315,6 +315,25 @@ type Manifest struct {
|
||||
// that could only see its own ports would write a rule set that closed everything else.
|
||||
Filtering *Filtering `json:"filtering,omitempty"`
|
||||
|
||||
// Facts are things only the mesh knows, written where this module asks for them.
|
||||
//
|
||||
// **The graph is the control plane's; how a machine uses it is the module's.** The mesh knows
|
||||
// which machines exist, what they are called and where they are. Making a name resolve, or a
|
||||
// peer reachable, is somebody's software — dnsmasq, a resolver, a VPN — and the mesh has no
|
||||
// business shipping one, choosing which, or knowing its configuration language.
|
||||
//
|
||||
// So a module says *put the node names here* and owns everything after that. The same shape as
|
||||
// `filtering`, generalised: a fact, and a path.
|
||||
//
|
||||
// It replaces three modules that existed only because computed output needed somewhere to
|
||||
// live — they ran no software, could not be swapped for anything, and appeared in the graph as
|
||||
// modules while being a data channel wearing a costume.
|
||||
//
|
||||
// Keyed by fact name; the names are a closed list, because a module asking for one the mesh
|
||||
// does not compute is asking for something nobody will write, and finding that out on a machine
|
||||
// is worse than being told here.
|
||||
Facts map[string]string `json:"facts,omitempty"`
|
||||
|
||||
// Certificate is where this module wants a certificate for its machine's name inside the
|
||||
// mesh, and where the key that goes with it can be found.
|
||||
//
|
||||
@@ -389,6 +408,26 @@ type Artifact struct {
|
||||
// version and an operating system — while letting each be built and published separately.
|
||||
// Empty means the whole recipe, which is what a module with one image says by saying nothing.
|
||||
Target string `json:"target,omitempty"`
|
||||
|
||||
// Language is what this module's code is written in, for a bundle.
|
||||
//
|
||||
// **Declared, never guessed.** Inferring it from what files happen to be present makes a
|
||||
// module's build depend on a directory listing, and a module that adds a stray file builds
|
||||
// differently for a reason nobody can see. It is also the only thing a bundle needs to say:
|
||||
// everything else about the toolchain — which compiler, which flags, which base — is the
|
||||
// mesh's, and a module that could override it would be writing a Dockerfile again.
|
||||
//
|
||||
// Empty for every other kind, which do not compile.
|
||||
Language string `json:"language,omitempty"`
|
||||
|
||||
// Entrypoints are the compiled files a tool host should load from this module, relative to the
|
||||
// bundle's root.
|
||||
//
|
||||
// **Named rather than derived from which files exist**, for the same reason as the language:
|
||||
// the module knows what it serves, and a build that guesses would change meaning when
|
||||
// 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.
|
||||
Entrypoints []string `json:"entrypoints,omitempty"`
|
||||
}
|
||||
|
||||
// Kinds an artifact may be.
|
||||
@@ -397,6 +436,21 @@ const (
|
||||
ArtifactImage = "image"
|
||||
// ArtifactArchive is a directory in this repository, packed.
|
||||
ArtifactArchive = "archive"
|
||||
// ArtifactBundle is this module's own code, COMPILED by a toolchain and then packed.
|
||||
//
|
||||
// **The one recipe that both builds and packs**, and the reason it exists is the authoring
|
||||
// burden. An `archive` packs a directory as it stands, so shipping compiled output means
|
||||
// compiling somewhere first — which means a Dockerfile, repeating the same incantation in
|
||||
// every module: two base arguments, a working directory chosen so the SDK resolves upward, the
|
||||
// compiler invoked by absolute path because the usual symlink is resolved away when the base is
|
||||
// assembled, a second stage, an environment variable naming the entrypoints. Most of the
|
||||
// catalogue is unconverted and that is why.
|
||||
//
|
||||
// A bundle says what the module is written in and nothing about how. The mesh knows what a
|
||||
// language implies, which is the whole of the difference: a Dockerfile is right for software
|
||||
// that needs a particular base, and wrong for "compile my module's code", which is the same
|
||||
// operation every time.
|
||||
ArtifactBundle = "bundle"
|
||||
// ArtifactUpstream is an image somebody else built, mirrored into the mesh's own registry and
|
||||
// pinned by the digest it lands with.
|
||||
//
|
||||
@@ -409,6 +463,13 @@ const (
|
||||
// Mirroring is what the bootstrap already does by hand: the lab stocks upstream images into
|
||||
// the registry a first node pulls from. This makes that a thing a module can say.
|
||||
ArtifactUpstream = "upstream"
|
||||
|
||||
// ArtifactPackage is this module's own code, compiled and published to the mesh's package
|
||||
// registry by version, for other modules to consume when they are built — the SDK above all
|
||||
// (novox/hq ADR 0076). Like a bundle it is built from the module's own directory and names a
|
||||
// language; unlike a bundle it is not a resource on any machine, it is a build input. It is
|
||||
// compiled on a PUBLIC base, never the mesh toolchain, because the toolchain is built from it.
|
||||
ArtifactPackage = "package"
|
||||
)
|
||||
|
||||
// ArtifactStoreProvision is the name a module offers when it is the mesh's store for what modules
|
||||
@@ -645,7 +706,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
// that provides the store and also builds something asks the mesh to put an artifact into the
|
||||
// thing that artifact is needed to create.
|
||||
//
|
||||
// It is the question the substrate record asks of every candidate — can it grant itself the
|
||||
// It is the question the foundation record asks of every candidate — can it grant itself the
|
||||
// thing it provides? The store cannot create its own database, the broker cannot create its
|
||||
// own virtual host, and a registry cannot grant itself a repository. Such a module names its
|
||||
// image, exactly as the bundle names the three a first node starts from.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Telling a module which port it was given.
|
||||
//
|
||||
// **The mesh assigns the machine-side port and a module does not choose one**
|
||||
// ([ADR 0038](../../02-DECISIONS/0038-the-mesh-assigns-the-port.md)). For a container that is
|
||||
// invisible: the mesh rewrites `ports` into `assigned:wanted`, the software inside binds the number
|
||||
// it has always bound, and the machine publishes a different one.
|
||||
//
|
||||
// **A process has no such layer.** It runs on the machine, there is nothing to rewrite, and it
|
||||
// binds whatever its configuration says — so without this, every process binds the number written
|
||||
// in its own config, two modules declaring the same one collide, and the mesh's whole reason for
|
||||
// assigning ports is defeated by the resource kind that most needs it.
|
||||
//
|
||||
// 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 into its own configuration exactly as it writes an
|
||||
// address it was bound to.
|
||||
|
||||
// ofPort is where a module asks which port it was given: ${port:<the port its software uses>}.
|
||||
var ofPort = regexp.MustCompile(`\$\{port:([0-9]+)\}`)
|
||||
|
||||
// portsUsed are the ports a file's content asks about, first appearance first.
|
||||
func portsUsed(content string) []int {
|
||||
var used []int
|
||||
seen := map[int]bool{}
|
||||
for _, m := range ofPort.FindAllStringSubmatch(content, -1) {
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err != nil || seen[n] {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
used = append(used, n)
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// portInto replaces a file's ${port:…} placeholders with what this machine assigned.
|
||||
//
|
||||
// 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.
|
||||
// Left alone, the literal would be written into a configuration file and read as a port number.
|
||||
func portInto(resource map[string]any, module string, listens []Listening, with Rendering) error {
|
||||
if fmt.Sprint(resource["type"]) != "file" {
|
||||
return nil
|
||||
}
|
||||
content, ok := resource["content"].(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, wanted := range portsUsed(content) {
|
||||
var declared bool
|
||||
for _, l := range listens {
|
||||
if l.Port == wanted {
|
||||
declared = true
|
||||
}
|
||||
}
|
||||
if !declared {
|
||||
return fmt.Errorf(
|
||||
"%s has a file that says ${port:%d}, and %s does not say it listens on %d. A "+
|
||||
"module is told the port it was given for something it declared, and %s",
|
||||
module, wanted, module, wanted, orNoListens(listens))
|
||||
}
|
||||
content = strings.ReplaceAll(content, fmt.Sprintf("${port:%d}", wanted),
|
||||
strconv.Itoa(with.machinePort(module, wanted)))
|
||||
resource["content"] = content
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// orNoListens says what would have worked, so a refusal is one edit from right.
|
||||
func orNoListens(listens []Listening) string {
|
||||
if len(listens) == 0 {
|
||||
return "it declares no ports at all"
|
||||
}
|
||||
said := make([]string, 0, len(listens))
|
||||
for _, l := range listens {
|
||||
said = append(said, strconv.Itoa(l.Port))
|
||||
}
|
||||
return "it declares " + strings.Join(said, ", ")
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **A process binds the port the mesh gave it, not the one it wrote down.**
|
||||
//
|
||||
// A container never needed this: the mesh rewrites its `ports` into assigned:wanted, so the
|
||||
// software binds the number it always bound and the machine publishes another. A process runs on
|
||||
// the machine with nothing to rewrite, so without a way to ask, every process binds the number in
|
||||
// its own configuration and two modules declaring the same one collide — which is the whole
|
||||
// problem ADR 0038 exists to prevent, reintroduced by the resource kind that most needs it.
|
||||
func TestAModuleIsToldWhichPortItWasGiven(t *testing.T) {
|
||||
file := map[string]any{
|
||||
"type": "file", "id": "settings",
|
||||
"content": "LISTEN=${port:8080}\n",
|
||||
}
|
||||
listens := []Listening{{Port: 8080, From: FromMesh}}
|
||||
with := Rendering{Ports: map[string]map[int]int{"showcase": {8080: 21000}}}
|
||||
|
||||
if err := portInto(file, "showcase", listens, with); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := file["content"].(string); got != "LISTEN=21000\n" {
|
||||
t.Fatalf("the module was not told its assigned port: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing assigned yet, it is told the port it asked about — so a mesh that has not made an
|
||||
// assignment still composes something coherent rather than writing a zero.
|
||||
func TestWithNoAssignmentAModuleIsToldWhatItAskedFor(t *testing.T) {
|
||||
file := map[string]any{"type": "file", "content": "LISTEN=${port:8080}\n"}
|
||||
if err := portInto(file, "showcase", []Listening{{Port: 8080}}, Rendering{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := file["content"].(string); got != "LISTEN=8080\n" {
|
||||
t.Fatalf("an unassigned port did not fall back to what was declared: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **Asking about a port it never declared is refused**, and the refusal says what it did declare.
|
||||
// The module is asking about something the mesh has no opinion on, and answering would be a guess
|
||||
// written into a configuration file as a port number.
|
||||
func TestAskingAboutAnUndeclaredPortIsRefused(t *testing.T) {
|
||||
file := map[string]any{"type": "file", "content": "LISTEN=${port:9999}\n"}
|
||||
err := portInto(file, "showcase", []Listening{{Port: 8080}}, Rendering{})
|
||||
if err == nil {
|
||||
t.Fatal("a module was told a port it never said it listens on")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "8080") {
|
||||
t.Fatalf("the refusal does not say what would have worked: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// And a file mentioning no port is left exactly as it was.
|
||||
func TestAFileWithNoPortIsUntouched(t *testing.T) {
|
||||
file := map[string]any{"type": "file", "content": "GREETING=hello\n"}
|
||||
if err := portInto(file, "showcase", nil, Rendering{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := file["content"].(string); got != "GREETING=hello\n" {
|
||||
t.Fatalf("a file with no port was changed: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,5 @@ func TestPrintRehearsalRuleset(t *testing.T) {
|
||||
rules := mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "pub", Listens: []Listening{{Port: 8099, From: FromMesh, Why: "the thing it serves"}}},
|
||||
}}, nil)
|
||||
t.Log("\n" + AsNftables(rules, []string{"192.0.2.20"}, true))
|
||||
t.Log("\n" + AsNftables(rules, []string{"192.0.2.20"}, true, nil))
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-control/internal/overlay"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/overlay"
|
||||
)
|
||||
|
||||
// The manifests the control plane actually ships, resolved.
|
||||
@@ -19,7 +19,7 @@ func provided(t *testing.T) map[string]catalogue.Manifest {
|
||||
t.Helper()
|
||||
out := map[string]catalogue.Manifest{}
|
||||
for _, raw := range []map[string]any{
|
||||
overlay.Manifest(), overlay.NamesManifest(), overlay.DomainManifest(),
|
||||
overlay.Manifest(), overlay.DomainManifest(),
|
||||
} {
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
@@ -44,22 +44,51 @@ func TestTheShippedNetworkingModulesResolveOnTheirOwn(t *testing.T) {
|
||||
for _, m := range got.Modules {
|
||||
have = append(have, m.Module)
|
||||
}
|
||||
for _, want := range []string{overlay.Domain, overlay.Name, overlay.Names} {
|
||||
for _, want := range []string{overlay.Domain, overlay.Name} {
|
||||
if !strings.Contains(strings.Join(have, " "), want) {
|
||||
t.Fatalf("%s did not bring in %s: %v", overlay.Domain, want, have)
|
||||
}
|
||||
}
|
||||
|
||||
// **The names come WITH the network now, not from a third module.** Being on the private
|
||||
// network is what gives a machine a name, so the provider asks for the node-names fact and
|
||||
// there is nothing else to bring in. A module that ran nothing used to be here.
|
||||
for _, m := range got.Modules {
|
||||
if m.Module == overlay.Name && m.Facts["node-names"] == "" {
|
||||
t.Fatalf("the network's provider does not ask for the names: %+v", m.Facts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheShippedWireGuardModuleClaimsBeingTheNetwork(t *testing.T) {
|
||||
// Without this, a person who chose another VPN gets WireGuard as well, dragged in by the
|
||||
// names, and is not told. The claim is the only thing that catches it.
|
||||
func TestAnotherVPNSatisfiesNetworkingWithoutDraggingWireGuardIn(t *testing.T) {
|
||||
// **This inverted, and the inversion is the improvement.** The names used to be a module that
|
||||
// required the mesh's own addressing, which only WireGuard provided — so choosing another VPN
|
||||
// dragged WireGuard in anyway, and the node-scoped claim existed to at least make that
|
||||
// collision loud. With the names a fact rather than a provision, a person who chose tailscale
|
||||
// gets tailscale, and there is nothing left to collide.
|
||||
shipped := provided(t)
|
||||
_, err := catalogue.Resolve(
|
||||
got, err := catalogue.Resolve(
|
||||
withTailscale(shipped),
|
||||
[]string{overlay.Domain, "tailscale"},
|
||||
catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{})
|
||||
if err != nil {
|
||||
t.Fatalf("choosing another VPN was refused: %v", err)
|
||||
}
|
||||
for _, m := range got.Modules {
|
||||
if m.Module == overlay.Name {
|
||||
t.Fatalf("the other VPN was chosen and WireGuard came anyway: %v", got.Modules)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoVPNsAssignedTogetherStillCollide(t *testing.T) {
|
||||
// The claim still guards the case it was always for: both assigned EXPLICITLY, which is a
|
||||
// machine with two private networks and a coin toss about which one a peer reaches it on.
|
||||
shipped := provided(t)
|
||||
_, err := catalogue.Resolve(
|
||||
withTailscale(shipped),
|
||||
[]string{overlay.Name, "tailscale"},
|
||||
catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{})
|
||||
if err == nil {
|
||||
t.Fatal("a machine was given two private networks and nobody was told")
|
||||
}
|
||||
@@ -68,20 +97,9 @@ func TestTheShippedWireGuardModuleClaimsBeingTheNetwork(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheShippedNamesModuleNeedsTheMeshsOwnAddresses(t *testing.T) {
|
||||
// Over a VPN whose addresses the mesh does not hand out, it has no names to write. Refusing
|
||||
// is what stops a machine getting a hosts file that means nothing on it.
|
||||
shipped := provided(t)
|
||||
delete(shipped, overlay.Name)
|
||||
_, err := catalogue.Resolve(shipped, []string{overlay.Names}, catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("the mesh's names resolved with nothing handing out the mesh's addresses")
|
||||
}
|
||||
if !strings.Contains(err.Error(), overlay.Addressing) {
|
||||
t.Fatalf("the refusal does not name what is missing: %v", err)
|
||||
}
|
||||
}
|
||||
// The names-need-addressing test went with the names module: names are a fact now, and a machine
|
||||
// the mesh cannot place is simply left out of the file (facts_test.go) — which is the same
|
||||
// protection, enforced where the file is written rather than by a provision refusing.
|
||||
|
||||
func withTailscale(shelf map[string]catalogue.Manifest) map[string]catalogue.Manifest {
|
||||
out := map[string]catalogue.Manifest{}
|
||||
|
||||
@@ -287,8 +287,19 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
if satisfied[want] && !isModule(catalogue, want) {
|
||||
if brokered[want] {
|
||||
// Answered here, and still a need: the provider is this node.
|
||||
//
|
||||
// **The same loopback fallback as the branch below, and it was missing here.** A
|
||||
// machine with no private network has no `at`, and this branch passed that through
|
||||
// — so a consumer whose file says `${bound:<provision>:at}:${bound:...:port}` was
|
||||
// handed `:5000`, a name with no host, written into its environment without
|
||||
// complaint. The sibling case a few lines down had the fallback and the reasoning
|
||||
// for it; only this one did not. A machine off the network still reaches itself.
|
||||
at := node.At
|
||||
if at == "" {
|
||||
at = "127.0.0.1"
|
||||
}
|
||||
needs = append(needs, Needed{
|
||||
Name: want, From: node.Name, At: node.At,
|
||||
Name: want, From: node.Name, At: at,
|
||||
Serves: servedHere(catalogue, chosen, want), For: because[want]})
|
||||
} else if served := servedHere(catalogue, chosen, want); len(served) > 0 {
|
||||
// Answered here with no credential to mint, but the provider serves facts the
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
|
||||
// A MESH-SCOPED provider on the consumer's own node must deliver a usable address too.
|
||||
//
|
||||
// The sibling case — a node-scoped provider minting no credential — has had this fallback and a
|
||||
// test for it for some time. This branch did not, and the difference is invisible until something
|
||||
// puts the address into a string: the mesh's own artifact store is mesh-scoped and lives on the
|
||||
// same machine as the builder that pushes to it, so the builder's environment was written as
|
||||
// `MESH_REGISTRY=:5000`. Nothing refused it. The runtime did, several steps later, with
|
||||
// `":5000/mesh-tools/build" is not a valid repository/tag` — a message about a tag, for a fault in
|
||||
// how a binding was resolved.
|
||||
//
|
||||
// A machine off the private network still reaches itself.
|
||||
func TestAMeshScopedProviderOnThisNodeStillCarriesAnAddress(t *testing.T) {
|
||||
provider := Manifest{Module: "registry",
|
||||
Provides: []Offer{{Name: "artifact-store", Scope: ScopeMesh}},
|
||||
Serves: map[string]map[string]any{
|
||||
"artifact-store": {"port": 5000}}}
|
||||
consumer := Manifest{Module: "builder", Requires: []string{"artifact-store"}}
|
||||
|
||||
// No `At`: a mesh with no private network raised, which is every mesh before somebody places
|
||||
// its nodes on one — including the moment just after it is installed.
|
||||
got, err := Resolve(
|
||||
map[string]Manifest{"registry": provider, "builder": consumer},
|
||||
[]string{"builder", "registry"},
|
||||
Node{Name: "anchor"},
|
||||
World{})
|
||||
if err != nil {
|
||||
t.Fatalf("a mesh-scoped provision answered on this very node was refused: %v", err)
|
||||
}
|
||||
|
||||
var found *Needed
|
||||
for i := range got.Needs {
|
||||
if got.Needs[i].Name == "artifact-store" && got.Needs[i].For == "builder" {
|
||||
found = &got.Needs[i]
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("the store was not delivered to the builder at all: %+v", got.Needs)
|
||||
}
|
||||
if found.At == "" {
|
||||
t.Fatalf("the binding carries no address, so anything composing a host from it gets none: %+v", found)
|
||||
}
|
||||
if found.At != "127.0.0.1" {
|
||||
t.Fatalf("off the private network the address must fall back to loopback, got %q", found.At)
|
||||
}
|
||||
if got, want := plainly(found.Serves["port"]), "5000"; got != want {
|
||||
t.Fatalf("the port was not delivered: got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **The showcase module is parsed by the real parser, in the real test suite.**
|
||||
//
|
||||
// A module that exercises every capability is only worth having if something checks it still does.
|
||||
// Written as a test rather than a script so it runs whenever anything about manifests changes —
|
||||
// which is exactly when a module using all of it would quietly stop being valid.
|
||||
func TestTheShowcaseModuleIsAValidManifest(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../../mesh-catalog/modules/showcase/module.json")
|
||||
if err != nil {
|
||||
t.Skipf("the catalogue is not beside this checkout: %v", err)
|
||||
}
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("the module that exercises everything does not parse:\n%v", err)
|
||||
}
|
||||
|
||||
// Every resource kind a MODULE may use, actually in it.
|
||||
//
|
||||
// Two of the host's eleven are deliberately absent, and the reasons are worth keeping:
|
||||
//
|
||||
// - `action` is refused to modules outright. The link may not carry a command to run (ADR
|
||||
// 0005), so a module that needs something done ships a program that reads what the mesh
|
||||
// delivered and reconciles — which is what a run-once `process` is.
|
||||
// - `service` puts an EXISTING unit into a state and deliberately installs none, which is
|
||||
// right for software that ships its own unit. A module whose code the mesh built has no
|
||||
// such unit until the mesh writes one, and that is a `process`.
|
||||
kinds := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
kind, _ := r["type"].(string)
|
||||
kinds[kind] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
"access", "archive", "container", "directory", "file", "network", "package",
|
||||
"process", "user",
|
||||
} {
|
||||
if !kinds[want] {
|
||||
t.Errorf("showcase no longer exercises %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// And all three ways a module's own code can run, which is the thing most easily lost.
|
||||
var stays, once, scheduled bool
|
||||
for _, r := range m.Resources {
|
||||
if kind, _ := r["type"].(string); kind != "process" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case r["run-once"] == true:
|
||||
once = true
|
||||
case r["schedule"] != nil:
|
||||
scheduled = true
|
||||
default:
|
||||
stays = true
|
||||
}
|
||||
}
|
||||
if !stays || !once || !scheduled {
|
||||
t.Errorf("showcase does not exercise all three process modes: stays=%v once=%v scheduled=%v",
|
||||
stays, once, scheduled)
|
||||
}
|
||||
|
||||
// And the artifact kinds, including the one that compiles.
|
||||
var bundle, archive, upstream bool
|
||||
for _, a := range m.Build.Artifacts {
|
||||
switch a.Kind {
|
||||
case ArtifactBundle:
|
||||
bundle = true
|
||||
case ArtifactArchive:
|
||||
archive = true
|
||||
case ArtifactUpstream:
|
||||
upstream = true
|
||||
}
|
||||
}
|
||||
if !bundle || !archive || !upstream {
|
||||
t.Errorf("showcase does not exercise every artifact kind: bundle=%v archive=%v upstream=%v",
|
||||
bundle, archive, upstream)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/novox/mesh-control/internal/store"
|
||||
"github.com/novox/mesh-controller/internal/store"
|
||||
)
|
||||
|
||||
// Name is what this context is called: its database and its credential are named after it.
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/novox/mesh-control/internal/store"
|
||||
"github.com/novox/mesh-controller/internal/store"
|
||||
)
|
||||
|
||||
func fresh(t *testing.T) *Identity {
|
||||
|
||||
@@ -3,7 +3,7 @@ package inventory
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// The image every module is compiled on top of is built and never run.
|
||||
|
||||
@@ -3,6 +3,7 @@ package inventory
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -23,6 +24,18 @@ type Build struct {
|
||||
Commit string
|
||||
// On is the machine that did it.
|
||||
On string
|
||||
// Path is where inside the repository the module lives (novox/hq ADR 0069).
|
||||
Path string
|
||||
// Manifest is the declaration the builder resolved, as it announced it.
|
||||
//
|
||||
// **Kept because the catalogue may not have been listening.** The announcement carries this
|
||||
// and the catalogue turns it into the module's requires/provides edges. On a fresh mesh the
|
||||
// modules built before the catalogue exists are exactly the ones it most needs, so the mesh
|
||||
// has to be able to say afterwards what they declared (novox/hq 04-ISSUES/050).
|
||||
Manifest []byte
|
||||
// Against is every artifact this build stood on, as references rather than module names —
|
||||
// what makes a build edge derived rather than declared (ADR 0009).
|
||||
Against []string
|
||||
// Failed is the builder's own words, empty when it worked.
|
||||
Failed string
|
||||
Made []Artifact
|
||||
@@ -49,15 +62,21 @@ func (i *Inventory) RecordBuild(ctx context.Context, b Build) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
against, err := json.Marshal(b.Against)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var module *string
|
||||
if b.Module != "" {
|
||||
module = &b.Module
|
||||
}
|
||||
_, err = i.store.Pool().Exec(ctx,
|
||||
`insert into build (id, repository, ref, module, commit_hash, built_on, failed, made)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`insert into build (id, repository, ref, module, commit_hash, built_on, failed, made,
|
||||
source_path, manifest, built_against)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
on conflict (id) do nothing`,
|
||||
b.ID, b.Repository, b.Ref, module, b.Commit, b.On, b.Failed, made)
|
||||
b.ID, b.Repository, b.Ref, module, b.Commit, b.On, b.Failed, made,
|
||||
b.Path, manifestOrNil(b.Manifest), against)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -144,3 +163,75 @@ func (i *Inventory) Held(ctx context.Context) (map[string]string, error) {
|
||||
}
|
||||
return held, rows.Err()
|
||||
}
|
||||
|
||||
// manifestOrNil keeps the difference between "declared nothing" and "predates this being kept".
|
||||
//
|
||||
// A build recorded before the mesh kept manifests has no manifest, and that is not the same as one
|
||||
// whose manifest was empty. A replay can then say which it is holding instead of inventing an
|
||||
// empty declaration for a module that certainly had one.
|
||||
func manifestOrNil(raw []byte) any {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// Announceable is every build worth telling a catalogue about, oldest first.
|
||||
//
|
||||
// **Oldest first, because a graph is built in the order things happened.** Registering a module
|
||||
// that stands on a base before the base itself would make the edge point at a version the
|
||||
// catalogue has not seen, and the shape of a fresh mesh guarantees that order matters: the base is
|
||||
// always first and always the one that was missed.
|
||||
//
|
||||
// Only builds that succeeded and know what they built. A failure produced no module-version, and
|
||||
// announcing one would put something in the graph that was never made — the same rule the builder
|
||||
// follows when it decides whether to announce at all.
|
||||
//
|
||||
// One row per module and commit: a module built twice at the same commit is one fact, and the
|
||||
// latest row is the one whose artifacts are current.
|
||||
func (i *Inventory) Announceable(ctx context.Context) ([]Build, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select distinct on (module, commit_hash)
|
||||
id, repository, ref, module, commit_hash, built_on, failed, made,
|
||||
source_path, manifest, built_against, at
|
||||
from build
|
||||
where failed = '' and module is not null and module <> '' and commit_hash <> ''
|
||||
order by module, commit_hash, at desc`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Build
|
||||
for rows.Next() {
|
||||
var b Build
|
||||
var made []byte
|
||||
var manifest, against []byte
|
||||
if err := rows.Scan(&b.ID, &b.Repository, &b.Ref, &b.Module, &b.Commit,
|
||||
&b.On, &b.Failed, &made, &b.Path, &manifest, &against, &b.At); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(made, &b.Made); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Null rather than empty is a build recorded before the mesh kept these, and saying so is
|
||||
// the point of keeping them nullable: the replay carries nothing rather than an empty
|
||||
// declaration for a module that certainly had one.
|
||||
if len(manifest) > 0 {
|
||||
b.Manifest = manifest
|
||||
}
|
||||
if len(against) > 0 {
|
||||
if err := json.Unmarshal(against, &b.Against); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Sorted here rather than in the query, because `distinct on` fixes the ordering it needs and
|
||||
// the order that matters to a catalogue is a different one.
|
||||
sort.Slice(out, func(a, b int) bool { return out[a].At.Before(out[b].At) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// ErrNoSuchModule is what the mesh says about a module it has never been told about.
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
func manifest(name string, provides, requires []string) catalogue.Manifest {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// What removing a module takes with it, and what re-registering one does not.
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/novox/mesh-control/internal/store"
|
||||
"github.com/novox/mesh-controller/internal/store"
|
||||
)
|
||||
|
||||
// ForTest is a fresh inventory in a database of its own, dropped when the test ends.
|
||||
|
||||
@@ -10,7 +10,7 @@ package inventory
|
||||
import (
|
||||
"embed"
|
||||
|
||||
"github.com/novox/mesh-control/internal/store"
|
||||
"github.com/novox/mesh-controller/internal/store"
|
||||
)
|
||||
|
||||
// Name is what this context is called: its database, and the environment variable holding the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- Ports a machine holds that the mesh did not assign.
|
||||
--
|
||||
-- novox/hq ADR 0038. The substrate is not a module: a node raises it from the bundle it carries
|
||||
-- novox/hq ADR 0038. The foundation is not a module: a node raises it from the bundle it carries
|
||||
-- before any mesh exists, so the control plane has never heard of the store or the broker. Told
|
||||
-- what they hold, it can put a module somewhere else; not told, it hands out a port one of them
|
||||
-- has and finds out from a container runtime three layers down.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- What a build result carried and the mesh threw away.
|
||||
--
|
||||
-- novox/hq 04-ISSUES/050. The builder announces a build with the resolved manifest, the path
|
||||
-- inside the repository, and every artifact it was built against. The control plane receives all
|
||||
-- of it and kept none of it: `build` held the repository, the ref, the commit and what was made.
|
||||
--
|
||||
-- That was survivable while the catalogue heard the same announcement directly. It stops being
|
||||
-- survivable the moment the catalogue was not there to hear it — which on a fresh mesh is always,
|
||||
-- and always for the same modules. The shared base, the store the catalogue itself runs on, and
|
||||
-- the catalogue: each is necessarily built BEFORE the catalogue exists to hear about it, so the
|
||||
-- graph's foundation is the part the graph never sees.
|
||||
--
|
||||
-- Replaying those builds needs what they said, not a summary of it. Without the manifest there
|
||||
-- are no requires/provides edges; without `against` there are no build edges, which are the ones
|
||||
-- that answer "a base moved, what must be rebuilt". A replay carrying neither would restore the
|
||||
-- module list and leave the question the catalogue exists for still wrong, while looking fixed.
|
||||
--
|
||||
-- Empty and null-free, so every build recorded before this keeps exactly the meaning it had: a
|
||||
-- row with no manifest is one that predates this, and a replay says so rather than inventing an
|
||||
-- empty declaration.
|
||||
--
|
||||
-- `built_against` rather than `built_on`: that column already exists and means the MACHINE that
|
||||
-- did the build, which is a different fact about a different subject.
|
||||
alter table build add column source_path text not null default '';
|
||||
alter table build add column manifest jsonb;
|
||||
alter table build add column built_against jsonb;
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/novox/mesh-control/internal/store"
|
||||
"github.com/novox/mesh-controller/internal/store"
|
||||
)
|
||||
|
||||
// Inventory is this context, holding the store it exclusively owns.
|
||||
|
||||
@@ -120,7 +120,7 @@ func (i *Inventory) assignPort(
|
||||
if err != nil {
|
||||
return Assigned{}, err
|
||||
}
|
||||
// And what the machine itself says it already holds — the substrate it raised before there
|
||||
// And what the machine itself says it already holds — the foundation it raised before there
|
||||
// was a mesh to ask (novox/hq ADR 0038). Not assignments: nothing here chose them, and
|
||||
// nothing here can move them.
|
||||
carried, err := i.carriedOn(ctx, nodeID)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
func aNodeWithModules(t *testing.T, modules ...string) (*Inventory, string) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/novox/mesh-control/internal/secrets"
|
||||
"github.com/novox/mesh-controller/internal/secrets"
|
||||
)
|
||||
|
||||
// Where sealed secrets live.
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/novox/mesh-control/internal/secrets"
|
||||
"github.com/novox/mesh-controller/internal/secrets"
|
||||
)
|
||||
|
||||
// Shape is how a vendor's credential behaves, and the switch the ADR 0050 carve-out turns on.
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/novox/mesh-control/internal/store"
|
||||
"github.com/novox/mesh-controller/internal/store"
|
||||
)
|
||||
|
||||
// ForTest is a fresh licence store in a database of its own, dropped when the test ends.
|
||||
|
||||
@@ -20,9 +20,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/novox/mesh-control/internal/licences/adapters"
|
||||
"github.com/novox/mesh-control/internal/secrets"
|
||||
"github.com/novox/mesh-control/internal/store"
|
||||
"github.com/novox/mesh-controller/internal/licences/adapters"
|
||||
"github.com/novox/mesh-controller/internal/secrets"
|
||||
"github.com/novox/mesh-controller/internal/store"
|
||||
)
|
||||
|
||||
// Name is what this context is called: its database and its credential are named after it.
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
|
||||
"github.com/novox/mesh-control/internal/licences/adapters"
|
||||
"github.com/novox/mesh-control/internal/secrets"
|
||||
"github.com/novox/mesh-controller/internal/licences/adapters"
|
||||
"github.com/novox/mesh-controller/internal/secrets"
|
||||
)
|
||||
|
||||
// nodeKeyPair is a node's key as the node would hold it: the public half the mesh seals to, and an
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/secrets"
|
||||
"github.com/novox/mesh-controller/internal/secrets"
|
||||
)
|
||||
|
||||
// SubmitRefresh is the boundary a manager NODE crosses to publish a refresh it performed: the control
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-control/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// What a node says when it joins, as that node's own code writes it.
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
// Skipped unless MESH_ENROL names the file the host's suite wrote:
|
||||
//
|
||||
// mesh-host: MESH_ENROL_OUT=/tmp/enrol.json go test ./internal/link/
|
||||
// mesh-control: MESH_ENROL=/tmp/enrol.json make check
|
||||
// mesh-controller: MESH_ENROL=/tmp/enrol.json make check
|
||||
//
|
||||
// **What this does not cover**, said so nobody reads more into a pass than is there: the full
|
||||
// enrolment path also issues a broker account, and that needs a broker. What is checked here is
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/novox/mesh-control/internal/broker"
|
||||
"github.com/novox/mesh-control/internal/identity"
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/identity"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// Enrolment is what actually happens when a node presents a token: the token is spent, the key is
|
||||
|
||||
@@ -80,10 +80,60 @@ const KeyModuleBuilt = "module.builder.built"
|
||||
// two would eventually disagree (novox/hq ADR 0072).
|
||||
const KeyModuleUpgraded = "module.mesh-catalog.upgraded"
|
||||
|
||||
// KeyCatchingUp is the catalogue saying it has just started and may have missed things.
|
||||
//
|
||||
// **A durable queue only keeps what arrived after it existed.** The catalogue's own queue is
|
||||
// durable, so nothing is lost once it is running — but the modules built before it first ran were
|
||||
// announced to a queue that did not exist yet, and on a fresh mesh those are, necessarily, the
|
||||
// shared base, the store the catalogue runs on, and the catalogue itself. The graph's foundation
|
||||
// is the part it never hears about (novox/hq 04-ISSUES/050).
|
||||
//
|
||||
// So it asks, and the control plane answers with what it recorded. Asking rather than being told
|
||||
// because only the catalogue knows it has a gap; the control plane cannot tell a fresh catalogue
|
||||
// from one that is merely quiet.
|
||||
const KeyCatchingUp = "module.mesh-catalog.catching-up"
|
||||
|
||||
// CatchUpQueue is where that lands. Durable, for the same reason the upgrade queue is: a catalogue
|
||||
// that started while the control plane was restarting is exactly the one with a gap to fill.
|
||||
const CatchUpQueue = "control.catchup"
|
||||
|
||||
// UpgradeQueue is where those land. Durable and named, not a temporary queue: an upgrade announced
|
||||
// while the control plane is restarting is exactly the one that must not be missed.
|
||||
const UpgradeQueue = "control.upgrades"
|
||||
|
||||
// Replayer answers a catalogue that says it has just started.
|
||||
//
|
||||
// It is handed every build the mesh recorded, oldest first, and re-announces each. The catalogue
|
||||
// registers them as history: a replayed build changed nothing in the world, so announcing it as an
|
||||
// upgrade would have the mesh act on news that is years old.
|
||||
type Replayer interface {
|
||||
// Announceable is every build worth re-announcing, oldest first.
|
||||
//
|
||||
// It hands them back rather than publishing them: the wire belongs to this package, and a
|
||||
// replay that built its own announcements could drift from what the builder emits — which is
|
||||
// the one thing it must match exactly, because the catalogue has a single handler for both.
|
||||
Announceable(ctx context.Context) ([]Announcement, error)
|
||||
}
|
||||
|
||||
// Announcement is a build, in the shape the builder announces one.
|
||||
//
|
||||
// The field names are the wire's, not Go's, because a catalogue reads these and a rename here is
|
||||
// an event nobody handles.
|
||||
type Announcement struct {
|
||||
Module string `json:"module"`
|
||||
Commit string `json:"commit"`
|
||||
Repository string `json:"repository"`
|
||||
Path string `json:"path"`
|
||||
Ref string `json:"ref"`
|
||||
Manifest json.RawMessage `json:"manifest,omitempty"`
|
||||
Against []string `json:"against,omitempty"`
|
||||
Made []MadeArtifact `json:"made,omitempty"`
|
||||
// Replay says this is history rather than news: it was built once, and this is the mesh
|
||||
// telling a catalogue that missed it. A consumer registers it and announces nothing — an
|
||||
// upgrade that happened months ago is not one anything should act on now.
|
||||
Replay bool `json:"replay,omitempty"`
|
||||
}
|
||||
|
||||
// Upgraded is what the catalogue says when a module's current version moves.
|
||||
type Upgraded struct {
|
||||
Module string `json:"module"`
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-control/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// Turning what a node said into what the mesh keeps.
|
||||
@@ -152,7 +152,7 @@ func TestABareAliveDoesNotWipeTheDeclarationThatSaysANodeIsCurrent(t *testing.T)
|
||||
func TestAFailureDoesNotBecomeTheAccountOfWhatTheMachineHolds(t *testing.T) {
|
||||
// A partial list is not an account of what the machine holds. Recording one as though it
|
||||
// were would tell a rebuilding node to remove what it still has — which is the fault that
|
||||
// destroyed a substrate once (novox/hq 04-ISSUES/010).
|
||||
// destroyed a foundation once (novox/hq 04-ISSUES/010).
|
||||
inv := inventory.ForTest(t)
|
||||
ctx := context.Background()
|
||||
node, err := inv.AddNode(ctx, "workstation")
|
||||
|
||||
@@ -88,7 +88,7 @@ type Report struct {
|
||||
|
||||
// Carried are the machine's ports held by what that host raised from its own bundle.
|
||||
//
|
||||
// **The half the mesh cannot know** (novox/hq ADR 0038). The substrate is not a module — a
|
||||
// **The half the mesh cannot know** (novox/hq ADR 0038). The foundation is not a module — a
|
||||
// node raises it before any mesh exists — so without being told, the mesh assigns a module a
|
||||
// port the store or the broker already holds, and hears about it from a container runtime.
|
||||
//
|
||||
|
||||
@@ -51,6 +51,7 @@ type Server struct {
|
||||
recorder Recorder
|
||||
log *log.Logger
|
||||
upgrader Upgrader
|
||||
replayer Replayer
|
||||
}
|
||||
|
||||
// Records tells the server where to keep build results.
|
||||
@@ -89,6 +90,21 @@ func (s *Server) Follows(u Upgrader) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Answers binds the queue a catalogue's catch-up request arrives on.
|
||||
//
|
||||
// **Not bound unless something is listening**, for the same reason upgrades are not: a durable
|
||||
// queue with no consumer fills quietly and the first symptom is a broker out of disk.
|
||||
func (s *Server) Answers(r Replayer) error {
|
||||
if _, err := s.channel.QueueDeclare(CatchUpQueue, true, false, false, false, nil); err != nil {
|
||||
return fmt.Errorf("cannot declare the %s queue: %w", CatchUpQueue, err)
|
||||
}
|
||||
if err := s.channel.QueueBind(CatchUpQueue, KeyCatchingUp, EventsExchange, false, nil); err != nil {
|
||||
return fmt.Errorf("cannot bind %s to %s/%s: %w", CatchUpQueue, EventsExchange, KeyCatchingUp, err)
|
||||
}
|
||||
s.replayer = r
|
||||
return nil
|
||||
}
|
||||
|
||||
// Connect opens the control plane's own connection to the broker.
|
||||
func Connect(enroller Enroller, listener Listener) (*Server, error) {
|
||||
url := strings.TrimSpace(os.Getenv(AMQPVar))
|
||||
@@ -187,6 +203,18 @@ func (s *Server) Serve(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Its own queue and its own consumer, for the reason above: two consumers on one queue split
|
||||
// its messages, and a catch-up request going to whichever half was not listening is a gap that
|
||||
// looks like a working mesh.
|
||||
var catchups <-chan amqp.Delivery
|
||||
if s.replayer != nil {
|
||||
catchups, err = s.channel.ConsumeWithContext(ctx, CatchUpQueue, "control-plane-catchup",
|
||||
false, false, false, false, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
closed := s.conn.NotifyClose(make(chan *amqp.Error, 1))
|
||||
s.log.Printf("consuming %s, bound to %s/{%s,%s,%s,%s}",
|
||||
ControlQueue, Exchange, KeyEnrol, KeyReport, KeyAlive, KeyBuilt)
|
||||
@@ -198,6 +226,14 @@ func (s *Server) Serve(ctx context.Context) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case delivery, ok := <-catchups:
|
||||
if !ok {
|
||||
if catchups != nil {
|
||||
return errors.New("the broker stopped delivering catch-up requests")
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.catchingUp(ctx, delivery)
|
||||
case delivery, ok := <-upgrades:
|
||||
// A nil channel blocks for ever, so this case simply never fires when nothing is
|
||||
// listening for upgrades. Closed is different, and means the broker stopped.
|
||||
@@ -379,6 +415,37 @@ func (s *Server) handleBuilt(ctx context.Context, delivery amqp.Delivery) {
|
||||
// none of those get better by being handed the same message again. Requeuing would put a poison
|
||||
// message at the head of a durable queue and stop every upgrade behind it, which turns one module
|
||||
// nobody can push into a mesh that stops following its own catalogue.
|
||||
// catchingUp answers a catalogue that has just started and may have missed builds.
|
||||
//
|
||||
// Acknowledged before the work, deliberately: a replay that fails is not one that succeeds by
|
||||
// being handed the same request again, and the catalogue asks every time it starts. Requeueing a
|
||||
// poison request would stop every later catch-up behind it.
|
||||
func (s *Server) catchingUp(ctx context.Context, delivery amqp.Delivery) {
|
||||
defer func() { _ = delivery.Ack(false) }()
|
||||
if s.replayer == nil {
|
||||
s.log.Printf("a catalogue asked to catch up and this control plane has nothing to replay")
|
||||
return
|
||||
}
|
||||
announcements, err := s.replayer.Announceable(ctx)
|
||||
if err != nil {
|
||||
s.log.Printf("a catalogue asked to catch up and the mesh could not read its builds: %v", err)
|
||||
return
|
||||
}
|
||||
sent := 0
|
||||
for _, a := range announcements {
|
||||
a.Replay = true
|
||||
if err := EmitEvent(ctx, s.channel, KeyModuleBuilt, "control-plane", "", a); err != nil {
|
||||
// Said and abandoned rather than retried: the catalogue asks again every time it
|
||||
// starts, and half a graph delivered twice is no better than half delivered once.
|
||||
s.log.Printf("replaying %s at %s failed, and the rest is abandoned: %v",
|
||||
a.Module, short(a.Commit), err)
|
||||
return
|
||||
}
|
||||
sent++
|
||||
}
|
||||
s.log.Printf("a catalogue asked to catch up; re-announced %d build(s)", sent)
|
||||
}
|
||||
|
||||
func (s *Server) upgraded(ctx context.Context, delivery amqp.Delivery) {
|
||||
defer func() { _ = delivery.Ack(false) }()
|
||||
var u Upgraded
|
||||
|
||||
@@ -127,7 +127,7 @@ func config(node Node, peers []Peer, keyPath string) string {
|
||||
b.WriteString("PostDown = sysctl -q -w net.ipv4.ip_forward=0\n")
|
||||
|
||||
// And past the machine's own firewall, which on any node with a container runtime is
|
||||
// closed. Docker sets the FORWARD policy to DROP and inserts its chains, so the substrate
|
||||
// closed. Docker sets the FORWARD policy to DROP and inserts its chains, so the foundation
|
||||
// this mesh installs at tier 1 silently breaks the network it builds at tier 2: every
|
||||
// spoke reaches the hub, no spoke reaches any other, and every part of it reports
|
||||
// success. Found in the lab; nothing about it is visible from the mesh's own state.
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// The private network as a module rather than as code beside the module system.
|
||||
@@ -29,19 +29,14 @@ import (
|
||||
// how a mesh ends up unable to have a second one.
|
||||
const Requirement = "private-network"
|
||||
|
||||
// Name is the module that answers it with WireGuard, and Names is the one that gives the machines
|
||||
// names. Two modules rather than one, because they are two different things: names would be the
|
||||
// same over any private network, and they are only bundled here by an accident of both being
|
||||
// computed.
|
||||
const (
|
||||
Name = "mesh-wireguard"
|
||||
Names = "mesh-names"
|
||||
)
|
||||
|
||||
// Resolution is what a module asks for when it needs to reach other machines by name. Separate
|
||||
// from Requirement because they are separate jobs: one is whether packets arrive, the other is
|
||||
// whether a name means anything. A machine can want the first without the second.
|
||||
const Resolution = "name-resolution"
|
||||
// Name is the module that answers it with WireGuard.
|
||||
//
|
||||
// **The names went with it.** A mesh-names module used to sit beside this — it wrote /etc/hosts
|
||||
// and ran nothing, which is not a module. Being on the private network is what gives a machine a
|
||||
// name, so this module asks for the `node-names` fact and the mesh writes the file. The
|
||||
// name-resolution provision went the same way: names are facts the mesh computes, not something a
|
||||
// module that runs nowhere can provide.
|
||||
const Name = "mesh-wireguard"
|
||||
|
||||
// Addressing is the mesh handing out addresses on the private network itself.
|
||||
//
|
||||
@@ -131,37 +126,6 @@ func (g *Generator) Resources(node string) ([]map[string]any, bool, error) {
|
||||
return parsed.Resources, true, nil
|
||||
}
|
||||
|
||||
// NameGenerator answers what one node's hosts file is.
|
||||
//
|
||||
// Separate from the interface and the peers because it is a separate concern. A machine's names
|
||||
// come from the mesh knowing every machine, not from how the packets travel — over a different
|
||||
// private network the peers would be written by something else and this would be unchanged.
|
||||
type NameGenerator struct{ nodes []Node }
|
||||
|
||||
// NamesFor builds the name generator over the machines on the private network.
|
||||
func NamesFor(nodes []Node) *NameGenerator { return &NameGenerator{nodes: nodes} }
|
||||
|
||||
// Resources is the one file.
|
||||
func (g *NameGenerator) Resources(node string) ([]map[string]any, bool, error) {
|
||||
var found bool
|
||||
for _, n := range g.nodes {
|
||||
if n.Name == node {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, false, nil
|
||||
}
|
||||
hosts, err := Hosts(g.nodes, node)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return []map[string]any{{
|
||||
"id": "mesh-names", "type": "file", "path": HostsPath,
|
||||
"mode": "0644", "content": hosts,
|
||||
}}, true, nil
|
||||
}
|
||||
|
||||
// Nodes are the machines this generator was built over, so a caller can say who is on the network.
|
||||
func (g *Generator) Nodes() []Node { return g.nodes }
|
||||
|
||||
@@ -179,55 +143,25 @@ func Manifest() map[string]any {
|
||||
"version": "1",
|
||||
"computed": Name,
|
||||
"provides": []string{Requirement, Addressing},
|
||||
"claims": []map[string]any{{"name": TheNetwork, "scope": "node"}},
|
||||
}
|
||||
}
|
||||
|
||||
// NamesManifest is the module that gives machines names on the private network.
|
||||
//
|
||||
// It requires the network rather than providing it, which is the whole reason it is separate: a
|
||||
// name resolves to an address on the private wire, so having names without being on it would
|
||||
// point every machine at somewhere it cannot reach.
|
||||
func NamesManifest() map[string]any {
|
||||
return map[string]any{
|
||||
"module": Names,
|
||||
"version": "1",
|
||||
"computed": Names,
|
||||
"provides": []string{Resolution},
|
||||
"requires": []string{Addressing},
|
||||
}
|
||||
}
|
||||
|
||||
// ResolverManifest is what a resolver on this machine must know: every name under every machine.
|
||||
//
|
||||
// **It writes the data and runs no daemon.** A resolver is third-party software, and third-party
|
||||
// software runs *on* the mesh rather than being *of* it
|
||||
// ([ADR 0001](novox/hq)) — the mesh has no business shipping one, choosing which one, or knowing
|
||||
// its configuration language. What only the mesh can know is which machines exist and where they
|
||||
// are, so that is what it computes.
|
||||
//
|
||||
// So a module that runs a resolver requires what this provides, and reads one file. Swapping the
|
||||
// daemon changes that module and nothing here.
|
||||
//
|
||||
// **Separate from names rather than part of them**, because a machine with no container runtime
|
||||
// can still have a hosts file. Folding them together would take exact names away from a machine
|
||||
// that cannot run a daemon, to give it a wildcard it cannot use either.
|
||||
func ResolverManifest() map[string]any {
|
||||
return map[string]any{
|
||||
"module": Resolver,
|
||||
"version": "1",
|
||||
"computed": Resolver,
|
||||
"requires": []string{Resolution},
|
||||
"provides": []string{ResolverData},
|
||||
// Being on the private network is what gives a machine a name, so the module that puts it
|
||||
// there is what writes them. Asked for rather than generated by a module of its own: the
|
||||
// mesh knows which machines exist and where; writing that into a hosts file is not a thing
|
||||
// that needs a module to run nowhere.
|
||||
"facts": map[string]string{"node-names": "/etc/hosts"},
|
||||
"claims": []map[string]any{{"name": TheNetwork, "scope": "node"}},
|
||||
}
|
||||
}
|
||||
|
||||
// DomainManifest is the module that means "get the network working".
|
||||
func DomainManifest() map[string]any {
|
||||
return map[string]any{
|
||||
"module": Domain,
|
||||
"version": "1",
|
||||
"requires": []string{Requirement, Resolution},
|
||||
"module": Domain,
|
||||
"version": "1",
|
||||
// **Only the network now.** It used to require name-resolution as well, answered by a
|
||||
// module that wrote a hosts file and ran nothing. Names are not a provision — they are a
|
||||
// fact the mesh computes, and whatever puts a machine on the private network writes them,
|
||||
// because a mesh name IS an address on that network.
|
||||
"requires": []string{Requirement},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -49,53 +47,7 @@ var nodeName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||
// InternalName is a node's name inside the mesh.
|
||||
func InternalName(node string) string { return node + "." + Suffix() }
|
||||
|
||||
// Hosts writes the name file for one node.
|
||||
//
|
||||
// Every node in the mesh, including this one. Including itself because a machine referring to
|
||||
// itself by its mesh name should get its overlay address rather than a loopback — otherwise a
|
||||
// service that binds to the name it was given ends up unreachable from everywhere else.
|
||||
//
|
||||
// The machine's own loopback lines come first and are not the mesh's to have an opinion about,
|
||||
// but they have to be here: this file is generated whole, so anything left out is removed.
|
||||
func Hosts(nodes []Node, self string) (string, error) {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever a node\n")
|
||||
b.WriteString("# joins or leaves, and an edit would survive until then and vanish.\n\n")
|
||||
|
||||
// The floor every Linux expects, and which removing would break things that have nothing to
|
||||
// do with the mesh.
|
||||
b.WriteString("127.0.0.1\tlocalhost\n")
|
||||
b.WriteString("::1\t\tlocalhost ip6-localhost ip6-loopback\n")
|
||||
if self != "" {
|
||||
fmt.Fprintf(&b, "127.0.1.1\t%s\n", self)
|
||||
}
|
||||
|
||||
named := make([]Node, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
if n.Address == "" {
|
||||
// A node with no address on the network has no name here. Writing one that resolves
|
||||
// to nothing is worse than not writing it: a connection to an address that does not
|
||||
// answer hangs, where a name that does not resolve fails at once and says so.
|
||||
continue
|
||||
}
|
||||
if !nodeName.MatchString(n.Name) {
|
||||
return "", fmt.Errorf(
|
||||
"%q cannot be a mesh name: it becomes a hostname, so it is lower-case letters, "+
|
||||
"digits and dashes", n.Name)
|
||||
}
|
||||
named = append(named, n)
|
||||
}
|
||||
sort.Slice(named, func(i, j int) bool { return named[i].Name < named[j].Name })
|
||||
|
||||
if len(named) > 0 {
|
||||
fmt.Fprintf(&b, "\n# the mesh, %d node(s)\n", len(named))
|
||||
}
|
||||
for _, n := range named {
|
||||
line := fmt.Sprintf("%s\t%s\t%s", n.Address, InternalName(n.Name), n.Name)
|
||||
if n.Name == self {
|
||||
line += "\t# this machine"
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
// **What remains of a larger file.** The rest wrote /etc/hosts — that is the `node-names` fact now
|
||||
// (catalogue.FactsInto), computed where the graph lives instead of by a module that ran nothing.
|
||||
// The naming stays here, because several things compose a node's internal name and one of them
|
||||
// writing the suffix differently would be a name nothing answers to.
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Names are their own module.
|
||||
//
|
||||
// They used to arrive inside the WireGuard declaration, on the argument that a machine with peers
|
||||
// and no names is half on the network. True, and the wrong place to fix it: names would be
|
||||
// identical over a different private network, so bundling them made one module out of two things.
|
||||
|
||||
func TestAMachineNotOnTheNetworkGetsNoNames(t *testing.T) {
|
||||
// Names resolve to addresses on the private wire. Giving them to a machine that is not on it
|
||||
// would point every lookup somewhere it cannot reach — worse than having no names at all.
|
||||
g := NamesFor([]Node{at("anchor", "dc", "10.42.0.1", "198.51.100.10:51820", true)})
|
||||
_, part, err := g.Resources("laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if part {
|
||||
t.Fatal("a machine that is not on the private network was given the mesh's names")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNamesAreOneFileAndSayWhoIsAsking(t *testing.T) {
|
||||
g := NamesFor([]Node{
|
||||
at("anchor", "dc", "10.42.0.1", "198.51.100.10:51820", true),
|
||||
at("workstation", "house", "10.42.0.2", "", false),
|
||||
})
|
||||
out, part, err := g.Resources("workstation")
|
||||
if err != nil || !part {
|
||||
t.Fatalf("part=%v err=%v", part, err)
|
||||
}
|
||||
if len(out) != 1 || out[0]["path"] != HostsPath {
|
||||
t.Fatalf("got %v", out)
|
||||
}
|
||||
content := out[0]["content"].(string)
|
||||
if !strings.Contains(content, "anchor.internal") {
|
||||
t.Fatalf("another machine on the network has no name here:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "this machine") {
|
||||
t.Fatalf("the file does not say which machine it is on:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheWireGuardDeclarationNoLongerCarriesTheNames(t *testing.T) {
|
||||
// The split, asserted. Two modules, so a machine can have the peers from one and the names
|
||||
// from another — which is what makes a second VPN possible at all.
|
||||
raw, err := Declaration(
|
||||
at("workstation", "house", "10.42.0.2", "", false),
|
||||
[]Peer{{Name: "anchor", Key: "PUB", Allowed: "10.42.0.0/16",
|
||||
Endpoint: "198.51.100.10:51820"}}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), HostsPath) {
|
||||
t.Fatalf("the WireGuard declaration still writes %s", HostsPath)
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func hostsFor(t *testing.T, self string, nodes ...Node) string {
|
||||
t.Helper()
|
||||
out, err := Hosts(nodes, self)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestEveryNodeWithAPlaceGetsAName(t *testing.T) {
|
||||
got := hostsFor(t, "laptop",
|
||||
Node{Name: "anchor", Address: "10.42.0.1"},
|
||||
Node{Name: "laptop", Address: "10.42.0.2"})
|
||||
|
||||
for _, want := range []string{"10.42.0.1\tanchor.internal\tanchor", "10.42.0.2\tlaptop.internal\tlaptop"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("no entry for %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeSeesItselfAtItsOverlayAddress(t *testing.T) {
|
||||
// Not at a loopback. A service that binds to the name the machine was given would otherwise
|
||||
// listen somewhere nothing else can reach, and the failure appears on every other node rather
|
||||
// than this one.
|
||||
got := hostsFor(t, "laptop", Node{Name: "laptop", Address: "10.42.0.2"})
|
||||
if !strings.Contains(got, "10.42.0.2\tlaptop.internal") {
|
||||
t.Error("a node does not resolve its own mesh name to its overlay address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMachinesOwnLoopbackSurvives(t *testing.T) {
|
||||
// This file is generated whole, so anything left out is removed. Dropping localhost would
|
||||
// break things that have nothing to do with the mesh, on a machine the mesh was asked to
|
||||
// improve.
|
||||
got := hostsFor(t, "laptop", Node{Name: "laptop", Address: "10.42.0.2"})
|
||||
if !strings.Contains(got, "127.0.0.1\tlocalhost") {
|
||||
t.Error("localhost is missing; this file replaces the machine's own")
|
||||
}
|
||||
if !strings.Contains(got, "::1") {
|
||||
t.Error("the IPv6 loopback is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeWithNoAddressGetsNoName(t *testing.T) {
|
||||
// A name resolving to nothing is worse than no name: a connection to an address that does not
|
||||
// answer hangs, where a name that does not resolve fails at once and says which name it was.
|
||||
got := hostsFor(t, "laptop",
|
||||
Node{Name: "laptop", Address: "10.42.0.2"},
|
||||
Node{Name: "newcomer", Address: ""})
|
||||
if strings.Contains(got, "newcomer") {
|
||||
t.Error("a node with no address on the network was given a name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestANameThatCannotBeAHostnameIsRefused(t *testing.T) {
|
||||
// Refused here, where a person is looking, rather than written into a file that every
|
||||
// machine then reads and disagrees about.
|
||||
for _, bad := range []string{"Anchor", "my node", "under_score", "-leading", "trailing-"} {
|
||||
if _, err := Hosts([]Node{{Name: bad, Address: "10.42.0.1"}}, ""); err == nil {
|
||||
t.Errorf("%q was accepted as a mesh name", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheOrderIsStable(t *testing.T) {
|
||||
// The file is rewritten whenever anything changes, and a file whose lines move for no reason
|
||||
// makes every reconcile look like a change — which means a service that reflects it restarts
|
||||
// for ever.
|
||||
a := hostsFor(t, "", Node{Name: "b", Address: "10.42.0.2"}, Node{Name: "a", Address: "10.42.0.1"})
|
||||
b := hostsFor(t, "", Node{Name: "a", Address: "10.42.0.1"}, Node{Name: "b", Address: "10.42.0.2"})
|
||||
if a != b {
|
||||
t.Error("the same mesh produced two different files depending on the order it was read in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSuffixIsReservedForThis(t *testing.T) {
|
||||
// `.internal` was reserved by IANA in 2024 for exactly this. A name under it can never
|
||||
// collide with a public one, so an internal name that leaks into a public resolver fails
|
||||
// rather than reaching a stranger's machine.
|
||||
if InternalName("anchor") != "anchor.internal" {
|
||||
t.Errorf("internal names end in %q", Suffix())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSuffixCanBeChosen(t *testing.T) {
|
||||
t.Setenv(SuffixVar, ".mesh")
|
||||
if InternalName("anchor") != "anchor.mesh" {
|
||||
t.Errorf("got %q", InternalName("anchor"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFileSaysItIsGenerated(t *testing.T) {
|
||||
got := hostsFor(t, "laptop", Node{Name: "laptop", Address: "10.42.0.2"})
|
||||
if !strings.Contains(got, "Do not edit") {
|
||||
t.Error("a generated file does not say so")
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
func networkOf(t *testing.T, nodes []Node) *Generator {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user