Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions internal/docker/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions internal/docker/inspect.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand Down Expand Up @@ -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 {
Expand Down
100 changes: 100 additions & 0 deletions internal/docker/inspect_integration_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
115 changes: 115 additions & 0 deletions internal/docker/inspect_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading