diff --git a/Dockerfile b/Dockerfile index 84c032a..6f5385f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Makefile b/Makefile index 9395e5d..cf824c7 100644 --- a/Makefile +++ b/Makefile @@ -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= or fix the manifest"; exit 1; } + @test -n "$(GO_BASE)" || { echo "no GO_BASE; pass GO_BASE="; 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= or fix the manifest"; exit 1; } + @test -n "$(GO_BASE)" || { echo "no GO_BASE; pass GO_BASE="; 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' diff --git a/README.md b/README.md index 427ed83..5669598 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/mesh-controller/addresses_test.go b/cmd/mesh-controller/addresses_test.go index 5a23d2e..6e82466 100644 --- a/cmd/mesh-controller/addresses_test.go +++ b/cmd/mesh-controller/addresses_test.go @@ -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 } diff --git a/internal/catalogue/controller_is_a_process_test.go b/internal/catalogue/controller_is_a_process_test.go new file mode 100644 index 0000000..f5dc530 --- /dev/null +++ b/internal/catalogue/controller_is_a_process_test.go @@ -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) + } +} diff --git a/internal/catalogue/genesis_image_test.go b/internal/catalogue/genesis_image_test.go new file mode 100644 index 0000000..ad3f0f1 --- /dev/null +++ b/internal/catalogue/genesis_image_test.go @@ -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)) + } +} diff --git a/internal/catalogue/seat_into_test.go b/internal/catalogue/seat_into_test.go index 04b911a..7cffab7 100644 --- a/internal/catalogue/seat_into_test.go +++ b/internal/catalogue/seat_into_test.go @@ -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 } diff --git a/internal/store/manifest_test.go b/internal/store/manifest_test.go index 1a47e6a..71735c1 100644 --- a/internal/store/manifest_test.go +++ b/internal/store/manifest_test.go @@ -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"] } } diff --git a/module.json b/module.json index 4dfaa22..50b0eb4 100644 --- a/module.json +++ b/module.json @@ -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" } ] }