Extends the applier past the filesystem to the two types the workloads need: a container and the private network it joins. The workloads are the bulk of what a cutover re-declares (research 009), so this is what makes a workload manifest actually appliable. - container: run/reconcile/remove over the runtime. Up to date means a container that is ours (a spec-hash label matches this exact declaration) AND running; anything else — a changed spec, a stopped container, or a foreign one the old control plane left by that name — is recreated into ours. Safe because a container carries no state: its data is in bind-mounted directories declared separately, and recreating it never touches them. Read-back asks the runtime whether it is actually running on the declared spec, because 'started' only means the runtime returned. - network: create if absent, adopt if present, remove only what it created. - The runtime is driven through a Runner, faked in unit tests and exercised for real in a smoke test that stands a container up, proves idempotency, and tears it down — skipped, never failed, where the runtime is absent. The store's per-resource reference generalises from a path to a ref: a path for files and directories, a name for containers and networks. Verified end to end through the binary: a container on a bind mount, then dropped from the declaration — the container is removed and the data directory survives, which is the migration property itself. Still deferred: sealed secrets, and package/service/archive/user/action — refused whole until built, never half-applied. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
236 lines
7.4 KiB
Go
236 lines
7.4 KiB
Go
package apply
|
|
|
|
import (
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// fakeRuntime scripts the container runtime: it records every invocation and answers `inspect`
|
|
// from a small in-memory model of which containers exist, whether they run, and their spec label.
|
|
type fakeRuntime struct {
|
|
calls []string
|
|
exists map[string]bool
|
|
running map[string]bool
|
|
label map[string]string
|
|
failOnRun bool
|
|
}
|
|
|
|
func newFakeRuntime() *fakeRuntime {
|
|
return &fakeRuntime{
|
|
exists: map[string]bool{},
|
|
running: map[string]bool{},
|
|
label: map[string]string{},
|
|
}
|
|
}
|
|
|
|
func (f *fakeRuntime) run(name string, args ...string) (string, error) {
|
|
f.calls = append(f.calls, name+" "+strings.Join(args, " "))
|
|
switch {
|
|
case len(args) >= 1 && args[0] == "inspect":
|
|
container := args[len(args)-1]
|
|
if !f.exists[container] {
|
|
return "", fmt.Errorf("Error: No such object: %s", container)
|
|
}
|
|
return fmt.Sprintf("%v|%s", f.running[container], f.label[container]), nil
|
|
case len(args) >= 1 && args[0] == "run":
|
|
if f.failOnRun {
|
|
return "", fmt.Errorf("simulated run failure")
|
|
}
|
|
name, hash := parseRunNameAndLabel(args)
|
|
f.exists[name] = true
|
|
f.running[name] = true
|
|
f.label[name] = hash
|
|
return name, nil
|
|
case len(args) >= 2 && args[0] == "rm":
|
|
container := args[len(args)-1]
|
|
delete(f.exists, container)
|
|
delete(f.running, container)
|
|
delete(f.label, container)
|
|
return "", nil
|
|
case len(args) >= 2 && args[0] == "network" && args[1] == "inspect":
|
|
return "", fmt.Errorf("Error: No such network")
|
|
case len(args) >= 2 && args[0] == "network" && args[1] == "create":
|
|
return "", nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func parseRunNameAndLabel(args []string) (name, hash string) {
|
|
for i := 0; i < len(args)-1; i++ {
|
|
switch args[i] {
|
|
case "--name":
|
|
name = args[i+1]
|
|
case "--label":
|
|
if v, ok := strings.CutPrefix(args[i+1], specLabel+"="); ok {
|
|
hash = v
|
|
}
|
|
}
|
|
}
|
|
return name, hash
|
|
}
|
|
|
|
func containerResource(t *testing.T, name, image string) Resource {
|
|
t.Helper()
|
|
d := mustParse(t, `{"version":1,"resources":[
|
|
{"id":"`+name+`","type":"container","name":"`+name+`","image":"`+image+`",
|
|
"network":"n","env":{"A":"1","B":"2"},"ports":["3000"],
|
|
"volumes":["/services/x/data:/data"]}]}`)
|
|
return d.Resources[0]
|
|
}
|
|
|
|
// A container that does not exist is created; applying the same declaration again finds it up to
|
|
// date and starts nothing.
|
|
func TestContainerApplyCreatesThenIsIdempotent(t *testing.T) {
|
|
f := newFakeRuntime()
|
|
c := containerApplier{run: f.run}
|
|
r := containerResource(t, "gitea", "gitea/gitea@sha256:abc")
|
|
|
|
created, err := c.Apply(r)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !created {
|
|
t.Fatal("first apply did not report creating the container")
|
|
}
|
|
if countCalls(f.calls, "run") != 1 {
|
|
t.Fatalf("expected exactly one run, got calls: %v", f.calls)
|
|
}
|
|
|
|
f.calls = nil
|
|
created, err = c.Apply(r)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if created {
|
|
t.Fatal("second apply recreated an up-to-date container")
|
|
}
|
|
if countCalls(f.calls, "run") != 0 {
|
|
t.Fatalf("idempotent apply still ran the container: %v", f.calls)
|
|
}
|
|
}
|
|
|
|
// A container whose spec changed is recreated: the old one removed, a new one started.
|
|
func TestContainerRecreatedWhenSpecChanges(t *testing.T) {
|
|
f := newFakeRuntime()
|
|
c := containerApplier{run: f.run}
|
|
|
|
if _, err := c.Apply(containerResource(t, "gitea", "gitea/gitea@sha256:old")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.calls = nil
|
|
// A new image is a new spec hash.
|
|
if _, err := c.Apply(containerResource(t, "gitea", "gitea/gitea@sha256:new")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if countCalls(f.calls, "rm") != 1 || countCalls(f.calls, "run") != 1 {
|
|
t.Fatalf("a changed spec should remove and recreate; calls: %v", f.calls)
|
|
}
|
|
}
|
|
|
|
// A foreign container by the same name — no spec label — is taken over, because a container holds
|
|
// no state; its data is in bind mounts declared separately.
|
|
func TestForeignContainerIsTakenOver(t *testing.T) {
|
|
f := newFakeRuntime()
|
|
f.exists["gitea"] = true
|
|
f.running["gitea"] = true
|
|
f.label["gitea"] = "" // started by something else, unlabelled
|
|
c := containerApplier{run: f.run}
|
|
|
|
created, err := c.Apply(containerResource(t, "gitea", "gitea/gitea@sha256:abc"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !created {
|
|
t.Fatal("taking over a foreign container should count as creating ours")
|
|
}
|
|
if countCalls(f.calls, "rm") != 1 {
|
|
t.Fatalf("the foreign container should have been replaced: %v", f.calls)
|
|
}
|
|
}
|
|
|
|
// runArgs carries every declared facet through to the invocation, env in sorted order.
|
|
func TestRunArgsAreComplete(t *testing.T) {
|
|
r := containerResource(t, "gitea", "img@sha256:abc")
|
|
args := strings.Join(runArgs(r, "hash123"), " ")
|
|
for _, want := range []string{
|
|
"run -d --name gitea --label mesh-host.spec=hash123",
|
|
"--network n", "--env-file", "-e A=1 -e B=2", "-p 3000",
|
|
"-v /services/x/data:/data", "img@sha256:abc",
|
|
} {
|
|
if want == "--env-file" {
|
|
continue // this resource declares none; the others must all be present
|
|
}
|
|
if !strings.Contains(args, want) {
|
|
t.Errorf("run args missing %q\n got: %s", want, args)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A container resource that names no image is refused rather than started blank.
|
|
func TestContainerWithoutImageIsRefused(t *testing.T) {
|
|
// Built directly: the shape allows omitting image, but the applier must not.
|
|
d := mustParse(t, `{"version":1,"resources":[
|
|
{"id":"x","type":"container","name":"x"}]}`)
|
|
f := newFakeRuntime()
|
|
if _, err := (containerApplier{run: f.run}).Apply(d.Resources[0]); err == nil {
|
|
t.Fatal("a container with no image was started")
|
|
}
|
|
}
|
|
|
|
func countCalls(calls []string, verb string) int {
|
|
n := 0
|
|
for _, c := range calls {
|
|
// docker <verb> ... — verb is the first arg after the runtime name.
|
|
fields := strings.Fields(c)
|
|
if len(fields) >= 2 && fields[1] == verb {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// Smoke test against the real runtime: a container really comes up, is idempotent, and is
|
|
// removed — the read-back path this whole design rests on, exercised for real. Skipped where the
|
|
// runtime or its image is not available, never failed for the environment (novox/hq ADR 0034).
|
|
func TestContainerAgainstRealRuntime(t *testing.T) {
|
|
if _, err := exec.LookPath(containerRuntime); err != nil {
|
|
t.Skipf("%s not installed", containerRuntime)
|
|
}
|
|
if out, err := exec.Command(containerRuntime, "run", "--rm", "alpine", "true").CombinedOutput(); err != nil {
|
|
t.Skipf("cannot run a probe container (no image/daemon): %s", strings.TrimSpace(string(out)))
|
|
}
|
|
|
|
name := "mesh-host-apply-smoke"
|
|
_ = exec.Command(containerRuntime, "rm", "-f", name).Run()
|
|
t.Cleanup(func() { _ = exec.Command(containerRuntime, "rm", "-f", name).Run() })
|
|
|
|
r := mustParse(t, `{"version":1,"resources":[
|
|
{"id":"c","type":"container","name":"`+name+`","image":"alpine","args":["sleep","30"]}]}`).Resources[0]
|
|
c := containerApplier{run: execRunner}
|
|
|
|
created, err := c.Apply(r)
|
|
if err != nil {
|
|
t.Fatalf("real apply failed: %v", err)
|
|
}
|
|
if !created {
|
|
t.Fatal("first real apply did not create the container")
|
|
}
|
|
// Idempotent: the running container is left alone.
|
|
created, err = c.Apply(r)
|
|
if err != nil {
|
|
t.Fatalf("real re-apply failed: %v", err)
|
|
}
|
|
if created {
|
|
t.Fatal("real re-apply recreated an up-to-date container")
|
|
}
|
|
// Removal really removes it.
|
|
if err := c.Remove(Record{ID: "c", Type: "container", Ref: name, Created: true}); err != nil {
|
|
t.Fatalf("real remove failed: %v", err)
|
|
}
|
|
if out, _ := exec.Command(containerRuntime, "inspect", name).CombinedOutput(); !strings.Contains(strings.ToLower(string(out)), "no such") {
|
|
t.Fatalf("container still present after removal: %s", out)
|
|
}
|
|
}
|