Skip to content

Commit 5ae40d8

Browse files
gustavobertoiclaude
andcommitted
feat(docker): M2 network-ensure, container enumeration, compose driver (spec 03)
Extends the read-only Client and adds the lifecycle driver (DECISIONS D4/D5): - Client.EnsureNetwork / NetworkExists: idempotent tool-owned external bridge network (devstack owns it because Compose refuses to create external networks). - Client.ListManaged: label-filtered container enumeration with All=true and compose one-offs excluded — the basis for ref-count reconciliation from live reality; Container/PortBinding read-only projections. - moby-backed impls (moby/moby/client) + in-memory MockClient so workspace reconcile/up logic is unit/race-testable without a daemon. - Compose driver: `docker compose -p <project> -f <file>` up/down/stop/build via an injectable Runner; secrets pass through exec env (never a file, §7.5); failures wrap command + exit code + stderr (CmdError, §7.6). Command construction + mock filtering are unit-tested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a49ea6c commit 5ae40d8

6 files changed

Lines changed: 446 additions & 0 deletions

File tree

internal/docker/compose.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package docker
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"io"
8+
"os"
9+
"os/exec"
10+
"strings"
11+
)
12+
13+
// Runner executes external commands. It is injectable so compose command
14+
// construction is unit-testable without a real `docker` binary.
15+
type Runner interface {
16+
// Run streams stdout/stderr to the user (image-pull/up progress).
17+
Run(ctx context.Context, env []string, dir, name string, args ...string) error
18+
// Output captures stdout (for `compose ps --format json` and friends).
19+
Output(ctx context.Context, env []string, dir, name string, args ...string) ([]byte, error)
20+
}
21+
22+
// ExecRunner runs commands via os/exec, inheriting the process env plus extra
23+
// env. Secrets are passed through env here and never written to a file (§7.5).
24+
// A failure is wrapped with the command + exit code + captured stderr so the
25+
// error is self-debuggable (ARCHITECTURE §7.6).
26+
type ExecRunner struct{}
27+
28+
func (ExecRunner) Run(ctx context.Context, env []string, dir, name string, args ...string) error {
29+
cmd := exec.CommandContext(ctx, name, args...)
30+
cmd.Dir = dir
31+
cmd.Env = append(os.Environ(), env...)
32+
cmd.Stdout = os.Stdout
33+
var stderr bytes.Buffer
34+
cmd.Stderr = io.MultiWriter(os.Stderr, &stderr)
35+
if err := cmd.Run(); err != nil {
36+
return &CmdError{Cmd: name + " " + strings.Join(args, " "), Err: err, Stderr: strings.TrimSpace(stderr.String())}
37+
}
38+
return nil
39+
}
40+
41+
func (ExecRunner) Output(ctx context.Context, env []string, dir, name string, args ...string) ([]byte, error) {
42+
cmd := exec.CommandContext(ctx, name, args...)
43+
cmd.Dir = dir
44+
cmd.Env = append(os.Environ(), env...)
45+
var stdout, stderr bytes.Buffer
46+
cmd.Stdout = &stdout
47+
cmd.Stderr = &stderr
48+
if err := cmd.Run(); err != nil {
49+
return nil, &CmdError{Cmd: name + " " + strings.Join(args, " "), Err: err, Stderr: strings.TrimSpace(stderr.String())}
50+
}
51+
return stdout.Bytes(), nil
52+
}
53+
54+
// CmdError carries the failed command, its error, and captured stderr.
55+
type CmdError struct {
56+
Cmd string
57+
Stderr string
58+
Err error
59+
}
60+
61+
func (e *CmdError) Error() string {
62+
if e.Stderr != "" {
63+
return fmt.Sprintf("`%s` failed: %v\n%s", e.Cmd, e.Err, e.Stderr)
64+
}
65+
return fmt.Sprintf("`%s` failed: %v", e.Cmd, e.Err)
66+
}
67+
68+
func (e *CmdError) Unwrap() error { return e.Err }
69+
70+
// Compose drives the `docker compose` CLI for a single stack with an explicit
71+
// project name and compose file (DECISIONS D5). Lifecycle verbs run here;
72+
// container enumeration stays on the read-only SDK Client.
73+
type Compose struct {
74+
Project string // -p <project>
75+
File string // -f <compose file>
76+
Dir string // working dir (build contexts resolve relative to it)
77+
Env []string // extra env (resolved secrets), appended to os.Environ
78+
Runner Runner
79+
}
80+
81+
// NewCompose builds a Compose driver using the real exec runner.
82+
func NewCompose(project, file, dir string) *Compose {
83+
return &Compose{Project: project, File: file, Dir: dir, Runner: ExecRunner{}}
84+
}
85+
86+
func (c *Compose) base() []string {
87+
return []string{"compose", "-p", c.Project, "-f", c.File}
88+
}
89+
90+
// Up brings the stack (or the named subset of services) up detached. With no
91+
// services it brings the whole stack up. It does NOT pass --remove-orphans: the
92+
// shared stack is brought up service-by-service and stateful services must never
93+
// be disturbed implicitly (spec 03).
94+
func (c *Compose) Up(ctx context.Context, services ...string) error {
95+
args := append(c.base(), "up", "-d")
96+
args = append(args, services...)
97+
return c.Runner.Run(ctx, c.Env, c.Dir, "docker", args...)
98+
}
99+
100+
// Down stops and removes the stack's containers (and its default network).
101+
// Named external networks are NOT removed by compose — devstack owns those.
102+
// volumes=true also removes named volumes (destructive — caller confirms).
103+
func (c *Compose) Down(ctx context.Context, volumes bool) error {
104+
args := append(c.base(), "down")
105+
if volumes {
106+
args = append(args, "--volumes")
107+
}
108+
return c.Runner.Run(ctx, c.Env, c.Dir, "docker", args...)
109+
}
110+
111+
// Stop pauses the stack (or named services) without removing containers — used
112+
// by `shared gc`/autostop where the data volumes must survive.
113+
func (c *Compose) Stop(ctx context.Context, services ...string) error {
114+
args := append(c.base(), "stop")
115+
args = append(args, services...)
116+
return c.Runner.Run(ctx, c.Env, c.Dir, "docker", args...)
117+
}
118+
119+
// Build rebuilds the named services (with --no-cache for the selective-rebuild
120+
// contexts the generate ledger flagged). With no services, builds all.
121+
func (c *Compose) Build(ctx context.Context, noCache bool, services ...string) error {
122+
args := append(c.base(), "build")
123+
if noCache {
124+
args = append(args, "--no-cache")
125+
}
126+
args = append(args, services...)
127+
return c.Runner.Run(ctx, c.Env, c.Dir, "docker", args...)
128+
}

internal/docker/compose_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package docker
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
)
8+
9+
// fakeRunner records invocations so command construction can be asserted without
10+
// a real docker binary.
11+
type fakeRunner struct {
12+
calls [][]string
13+
}
14+
15+
func (f *fakeRunner) Run(_ context.Context, _ []string, _, name string, args ...string) error {
16+
f.calls = append(f.calls, append([]string{name}, args...))
17+
return nil
18+
}
19+
20+
func (f *fakeRunner) Output(_ context.Context, _ []string, _, name string, args ...string) ([]byte, error) {
21+
f.calls = append(f.calls, append([]string{name}, args...))
22+
return nil, nil
23+
}
24+
25+
func (f *fakeRunner) last() string { return strings.Join(f.calls[len(f.calls)-1], " ") }
26+
27+
func newTestCompose() (*Compose, *fakeRunner) {
28+
r := &fakeRunner{}
29+
c := &Compose{Project: "devstack-api", File: "/ws/.devstack/docker-compose.yaml", Dir: "/ws/.devstack", Runner: r}
30+
return c, r
31+
}
32+
33+
func TestComposeUp(t *testing.T) {
34+
c, r := newTestCompose()
35+
if err := c.Up(context.Background()); err != nil {
36+
t.Fatal(err)
37+
}
38+
want := "docker compose -p devstack-api -f /ws/.devstack/docker-compose.yaml up -d"
39+
if got := r.last(); got != want {
40+
t.Errorf("up = %q, want %q", got, want)
41+
}
42+
}
43+
44+
func TestComposeUpSubset(t *testing.T) {
45+
c, r := newTestCompose()
46+
c.Project = "devstack-shared"
47+
_ = c.Up(context.Background(), "postgres", "redis")
48+
if got := r.last(); !strings.HasSuffix(got, "up -d postgres redis") {
49+
t.Errorf("subset up = %q", got)
50+
}
51+
}
52+
53+
func TestComposeDownAndStop(t *testing.T) {
54+
c, r := newTestCompose()
55+
_ = c.Down(context.Background(), false)
56+
if got := r.last(); !strings.HasSuffix(got, "down") {
57+
t.Errorf("down = %q", got)
58+
}
59+
_ = c.Down(context.Background(), true)
60+
if got := r.last(); !strings.HasSuffix(got, "down --volumes") {
61+
t.Errorf("down --volumes = %q", got)
62+
}
63+
_ = c.Stop(context.Background(), "postgres")
64+
if got := r.last(); !strings.HasSuffix(got, "stop postgres") {
65+
t.Errorf("stop = %q", got)
66+
}
67+
}
68+
69+
func TestComposeBuildNoCache(t *testing.T) {
70+
c, r := newTestCompose()
71+
_ = c.Build(context.Background(), true, "api")
72+
if got := r.last(); !strings.HasSuffix(got, "build --no-cache api") {
73+
t.Errorf("build = %q", got)
74+
}
75+
}
76+
77+
func TestCmdErrorMessage(t *testing.T) {
78+
e := &CmdError{Cmd: "docker compose up", Stderr: "network not found", Err: context.Canceled}
79+
if !strings.Contains(e.Error(), "network not found") || !strings.Contains(e.Error(), "docker compose up") {
80+
t.Errorf("error message = %q", e.Error())
81+
}
82+
}

