Compare commits

...
5 Commits
Author SHA1 Message Date
jochen b5438bb331 Pin the image's Go base in the Dockerfile, which genesis builds as it stands (hq issue 223)
Genesis now raises a process-form controller as a container built from
this repository's Dockerfile with no build arguments (mesh-host
bootstrap, novox/hq issue 223); the manifest builds no image, so nothing
passes the base in. The default was a tag older than go.mod asks for.
It is now the digest the Makefile pins, and a test holds the two equal.
2026-10-04 01:49:05 +02:00
jochen c23be73d4d Run the controller as a Go bundle the host starts as a process (hq issue 213)
The controller is a Go program and was the one piece of the mesh's own Go
code still shipped and run as an image (novox/hq issue 213; ADR 0188 §1:
a module's own code is bundles; §3: a service bundle is a process).

The manifest now builds one Go bundle, `controller`, and runs it as the
process `mesh-controller` (`./mesh-controller serve`) under an account
the module declares. What the container gave it, replaced:

- host network: a process is on the host's network; nothing it reads
  names a container network
- user 65534: the account `mesh-controller`, which owns its secrets and
  its state directory
- the eight mounts: the env names the host paths the mesh already places
  (the store, broker and bus files under the state directory, the
  broker's certificate under /var/lib/mesh-broker-tls); the `broker`
  mount was read by nothing and is gone with the others
- `container-runtime` is no longer required on its machine

Its preparation is the same binary with `prepare`, as a run-once process,
and the process `replaces` the container `server`: the host keeps the
container answering until the process is running (mesh-host). Needs the
previous commit live in the running controller, and the host's
`replaces` on the controller's machine, before it is registered.

No image is built by the mesh any more. The Dockerfile stays for genesis
and the lab (`make image`, its Go base now pinned in the Makefile).
2026-10-04 01:45:25 +02:00
mesh-admin d2d171f2d2 Merge pull request 'Compose a module's Go service as a process the host runs (hq issue 213, 1 of 2)' (#252) from fix/issue-213-the-controller-is-a-process into main 2026-10-03 23:40:38 +00:00
mesh-admin 73fa64ea68 Merge pull request 'A TypeScript bundle installs its module's own packages before it is compiled (hq ADR 0198 §4)' (#255) from feat/a-bundle-installs-its-own-packages into main 2026-10-03 23:20:57 +00:00
jochen 1a13dbeb17 A TypeScript bundle installs its module's own packages before it is compiled
A bundle could import only what the toolchain image carried: the compiler and the bundler resolve an import from the module's directory and then the toolchain's node_modules, and nothing ever put anything in the first. So a module needing a database driver (pg, mongodb, mssql) could not be a bundle, and kept a container whose recipe installed it (hq ADR 0198 §4: the backend's own driver inside the bundle).

Now, when a module's package.json depends on anything beyond the SDK, the build installs its production dependencies into the module's directory, in the toolchain image, before the compile: npm ci from the lockfile when there is one, npm install from the ranges otherwise, the mesh's registry for the SDK's scope and the public one for the rest, install scripts off. esbuild then inlines them. A module depending only on the SDK runs exactly the commands it did before.

The SDK stays the toolchain's (hq issue 212): it is taken out of what is installed and any copy something pulls in is removed, so every import of it resolves past the module's node_modules to the one the toolchain carries; a module's own range never shadows it. npm's verified download cache is a named volume; nothing installed is kept between builds. Without a registry, a scoped package is refused rather than resolved on the public registry.
2026-10-04 01:17:49 +02:00
13 changed files with 519 additions and 64 deletions
+8 -2
View File
@@ -1,5 +1,11 @@
ARG GO_BASE=golang:1.25-alpine # The Go it builds with, pinned here because genesis builds this file with no arguments (novox/hq
# The control plane's image. # issue 223) — the Makefile passes the same digest. A tag older than go.mod asks for is how
# `make image` broke once before (issue 146).
ARG GO_BASE=golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c
# The control plane's image — for genesis and the lab only. The mesh runs the controller as a Go
# bundle the host starts as a process (module.json; novox/hq issue 213), and builds no image of it.
# Genesis builds this file and raises it as the container the process replaces on the first push
# (mesh-host internal/bootstrap, novox/hq issue 223).
# #
# novox/hq ADR 0006: this image is pinned by digest in the bundle the host carries, fetched on a # novox/hq ADR 0006: this image is pinned by digest in the bundle the host carries, fetched on a
# machine where no mesh exists yet, and run before there is anything to check it against. So it # machine where no mesh exists yet, and run before there is anything to check it against. So it
+8 -4
View File
@@ -27,17 +27,21 @@ build:
IMAGE ?= mesh-controller:$(VERSION) IMAGE ?= mesh-controller:$(VERSION)
DEV_TAG ?= mesh-controller:development DEV_TAG ?= mesh-controller:development
# The base the module declares, read from the manifest rather than written here twice. # The Go base the image is built on.
# #
# **`make image` was broken and stayed broken**, because the Dockerfile's fallback base was a Go # **`make image` was broken and stayed broken**, because the Dockerfile's fallback base was a Go
# older than go.mod asks for: every build died at `go mod download` with "go.mod requires go >= # older than go.mod asks for: every build died at `go mod download` with "go.mod requires go >=
# 1.26.0", and the pipeline never saw it because the pipeline passes the declared base in. Anybody # 1.26.0", and the pipeline never saw it because the pipeline passes the declared base in. Anybody
# building the image by hand hit it and had to find the digest themselves (novox/hq 04-ISSUES/146, # building the image by hand hit it and had to find the digest themselves (novox/hq 04-ISSUES/146,
# what it cost). # what it cost).
GO_BASE ?= $(shell python3 -c "import json;print(next(o['image'] for o in json.load(open('module.json'))['build']['on'] if o['arg']=='GO_BASE'))" 2>/dev/null) #
# **Pinned here since the manifest stopped building an image** (novox/hq issue 213): the mesh builds
# the controller as a Go bundle with its own toolchain, and only `make image` — genesis and the lab —
# still needs a Go base. The digest is the one the manifest declared until then.
GO_BASE ?= golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c
image: image:
@test -n "$(GO_BASE)" || { echo "module.json declares no GO_BASE; pass GO_BASE=<image> or fix the manifest"; exit 1; } @test -n "$(GO_BASE)" || { echo "no GO_BASE; pass GO_BASE=<image>"; exit 1; }
docker build --build-arg GO_BASE=$(GO_BASE) --build-arg VERSION=$(VERSION) -t $(IMAGE) -t $(DEV_TAG) . docker build --build-arg GO_BASE=$(GO_BASE) --build-arg VERSION=$(VERSION) -t $(IMAGE) -t $(DEV_TAG) .
@echo @echo
@docker image inspect $(IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes' @docker image inspect $(IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
@@ -48,7 +52,7 @@ BUILDER_IMAGE ?= mesh-builder:$(VERSION)
BUILDER_DEV_TAG ?= mesh-builder:development BUILDER_DEV_TAG ?= mesh-builder:development
builder-image: builder-image:
@test -n "$(GO_BASE)" || { echo "module.json declares no GO_BASE; pass GO_BASE=<image> or fix the manifest"; exit 1; } @test -n "$(GO_BASE)" || { echo "no GO_BASE; pass GO_BASE=<image>"; exit 1; }
docker build --build-arg GO_BASE=$(GO_BASE) -f cmd/mesh-builder/Dockerfile -t $(BUILDER_IMAGE) -t $(BUILDER_DEV_TAG) . docker build --build-arg GO_BASE=$(GO_BASE) -f cmd/mesh-builder/Dockerfile -t $(BUILDER_IMAGE) -t $(BUILDER_DEV_TAG) .
@echo @echo
@docker image inspect $(BUILDER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes' @docker image inspect $(BUILDER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
+7
View File
@@ -193,6 +193,13 @@ passes every check that only looks at the message.
## The image ## The image
**The mesh no longer runs the controller from it** (novox/hq issue 213). The module declares a Go
bundle, `controller`, which the host on the controller's machine unpacks and runs as the process
`mesh-controller` under the account of the same name (ADR 0188 §1, §3). The image stays for what
still runs a container of the controller: genesis, which raises the first controller from it and
installs the module from its manifest (mesh-host `internal/bootstrap`), and the lab. Neither is the
mesh's own build any more — `make image` builds it.
`FROM scratch`, holding one statically linked binary and nothing else — no shell, no package `FROM scratch`, holding one statically linked binary and nothing else — no shell, no package
manager, no libc, no CA certificates. manager, no libc, no CA certificates.
+6 -6
View File
@@ -145,7 +145,7 @@ func TestTheRegistryTrustAndEveryImageFollowThePortTheNodeGaveTheStore(t *testin
// //
// Composed from the control plane's own manifest against a real inventory: the store's module is // Composed from the control plane's own manifest against a real inventory: the store's module is
// given 6852 on this node the way genesis or an operator gives it, and the control plane's // given 6852 on this node the way genesis or an operator gives it, and the control plane's
// container is told so beside the sealed connection genesis wrote. // process is told so beside the sealed connection genesis wrote.
func TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker(t *testing.T) { func TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker(t *testing.T) {
open := aMesh(t) open := aMesh(t)
ctx := t.Context() ctx := t.Context()
@@ -157,8 +157,8 @@ func TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker(t *testing.T)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
control, err := withSeatPorts(m).Resolve([]catalogue.Built{{Name: "server", Kind: catalogue.ArtifactImage, control, err := withSeatPorts(m).Resolve([]catalogue.Built{{Name: "controller", Kind: catalogue.ArtifactBundle,
Reference: "registry.example/control@" + aDigest}}) Reference: "https://registry.example/mesh-controller/controller.tar.gz", Digest: aDigest}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -200,12 +200,12 @@ func TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker(t *testing.T)
var env map[string]any var env map[string]any
for _, r := range composed(t, open, "anchor").Resources { for _, r := range composed(t, open, "anchor").Resources {
if r["id"] == "mesh-controller.server" { if r["id"] == "mesh-controller.controller" {
env, _ = r["env"].(map[string]any) env, _ = r["env"].(map[string]any)
} }
} }
if env == nil { if env == nil {
t.Fatal("the control plane's container is not in its own node's declaration") t.Fatal("the control plane's process is not in its own node's declaration")
} }
for key, want := range map[string]string{ for key, want := range map[string]string{
"MESH_STORE_INVENTORY_PORT": "6852", "MESH_STORE_INVENTORY_PORT": "6852",
@@ -238,7 +238,7 @@ func withSeatPorts(m catalogue.Manifest) catalogue.Manifest {
out := m out := m
out.Resources = nil out.Resources = nil
for _, r := range m.Resources { for _, r := range m.Resources {
if r["type"] != "container" { if r["type"] != "container" && r["type"] != "process" {
out.Resources = append(out.Resources, r) out.Resources = append(out.Resources, r)
continue continue
} }
+7 -2
View File
@@ -215,7 +215,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name }) sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
for _, a := range artifacts { for _, a := range artifacts {
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a)) say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, seatBases, say) made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, npmrc, seatBases, say)
if err != nil { if err != nil {
say("artifact", "%s FAILED: %v", a.Name, err) say("artifact", "%s FAILED: %v", a.Name, err)
return Result{}, err return Result{}, err
@@ -443,7 +443,7 @@ func wantsPackages(manifest catalogue.Manifest, within string) bool {
func one(ctx context.Context, run Runner, publish Publisher, func one(ctx context.Context, run Runner, publish Publisher,
module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string, module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string,
held map[string]string, npmrc string, seats map[string]string, held map[string]string, npmrc string, registry Npmrc, seats map[string]string,
say func(step, format string, args ...any)) (catalogue.Built, error) { say func(step, format string, args ...any)) (catalogue.Built, error) {
switch a.Kind { switch a.Kind {
@@ -582,6 +582,11 @@ func one(ctx context.Context, run Runner, publish Publisher,
"holds no copy of it. Build %s first", "holds no copy of it. Build %s first",
module, a.Name, chain.Language, chain.Base, chain.Artifact, chain.Base) module, a.Name, chain.Language, chain.Base, chain.Artifact, chain.Base)
} }
// The module's own packages first, where the compiler and the bundler resolve them from
// (dependencies.go); nothing at all for a module whose package.json names only the SDK.
if err := installOwn(ctx, run, tree, chain, base, registry, say); err != nil {
return catalogue.Built{}, fmt.Errorf("%s: %s: %w", module, a.Name, err)
}
say("bundle", "compiling %s in %s's toolchain", a.Language, chain.Base) say("bundle", "compiling %s in %s's toolchain", a.Language, chain.Base)
compiled, err := compile(ctx, run, tree, chain, base, a) compiled, err := compile(ctx, run, tree, chain, base, a)
if err != nil { if err != nil {
+147
View File
@@ -0,0 +1,147 @@
package builder
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// A module's own packages, installed before its bundle is compiled, so the bundler inlines them.
//
// **A bundle could only import what the toolchain happened to carry.** The compiler and the bundler
// resolve an import by walking up from the module's source: the module's own directory first, then
// the toolchain image's node_modules. Nothing ever put anything in the first, so a module needing a
// database driver (`pg`, `mongodb`, `mssql`) could not be a bundle at all, and kept a container whose
// recipe installed it by hand (novox/hq ADR 0198 §4: "the backend's own driver inside the bundle").
// Now the module's `package.json` says what it depends on, as any Node package does, and the build
// installs exactly that into the module's own directory before compiling.
//
// **The SDK the toolchain carries is the one a bundle is built with, whatever the module says**
// (novox/hq issue 212: the toolchain is rebuilt on every SDK release and every bundle after it). A
// module's `package.json` names `@novox/mesh-sdk` with a range — it has to, to type-check on a
// workstation — and installing that range would shadow the toolchain's copy for this module alone:
// one module compiled against an older SDK than its neighbours, chosen by a caret nobody re-reads.
// So the SDK is taken out of what is installed (and never fetched), and any copy something else
// pulls in is removed afterwards; every import of it resolves past the module's node_modules to the
// toolchain's. A module therefore cannot pin a different SDK, by design: the toolchain is the pin.
//
// **Correctness before speed.** Every build installs afresh into a fresh clone, from the lockfile
// when the module has one (`npm ci`, exact) and from its ranges otherwise; nothing installed is kept
// between builds. What is shared is npm's own download cache, a named volume, which is
// content-addressed and verified by integrity on every read — it saves the network, never the
// install. Install scripts do not run: the build node runs nobody's postinstall, and what a script
// would build natively could not be inlined into one file anyway.
// sdkPackage is the package a TypeScript bundle's launcher serves through, and the one package a
// module's own dependencies never supply (above).
const sdkPackage = "@novox/mesh-sdk"
// npmCache is the named volume npm's download cache lives in across builds on one build node.
const npmCache = "mesh-builder-npm-cache"
// ownDependencies is what a module's package.json depends on beyond the SDK, sorted; nothing when
// the module has no package.json or depends on nothing else — which builds exactly as before.
func ownDependencies(tree string) ([]string, error) {
raw, err := os.ReadFile(filepath.Join(tree, "package.json"))
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var p struct {
Dependencies map[string]string `json:"dependencies"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("the module's package.json is not JSON: %w", err)
}
var names []string
for name := range p.Dependencies {
if name != sdkPackage {
names = append(names, name)
}
}
sort.Strings(names)
return names, nil
}
// installSteps is the script run inside the toolchain image, from the module's own directory ($0).
// It works in a scratch copy so the module's package.json and lockfile are never rewritten, takes
// the SDK out of what is installed, installs production dependencies only, removes any copy of the
// SDK something pulled in, and puts the result at the module's node_modules.
const installSteps = `set -e
work="$(mktemp -d)"
cp "$0/package.json" "$work/"
if [ -f "$0/package-lock.json" ]; then cp "$0/package-lock.json" "$work/"; fi
cd "$work"
node -e '
const fs = require("fs"), sdk = process.argv[1];
const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
for (const k of ["dependencies", "peerDependencies", "optionalDependencies"]) if (p[k]) delete p[k][sdk];
delete p.devDependencies; delete p.scripts;
fs.writeFileSync("package.json", JSON.stringify(p));
' "$1"
shift
if [ -f package-lock.json ]; then
npm ci --omit=dev --omit=peer --ignore-scripts --no-audit --no-fund "$@"
else
npm install --omit=dev --omit=peer --ignore-scripts --no-audit --no-fund --no-package-lock "$@"
fi
find node_modules -depth -type d -path "*/node_modules/@novox/mesh-sdk" -exec rm -rf {} +
rm -rf "$0/node_modules"
cp -a node_modules "$0/node_modules"
`
// installOwn installs a TypeScript module's own production dependencies into its directory, in the
// toolchain image, before the compile — or does nothing at all for a module that has none.
func installOwn(ctx context.Context, run Runner, tree string, chain Toolchain, base string,
registry Npmrc, say func(step, format string, args ...any)) error {
if chain.Language != "typescript" {
return nil
}
deps, err := ownDependencies(tree)
if err != nil || len(deps) == 0 {
return err
}
scoped := strings.TrimSpace(registry.Scope)
if !registry.Enabled() {
// **No registry, no scoped package.** Without the mesh's registry a scoped name resolves on
// the public one, where anybody may have published it: a dependency that installs is not
// the dependency the module meant.
for _, d := range deps {
if strings.HasPrefix(d, "@novox/") {
return fmt.Errorf("the module depends on %s, and this build knows no package registry "+
"for its scope; it would resolve from the public registry, which is not where the "+
"mesh publishes it", d)
}
}
}
const within = "/app/modules/module"
invocation := []string{"run", "--rm",
"--volume", tree + ":" + within,
"--volume", npmCache + ":/root/.npm",
"--workdir", within}
var flags []string
if registry.Enabled() {
// The registry is reached where the binding says it is, which may be this machine's own
// loopback — the reason an image build that resolves packages runs on the host network too.
invocation = append(invocation, "--network", "host")
reg := strings.TrimSpace(registry.Registry)
if !strings.HasSuffix(reg, "/") {
reg += "/"
}
flags = append(flags, "--"+scoped+":registry="+reg)
}
invocation = append(invocation, base, "sh", "-c", installSteps, within, sdkPackage)
invocation = append(invocation, flags...)
say("bundle", "installing the module's own packages: %s", strings.Join(deps, ", "))
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
return fmt.Errorf("installing the module's own packages (%s): %w", strings.Join(deps, ", "), err)
}
return nil
}
+131
View File
@@ -0,0 +1,131 @@
package builder
import (
"context"
"strings"
"testing"
)
// A module's own packages (dependencies.go): installed into its own directory, in the toolchain,
// before the compile, so the bundler inlines them — the SDK always the toolchain's.
func buildWithPackageJSON(t *testing.T, pkg string, extra map[string]string, registry Npmrc) (*recorded, error) {
t.Helper()
files := map[string]string{"index.ts": "console.log(1)"}
if pkg != "" {
files["package.json"] = pkg
}
for k, v := range extra {
files[k] = v
}
r, workspace := aRepository(t, aBundle, files)
held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)}
_, err := Build(context.Background(), compiling{r}.run, r,
"https://forge.invalid/greeter.git", "", "", workspace, held, registry, GitCredential{}, nil)
return r, err
}
func installs(r *recorded) []string {
var out []string
for _, line := range r.ran {
if strings.HasPrefix(line, "docker run") && strings.Contains(line, "npm ci") {
out = append(out, line)
}
}
return out
}
func compileIndex(r *recorded) int {
for i, line := range r.ran {
if strings.Contains(line, "--outDir") {
return i
}
}
return -1
}
func TestAModulesOwnPackagesAreInstalledInTheToolchainBeforeTheCompile(t *testing.T) {
r, err := buildWithPackageJSON(t, `{"type":"module","dependencies":{"@novox/mesh-sdk":"^0.1.0","pg":"^8"},"devDependencies":{"typescript":"^5"}}`,
nil, Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm"})
if err != nil {
t.Fatal(err)
}
got := installs(r)
if len(got) != 1 {
t.Fatalf("want one install of the module's own packages:\n%s", strings.Join(r.ran, "\n"))
}
line := got[0]
for _, want := range []string{
"mesh-tools/build@sha256:", // in the toolchain image
":/app/modules/module", // into the module's own directory
"--workdir /app/modules/module", //
npmCache + ":/root/.npm", // npm's verified download cache, and only that
"--omit=dev", "--ignore-scripts", // production packages, no build-node scripts
"npm ci", "npm install", "--no-package-lock", // the lockfile when there is one, else the ranges
"--@novox:registry=https://forge.invalid/api/packages/novox/npm/", // the scope from the mesh's registry
"--network host",
"@novox/mesh-sdk", // named, to be taken out of what is installed
} {
if !strings.Contains(line, want) {
t.Errorf("the install lacks %q:\n%s", want, line)
}
}
// The SDK is the toolchain's: never installed from the module's range, and any copy removed.
if !strings.Contains(line, `delete p[k][sdk]`) || !strings.Contains(line, `-path "*/node_modules/@novox/mesh-sdk" -exec rm -rf`) {
t.Errorf("the module's own SDK range could shadow the toolchain's SDK:\n%s", line)
}
if i, c := strings.Index(strings.Join(r.ran, "\n"), "npm ci"), compileIndex(r); c < 0 ||
i > strings.Index(strings.Join(r.ran, "\n"), "--outDir") {
t.Fatalf("the install did not run before the compile:\n%s", strings.Join(r.ran, "\n"))
}
}
// **A module with nothing beyond the SDK builds exactly as before**: the same commands, no install.
func TestAModuleDependingOnlyOnTheSDKBuildsExactlyAsBefore(t *testing.T) {
without, err := buildWithPackageJSON(t, "", nil, Npmrc{})
if err != nil {
t.Fatal(err)
}
for _, pkg := range []string{
`{"type":"module","dependencies":{"@novox/mesh-sdk":"^0.1.0"},"devDependencies":{"typescript":"^5"}}`,
`{"type":"module"}`,
} {
with, err := buildWithPackageJSON(t, pkg, map[string]string{"package-lock.json": "{}"}, Npmrc{Scope: "@novox", Registry: "https://forge.invalid/npm/"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(strings.Join(with.ran, "\n"), "npm ") {
t.Fatalf("a module depending on nothing but the SDK ran npm:\n%s", strings.Join(with.ran, "\n"))
}
if len(with.ran) != len(without.ran) {
t.Fatalf("a module depending only on the SDK built differently from one with no package.json:\n%s\n---\n%s",
strings.Join(with.ran, "\n"), strings.Join(without.ran, "\n"))
}
}
}
// Without the mesh's registry a scoped package would resolve on the public one: refused by name.
func TestAScopedPackageWithNoRegistryIsRefused(t *testing.T) {
r, err := buildWithPackageJSON(t, `{"dependencies":{"@novox/mesh-sdk":"^0.1.0","@novox/other":"^1"}}`, nil, Npmrc{})
if err == nil || !strings.Contains(err.Error(), "@novox/other") {
t.Fatalf("a scoped package was installed with no registry for its scope: %v", err)
}
if strings.Contains(strings.Join(r.ran, "\n"), "--outDir") {
t.Fatal("the compile ran after the refusal")
}
// A public package installs without one, from the public registry and nothing else.
r, err = buildWithPackageJSON(t, `{"dependencies":{"mssql":"^11"}}`, nil, Npmrc{})
if err != nil {
t.Fatal(err)
}
if got := installs(r); len(got) != 1 || strings.Contains(got[0], ":registry=") || strings.Contains(got[0], "--network host") {
t.Fatalf("a public package's install: %v", got)
}
}
func TestAnUnreadablePackageJSONIsRefusedByName(t *testing.T) {
_, err := buildWithPackageJSON(t, `{"dependencies":`, nil, Npmrc{})
if err == nil || !strings.Contains(err.Error(), "package.json") {
t.Fatalf("a broken package.json was not refused by name: %v", err)
}
}
+3 -2
View File
@@ -67,8 +67,9 @@ type Toolchain struct {
// `package.json` saying `"type": "module"` — Node reads a bare `.js` as CommonJS otherwise, so a // `package.json` saying `"type": "module"` — Node reads a bare `.js` as CommonJS otherwise, so a
// bundle with its dependencies and without that line still fails to start — and the pruned, // bundle with its dependencies and without that line still fails to start — and the pruned,
// production-only node_modules the runtime itself ships with: the SDK's and the runtime's // production-only node_modules the runtime itself ships with: the SDK's and the runtime's
// dependencies, and nothing module-specific yet (novox/hq ADR 0188 §5: a skeleton; a module's // dependencies, and nothing module-specific (a module's own npm dependencies are installed into
// own npm dependencies are a later step). Empty for a language whose bundle carries its own — // its own directory before the compile and inlined by the bundler: dependencies.go). Empty for a
// language whose bundle carries its own —
// a Go binary is static, a Python bundle is installed with its dependencies. // a Go binary is static, a Python bundle is installed with its dependencies.
// //
// A toolchain image without the directory fails the build by name rather than packing a bundle // A toolchain image without the directory fails the build by name rather than packing a bundle
@@ -0,0 +1,131 @@
package catalogue
import (
"encoding/json"
"fmt"
"os"
"strings"
"testing"
)
// novox/hq issue 213: the controller is a Go program and was the one piece of the mesh's own Go
// code still shipped as an image (ADR 0188 §1). Its own manifest, composed for the machine that runs
// it, is a Go bundle run by the host as a process — and no container.
func TestTheControllerIsAProcessAndNoContainer(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
m, err := ParseManifest(raw)
if err != nil {
t.Fatalf("the controller's own manifest does not parse:\n%v", err)
}
if m.Build == nil || len(m.Build.Artifacts) != 1 {
t.Fatalf("the controller builds %+v; it is one bundle", m.Build)
}
a := m.Build.Artifacts[0]
if a.Kind != ArtifactBundle || a.Language != "go" || a.System == "" || BinaryOf(a) != "mesh-controller" {
t.Fatalf("the controller's artifact is %+v, not a Go bundle naming its system and binary", a)
}
for _, c := range m.Capabilities {
if c == "container-runtime" {
t.Error("the controller still requires a container runtime on its machine")
}
}
digest := "sha256:" + strings.Repeat("c", 64)
control, err := m.Resolve([]Built{{Name: a.Name, Kind: ArtifactBundle,
Reference: ArtifactStoreScheme + "mesh-controller/" + a.Name + "@" + digest, Digest: digest}})
if err != nil {
t.Fatal(err)
}
// The node's runtime does not launch it: it serves its seat's verbs itself.
if loads := control.Bundles[0].Loads; len(loads) != 0 {
t.Errorf("the node's runtime would launch the controller as a tools bundle: %v", loads)
}
needed := map[string]map[string]string{"mesh-controller": {}}
for name := range m.OwnSecrets {
needed["mesh-controller"][name] = "sealed-" + name
}
out, err := Resolution{Node: "anchor", Modules: []Manifest{control}}.Declaration(Rendering{
Needed: needed, ArtifactStore: "anchor.internal:5100",
Seats: map[string]map[int]int{"mesh-store": {5432: 6852}},
})
if err != nil {
t.Fatalf("the controller does not compose: %v", err)
}
var process, step map[string]any
account, firstSecret := -1, -1
for i, r := range out {
switch {
case r["type"] == "container":
t.Errorf("the controller's declaration still runs a container: %v", r)
case r["id"] == "mesh-controller.controller":
process = r
case r["id"] == "mesh-controller.controller-prepare":
step = r
if process != nil {
t.Error("the controller's preparation is placed after the process it prepares for")
}
case r["type"] == "user" && r["name"] == "mesh-controller":
account = i
case strings.HasPrefix(fmt.Sprint(r["id"]), "mesh-controller.needs-") && firstSecret < 0:
firstSecret = i
}
}
if process == nil {
t.Fatalf("the controller's process is not in its declaration: %v", out)
}
if run, _ := json.Marshal(process["run"]); string(run) != `["./mesh-controller","serve"]` {
t.Errorf("the controller is run as %s, not its own bundle's binary", run)
}
if process["source"] != "anchor.internal:5100/mesh-controller/"+a.Name+"@"+digest || process["digest"] != digest {
t.Errorf("the controller's bundle is fetched from %v (%v)", process["source"], process["digest"])
}
// The user: an account the host declares, which owns what the process reads.
if process["user"] != "mesh-controller" || account < 0 {
t.Errorf("the controller runs as %v, and the account declared is at %d", process["user"], account)
}
if firstSecret >= 0 && account > firstSecret {
t.Error("the controller's secrets are written before the account they belong to exists")
}
for _, r := range out {
if strings.HasPrefix(fmt.Sprint(r["id"]), "mesh-controller.needs-") && r["owner"] != "mesh-controller" {
t.Errorf("%v belongs to %v, which the controller's process cannot read", r["id"], r["owner"])
}
}
if dir := fileNamed(out, "mesh-controller.mesh-state"); dir == nil || dir["owner"] != "mesh-controller" {
t.Errorf("the controller's state directory is not its account's to enter: %v", dir)
}
// Each mount became a path the process reads: nothing it is told is a path inside a container.
state := fmt.Sprint(fileNamed(out, "mesh-controller.mesh-state")["path"])
env, _ := process["env"].(map[string]any)
for key, value := range env {
v := fmt.Sprint(value)
if strings.HasPrefix(v, "/run/secrets") || strings.HasPrefix(v, "/broker-tls") {
t.Errorf("%s=%s is a path inside the container the controller no longer runs in", key, v)
}
if strings.HasSuffix(key, "_FILE") && !strings.HasPrefix(v, state+"/") {
t.Errorf("%s=%s is not one of the files the mesh places for it", key, v)
}
}
if env["MESH_BROKER_CERTIFICATE"] != "/var/lib/mesh-broker-tls/tls.crt" {
t.Errorf("the controller reads the broker's certificate from %v", env["MESH_BROKER_CERTIFICATE"])
}
if env["MESH_STORE_INVENTORY_PORT"] != "6852" {
t.Errorf("the controller is told the store is on %v; the node put it on 6852", env["MESH_STORE_INVENTORY_PORT"])
}
// The handover: the container it ran as goes only once this is running.
if got, _ := json.Marshal(process["replaces"]); string(got) != `["mesh-controller.server"]` {
t.Errorf("the controller's process replaces %s, not the container it ran as", got)
}
// And its state is prepared first, by the same program as the same account.
if step == nil || step["run-once"] != true || step["user"] != "mesh-controller" {
t.Fatalf("the controller's preparation is %v", step)
}
if run, _ := json.Marshal(step["run"]); string(run) != `["./mesh-controller","prepare"]` {
t.Errorf("the controller's preparation runs %s", run)
}
}
+29
View File
@@ -0,0 +1,29 @@
package catalogue
import (
"os"
"regexp"
"testing"
)
// novox/hq issue 223: genesis raises the controller as a container built from this repository's own
// Dockerfile, with no build arguments — the manifest no longer builds an image, so nothing passes a
// base in. The Dockerfile's own default must therefore be a Go that builds this module, pinned by
// digest, and the replacement the manifest's process names must be the container genesis raises.
func TestGenesisCanBuildTheControllersImageAsItStands(t *testing.T) {
raw, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatal(err)
}
if !regexp.MustCompile(`(?m)^ARG GO_BASE=golang@sha256:[0-9a-f]{64}$`).Match(raw) {
t.Fatal("the Dockerfile's default Go base is not pinned by digest; genesis builds it with no arguments")
}
makefile, err := os.ReadFile("../../Makefile")
if err != nil {
t.Fatal(err)
}
pin := regexp.MustCompile(`golang@sha256:[0-9a-f]{64}`)
if string(pin.Find(raw)) != string(pin.Find(makefile)) {
t.Errorf("the Dockerfile and the Makefile build on different Go: %s, %s", pin.Find(raw), pin.Find(makefile))
}
}
+10 -9
View File
@@ -101,7 +101,7 @@ func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
} }
// The manifest itself names them now; withSeatPorts is a no-op on it, and this holds it so. // The manifest itself names them now; withSeatPorts is a no-op on it, and this holds it so.
for _, r := range m.Resources { for _, r := range m.Resources {
if r["type"] != "container" { if r["type"] != "process" {
continue continue
} }
env, _ := r["env"].(map[string]any) env, _ := r["env"].(map[string]any)
@@ -113,8 +113,9 @@ func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
} }
m = withSeatPorts(m) m = withSeatPorts(m)
control, err := m.Resolve([]Built{{ control, err := m.Resolve([]Built{{
Name: "server", Kind: ArtifactImage, Name: "controller", Kind: ArtifactBundle,
Reference: ArtifactStoreScheme + "mesh-controller/server@sha256:" + strings.Repeat("c", 64), Reference: ArtifactStoreScheme + "mesh-controller/controller@sha256:" + strings.Repeat("c", 64),
Digest: "sha256:" + strings.Repeat("c", 64),
}}) }})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -135,9 +136,9 @@ func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("the control plane does not compose: %v", err) t.Fatalf("the control plane does not compose: %v", err)
} }
server := fileNamed(out, "mesh-controller.server") server := fileNamed(out, "mesh-controller.controller")
if server == nil { if server == nil {
t.Fatalf("the control plane's container is not in the declaration: %v", out) t.Fatalf("the control plane's process is not in the declaration: %v", out)
} }
env, _ := server["env"].(map[string]any) env, _ := server["env"].(map[string]any)
for key, want := range map[string]string{ for key, want := range map[string]string{
@@ -151,8 +152,8 @@ func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
t.Errorf("the control plane is told %s=%v; the node put it on %s", key, env[key], want) t.Errorf("the control plane is told %s=%v; the node put it on %s", key, env[key], want)
} }
} }
if got := server["image"]; got != "anchor.internal:5100/mesh-controller/server@sha256:"+strings.Repeat("c", 64) { if got := server["source"]; got != "anchor.internal:5100/mesh-controller/controller@sha256:"+strings.Repeat("c", 64) {
t.Errorf("the control plane's own image is %v, not routed through the store", got) t.Errorf("the control plane's own bundle is fetched from %v, not routed through the store", got)
} }
// And on a mesh where the foundation is where genesis raised it, nothing is added. // And on a mesh where the foundation is where genesis raised it, nothing is added.
@@ -160,7 +161,7 @@ func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
env, _ = fileNamed(out, "mesh-controller.server")["env"].(map[string]any) env, _ = fileNamed(out, "mesh-controller.controller")["env"].(map[string]any)
if env["MESH_STORE_INVENTORY_PORT"] != "" { if env["MESH_STORE_INVENTORY_PORT"] != "" {
t.Errorf("with no settings, the control plane is told %v", env) t.Errorf("with no settings, the control plane is told %v", env)
} }
@@ -186,7 +187,7 @@ func withSeatPorts(m Manifest) Manifest {
out := m out := m
out.Resources = nil out.Resources = nil
for _, r := range m.Resources { for _, r := range m.Resources {
if r["type"] != "container" { if r["type"] != "container" && r["type"] != "process" {
out.Resources = append(out.Resources, r) out.Resources = append(out.Resources, r)
continue continue
} }
+1 -1
View File
@@ -30,7 +30,7 @@ func TestTheManifestsOwnPlaceholderUnfilledLeavesTheStoreWhereTheFileSays(t *tes
} }
var written string var written string
for _, r := range m.Resources { for _, r := range m.Resources {
if r.Type == "container" { if r.Type == "process" {
written = r.Env["MESH_STORE_INVENTORY_PORT"] written = r.Env["MESH_STORE_INVENTORY_PORT"]
} }
} }
+31 -38
View File
@@ -2,9 +2,6 @@
"module": "mesh-controller", "module": "mesh-controller",
"version": "1", "version": "1",
"slug": "control", "slug": "control",
"capabilities": [
"container-runtime"
],
"claims": [ "claims": [
{ {
"name": "mesh-controller", "name": "mesh-controller",
@@ -26,7 +23,7 @@
"broker-address": "${dir:mesh-state}/broker-address", "broker-address": "${dir:mesh-state}/broker-address",
"bus": "${dir:mesh-state}/bus" "bus": "${dir:mesh-state}/bus"
}, },
"secrets-owner": "65534:65534", "secrets-owner": "mesh-controller",
"prepares": true, "prepares": true,
"tools": [ "tools": [
"tools", "tools",
@@ -43,62 +40,58 @@
"build" "build"
], ],
"resources": [ "resources": [
{
"id": "account",
"type": "user",
"name": "mesh-controller",
"shell": "/usr/bin/nologin",
"home": "/var/lib/mesh-controller"
},
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"mode": "0700", "mode": "0700",
"place": "mesh" "place": "mesh",
"owner": "mesh-controller"
}, },
{ {
"id": "server", "id": "controller",
"type": "container", "type": "process",
"name": "mesh-controller", "name": "mesh-controller",
"network": "host", "artifact": "controller",
"args": [ "run": [
"./mesh-controller",
"serve" "serve"
], ],
"user": "mesh-controller",
"env": { "env": {
"MESH_BROKER_CERTIFICATE": "/broker-tls/tls.crt", "MESH_BROKER_CERTIFICATE": "/var/lib/mesh-broker-tls/tls.crt",
"MESH_STORE_INVENTORY_FILE": "/run/secrets/inventory", "MESH_STORE_INVENTORY_FILE": "${dir:mesh-state}/inventory",
"MESH_STORE_IDENTITY_FILE": "/run/secrets/identity", "MESH_STORE_IDENTITY_FILE": "${dir:mesh-state}/identity",
"MESH_STORE_LICENCES_FILE": "/run/secrets/licences", "MESH_STORE_LICENCES_FILE": "${dir:mesh-state}/licences",
"MESH_BROKER_MANAGEMENT_FILE": "/run/secrets/broker-management", "MESH_BROKER_MANAGEMENT_FILE": "${dir:mesh-state}/broker-management",
"MESH_BROKER_ADDRESS_FILE": "/run/secrets/broker-address", "MESH_BROKER_ADDRESS_FILE": "${dir:mesh-state}/broker-address",
"MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}", "MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}",
"MESH_STORE_IDENTITY_PORT": "${seat:mesh-store:5432}", "MESH_STORE_IDENTITY_PORT": "${seat:mesh-store:5432}",
"MESH_STORE_LICENCES_PORT": "${seat:mesh-store:5432}", "MESH_STORE_LICENCES_PORT": "${seat:mesh-store:5432}",
"MESH_BROKER_MANAGEMENT_PORT": "${seat:mesh-broker:15672}", "MESH_BROKER_MANAGEMENT_PORT": "${seat:mesh-broker:15672}",
"MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}", "MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}",
"MESH_BUS_NATS_FILE": "/run/secrets/bus" "MESH_BUS_NATS_FILE": "${dir:mesh-state}/bus"
}, },
"volumes": [ "replaces": [
"/var/lib/mesh-broker-tls:/broker-tls:ro", "server"
"${dir:mesh-state}/inventory:/run/secrets/inventory:ro",
"${dir:mesh-state}/identity:/run/secrets/identity:ro",
"${dir:mesh-state}/licences:/run/secrets/licences:ro",
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/bus:/run/secrets/bus:ro",
"${dir:mesh-state}/broker-management:/run/secrets/broker-management:ro",
"${dir:mesh-state}/broker-address:/run/secrets/broker-address:ro"
],
"artifact": "server",
"restart-on": [
"control-env"
] ]
} }
], ],
"build": { "build": {
"artifacts": [ "artifacts": [
{ {
"name": "server", "name": "controller",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "language": "go",
} "system": "arch",
], "from": "cmd/mesh-controller",
"on": [ "binary": "mesh-controller"
{
"arg": "GO_BASE",
"image": "golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c"
} }
] ]
} }