Skip to content

Commit 35cbd1e

Browse files
gustavobertoiclaude
andcommitted
feat(docker): C2 — read-only ContainerInspect/ContainerLogs + mock (spec 10)
Grow the read-only Engine SDK Client with the two probes the health poller and up saga need: - ContainerInspect → ContainerDetails projection (State, Running, ExitCode, and .State.Health.Status as a typed HealthStatus). Health is "" when the container declares no healthcheck (nil State.Health), distinguishing "no signal" from "starting"; HasHealthcheck()/Healthy() helpers encode the rule. - ContainerLogs → combined stdout+stderr, tail-limited, demuxed via stdcopy into one buffer so frames stay interleaved (the fail-fast diagnostic). Both are strictly read-only (ARCHITECTURE §4) so they stay outside the flock. MockClient gains Details/LogLines maps + InspectErr/LogsErr for daemon-free unit tests; a `//go:build integration` test validates the moby field mapping against a real container (healthy + no-healthcheck cases, log demux). go mod tidy promotes moby/moby/api to a direct dep (stdcopy import). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8aecbe5 commit 35cbd1e

6 files changed

Lines changed: 369 additions & 3 deletions

File tree

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ require (
1212
github.com/goccy/go-yaml v1.19.2
1313
github.com/gofrs/flock v0.13.0
1414
github.com/jackc/pgx/v5 v5.10.0
15+
github.com/moby/moby/api v1.54.2
1516
github.com/moby/moby/client v0.4.1
1617
github.com/spf13/cobra v1.10.2
1718
golang.org/x/mod v0.37.0
@@ -55,7 +56,6 @@ require (
5556
github.com/mattn/go-runewidth v0.0.20 // indirect
5657
github.com/mattn/go-shellwords v1.0.12 // indirect
5758
github.com/moby/docker-image-spec v1.3.1 // indirect
58-
github.com/moby/moby/api v1.54.2 // indirect
5959
github.com/muesli/cancelreader v0.2.2 // indirect
6060
github.com/muesli/mango v0.1.0 // indirect
6161
github.com/muesli/mango-cobra v1.2.0 // indirect

‎internal/docker/docker.go‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,55 @@ type Client interface {
4646
// All=true (so stopped containers are visible) and compose one-offs excluded
4747
// (DECISIONS D5) — the basis for ref-count reconciliation from live reality.
4848
ListManaged(ctx context.Context, labels map[string]string) ([]Container, error)
49+
// ContainerInspect returns the read-only health/state projection for one
50+
// container, keyed by ID or name. This is the tool-side readiness signal the
51+
// health poller and the up saga gate on (.State.Health.Status); it never
52+
// mutates anything, so it stays outside the flock (spec 10, ARCHITECTURE §4).
53+
ContainerInspect(ctx context.Context, id string) (ContainerDetails, error)
54+
// ContainerLogs returns up to `tail` trailing lines of a container's combined
55+
// stdout+stderr (tail<=0 means all) — the fail-fast diagnostic inlined when a
56+
// dependency goes unhealthy or exits during `up` (spec 10). Read-only.
57+
ContainerLogs(ctx context.Context, id string, tail int) (string, error)
4958
// Close releases the underlying connection.
5059
Close() error
5160
}
5261

62+
// HealthStatus mirrors Docker's .State.Health.Status. The empty string means the
63+
// container declares no healthcheck at all (inspect reports a nil Health block);
64+
// `none` can also appear via the list API. Both mean "no health signal" — use
65+
// HasHealthcheck rather than comparing to a single sentinel.
66+
type HealthStatus string
67+
68+
// Docker's exact health states (spec 10 §gotchas).
69+
const (
70+
HealthNone HealthStatus = "none" // no healthcheck declared
71+
HealthStarting HealthStatus = "starting" // check running, not yet ready
72+
HealthHealthy HealthStatus = "healthy" // check passing
73+
HealthUnhealthy HealthStatus = "unhealthy" // check failing
74+
)
75+
76+
// ContainerDetails is the read-only projection ContainerInspect returns: just
77+
// enough state for the health gate and saga compensation, nothing that would
78+
// tempt a write (ARCHITECTURE §4 keeps the SDK strictly read-only).
79+
type ContainerDetails struct {
80+
ID string
81+
Name string // primary name, leading slash stripped
82+
Labels map[string]string
83+
State string // created|running|paused|restarting|removing|exited|dead
84+
Running bool // .State.Running
85+
ExitCode int // .State.ExitCode (meaningful once exited)
86+
Health HealthStatus // "" when the container has no Health block
87+
}
88+
89+
// HasHealthcheck reports whether the container declares a healthcheck (so its
90+
// Health status is a meaningful gate, not just "running").
91+
func (d ContainerDetails) HasHealthcheck() bool {
92+
return d.Health != "" && d.Health != HealthNone
93+
}
94+
95+
// Healthy reports whether the container has a healthcheck that is passing.
96+
func (d ContainerDetails) Healthy() bool { return d.Health == HealthHealthy }
97+
5398
// Container is the read-only projection of a container devstack cares about.
5499
type Container struct {
55100
ID string

‎internal/docker/inspect.go‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package docker
22

33
import (
4+
"bytes"
45
"context"
56
"fmt"
7+
"strconv"
68
"strings"
79

10+
"github.com/moby/moby/api/pkg/stdcopy"
811
moby "github.com/moby/moby/client"
912
)
1013

@@ -99,6 +102,56 @@ func (m *mobyClient) ListManaged(ctx context.Context, labels map[string]string)
99102
return out, nil
100103
}
101104

105+
// ContainerInspect returns the read-only health/state projection for one
106+
// container (spec 10). Health is "" when the container declares no healthcheck
107+
// (the State.Health block is nil), distinguishing "no signal" from "starting".
108+
func (m *mobyClient) ContainerInspect(ctx context.Context, id string) (ContainerDetails, error) {
109+
res, err := m.cli.ContainerInspect(ctx, id, moby.ContainerInspectOptions{})
110+
if err != nil {
111+
return ContainerDetails{}, fmt.Errorf("inspect container %q: %w", id, err)
112+
}
113+
c := res.Container
114+
d := ContainerDetails{
115+
ID: c.ID,
116+
Name: strings.TrimPrefix(c.Name, "/"),
117+
}
118+
if c.Config != nil {
119+
d.Labels = c.Config.Labels
120+
}
121+
if c.State != nil {
122+
d.State = string(c.State.Status)
123+
d.Running = c.State.Running
124+
d.ExitCode = c.State.ExitCode
125+
if c.State.Health != nil {
126+
d.Health = HealthStatus(c.State.Health.Status)
127+
}
128+
}
129+
return d, nil
130+
}
131+
132+
// ContainerLogs returns up to `tail` trailing lines of a container's combined
133+
// stdout+stderr (spec 10 fail-fast diagnostics). The Engine multiplexes the two
134+
// streams for non-TTY containers (the devstack default — compose runs services
135+
// without a TTY); stdcopy demuxes both into one buffer, preserving frame order.
136+
func (m *mobyClient) ContainerLogs(ctx context.Context, id string, tail int) (string, error) {
137+
opts := moby.ContainerLogsOptions{ShowStdout: true, ShowStderr: true}
138+
if tail > 0 {
139+
opts.Tail = strconv.Itoa(tail)
140+
}
141+
rc, err := m.cli.ContainerLogs(ctx, id, opts)
142+
if err != nil {
143+
return "", fmt.Errorf("logs for container %q: %w", id, err)
144+
}
145+
defer func() { _ = rc.Close() }()
146+
var buf bytes.Buffer
147+
// Both streams demux into one buffer so stdout/stderr stay interleaved in the
148+
// order the daemon emitted them — the most faithful tail for a diagnostic.
149+
if _, err := stdcopy.StdCopy(&buf, &buf, rc); err != nil {
150+
return "", fmt.Errorf("read logs for container %q: %w", id, err)
151+
}
152+
return buf.String(), nil
153+
}
154+
102155
// primaryName returns the first container name with the leading '/' stripped.
103156
func primaryName(names []string) string {
104157
if len(names) == 0 {
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//go:build integration
2+
3+
package docker
4+
5+
import (
6+
"context"
7+
"os"
8+
"os/exec"
9+
"strconv"
10+
"strings"
11+
"testing"
12+
"time"
13+
)
14+
15+
// TestContainerInspectLogs_RealDaemon validates the moby field mapping against a
16+
// real Engine: a container with a healthcheck must report .State.Health.Status
17+
// through ContainerInspect, and its stdout must demux through ContainerLogs.
18+
// Tagged `integration` so the daemon-free unit lane skips it (run via the
19+
// integration CI lane / `go test -tags=integration ./internal/docker`).
20+
func TestContainerInspectLogs_RealDaemon(t *testing.T) {
21+
ctx := context.Background()
22+
cli, err := NewClient(ctx)
23+
if err != nil {
24+
t.Fatalf("NewClient: %v", err)
25+
}
26+
defer func() { _ = cli.Close() }()
27+
if err := cli.Ping(ctx); err != nil {
28+
t.Skipf("no reachable Docker daemon: %v", err)
29+
}
30+
31+
// Pre-pull so `docker run -d` emits only the container ID on stdout (pull
32+
// progress would otherwise go to stderr and, worse, racily delay the run).
33+
if out, err := exec.CommandContext(ctx, "docker", "pull", "busybox").CombinedOutput(); err != nil {
34+
t.Fatalf("docker pull busybox: %v\n%s", err, out)
35+
}
36+
37+
name := "devstack-it-c2-" + strconv.Itoa(os.Getpid())
38+
// A container that prints a known line and stays up, with a trivially-passing
39+
// healthcheck so .State.Health.Status reaches `healthy` quickly.
40+
run := exec.CommandContext(ctx, "docker", "run", "-d", "--name", name,
41+
"--health-cmd", "true", "--health-interval", "1s", "--health-retries", "1",
42+
"--health-start-period", "0s",
43+
"busybox", "sh", "-c", "echo hello-from-c2; sleep 60")
44+
out, err := run.Output() // stdout only → just the container ID
45+
if err != nil {
46+
t.Fatalf("docker run: %v", err)
47+
}
48+
id := strings.TrimSpace(string(out))
49+
t.Cleanup(func() {
50+
_ = exec.Command("docker", "rm", "-f", name).Run()
51+
})
52+
53+
// Poll inspect until the healthcheck flips to healthy (bounded).
54+
deadline := time.Now().Add(30 * time.Second)
55+
var d ContainerDetails
56+
for {
57+
d, err = cli.ContainerInspect(ctx, id)
58+
if err != nil {
59+
t.Fatalf("ContainerInspect: %v", err)
60+
}
61+
if d.Healthy() {
62+
break
63+
}
64+
if time.Now().After(deadline) {
65+
t.Fatalf("container never became healthy: state=%q health=%q", d.State, d.Health)
66+
}
67+
time.Sleep(500 * time.Millisecond)
68+
}
69+
if !d.Running {
70+
t.Errorf("Running = false, want true (state=%q)", d.State)
71+
}
72+
if !d.HasHealthcheck() {
73+
t.Errorf("HasHealthcheck = false, want true (health=%q)", d.Health)
74+
}
75+
76+
logs, err := cli.ContainerLogs(ctx, id, 10)
77+
if err != nil {
78+
t.Fatalf("ContainerLogs: %v", err)
79+
}
80+
if !strings.Contains(logs, "hello-from-c2") {
81+
t.Errorf("logs = %q, want to contain the printed line", logs)
82+
}
83+
84+
// A container with no healthcheck reports empty Health, not "starting".
85+
name2 := name + "-nohc"
86+
out2, err := exec.CommandContext(ctx, "docker", "run", "-d", "--name", name2,
87+
"busybox", "sh", "-c", "sleep 60").Output()
88+
if err != nil {
89+
t.Fatalf("docker run (no hc): %v", err)
90+
}
91+
id2 := strings.TrimSpace(string(out2))
92+
t.Cleanup(func() { _ = exec.Command("docker", "rm", "-f", name2).Run() })
93+
d2, err := cli.ContainerInspect(ctx, id2)
94+
if err != nil {
95+
t.Fatalf("ContainerInspect (no hc): %v", err)
96+
}
97+
if d2.HasHealthcheck() {
98+
t.Errorf("HasHealthcheck = true for a container with no healthcheck (health=%q)", d2.Health)
99+
}
100+
}

‎internal/docker/inspect_test.go‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package docker
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
)
8+
9+
func TestContainerDetailsHealth(t *testing.T) {
10+
cases := []struct {
11+
health HealthStatus
12+
hasCheck bool
13+
healthy bool
14+
}{
15+
{"", false, false},
16+
{HealthNone, false, false},
17+
{HealthStarting, true, false},
18+
{HealthHealthy, true, true},
19+
{HealthUnhealthy, true, false},
20+
}
21+
for _, c := range cases {
22+
d := ContainerDetails{Health: c.health}
23+
if got := d.HasHealthcheck(); got != c.hasCheck {
24+
t.Errorf("HasHealthcheck(%q) = %v, want %v", c.health, got, c.hasCheck)
25+
}
26+
if got := d.Healthy(); got != c.healthy {
27+
t.Errorf("Healthy(%q) = %v, want %v", c.health, got, c.healthy)
28+
}
29+
}
30+
}
31+
32+
func TestMockContainerInspect(t *testing.T) {
33+
ctx := context.Background()
34+
m := &MockClient{Details: map[string]ContainerDetails{
35+
"shared-postgres": {ID: "abc", Name: "shared-postgres", State: "running",
36+
Running: true, Health: HealthHealthy},
37+
}}
38+
39+
d, err := m.ContainerInspect(ctx, "shared-postgres")
40+
if err != nil {
41+
t.Fatal(err)
42+
}
43+
if !d.Running || !d.Healthy() {
44+
t.Errorf("inspect = %+v, want running+healthy", d)
45+
}
46+
47+
if _, err := m.ContainerInspect(ctx, "ghost"); err == nil {
48+
t.Error("inspect of unseeded container should error")
49+
}
50+
51+
sentinel := errors.New("boom")
52+
m.InspectErr = sentinel
53+
if _, err := m.ContainerInspect(ctx, "shared-postgres"); !errors.Is(err, sentinel) {
54+
t.Errorf("InspectErr not propagated: %v", err)
55+
}
56+
}
57+
58+
func TestMockContainerLogsTail(t *testing.T) {
59+
ctx := context.Background()
60+
m := &MockClient{LogLines: map[string]string{
61+
"api": "l1\nl2\nl3\nl4\nl5\n",
62+
}}
63+
64+
all, err := m.ContainerLogs(ctx, "api", 0)
65+
if err != nil {
66+
t.Fatal(err)
67+
}
68+
if all != "l1\nl2\nl3\nl4\nl5\n" {
69+
t.Errorf("tail=0 = %q, want all lines", all)
70+
}
71+
72+
last2, err := m.ContainerLogs(ctx, "api", 2)
73+
if err != nil {
74+
t.Fatal(err)
75+
}
76+
if last2 != "l4\nl5\n" {
77+
t.Errorf("tail=2 = %q, want last 2 lines", last2)
78+
}
79+
80+
// More requested than present → all returned, untouched.
81+
if got, _ := m.ContainerLogs(ctx, "api", 99); got != "l1\nl2\nl3\nl4\nl5\n" {
82+
t.Errorf("tail=99 = %q, want all lines", got)
83+
}
84+
85+
// Unknown container → empty, no error (mirrors an empty log).
86+
if got, _ := m.ContainerLogs(ctx, "ghost", 5); got != "" {
87+
t.Errorf("unknown container logs = %q, want empty", got)
88+
}
89+
90+
sentinel := errors.New("nope")
91+
m.LogsErr = sentinel
92+
if _, err := m.ContainerLogs(ctx, "api", 1); !errors.Is(err, sentinel) {
93+
t.Errorf("LogsErr not propagated: %v", err)
94+
}
95+
}
96+
97+
func TestLastLines(t *testing.T) {
98+
cases := []struct {
99+
in string
100+
n int
101+
want string
102+
}{
103+
{"", 5, ""},
104+
{"a\nb\nc\n", 0, ""},
105+
{"a\nb\nc\n", 2, "b\nc\n"},
106+
{"a\nb\nc", 2, "b\nc"}, // no trailing newline preserved
107+
{"a\nb\nc\n", 10, "a\nb\nc\n"},
108+
{"solo", 1, "solo"},
109+
}
110+
for _, c := range cases {
111+
if got := lastLines(c.in, c.n); got != c.want {
112+
t.Errorf("lastLines(%q, %d) = %q, want %q", c.in, c.n, got, c.want)
113+
}
114+
}
115+
}

0 commit comments

Comments
 (0)