Merge pull request 'Run the controller as a Go bundle the host starts as a process (hq issue 213, 2 of 2)' (#253) from fix/issue-213-the-controller-is-a-process-manifest into main

This commit was merged in pull request #253.
This commit is contained in:
2026-10-04 00:06:44 +00:00
9 changed files with 231 additions and 60 deletions
+8 -2
View File
@@ -1,5 +1,11 @@
ARG GO_BASE=golang:1.25-alpine
# The control plane's image.
# The Go it builds with, pinned here because genesis builds this file with no arguments (novox/hq
# 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
# 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)
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
# older than go.mod asks for: every build died at `go mod download` with "go.mod requires go >=
# 1.26.0", and the pipeline never saw it because the pipeline passes the declared base in. Anybody
# building the image by hand hit it and had to find the digest themselves (novox/hq 04-ISSUES/146,
# what it cost).
GO_BASE ?= $(shell python3 -c "import json;print(next(o['image'] for o in json.load(open('module.json'))['build']['on'] if o['arg']=='GO_BASE'))" 2>/dev/null)
#
# **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:
@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) .
@echo
@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-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) .
@echo
@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 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
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
// 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) {
open := aMesh(t)
ctx := t.Context()
@@ -157,8 +157,8 @@ func TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker(t *testing.T)
if err != nil {
t.Fatal(err)
}
control, err := withSeatPorts(m).Resolve([]catalogue.Built{{Name: "server", Kind: catalogue.ArtifactImage,
Reference: "registry.example/control@" + aDigest}})
control, err := withSeatPorts(m).Resolve([]catalogue.Built{{Name: "controller", Kind: catalogue.ArtifactBundle,
Reference: "https://registry.example/mesh-controller/controller.tar.gz", Digest: aDigest}})
if err != nil {
t.Fatal(err)
}
@@ -200,12 +200,12 @@ func TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker(t *testing.T)
var env map[string]any
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)
}
}
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{
"MESH_STORE_INVENTORY_PORT": "6852",
@@ -238,7 +238,7 @@ func withSeatPorts(m catalogue.Manifest) catalogue.Manifest {
out := m
out.Resources = nil
for _, r := range m.Resources {
if r["type"] != "container" {
if r["type"] != "container" && r["type"] != "process" {
out.Resources = append(out.Resources, r)
continue
}
@@ -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.
for _, r := range m.Resources {
if r["type"] != "container" {
if r["type"] != "process" {
continue
}
env, _ := r["env"].(map[string]any)
@@ -113,8 +113,9 @@ func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
}
m = withSeatPorts(m)
control, err := m.Resolve([]Built{{
Name: "server", Kind: ArtifactImage,
Reference: ArtifactStoreScheme + "mesh-controller/server@sha256:" + strings.Repeat("c", 64),
Name: "controller", Kind: ArtifactBundle,
Reference: ArtifactStoreScheme + "mesh-controller/controller@sha256:" + strings.Repeat("c", 64),
Digest: "sha256:" + strings.Repeat("c", 64),
}})
if err != nil {
t.Fatal(err)
@@ -135,9 +136,9 @@ func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
if err != nil {
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 {
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)
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)
}
}
if got := server["image"]; got != "anchor.internal:5100/mesh-controller/server@sha256:"+strings.Repeat("c", 64) {
t.Errorf("the control plane's own image is %v, not routed through the store", got)
if got := server["source"]; got != "anchor.internal:5100/mesh-controller/controller@sha256:"+strings.Repeat("c", 64) {
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.
@@ -160,7 +161,7 @@ func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
if err != nil {
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"] != "" {
t.Errorf("with no settings, the control plane is told %v", env)
}
@@ -186,7 +187,7 @@ func withSeatPorts(m Manifest) Manifest {
out := m
out.Resources = nil
for _, r := range m.Resources {
if r["type"] != "container" {
if r["type"] != "container" && r["type"] != "process" {
out.Resources = append(out.Resources, r)
continue
}
+1 -1
View File
@@ -30,7 +30,7 @@ func TestTheManifestsOwnPlaceholderUnfilledLeavesTheStoreWhereTheFileSays(t *tes
}
var written string
for _, r := range m.Resources {
if r.Type == "container" {
if r.Type == "process" {
written = r.Env["MESH_STORE_INVENTORY_PORT"]
}
}
+31 -38
View File
@@ -2,9 +2,6 @@
"module": "mesh-controller",
"version": "1",
"slug": "control",
"capabilities": [
"container-runtime"
],
"claims": [
{
"name": "mesh-controller",
@@ -26,7 +23,7 @@
"broker-address": "${dir:mesh-state}/broker-address",
"bus": "${dir:mesh-state}/bus"
},
"secrets-owner": "65534:65534",
"secrets-owner": "mesh-controller",
"prepares": true,
"tools": [
"tools",
@@ -43,62 +40,58 @@
"build"
],
"resources": [
{
"id": "account",
"type": "user",
"name": "mesh-controller",
"shell": "/usr/bin/nologin",
"home": "/var/lib/mesh-controller"
},
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
"place": "mesh",
"owner": "mesh-controller"
},
{
"id": "server",
"type": "container",
"id": "controller",
"type": "process",
"name": "mesh-controller",
"network": "host",
"args": [
"artifact": "controller",
"run": [
"./mesh-controller",
"serve"
],
"user": "mesh-controller",
"env": {
"MESH_BROKER_CERTIFICATE": "/broker-tls/tls.crt",
"MESH_STORE_INVENTORY_FILE": "/run/secrets/inventory",
"MESH_STORE_IDENTITY_FILE": "/run/secrets/identity",
"MESH_STORE_LICENCES_FILE": "/run/secrets/licences",
"MESH_BROKER_MANAGEMENT_FILE": "/run/secrets/broker-management",
"MESH_BROKER_ADDRESS_FILE": "/run/secrets/broker-address",
"MESH_BROKER_CERTIFICATE": "/var/lib/mesh-broker-tls/tls.crt",
"MESH_STORE_INVENTORY_FILE": "${dir:mesh-state}/inventory",
"MESH_STORE_IDENTITY_FILE": "${dir:mesh-state}/identity",
"MESH_STORE_LICENCES_FILE": "${dir:mesh-state}/licences",
"MESH_BROKER_MANAGEMENT_FILE": "${dir:mesh-state}/broker-management",
"MESH_BROKER_ADDRESS_FILE": "${dir:mesh-state}/broker-address",
"MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}",
"MESH_STORE_IDENTITY_PORT": "${seat:mesh-store:5432}",
"MESH_STORE_LICENCES_PORT": "${seat:mesh-store:5432}",
"MESH_BROKER_MANAGEMENT_PORT": "${seat:mesh-broker:15672}",
"MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}",
"MESH_BUS_NATS_FILE": "/run/secrets/bus"
"MESH_BUS_NATS_FILE": "${dir:mesh-state}/bus"
},
"volumes": [
"/var/lib/mesh-broker-tls:/broker-tls:ro",
"${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"
"replaces": [
"server"
]
}
],
"build": {
"artifacts": [
{
"name": "server",
"kind": "image",
"from": "Dockerfile"
}
],
"on": [
{
"arg": "GO_BASE",
"image": "golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c"
"name": "controller",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-controller",
"binary": "mesh-controller"
}
]
}