diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index b2c2db9..d88f6b1 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1,9 +1,11 @@ // Command mesh-host is tier 0 of the Novox Mesh: the one thing installed by hand, and the // only thing that changes a machine. // -// Stage 1 (novox/hq 03-DESIGN/01-to-be/05-the-node-host.md) is profile and inventory only — -// the host reads what this machine can do and what it is, and reports it. It applies nothing, -// connects to nothing, and listens on nothing. +// It reports (profile, inventory) and it applies (novox/hq 03-DESIGN/01-to-be/05-the-node-host.md). +// `apply` takes a declaration and makes this machine match it, from a local file, with no mesh +// present — the network-free resource types first (directories, files, containers, networks). +// It still connects to nothing and listens on nothing: the link to the control plane, sealed +// secrets, and the remaining resource types are designed and not yet built. package main import ( @@ -173,10 +175,10 @@ func runApply(opts options) error { return writeJSON(res) } for _, r := range res.Applied { - fmt.Printf(" applied %-10s %s\n", r.Type, r.Path) + fmt.Printf(" applied %-10s %s\n", r.Type, r.Ref) } for _, r := range res.Removed { - fmt.Printf(" removed %-10s %s\n", r.Type, r.Path) + fmt.Printf(" removed %-10s %s\n", r.Type, r.Ref) } fmt.Printf("\n%d applied, %d removed\n", len(res.Applied), len(res.Removed)) return nil diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 04e6a66..dffe198 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -68,7 +68,7 @@ func Apply(decl Declaration, identity string, store *Store, appliers map[string] return Result{}, fmt.Errorf("applying %s %q: %w", r.Type, r.ID, err) } applied = append(applied, Record{ - ID: r.ID, Type: r.Type, Path: r.Path(), + ID: r.ID, Type: r.Type, Ref: r.ref(), Created: created || priorCreated[r.ID], }) } diff --git a/internal/apply/container.go b/internal/apply/container.go new file mode 100644 index 0000000..9141a5e --- /dev/null +++ b/internal/apply/container.go @@ -0,0 +1,219 @@ +package apply + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +// containerRuntime is the runtime the mesh uses. Named rather than assumed, so the one place that +// would change for podman is here — and profile already detects that a container-runtime is +// present before any container resource is placed on a node. +const containerRuntime = "docker" + +// specLabel carries a hash of the desired container spec. It is what lets a re-apply tell an +// up-to-date container from one that must be recreated, without parsing the runtime's own view of +// env, ports and mounts and hoping the comparison matches field for field. The host wrote the +// hash; the host trusts the hash. +const specLabel = "mesh-host.spec" + +// Runner executes a container-runtime command. Replaceable in tests, and exercised against the +// real runtime as well (novox/hq ADR 0034): a test that only fakes the runtime asserts the fake +// behaves as expected, so the real path is smoke-tested too. +type Runner func(name string, args ...string) (string, error) + +func execRunner(name string, args ...string) (string, error) { + out, err := exec.Command(name, args...).CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("%s %s: %w: %s", + name, strings.Join(args, " "), err, strings.TrimSpace(string(out))) + } + return string(out), nil +} + +// --- network --- + +type networkApplier struct{ run Runner } + +func (networkApplier) Type() string { return "network" } + +func (n networkApplier) Apply(r Resource) (bool, error) { + name := r.stringField("name") + if _, err := n.run(containerRuntime, "network", "inspect", name); err == nil { + return false, nil // already present — adopted, not created + } + if _, err := n.run(containerRuntime, "network", "create", name); err != nil { + return false, fmt.Errorf("creating network %s: %w", name, err) + } + // Read back: the network must now be inspectable, or the create did not take. + if _, err := n.run(containerRuntime, "network", "inspect", name); err != nil { + return true, fmt.Errorf("network %s did not take: %w", name, err) + } + return true, nil +} + +func (n networkApplier) Remove(rec Record) error { + if _, err := n.run(containerRuntime, "network", "rm", rec.Ref); err != nil { + if alreadyGone(err) { + return nil + } + return fmt.Errorf("removing network %s: %w", rec.Ref, err) + } + return nil +} + +// --- container --- + +type containerApplier struct{ run Runner } + +func (containerApplier) Type() string { return "container" } + +func (c containerApplier) Apply(r Resource) (bool, error) { + name := r.stringField("name") + image := r.stringField("image") + if image == "" { + return false, fmt.Errorf("container %q names no image", name) + } + hash := specHash(r) + + exists, running, curHash := c.inspect(name) + // A container is up to date only when it is ours (its spec label matches this exact spec) AND + // it is running. A foreign container by the same name — one the old control plane started, with + // no label — does not match, and is recreated into ours. That is safe: a container carries no + // state, its data lives in bind-mounted directories declared separately, and recreating it does + // not touch them. + upToDate := exists && running && curHash == hash + if upToDate { + return false, nil // present and correct; this apply created nothing + } + + if exists { + if _, err := c.run(containerRuntime, "rm", "-f", name); err != nil { + return false, fmt.Errorf("replacing container %s: %w", name, err) + } + } + if _, err := c.run(containerRuntime, runArgs(r, hash)...); err != nil { + return true, fmt.Errorf("starting container %s: %w", name, err) + } + + // Read back: the container must now be running, on this exact spec. "Service started" only + // means the runtime returned — the host asks whether it is actually up (novox/hq + // troubleshooting/service-started-is-not-ready). + exists, running, curHash = c.inspect(name) + if !exists || !running { + return true, fmt.Errorf("container %s did not come up", name) + } + if curHash != hash { + return true, fmt.Errorf("container %s came up on a spec that is not the one declared", name) + } + return true, nil +} + +func (c containerApplier) Remove(rec Record) error { + if _, err := c.run(containerRuntime, "rm", "-f", rec.Ref); err != nil { + if alreadyGone(err) { + return nil + } + return fmt.Errorf("removing container %s: %w", rec.Ref, err) + } + return nil +} + +// alreadyGone reports whether a removal failed only because the thing was not there — which is +// success, not failure. The runtime phrases it variously ("No such container", "no such object", +// "not found") across versions, so the match is lenient and case-insensitive. +func alreadyGone(err error) bool { + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "no such") || strings.Contains(msg, "not found") +} + +// inspect reports whether a container by this name exists, whether it is running, and the spec +// hash it was labelled with (empty for a container the host did not label). +func (c containerApplier) inspect(name string) (exists, running bool, hash string) { + out, err := c.run(containerRuntime, "inspect", "-f", + "{{.State.Running}}|{{index .Config.Labels \""+specLabel+"\"}}", name) + if err != nil { + return false, false, "" + } + parts := strings.SplitN(strings.TrimSpace(out), "|", 2) + running = parts[0] == "true" + if len(parts) == 2 && parts[1] != "" { + hash = parts[1] + } + return true, running, hash +} + +// runArgs builds the `docker run` invocation for r, labelled with its spec hash. +func runArgs(r Resource, hash string) []string { + args := []string{"run", "-d", "--name", r.stringField("name"), "--label", specLabel + "=" + hash} + if net := r.stringField("network"); net != "" { + args = append(args, "--network", net) + } + for _, ef := range r.stringSlice("env-file") { + args = append(args, "--env-file", ef) + } + // Env is emitted in the JSON-sorted order specHash also uses, so the invocation is stable. + env := r.stringMap("env") + for _, k := range sortedKeys(env) { + args = append(args, "-e", k+"="+env[k]) + } + for _, p := range r.stringSlice("ports") { + args = append(args, "-p", p) + } + for _, v := range r.stringSlice("volumes") { + args = append(args, "-v", v) + } + for _, h := range r.stringSlice("hosts") { + args = append(args, "--add-host", h) + } + args = append(args, r.stringField("image")) + args = append(args, r.stringSlice("args")...) + return args +} + +// specHash is a stable fingerprint of everything that decides whether a running container matches +// what is declared. Marshalled through a struct so the field set is explicit, and json.Marshal +// sorts map keys, so the same declaration always hashes the same. +func specHash(r Resource) string { + type spec struct { + Name string `json:"name"` + Image string `json:"image"` + Network string `json:"network"` + Env map[string]string `json:"env"` + EnvFile []string `json:"env_file"` + Ports []string `json:"ports"` + Volumes []string `json:"volumes"` + Hosts []string `json:"hosts"` + Args []string `json:"args"` + } + b, _ := json.Marshal(spec{ + Name: r.stringField("name"), + Image: r.stringField("image"), + Network: r.stringField("network"), + Env: r.stringMap("env"), + EnvFile: r.stringSlice("env-file"), + Ports: r.stringSlice("ports"), + Volumes: r.stringSlice("volumes"), + Hosts: r.stringSlice("hosts"), + Args: r.stringSlice("args"), + }) + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:])[:16] +} + +func sortedKeys(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + // small n; insertion order does not matter, only that it is stable and sorted + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j-1] > out[j]; j-- { + out[j-1], out[j] = out[j], out[j-1] + } + } + return out +} diff --git a/internal/apply/container_test.go b/internal/apply/container_test.go new file mode 100644 index 0000000..5632c87 --- /dev/null +++ b/internal/apply/container_test.go @@ -0,0 +1,235 @@ +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 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) + } +} diff --git a/internal/apply/declaration.go b/internal/apply/declaration.go index 215c311..3a3efdc 100644 --- a/internal/apply/declaration.go +++ b/internal/apply/declaration.go @@ -53,12 +53,21 @@ type Resource struct { Fields map[string]json.RawMessage } -// Path is the host-owned filesystem path this resource lives at. Every type this host applies -// so far is addressed by a path, and the store needs it to remove the resource later. +// Path is the host-owned filesystem path a file or directory resource lives at. func (r Resource) Path() string { return r.stringField("path") } +// ref is what the store records to find this resource again for removal: a filesystem path for +// files and directories, a name for containers and networks. Every resource is addressed by one +// or the other, and the parser refuses a resource that has neither. +func (r Resource) ref() string { + if p := r.stringField("path"); p != "" { + return p + } + return r.stringField("name") +} + func (r Resource) stringField(key string) string { raw, ok := r.Fields[key] if !ok { @@ -71,6 +80,29 @@ func (r Resource) stringField(key string) string { return s } +// stringSlice reads a field that is a JSON array of strings — ports, volumes, args, hosts. +func (r Resource) stringSlice(key string) []string { + raw, ok := r.Fields[key] + if !ok { + return nil + } + var out []string + _ = json.Unmarshal(raw, &out) + return out +} + +// stringMap reads a field that is a JSON object of string→string — a container's env. Keys are +// returned sorted by the caller when order matters, so a container's spec hash is stable. +func (r Resource) stringMap(key string) map[string]string { + raw, ok := r.Fields[key] + if !ok { + return nil + } + var out map[string]string + _ = json.Unmarshal(raw, &out) + return out +} + // shape is the set of field keys a resource type may carry, beyond the common id and type. // This is the host's half of a wire contract whose other half is the control plane's catalogue // (novox/mesh-control examples/modules/modules_test.go). Duplicated deliberately, because the @@ -84,6 +116,8 @@ func (r Resource) stringField(key string) string { var shapes = map[string][]string{ "directory": {"path", "mode", "owner"}, "file": {"path", "content", "mode", "owner"}, + "network": {"name"}, + "container": {"name", "image", "env", "env-file", "ports", "volumes", "args", "hosts", "network"}, } // Parse reads a declaration and refuses anything it does not fully understand. @@ -167,11 +201,13 @@ func parseResource(raw json.RawMessage) (Resource, error) { } } - if _, hasPath := extra["path"]; !hasPath { - return Resource{}, fmt.Errorf("%q is a %s and names no path", id, typ) + res := Resource{ID: id, Type: typ, Fields: extra} + if res.ref() == "" { + return Resource{}, fmt.Errorf( + "%q is a %s and names neither a path nor a name — the store would have no way to find "+ + "it again", id, typ) } - - return Resource{ID: id, Type: typ, Fields: extra}, nil + return res, nil } func decodeString(raw json.RawMessage) string { diff --git a/internal/apply/resources.go b/internal/apply/resources.go index c4d9b09..c1a69cc 100644 --- a/internal/apply/resources.go +++ b/internal/apply/resources.go @@ -26,11 +26,21 @@ type Applier interface { Remove(rec Record) error } -// Appliers is the set of types this host can apply, keyed by type name. +// Appliers is the set of types this host can apply, keyed by type name, using the real container +// runtime. func Appliers() map[string]Applier { + return appliersWith(execRunner) +} + +// appliersWith builds the applier set against a given runtime runner. The filesystem appliers +// ignore it; the container and network ones drive the runtime through it, which is where a test +// substitutes a fake. +func appliersWith(run Runner) map[string]Applier { return map[string]Applier{ "directory": directoryApplier{}, "file": fileApplier{}, + "network": networkApplier{run: run}, + "container": containerApplier{run: run}, } } @@ -82,12 +92,12 @@ func (directoryApplier) Remove(rec Record) error { // os.Remove, never RemoveAll: it fails on a non-empty directory, and that failure is the // point. A directory the host created but that now holds something is not the host's to // delete — data outlives the mesh that declared it (ADR 0030). - err := os.Remove(rec.Path) + err := os.Remove(rec.Ref) if os.IsNotExist(err) { return nil } if err != nil { - return fmt.Errorf("removing directory %s (left in place): %w", rec.Path, err) + return fmt.Errorf("removing directory %s (left in place): %w", rec.Ref, err) } return nil } @@ -148,12 +158,12 @@ func (fileApplier) Apply(r Resource) (bool, error) { } func (fileApplier) Remove(rec Record) error { - err := os.Remove(rec.Path) + err := os.Remove(rec.Ref) if os.IsNotExist(err) { return nil } if err != nil { - return fmt.Errorf("removing file %s: %w", rec.Path, err) + return fmt.Errorf("removing file %s: %w", rec.Ref, err) } return nil } diff --git a/internal/apply/store.go b/internal/apply/store.go index f9d07b2..a5dc4c0 100644 --- a/internal/apply/store.go +++ b/internal/apply/store.go @@ -28,9 +28,11 @@ type Store struct { // removed, which is the same rule that ADR 0018 exists to enforce: never act on a path you did // not create. type Record struct { - ID string `json:"id"` - Type string `json:"type"` - Path string `json:"path"` + ID string `json:"id"` + Type string `json:"type"` + // Ref is how the resource is found again for removal: a filesystem path for files and + // directories, a container or network name for those. + Ref string `json:"ref"` Created bool `json:"created"` }