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
6 changes: 6 additions & 0 deletions assets/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ RUN apt-get update && apt-get install -y \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*

ARG EXTRA_PACKAGES=""
RUN if [ -n "$EXTRA_PACKAGES" ]; then \
apt-get update && apt-get install -y $EXTRA_PACKAGES \
&& rm -rf /var/lib/apt/lists/*; \
fi

RUN npm install -g @anthropic-ai/claude-code

RUN userdel -r ubuntu 2>/dev/null; useradd -m -s /bin/bash -u 1000 agent
Expand Down
2 changes: 1 addition & 1 deletion internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ func Run(projectDir string, cfg *config.Config, apiKey, oauthToken string, docke

// Build image.
slog.Info("building docker image")
if err := d.docker.BuildImage(projectDir); err != nil {
if err := d.docker.BuildImage(projectDir, cfg.Docker.ExtraPackages); err != nil {
return fmt.Errorf("daemon: failed to build image: %w", err)
}

Expand Down
2 changes: 1 addition & 1 deletion internal/daemon/daemon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ type mockDockerClient struct {
logsErr error
}

func (m *mockDockerClient) BuildImage(projectDir string) error {
func (m *mockDockerClient) BuildImage(projectDir string, extraPackages []string) error {
return m.buildErr
}

Expand Down
11 changes: 9 additions & 2 deletions internal/docker/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ type AgentInfo struct {
// DockerClient is the interface for Docker operations so the daemon and CLI
// can be tested without a real Docker daemon.
type DockerClient interface {
BuildImage(projectDir string) error
BuildImage(projectDir string, extraPackages []string) error
StartAgent(ctx context.Context, opts AgentOpts) (string, error)
StopAgent(ctx context.Context, agentID int) error
StopAllAgents(ctx context.Context) error
Expand Down Expand Up @@ -113,7 +113,7 @@ func newClientWithAPI(projectName string, api dockerAPI) *Client {

// BuildImage writes the embedded Dockerfile and entrypoint into .metamorph/docker/,
// creates a tar build context, and builds the image.
func (c *Client) BuildImage(projectDir string) error {
func (c *Client) BuildImage(projectDir string, extraPackages []string) error {
buildDir := filepath.Join(projectDir, constants.DockerDir)
if err := os.MkdirAll(buildDir, 0755); err != nil {
return fmt.Errorf("docker: failed to create build dir: %w", err)
Expand All @@ -137,13 +137,20 @@ func (c *Client) BuildImage(projectDir string) error {
return fmt.Errorf("docker: failed to create build context: %w", err)
}

buildArgs := make(map[string]*string)
if len(extraPackages) > 0 {
pkgs := strings.Join(extraPackages, " ")
buildArgs["EXTRA_PACKAGES"] = &pkgs
}

ctx, cancel := context.WithTimeout(context.Background(), buildTimeout)
defer cancel()

resp, err := c.cli.ImageBuild(ctx, buildCtx, types.ImageBuildOptions{
Tags: []string{defaultImageTag},
Dockerfile: "Dockerfile",
Remove: true,
BuildArgs: buildArgs,
})
if err != nil {
return fmt.Errorf("docker: failed to build image: %w", err)
Expand Down
50 changes: 43 additions & 7 deletions internal/docker/docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,11 @@ type mockDocker struct {
logsErr error

// Track calls for assertions.
created []mockCreateCall
started []string
stopped []string
removed []string
buildOptions types.ImageBuildOptions
created []mockCreateCall
started []string
stopped []string
removed []string
}

type mockCreateCall struct {
Expand All @@ -68,6 +69,7 @@ func (m *mockDocker) Ping(ctx context.Context) (types.Ping, error) {
}

func (m *mockDocker) ImageBuild(ctx context.Context, buildContext io.Reader, options types.ImageBuildOptions) (types.ImageBuildResponse, error) {
m.buildOptions = options
if m.buildErr != nil {
return types.ImageBuildResponse{}, m.buildErr
}
Expand Down Expand Up @@ -121,7 +123,7 @@ func TestBuildImage(t *testing.T) {
mock := &mockDocker{buildBody: `{"stream":"Successfully built abc123"}`}
c := newClientWithAPI("test-project", mock)

if err := c.BuildImage(projectDir); err != nil {
if err := c.BuildImage(projectDir, nil); err != nil {
t.Fatalf("BuildImage: %v", err)
}

Expand All @@ -145,14 +147,48 @@ func TestBuildImage(t *testing.T) {
mock := &mockDocker{buildErr: fmt.Errorf("build failed")}
c := newClientWithAPI("test-project", mock)

err := c.BuildImage(projectDir)
err := c.BuildImage(projectDir, nil)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "failed to build image") {
t.Errorf("unexpected error: %v", err)
}
})

t.Run("passes extra packages as build arg", func(t *testing.T) {
projectDir := t.TempDir()

mock := &mockDocker{buildBody: `{"stream":"Successfully built abc123"}`}
c := newClientWithAPI("test-project", mock)

if err := c.BuildImage(projectDir, []string{"vim", "htop"}); err != nil {
t.Fatalf("BuildImage: %v", err)
}

got, ok := mock.buildOptions.BuildArgs["EXTRA_PACKAGES"]
if !ok || got == nil {
t.Fatal("expected EXTRA_PACKAGES build arg to be set")
}
if *got != "vim htop" {
t.Errorf("EXTRA_PACKAGES = %q, want %q", *got, "vim htop")
}
})

t.Run("omits build arg when no extra packages", func(t *testing.T) {
projectDir := t.TempDir()

mock := &mockDocker{buildBody: `{"stream":"Successfully built abc123"}`}
c := newClientWithAPI("test-project", mock)

if err := c.BuildImage(projectDir, nil); err != nil {
t.Fatalf("BuildImage: %v", err)
}

if _, ok := mock.buildOptions.BuildArgs["EXTRA_PACKAGES"]; ok {
t.Error("EXTRA_PACKAGES build arg should not be set when no extra packages")
}
})
}

func TestStartAgent(t *testing.T) {
Expand Down Expand Up @@ -716,7 +752,7 @@ func TestDockerClientInterface(t *testing.T) {
// mockDockerClient is a full mock of the DockerClient interface for consumers.
type mockDockerClient struct{}

func (m *mockDockerClient) BuildImage(projectDir string) error { return nil }
func (m *mockDockerClient) BuildImage(projectDir string, extraPackages []string) error { return nil }
func (m *mockDockerClient) StartAgent(ctx context.Context, opts AgentOpts) (string, error) {
return "mock-id", nil
}
Expand Down
Loading