internal/docker/docker.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,41 @@ type Client interface {
3535
// ContextName returns the active Docker context the client is bound to;
3636
// this keys the state ledger.
3737
ContextName() string
38+
// EnsureNetwork idempotently ensures an external bridge network named `name`
39+
// exists (inspect → create), applying labels on creation. devstack owns this
40+
// network's lifecycle because Compose refuses to create external networks
41+
// (ARCHITECTURE §4). Safe to call concurrently under the flock.
42+
EnsureNetwork(ctx context.Context, name string, labels map[string]string) error
43+
// NetworkExists reports whether a network with the exact name exists.
44+
NetworkExists(ctx context.Context, name string) (bool, error)
45+
// ListManaged returns containers carrying ALL of the given labels, with
46+
// All=true (so stopped containers are visible) and compose one-offs excluded
47+
// (DECISIONS D5) — the basis for ref-count reconciliation from live reality.
48+
ListManaged(ctx context.Context, labels map[string]string) ([]Container, error)
3849
// Close releases the underlying connection.
3950
Close() error
4051
}
4152

53+
// Container is the read-only projection of a container devstack cares about.
54+
type Container struct {
55+
ID string
56+
Name string // primary name, leading slash stripped
57+
Labels map[string]string
58+
State string // running | exited | created | ...
59+
Ports []PortBinding
60+
}
61+
62+
// Running reports whether the container is in the running state.
63+
func (c Container) Running() bool { return c.State == "running" }
64+
65+
// PortBinding is one published host port mapping on a container.
66+
type PortBinding struct {
67+
HostIP string
68+
HostPort int
69+
ContainerPort int
70+
Protocol string // tcp | udp | sctp
71+
}
72+
4273
// Version is a simple major.minor for tool-version gating.
4374
type Version struct{ Major, Minor int }
4475

