diff --git a/go.mod b/go.mod index 3777c38..29f10c2 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/goccy/go-yaml v1.19.2 github.com/gofrs/flock v0.13.0 github.com/jackc/pgx/v5 v5.10.0 + github.com/moby/moby/api v1.54.2 github.com/moby/moby/client v0.4.1 github.com/spf13/cobra v1.10.2 golang.org/x/mod v0.37.0 @@ -55,7 +56,6 @@ require ( github.com/mattn/go-runewidth v0.0.20 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/moby/api v1.54.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.1.0 // indirect github.com/muesli/mango-cobra v1.2.0 // indirect diff --git a/internal/docker/docker.go b/internal/docker/docker.go index e3bf540..35057b4 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -46,10 +46,55 @@ type Client interface { // All=true (so stopped containers are visible) and compose one-offs excluded // (DECISIONS D5) — the basis for ref-count reconciliation from live reality. ListManaged(ctx context.Context, labels map[string]string) ([]Container, error) + // ContainerInspect returns the read-only health/state projection for one + // container, keyed by ID or name. This is the tool-side readiness signal the + // health poller and the up saga gate on (.State.Health.Status); it never + // mutates anything, so it stays outside the flock (spec 10, ARCHITECTURE §4). + ContainerInspect(ctx context.Context, id string) (ContainerDetails, error) + // ContainerLogs returns up to `tail` trailing lines of a container's combined + // stdout+stderr (tail<=0 means all) — the fail-fast diagnostic inlined when a + // dependency goes unhealthy or exits during `up` (spec 10). Read-only. + ContainerLogs(ctx context.Context, id string, tail int) (string, error) // Close releases the underlying connection. Close() error } +// HealthStatus mirrors Docker's .State.Health.Status. The empty string means the +// container declares no healthcheck at all (inspect reports a nil Health block); +// `none` can also appear via the list API. Both mean "no health signal" — use +// HasHealthcheck rather than comparing to a single sentinel. +type HealthStatus string + +// Docker's exact health states (spec 10 §gotchas). +const ( + HealthNone HealthStatus = "none" // no healthcheck declared + HealthStarting HealthStatus = "starting" // check running, not yet ready + HealthHealthy HealthStatus = "healthy" // check passing + HealthUnhealthy HealthStatus = "unhealthy" // check failing +) + +// ContainerDetails is the read-only projection ContainerInspect returns: just +// enough state for the health gate and saga compensation, nothing that would +// tempt a write (ARCHITECTURE §4 keeps the SDK strictly read-only). +type ContainerDetails struct { + ID string + Name string // primary name, leading slash stripped + Labels map[string]string + State string // created|running|paused|restarting|removing|exited|dead + Running bool // .State.Running + ExitCode int // .State.ExitCode (meaningful once exited) + Health HealthStatus // "" when the container has no Health block +} + +// HasHealthcheck reports whether the container declares a healthcheck (so its +// Health status is a meaningful gate, not just "running"). +func (d ContainerDetails) HasHealthcheck() bool { + return d.Health != "" && d.Health != HealthNone +} + +// Healthy reports whether the container has a healthcheck that is passing. +func (d ContainerDetails) Healthy() bool { return d.Health == HealthHealthy } + // Container is the read-only projection of a container devstack cares about. type Container struct { ID string diff --git a/internal/docker/inspect.go b/internal/docker/inspect.go index b8a14a3..d9c1813 100644 --- a/internal/docker/inspect.go +++ b/internal/docker/inspect.go @@ -1,10 +1,13 @@ package docker import ( + "bytes" "context" "fmt" + "strconv" "strings" + "github.com/moby/moby/api/pkg/stdcopy" moby "github.com/moby/moby/client" ) @@ -99,6 +102,56 @@ func (m *mobyClient) ListManaged(ctx context.Context, labels map[string]string) return out, nil } +// ContainerInspect returns the read-only health/state projection for one +// container (spec 10). Health is "" when the container declares no healthcheck +// (the State.Health block is nil), distinguishing "no signal" from "starting". +func (m *mobyClient) ContainerInspect(ctx context.Context, id string) (ContainerDetails, error) { + res, err := m.cli.ContainerInspect(ctx, id, moby.ContainerInspectOptions{}) + if err != nil { + return ContainerDetails{}, fmt.Errorf("inspect container %q: %w", id, err) + } + c := res.Container + d := ContainerDetails{ + ID: c.ID, + Name: strings.TrimPrefix(c.Name, "/"), + } + if c.Config != nil { + d.Labels = c.Config.Labels + } + if c.State != nil { + d.State = string(c.State.Status) + d.Running = c.State.Running + d.ExitCode = c.State.ExitCode + if c.State.Health != nil { + d.Health = HealthStatus(c.State.Health.Status) + } + } + return d, nil +} + +// ContainerLogs returns up to `tail` trailing lines of a container's combined +// stdout+stderr (spec 10 fail-fast diagnostics). The Engine multiplexes the two +// streams for non-TTY containers (the devstack default — compose runs services +// without a TTY); stdcopy demuxes both into one buffer, preserving frame order. +func (m *mobyClient) ContainerLogs(ctx context.Context, id string, tail int) (string, error) { + opts := moby.ContainerLogsOptions{ShowStdout: true, ShowStderr: true} + if tail > 0 { + opts.Tail = strconv.Itoa(tail) + } + rc, err := m.cli.ContainerLogs(ctx, id, opts) + if err != nil { + return "", fmt.Errorf("logs for container %q: %w", id, err) + } + defer func() { _ = rc.Close() }() + var buf bytes.Buffer + // Both streams demux into one buffer so stdout/stderr stay interleaved in the + // order the daemon emitted them — the most faithful tail for a diagnostic. + if _, err := stdcopy.StdCopy(&buf, &buf, rc); err != nil { + return "", fmt.Errorf("read logs for container %q: %w", id, err) + } + return buf.String(), nil +} + // primaryName returns the first container name with the leading '/' stripped. func primaryName(names []string) string { if len(names) == 0 { diff --git a/internal/docker/inspect_integration_test.go b/internal/docker/inspect_integration_test.go new file mode 100644 index 0000000..ee72b96 --- /dev/null +++ b/internal/docker/inspect_integration_test.go @@ -0,0 +1,100 @@ +//go:build integration + +package docker + +import ( + "context" + "os" + "os/exec" + "strconv" + "strings" + "testing" + "time" +) + +// TestContainerInspectLogs_RealDaemon validates the moby field mapping against a +// real Engine: a container with a healthcheck must report .State.Health.Status +// through ContainerInspect, and its stdout must demux through ContainerLogs. +// Tagged `integration` so the daemon-free unit lane skips it (run via the +// integration CI lane / `go test -tags=integration ./internal/docker`). +func TestContainerInspectLogs_RealDaemon(t *testing.T) { + ctx := context.Background() + cli, err := NewClient(ctx) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer func() { _ = cli.Close() }() + if err := cli.Ping(ctx); err != nil { + t.Skipf("no reachable Docker daemon: %v", err) + } + + // Pre-pull so `docker run -d` emits only the container ID on stdout (pull + // progress would otherwise go to stderr and, worse, racily delay the run). + if out, err := exec.CommandContext(ctx, "docker", "pull", "busybox").CombinedOutput(); err != nil { + t.Fatalf("docker pull busybox: %v\n%s", err, out) + } + + name := "devstack-it-c2-" + strconv.Itoa(os.Getpid()) + // A container that prints a known line and stays up, with a trivially-passing + // healthcheck so .State.Health.Status reaches `healthy` quickly. + run := exec.CommandContext(ctx, "docker", "run", "-d", "--name", name, + "--health-cmd", "true", "--health-interval", "1s", "--health-retries", "1", + "--health-start-period", "0s", + "busybox", "sh", "-c", "echo hello-from-c2; sleep 60") + out, err := run.Output() // stdout only → just the container ID + if err != nil { + t.Fatalf("docker run: %v", err) + } + id := strings.TrimSpace(string(out)) + t.Cleanup(func() { + _ = exec.Command("docker", "rm", "-f", name).Run() + }) + + // Poll inspect until the healthcheck flips to healthy (bounded). + deadline := time.Now().Add(30 * time.Second) + var d ContainerDetails + for { + d, err = cli.ContainerInspect(ctx, id) + if err != nil { + t.Fatalf("ContainerInspect: %v", err) + } + if d.Healthy() { + break + } + if time.Now().After(deadline) { + t.Fatalf("container never became healthy: state=%q health=%q", d.State, d.Health) + } + time.Sleep(500 * time.Millisecond) + } + if !d.Running { + t.Errorf("Running = false, want true (state=%q)", d.State) + } + if !d.HasHealthcheck() { + t.Errorf("HasHealthcheck = false, want true (health=%q)", d.Health) + } + + logs, err := cli.ContainerLogs(ctx, id, 10) + if err != nil { + t.Fatalf("ContainerLogs: %v", err) + } + if !strings.Contains(logs, "hello-from-c2") { + t.Errorf("logs = %q, want to contain the printed line", logs) + } + + // A container with no healthcheck reports empty Health, not "starting". + name2 := name + "-nohc" + out2, err := exec.CommandContext(ctx, "docker", "run", "-d", "--name", name2, + "busybox", "sh", "-c", "sleep 60").Output() + if err != nil { + t.Fatalf("docker run (no hc): %v", err) + } + id2 := strings.TrimSpace(string(out2)) + t.Cleanup(func() { _ = exec.Command("docker", "rm", "-f", name2).Run() }) + d2, err := cli.ContainerInspect(ctx, id2) + if err != nil { + t.Fatalf("ContainerInspect (no hc): %v", err) + } + if d2.HasHealthcheck() { + t.Errorf("HasHealthcheck = true for a container with no healthcheck (health=%q)", d2.Health) + } +} diff --git a/internal/docker/inspect_test.go b/internal/docker/inspect_test.go new file mode 100644 index 0000000..55546af --- /dev/null +++ b/internal/docker/inspect_test.go @@ -0,0 +1,115 @@ +package docker + +import ( + "context" + "errors" + "testing" +) + +func TestContainerDetailsHealth(t *testing.T) { + cases := []struct { + health HealthStatus + hasCheck bool + healthy bool + }{ + {"", false, false}, + {HealthNone, false, false}, + {HealthStarting, true, false}, + {HealthHealthy, true, true}, + {HealthUnhealthy, true, false}, + } + for _, c := range cases { + d := ContainerDetails{Health: c.health} + if got := d.HasHealthcheck(); got != c.hasCheck { + t.Errorf("HasHealthcheck(%q) = %v, want %v", c.health, got, c.hasCheck) + } + if got := d.Healthy(); got != c.healthy { + t.Errorf("Healthy(%q) = %v, want %v", c.health, got, c.healthy) + } + } +} + +func TestMockContainerInspect(t *testing.T) { + ctx := context.Background() + m := &MockClient{Details: map[string]ContainerDetails{ + "shared-postgres": {ID: "abc", Name: "shared-postgres", State: "running", + Running: true, Health: HealthHealthy}, + }} + + d, err := m.ContainerInspect(ctx, "shared-postgres") + if err != nil { + t.Fatal(err) + } + if !d.Running || !d.Healthy() { + t.Errorf("inspect = %+v, want running+healthy", d) + } + + if _, err := m.ContainerInspect(ctx, "ghost"); err == nil { + t.Error("inspect of unseeded container should error") + } + + sentinel := errors.New("boom") + m.InspectErr = sentinel + if _, err := m.ContainerInspect(ctx, "shared-postgres"); !errors.Is(err, sentinel) { + t.Errorf("InspectErr not propagated: %v", err) + } +} + +func TestMockContainerLogsTail(t *testing.T) { + ctx := context.Background() + m := &MockClient{LogLines: map[string]string{ + "api": "l1\nl2\nl3\nl4\nl5\n", + }} + + all, err := m.ContainerLogs(ctx, "api", 0) + if err != nil { + t.Fatal(err) + } + if all != "l1\nl2\nl3\nl4\nl5\n" { + t.Errorf("tail=0 = %q, want all lines", all) + } + + last2, err := m.ContainerLogs(ctx, "api", 2) + if err != nil { + t.Fatal(err) + } + if last2 != "l4\nl5\n" { + t.Errorf("tail=2 = %q, want last 2 lines", last2) + } + + // More requested than present → all returned, untouched. + if got, _ := m.ContainerLogs(ctx, "api", 99); got != "l1\nl2\nl3\nl4\nl5\n" { + t.Errorf("tail=99 = %q, want all lines", got) + } + + // Unknown container → empty, no error (mirrors an empty log). + if got, _ := m.ContainerLogs(ctx, "ghost", 5); got != "" { + t.Errorf("unknown container logs = %q, want empty", got) + } + + sentinel := errors.New("nope") + m.LogsErr = sentinel + if _, err := m.ContainerLogs(ctx, "api", 1); !errors.Is(err, sentinel) { + t.Errorf("LogsErr not propagated: %v", err) + } +} + +func TestLastLines(t *testing.T) { + cases := []struct { + in string + n int + want string + }{ + {"", 5, ""}, + {"a\nb\nc\n", 0, ""}, + {"a\nb\nc\n", 2, "b\nc\n"}, + {"a\nb\nc", 2, "b\nc"}, // no trailing newline preserved + {"a\nb\nc\n", 10, "a\nb\nc\n"}, + {"solo", 1, "solo"}, + } + for _, c := range cases { + if got := lastLines(c.in, c.n); got != c.want { + t.Errorf("lastLines(%q, %d) = %q, want %q", c.in, c.n, got, c.want) + } + } +} diff --git a/internal/docker/mock.go b/internal/docker/mock.go index 2901b1a..9c6421d 100644 --- a/internal/docker/mock.go +++ b/internal/docker/mock.go @@ -1,6 +1,10 @@ package docker -import "context" +import ( + "context" + "fmt" + "strings" +) // MockClient is an in-memory Client for unit/race tests so concurrent-invocation // logic can be exercised without a real daemon (ARCHITECTURE §7.7). @@ -14,9 +18,16 @@ type MockClient struct { Networks map[string]bool // Containers is the in-memory container list ListManaged filters over. Containers []Container - // NetworkErr / ListErr force the corresponding op to fail. + // Details maps a container ID or name to the projection ContainerInspect + // returns; a miss yields a "no such container" error like the real daemon. + Details map[string]ContainerDetails + // LogLines maps a container ID or name to its canned log text. + LogLines map[string]string + // NetworkErr / ListErr / InspectErr / LogsErr force the op to fail. NetworkErr error ListErr error + InspectErr error + LogsErr error } var _ Client = (*MockClient)(nil) @@ -83,4 +94,46 @@ func (m *MockClient) ListManaged(_ context.Context, labels map[string]string) ([ return out, nil } +// ContainerInspect returns the seeded details for id (by ID or name), or a +// not-found error mirroring the daemon when unseeded. +func (m *MockClient) ContainerInspect(_ context.Context, id string) (ContainerDetails, error) { + if m.InspectErr != nil { + return ContainerDetails{}, m.InspectErr + } + if d, ok := m.Details[id]; ok { + return d, nil + } + return ContainerDetails{}, fmt.Errorf("no such container: %s", id) +} + +// ContainerLogs returns the seeded log text for id, tail-trimmed to the last +// `tail` lines (tail<=0 means all) to mirror the daemon's Tail behaviour. +func (m *MockClient) ContainerLogs(_ context.Context, id string, tail int) (string, error) { + if m.LogsErr != nil { + return "", m.LogsErr + } + out := m.LogLines[id] + if tail > 0 { + out = lastLines(out, tail) + } + return out, nil +} + +// lastLines returns the final n lines of s, preserving a trailing newline. +func lastLines(s string, n int) string { + if s == "" || n <= 0 { + return "" + } + trimmed := strings.TrimSuffix(s, "\n") + lines := strings.Split(trimmed, "\n") + if len(lines) <= n { + return s + } + out := strings.Join(lines[len(lines)-n:], "\n") + if strings.HasSuffix(s, "\n") { + out += "\n" + } + return out +} + func (m *MockClient) Close() error { return nil }