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).
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user