internal/docker/inspect.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package docker
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
moby "github.com/moby/moby/client"
9+
)
10+
11+
// networkDriver is the driver for the tool-owned shared bridge network.
12+
const networkDriver = "bridge"
13+
14+
// composeOneoffLabel marks `compose run`/`exec` one-off containers; excluding
15+
// them keeps them from inflating ref counts (DECISIONS D5).
16+
const composeOneoffLabel = "com.docker.compose.oneoff"
17+
18+
// EnsureNetwork idempotently ensures an external bridge network exists.
19+
func (m *mobyClient) EnsureNetwork(ctx context.Context, name string, labels map[string]string) error {
20+
exists, err := m.NetworkExists(ctx, name)
21+
if err != nil {
22+
return err
23+
}
24+
if exists {
25+
return nil
26+
}
27+
_, err = m.cli.NetworkCreate(ctx, name, moby.NetworkCreateOptions{
28+
Driver: networkDriver,
29+
Attachable: true,
30+
Labels: labels,
31+
})
32+
if err != nil {
33+
// Tolerate a concurrent creator (the flock makes this rare, but a foreign
34+
// process or another engine client could still win the race).
35+
if strings.Contains(strings.ToLower(err.Error()), "already exists") {
36+
return nil
37+
}
38+
return fmt.Errorf("create network %q: %w", name, err)
39+
}
40+
return nil
41+
}
42+
43+
// NetworkExists reports whether a network with the exact name exists. NetworkList
44+
// name filtering is a substring match, so the result is checked for an exact hit.
45+
func (m *mobyClient) NetworkExists(ctx context.Context, name string) (bool, error) {
46+
res, err := m.cli.NetworkList(ctx, moby.NetworkListOptions{
47+
Filters: moby.Filters{}.Add("name", name),
48+
})
49+
if err != nil {
50+
return false, fmt.Errorf("list networks: %w", err)
51+
}
52+
for _, n := range res.Items {
53+
if n.Name == name {
54+
return true, nil
55+
}
56+
}
57+
return false, nil
58+
}
59+
60+
// ListManaged returns containers carrying ALL the given labels, with stopped
61+
// containers included and compose one-offs excluded.
62+
func (m *mobyClient) ListManaged(ctx context.Context, labels map[string]string) ([]Container, error) {
63+
f := moby.Filters{}
64+
for k, v := range labels {
65+
f = f.Add("label", k+"="+v)
66+
}
67+
res, err := m.cli.ContainerList(ctx, moby.ContainerListOptions{All: true, Filters: f})
68+
if err != nil {
69+
return nil, fmt.Errorf("list containers: %w", err)
70+
}
71+
out := make([]Container, 0, len(res.Items))
72+
for _, s := range res.Items {
73+
if s.Labels[composeOneoffLabel] == "True" || s.Labels[composeOneoffLabel] == "true" {
74+
continue
75+
}
76+
c := Container{
77+
ID: s.ID,
78+
Name: primaryName(s.Names),
79+
Labels: s.Labels,
80+
State: string(s.State),
81+
}
82+
for _, p := range s.Ports {
83+
if p.PublicPort == 0 {
84+
continue // not published to the host
85+
}
86+
ip := ""
87+
if p.IP.IsValid() {
88+
ip = p.IP.String()
89+
}
90+
c.Ports = append(c.Ports, PortBinding{
91+
HostIP: ip,
92+
HostPort: int(p.PublicPort),
93+
ContainerPort: int(p.PrivatePort),
94+
Protocol: p.Type,
95+
})
96+
}
97+
out = append(out, c)
98+
}
99+
return out, nil
100+
}
101+
102+
// primaryName returns the first container name with the leading '/' stripped.
103+
func primaryName(names []string) string {
104+
if len(names) == 0 {
105+
return ""
106+
}
107+
return strings.TrimPrefix(names[0], "/")
108+
}

0 commit comments

Comments
 (0)