Merge pull request 'Raise a process-form controller at genesis as the container it replaces (hq issue 223)' (#87) from fix/issue-223-genesis-pivots-to-the-controllers-container into main
This commit was merged in pull request #87.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
package apply
|
||||
|
||||
// ForTests points where the host writes units and unpacks daemons at directories a test owns, and
|
||||
// waits nothing between its looks at a unit, until the returned function puts them back. For tests
|
||||
// in other packages that apply a process — the bootstrap's, which checks that what genesis raises is
|
||||
// what the controller's process takes over (novox/hq issue 223). Nothing outside a test calls it.
|
||||
func ForTests(units, daemons string) (restore func()) {
|
||||
wasUnits, wasDaemons, wasSettle, wasHandover := unitDir, daemonRoot, serviceSettle, handoverSettle
|
||||
unitDir, daemonRoot, serviceSettle, handoverSettle = units, daemons, 0, 0
|
||||
return func() {
|
||||
unitDir, daemonRoot, serviceSettle, handoverSettle = wasUnits, wasDaemons, wasSettle, wasHandover
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -112,6 +114,43 @@ func BuildControlPlane(ctx context.Context, run Runner, builderTag string, sourc
|
||||
return Built{}, nil
|
||||
}
|
||||
|
||||
// **Which form the controller is in at that commit** (novox/hq issue 223). An image the module
|
||||
// builds is the form genesis always raised, and the builder builds it as before. A process the
|
||||
// module runs from a Go bundle cannot be built here — its toolchain is one the mesh makes later —
|
||||
// so genesis builds the controller's own Dockerfile and raises it as the container that process
|
||||
// replaces (genesis_form.go).
|
||||
workspace, err := os.MkdirTemp("", "mesh-genesis-*")
|
||||
if err != nil {
|
||||
return Built{}, err
|
||||
}
|
||||
defer os.RemoveAll(workspace)
|
||||
dir, commit, err := cloneAt(ctx, run, builderTag, source, workspace)
|
||||
if err != nil {
|
||||
return Built{}, fmt.Errorf("the control plane could not be fetched from %s at %s: %w",
|
||||
source.Repository, shortRef(source.Ref), err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "module.json"))
|
||||
if err != nil {
|
||||
return Built{}, fmt.Errorf("%s at %s has no module manifest: %w", source.Repository, shortRef(source.Ref), err)
|
||||
}
|
||||
form, err := processFormOf(raw)
|
||||
if err != nil {
|
||||
return Built{}, err
|
||||
}
|
||||
if form.Found {
|
||||
image, err := buildGenesisImage(ctx, run, dir)
|
||||
if err != nil {
|
||||
return Built{}, err
|
||||
}
|
||||
manifest, err := genesisForm(raw, form, image)
|
||||
if err != nil {
|
||||
return Built{}, err
|
||||
}
|
||||
say(fmt.Sprintf(" built %s from %s, as the container its process %s replaces (%s)",
|
||||
ControlPlaneModule, shortRef(commit), form.Process, form.Replaces))
|
||||
return Built{Module: ControlPlaneModule, Commit: commit, Image: image, Manifest: manifest}, nil
|
||||
}
|
||||
|
||||
out, err := run(ctx, "docker", args...)
|
||||
if err != nil {
|
||||
return Built{}, fmt.Errorf("the control plane could not be built from %s at %s: %w",
|
||||
|
||||
@@ -49,10 +49,10 @@ func TestARepositoryAndACommitIsEnough(t *testing.T) {
|
||||
func TestTheBuildHandsOverTheManifestTheMeshWillHold(t *testing.T) {
|
||||
manifest := `{"module":"mesh-controller","version":"1","resources":[` +
|
||||
`{"id":"server","type":"container","name":"mesh-controller","image":"` + builtImage + `"}]}`
|
||||
runtime := &asked{answer: func(string, []string) (string, error) {
|
||||
runtime := &asked{answer: aRepository(t, imageFormManifest, func(string, []string) (string, error) {
|
||||
return `{"module":"mesh-controller","commit":"a1b2c3d4","manifest":` + manifest +
|
||||
`,"made":[{"name":"server","kind":"image","reference":"` + builtImage + `"}]}` + "\n", nil
|
||||
}}
|
||||
})}
|
||||
built, err := BuildControlPlane(context.Background(), runtime.run, "mesh-builder:test",
|
||||
Source{Repository: "https://example.invalid/mesh-controller.git", Ref: "a1b2c3d4"}, false, func(string) {})
|
||||
if err != nil {
|
||||
@@ -69,10 +69,10 @@ func TestTheBuildHandsOverTheManifestTheMeshWillHold(t *testing.T) {
|
||||
// A result without a manifest is a build the installer cannot finish, and it is refused beside the
|
||||
// builder that said it rather than at step 9 with a message about a missing file.
|
||||
func TestABuildReportingNoManifestIsRefused(t *testing.T) {
|
||||
runtime := &asked{answer: func(string, []string) (string, error) {
|
||||
runtime := &asked{answer: aRepository(t, imageFormManifest, func(string, []string) (string, error) {
|
||||
return `{"module":"mesh-controller","commit":"a1b2c3d4",` +
|
||||
`"made":[{"name":"server","kind":"image","reference":"` + builtImage + `"}]}`, nil
|
||||
}}
|
||||
})}
|
||||
_, err := BuildControlPlane(context.Background(), runtime.run, "mesh-builder:test",
|
||||
Source{Repository: "https://example.invalid/mesh-controller.git", Ref: "a1b2c3d4"}, false, func(string) {})
|
||||
if err == nil || !strings.Contains(err.Error(), "no manifest") {
|
||||
@@ -82,11 +82,11 @@ func TestABuildReportingNoManifestIsRefused(t *testing.T) {
|
||||
|
||||
// And a manifest that does not name the image the build produced describes some other build.
|
||||
func TestABuildWhoseManifestNamesAnotherImageIsRefused(t *testing.T) {
|
||||
runtime := &asked{answer: func(string, []string) (string, error) {
|
||||
runtime := &asked{answer: aRepository(t, imageFormManifest, func(string, []string) (string, error) {
|
||||
return `{"module":"mesh-controller","commit":"a1b2c3d4","manifest":{"module":"mesh-controller",` +
|
||||
`"resources":[{"id":"server","type":"container","image":"sha256:` + strings.Repeat("9", 64) + `"}]},` +
|
||||
`"made":[{"name":"server","kind":"image","reference":"` + builtImage + `"}]}`, nil
|
||||
}}
|
||||
})}
|
||||
_, err := BuildControlPlane(context.Background(), runtime.run, "mesh-builder:test",
|
||||
Source{Repository: "https://example.invalid/mesh-controller.git", Ref: "a1b2c3d4"}, false, func(string) {})
|
||||
if err == nil || !strings.Contains(err.Error(), "does not name that image") {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The controller as a process, raised at genesis as a container (novox/hq issue 213, issue 223).
|
||||
//
|
||||
// **The mesh runs the controller as a Go bundle the host starts as a process; genesis cannot.** A
|
||||
// process's bundle is fetched from the mesh's artifact store, which genesis raises long after the
|
||||
// controller, and a Go bundle is compiled in a toolchain the mesh builds later still. So genesis
|
||||
// pivots to the controller as it always has — an image it built from the controller's own
|
||||
// Dockerfile, run as a container the temporary controller composes — and the first time the mesh
|
||||
// builds the controller from its repository, the controller's own declaration is the process, which
|
||||
// names that container under `replaces`, and the host hands over: the process is started, seen up,
|
||||
// and only then is the container removed (mesh-host `replaces`, issue 213).
|
||||
//
|
||||
// **The container is genesis's shape, not the manifest's.** The manifest declares only the process.
|
||||
// From it genesis takes what the process is given — its environment, which is host paths and words —
|
||||
// and the id the process replaces; the container around it is written here: the image genesis built,
|
||||
// the host's network, and every host path the environment names mounted at the same path read-only.
|
||||
// It runs as the image's own unprivileged user (65534), so the secrets belong to that number until
|
||||
// the process's account takes them over — the image is FROM scratch and knows no account by name. Its
|
||||
// id is the one the process replaces, so the first declaration the mesh composes for this machine
|
||||
// hands this container over rather than leaving two controllers running.
|
||||
|
||||
// genesisUser is who the genesis container runs as — the image's own USER — and who its secrets
|
||||
// belong to until the process takes them over: the image has no passwd to look an account up in.
|
||||
const genesisUser = "65534:65534"
|
||||
|
||||
// ProcessForm is the controller's process, as its manifest declares it, and the container id that
|
||||
// process replaces. Found is false for a manifest in the image form — an older controller — which
|
||||
// genesis installs as it always did.
|
||||
type ProcessForm struct {
|
||||
Found bool
|
||||
Process string // the process resource's id
|
||||
Replaces string // the id of the container genesis raises in its place
|
||||
}
|
||||
|
||||
// processFormOf finds the controller's process in its manifest: a process resource running a bundle
|
||||
// the module builds, saying which one resource it replaces.
|
||||
func processFormOf(manifest []byte) (ProcessForm, error) {
|
||||
var m struct {
|
||||
Build *struct {
|
||||
Artifacts []struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
} `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(manifest, &m); err != nil {
|
||||
return ProcessForm{}, fmt.Errorf("the %s module's manifest is not readable: %w", ControlPlaneModule, err)
|
||||
}
|
||||
kinds := map[string]string{}
|
||||
if m.Build != nil {
|
||||
for _, a := range m.Build.Artifacts {
|
||||
kinds[a.Name] = a.Kind
|
||||
}
|
||||
}
|
||||
for _, kind := range kinds {
|
||||
if kind == "image" {
|
||||
return ProcessForm{}, nil // the image form: the builder builds it, as before
|
||||
}
|
||||
}
|
||||
var found []ProcessForm
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] != "process" || kinds[fmt.Sprint(r["artifact"])] != "bundle" {
|
||||
continue
|
||||
}
|
||||
if once, _ := r["run-once"].(bool); once {
|
||||
continue
|
||||
}
|
||||
replaces, _ := r["replaces"].([]any)
|
||||
if len(replaces) != 1 {
|
||||
return ProcessForm{}, fmt.Errorf(
|
||||
"the %s module runs as the process %v and says it replaces %v. Genesis raises the "+
|
||||
"controller as a container that process takes over, so the process names exactly "+
|
||||
"one resource it replaces — the id genesis gives the container",
|
||||
ControlPlaneModule, r["id"], r["replaces"])
|
||||
}
|
||||
found = append(found, ProcessForm{Found: true, Process: fmt.Sprint(r["id"]),
|
||||
Replaces: fmt.Sprint(replaces[0])})
|
||||
}
|
||||
if len(found) != 1 {
|
||||
return ProcessForm{}, fmt.Errorf(
|
||||
"the %s module builds no image and runs %d process(es) of its own; genesis raises one "+
|
||||
"controller, from the process its manifest declares", ControlPlaneModule, len(found))
|
||||
}
|
||||
return found[0], nil
|
||||
}
|
||||
|
||||
// genesisForm is the manifest genesis registers: the controller's own manifest, its process
|
||||
// replaced by the container genesis runs in its place, under the id the process replaces, running
|
||||
// the image genesis built. Resolved as a build would resolve it — no build section, the image named
|
||||
// — because that is what the temporary controller is handed.
|
||||
func genesisForm(manifest []byte, form ProcessForm, image string) ([]byte, error) {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(manifest, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources, _ := m["resources"].([]any)
|
||||
var out []any
|
||||
for _, raw := range resources {
|
||||
r, _ := raw.(map[string]any)
|
||||
if r == nil || r["id"] != form.Process {
|
||||
out = append(out, raw)
|
||||
continue
|
||||
}
|
||||
env, _ := r["env"].(map[string]any)
|
||||
var volumes []any
|
||||
for _, key := range sortedAnyKeys(env) {
|
||||
value := fmt.Sprint(env[key])
|
||||
if strings.HasPrefix(value, "/") || strings.HasPrefix(value, "${dir:") {
|
||||
volumes = append(volumes, value+":"+value+":ro")
|
||||
}
|
||||
}
|
||||
container := map[string]any{
|
||||
"id": form.Replaces, "type": "container", "name": ControlPlaneModule,
|
||||
"image": image, "network": "host", "args": []any{"serve"},
|
||||
}
|
||||
if len(env) > 0 {
|
||||
container["env"] = env
|
||||
}
|
||||
if len(volumes) > 0 {
|
||||
container["volumes"] = volumes
|
||||
}
|
||||
out = append(out, container)
|
||||
}
|
||||
m["resources"] = out
|
||||
// What the container reads must be readable by who it runs as. The process's account owns them
|
||||
// once the process takes over, and the host gives them to it in the same apply.
|
||||
m["secrets-owner"] = genesisUser
|
||||
// **Nothing to prepare at genesis.** The temporary controller — the same commit — migrated the
|
||||
// stores when the foundation raised it, and a preparation step is derived from a resource running
|
||||
// an artifact the module built, which a pinned image is not: the controller refuses `prepares`
|
||||
// with nothing to run it in. The process prepares the stores itself when it takes over.
|
||||
delete(m, "prepares")
|
||||
delete(m, "build")
|
||||
var b bytes.Buffer
|
||||
enc := json.NewEncoder(&b)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.TrimSpace(b.Bytes()), nil
|
||||
}
|
||||
|
||||
func sortedAnyKeys(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// cloneAt fetches the controller's repository at the commit genesis builds, into a directory of
|
||||
// this machine's, with the carried builder's git — the machine is not assumed to have one. Returns
|
||||
// the module's directory and the commit that was checked out.
|
||||
func cloneAt(ctx context.Context, run Runner, builderTag string, source Source, into string) (string, string, error) {
|
||||
git := func(args ...string) (string, error) {
|
||||
return run(ctx, "docker", append([]string{"run", "--rm", "-v", into + ":/ws",
|
||||
"--entrypoint", "git", builderTag}, args...)...)
|
||||
}
|
||||
if _, err := git("clone", "--quiet", source.Repository, "/ws/src"); err != nil {
|
||||
return "", "", fmt.Errorf("cloning %s: %w", source.Repository, err)
|
||||
}
|
||||
if _, err := git("-C", "/ws/src", "checkout", "--quiet", "--detach", source.Ref); err != nil {
|
||||
return "", "", fmt.Errorf("checking out %s: %w", shortRef(source.Ref), err)
|
||||
}
|
||||
commit, err := git("-C", "/ws/src", "rev-parse", "HEAD")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return filepath.Join(into, "src", source.Path), strings.TrimSpace(commit), nil
|
||||
}
|
||||
|
||||
// buildGenesisImage builds the controller's image from its own Dockerfile (the one `make image`
|
||||
// uses), on this machine, and names it by the digest of its own configuration, as the builder did.
|
||||
func buildGenesisImage(ctx context.Context, run Runner, dir string) (string, error) {
|
||||
if _, err := os.Stat(filepath.Join(dir, "Dockerfile")); err != nil {
|
||||
return "", fmt.Errorf("the %s repository has no Dockerfile, so genesis has no image to raise "+
|
||||
"the controller from: %w", ControlPlaneModule, err)
|
||||
}
|
||||
out, err := run(ctx, "docker", "build", "--quiet", dir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("building the %s image: %w", ControlPlaneModule, err)
|
||||
}
|
||||
image := strings.TrimSpace(out)
|
||||
if i := strings.LastIndex(image, "\n"); i >= 0 {
|
||||
image = strings.TrimSpace(image[i+1:])
|
||||
}
|
||||
if !strings.HasPrefix(image, "sha256:") {
|
||||
return "", fmt.Errorf("docker build said %q, which is not an image id", firstLine(out))
|
||||
}
|
||||
return image, nil
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// imageFormManifest is a controller from before issue 213: it builds an image and runs a container.
|
||||
const imageFormManifest = `{"module":"mesh-controller","version":"1","resources":[
|
||||
{"id":"server","type":"container","name":"mesh-controller","network":"host","artifact":"server"}],
|
||||
"build":{"artifacts":[{"name":"server","kind":"image","from":"Dockerfile"}]}}`
|
||||
|
||||
// processFormManifest is the controller's manifest as novox/mesh-controller declares it after issue
|
||||
// 213: a Go bundle the host runs as a process, replacing the container it ran as.
|
||||
const processFormManifest = `{
|
||||
"module": "mesh-controller", "version": "1", "slug": "control", "prepares": true,
|
||||
"claims": [{"name": "mesh-controller", "scope": "mesh"}],
|
||||
"accesses": [{"path": "/var/lib/mesh-broker-tls", "mode": "read"}],
|
||||
"own-secrets": {"inventory": "${dir:mesh-state}/inventory", "bus": "${dir:mesh-state}/bus"},
|
||||
"secrets-owner": "mesh-controller",
|
||||
"tools": ["status"],
|
||||
"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", "owner": "mesh-controller"},
|
||||
{"id": "controller", "type": "process", "name": "mesh-controller", "artifact": "controller",
|
||||
"run": ["./mesh-controller", "serve"], "user": "mesh-controller",
|
||||
"env": {"MESH_BROKER_CERTIFICATE": "/var/lib/mesh-broker-tls/tls.crt",
|
||||
"MESH_STORE_INVENTORY_FILE": "${dir:mesh-state}/inventory",
|
||||
"MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}",
|
||||
"MESH_BUS_NATS_FILE": "${dir:mesh-state}/bus"},
|
||||
"replaces": ["server"]}
|
||||
],
|
||||
"build": {"artifacts": [{"name": "controller", "kind": "bundle", "language": "go", "system": "arch",
|
||||
"from": "cmd/mesh-controller", "binary": "mesh-controller"}]}
|
||||
}`
|
||||
|
||||
// aRepository answers the carried builder's git as a clone of a repository holding this manifest and
|
||||
// a Dockerfile, and hands every other command to then.
|
||||
func aRepository(t *testing.T, manifest string, then func(string, []string) (string, error)) func(string, []string) (string, error) {
|
||||
t.Helper()
|
||||
return func(name string, args []string) (string, error) {
|
||||
if name == "docker" && len(args) > 5 && args[0] == "run" && args[4] == "--entrypoint" && args[5] == "git" {
|
||||
host := strings.TrimSuffix(args[3], ":/ws")
|
||||
joined := strings.Join(args, " ")
|
||||
switch {
|
||||
case strings.Contains(joined, " clone "):
|
||||
src := filepath.Join(host, "src")
|
||||
if err := os.MkdirAll(src, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "module.json"), []byte(manifest), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", os.WriteFile(filepath.Join(src, "Dockerfile"), []byte("FROM scratch\n"), 0o644)
|
||||
case strings.Contains(joined, "rev-parse"):
|
||||
return "a1b2c3d4e5f6\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
return then(name, args)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 223: a controller declared as a process is raised at genesis as a container built
|
||||
// from its own Dockerfile, not by the builder — which has no Go toolchain at genesis and would refuse.
|
||||
func TestAProcessFormControllerIsBuiltFromItsDockerfileAndRaisedAsAContainer(t *testing.T) {
|
||||
runtime := &asked{answer: aRepository(t, processFormManifest, func(name string, args []string) (string, error) {
|
||||
if name == "docker" && args[0] == "build" {
|
||||
return builtImage + "\n", nil
|
||||
}
|
||||
t.Fatalf("genesis ran %s %v; a process-form controller is built from its Dockerfile alone", name, args)
|
||||
return "", nil
|
||||
})}
|
||||
built, err := BuildControlPlane(context.Background(), runtime.run, "mesh-builder:test",
|
||||
Source{Repository: "https://example.invalid/mesh-controller.git", Ref: "a1b2c3d4"}, false, func(string) {})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if built.Image != builtImage || built.Commit != "a1b2c3d4e5f6" {
|
||||
t.Errorf("built %q from %q", built.Image, built.Commit)
|
||||
}
|
||||
if runtime.ran("mesh-builder:test build") {
|
||||
t.Error("the builder was asked to build a controller it cannot build at genesis")
|
||||
}
|
||||
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(built.Manifest, &m); err != nil {
|
||||
t.Fatalf("the genesis manifest is not JSON: %v", err)
|
||||
}
|
||||
if _, has := m["prepares"]; has {
|
||||
t.Error("the genesis manifest prepares its state; the temporary controller already did, and a pinned image is nothing the controller can derive a step from")
|
||||
}
|
||||
if _, has := m["build"]; has {
|
||||
t.Error("the genesis manifest still says how it is built; it is handed over resolved")
|
||||
}
|
||||
var container map[string]any
|
||||
for _, raw := range m["resources"].([]any) {
|
||||
r := raw.(map[string]any)
|
||||
if r["type"] == "process" {
|
||||
t.Errorf("the genesis manifest still runs the process: %v", r)
|
||||
}
|
||||
if r["type"] == "container" {
|
||||
container = r
|
||||
}
|
||||
}
|
||||
if container == nil {
|
||||
t.Fatal("the genesis manifest runs no container")
|
||||
}
|
||||
for key, want := range map[string]any{"id": "server", "name": "mesh-controller", "image": builtImage,
|
||||
"network": "host"} {
|
||||
if container[key] != want {
|
||||
t.Errorf("the genesis container's %s is %v, not %v", key, container[key], want)
|
||||
}
|
||||
}
|
||||
if _, has := container["user"]; has {
|
||||
t.Error("the genesis container says a user; the host's container has no such field, the image's USER is who it runs as")
|
||||
}
|
||||
if m["secrets-owner"] != "65534:65534" {
|
||||
t.Errorf("the secrets belong to %v, which the container cannot read as", m["secrets-owner"])
|
||||
}
|
||||
volumes, _ := json.Marshal(container["volumes"])
|
||||
for _, want := range []string{"${dir:mesh-state}/inventory:${dir:mesh-state}/inventory:ro",
|
||||
"/var/lib/mesh-broker-tls/tls.crt:/var/lib/mesh-broker-tls/tls.crt:ro"} {
|
||||
if !strings.Contains(string(volumes), want) {
|
||||
t.Errorf("the container does not mount %s: %s", want, volumes)
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(volumes), "seat:") {
|
||||
t.Errorf("a word that is not a path was mounted: %s", volumes)
|
||||
}
|
||||
|
||||
// And it is what step 9 installs: pinned, its container found, its stores delivered from its
|
||||
// environment — the same path an image-form controller takes.
|
||||
pinned, _, err := pinImage(built.Manifest, built.Image, "registry.internal:5000/mesh-controller@sha256:"+strings.Repeat("e", 64), ControlPlaneModule)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id := controlPlaneResourceIn(pinned); id != "server" {
|
||||
t.Errorf("step 9 finds the controller's container as %q", id)
|
||||
}
|
||||
wanted, err := secretsByVariableIn(pinned)
|
||||
if err != nil {
|
||||
t.Fatalf("step 9 cannot deliver the stores into the genesis container: %v", err)
|
||||
}
|
||||
if wanted["MESH_STORE_INVENTORY"] != "inventory" {
|
||||
t.Errorf("step 9 delivers %v", wanted)
|
||||
}
|
||||
}
|
||||
|
||||
// An older controller — an image and a container — is still built by the builder, as before.
|
||||
func TestAnImageFormControllerIsStillBuiltByTheBuilder(t *testing.T) {
|
||||
manifest := `{"module":"mesh-controller","version":"1","resources":[` +
|
||||
`{"id":"server","type":"container","name":"mesh-controller","image":"` + builtImage + `"}]}`
|
||||
runtime := &asked{answer: aRepository(t, imageFormManifest, func(name string, args []string) (string, error) {
|
||||
if name == "docker" && args[0] == "build" {
|
||||
t.Fatal("an image-form controller was built from its Dockerfile rather than by the builder")
|
||||
}
|
||||
return `{"module":"mesh-controller","commit":"a1b2c3d4","manifest":` + manifest +
|
||||
`,"made":[{"name":"server","kind":"image","reference":"` + builtImage + `"}]}`, nil
|
||||
})}
|
||||
built, err := BuildControlPlane(context.Background(), runtime.run, "mesh-builder:test",
|
||||
Source{Repository: "https://example.invalid/mesh-controller.git", Ref: "a1b2c3d4"}, false, func(string) {})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(built.Manifest) != manifest || !runtime.ran("mesh-builder:test build") {
|
||||
t.Errorf("the image form did not go through the builder: %s", built.Manifest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProcessFormNamingNoOneReplacementIsRefused(t *testing.T) {
|
||||
for _, bad := range []string{`"replaces": []`, `"replaces": ["a", "b"]`} {
|
||||
raw := strings.Replace(processFormManifest, `"replaces": ["server"]`, bad, 1)
|
||||
if _, err := processFormOf([]byte(raw)); err == nil {
|
||||
t.Errorf("a process saying %s was accepted; genesis would not know what to name its container", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
)
|
||||
|
||||
// novox/hq issue 223: what genesis raises is what the controller's process takes over. The temporary
|
||||
// controller composes the genesis container, and the host records it as `<module>.<its id>`; the
|
||||
// first declaration the mesh composes from the controller's real manifest names that same id under
|
||||
// the process's `replaces` (the composer prefixes both alike — mesh-controller's
|
||||
// TestTheControllerIsAProcessAndNoContainer). So the first apply hands over: the process is started,
|
||||
// seen up, and only then is the genesis container removed — one controller before, one after, never
|
||||
// none and never two left.
|
||||
func TestTheFirstApplyHandsTheGenesisContainerOverToTheProcess(t *testing.T) {
|
||||
restore := apply.ForTests(t.TempDir(), t.TempDir())
|
||||
defer restore()
|
||||
|
||||
form, err := processFormOf([]byte(processFormManifest))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
genesis, err := genesisForm([]byte(processFormManifest), form, builtImage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// What the host records for the genesis container, as the composer names it.
|
||||
recorded := ""
|
||||
var m struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(genesis, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "container" {
|
||||
recorded = ControlPlaneModule + "." + r["id"].(string)
|
||||
}
|
||||
}
|
||||
known := store.State{Resources: []store.Applied{{Origin: store.OriginDeclared, ID: recorded,
|
||||
Type: "container", Target: ControlPlaneModule}}}
|
||||
|
||||
// The controller's first composed declaration: its process, replacing what the manifest names.
|
||||
body, digest := aBundle(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(body) }))
|
||||
defer server.Close()
|
||||
replaces, _ := json.Marshal([]string{ControlPlaneModule + "." + form.Replaces})
|
||||
d, err := declaration.Parse([]byte(`{"declaration":1,"resources":[{"id":"` + ControlPlaneModule + "." + form.Process +
|
||||
`","type":"process","name":"mesh-controller","source":"` + server.URL + `/c.tgz","digest":"` + digest +
|
||||
`","run":["./mesh-controller","serve"],"replaces":` + string(replaces) + `}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var commands []string
|
||||
started, container := false, true
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
line := name + " " + strings.Join(args, " ")
|
||||
commands = append(commands, line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "systemctl restart mesh-controller.service"):
|
||||
started = true
|
||||
case strings.HasPrefix(line, "systemctl show mesh-controller.service") && started:
|
||||
return "ActiveState=active\nSubState=running\nMainPID=7\nNRestarts=0\n", nil
|
||||
case strings.HasPrefix(line, "docker rm -f mesh-controller"):
|
||||
if !started {
|
||||
t.Error("the genesis container was removed before the process was started")
|
||||
}
|
||||
container = false
|
||||
case strings.HasPrefix(line, "docker container inspect") && !container:
|
||||
return "", errors.New("no such container")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
sys, err := system.For("arch")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, after, err := apply.Apply(context.Background(), sys, d, known, store.OriginDeclared, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("the first apply did not hand over: %v\n%s", err, strings.Join(commands, "\n"))
|
||||
}
|
||||
if container {
|
||||
t.Fatalf("the genesis container is still running beside the process — two controllers:\n%s",
|
||||
strings.Join(commands, "\n"))
|
||||
}
|
||||
if _, still := after.Find(recorded); still {
|
||||
t.Error("the host still records the genesis container")
|
||||
}
|
||||
}
|
||||
|
||||
func aBundle(t *testing.T) ([]byte, string) {
|
||||
t.Helper()
|
||||
var raw bytes.Buffer
|
||||
zipped := gzip.NewWriter(&raw)
|
||||
w := tar.NewWriter(zipped)
|
||||
content := "#!/bin/sh\n"
|
||||
if err := w.WriteHeader(&tar.Header{Name: "mesh-controller", Mode: 0o755, Size: int64(len(content)), Typeflag: tar.TypeReg}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = w.Write([]byte(content))
|
||||
_ = w.Close()
|
||||
_ = zipped.Close()
|
||||
sum := sha256.Sum256(raw.Bytes())
|
||||
return raw.Bytes(), "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
Reference in New Issue
Block a user