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 internal/apple/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func (c *Container) Start(ctx context.Context) error {

// Attach runs the actual user command inside the container using
// `container exec --tty --interactive`. Apple Container handles TTY natively.
func (c *Container) Attach(ctx context.Context, w internal.Writer) error {
func (c *Container) Attach(ctx context.Context, cancel context.CancelFunc, w internal.Writer) error {
args := []string{"exec", "--tty", "--interactive"}

if c.workingDir != "" {
Expand Down
10 changes: 5 additions & 5 deletions internal/apple/container_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ func TestContainerAttach(t *testing.T) {
container := createTestContainer(t, runner)
w := &mockWriter{}

err := container.Attach(context.Background(), w)
err := container.Attach(context.Background(), func() {}, w)
require.NoError(t, err)

require.Len(t, runner.calls, 1)
Expand Down Expand Up @@ -244,7 +244,7 @@ func TestContainerAttach(t *testing.T) {
container := createTestContainer(t, runner)
w := &mockWriter{}

err := container.Attach(context.Background(), w)
err := container.Attach(context.Background(), func() {}, w)
require.Error(t, err)
require.Contains(t, err.Error(), "failed to exec in container")
})
Expand All @@ -260,7 +260,7 @@ func TestContainerWait(t *testing.T) {
container := createTestContainer(t, runner)
w := &mockWriter{}

err := container.Attach(context.Background(), w)
err := container.Attach(context.Background(), func() {}, w)
require.NoError(t, err)

// Use a writer that captures output
Expand All @@ -279,7 +279,7 @@ func TestContainerWait(t *testing.T) {
container := createTestContainer(t, runner)
w := &mockWriter{}

err := container.Attach(context.Background(), w)
err := container.Attach(context.Background(), func() {}, w)
require.NoError(t, err)

outWriter := &capturingWriter{}
Expand All @@ -306,7 +306,7 @@ func TestContainerWait(t *testing.T) {
container := createTestContainer(t, runner)
w := &mockWriter{}

err := container.Attach(context.Background(), w)
err := container.Attach(context.Background(), func() {}, w)
require.NoError(t, err)

outWriter := &capturingWriter{}
Expand Down
6 changes: 1 addition & 5 deletions internal/apple/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,12 @@ package apple
import (
"context"
"fmt"
"math"
"os"
"strconv"

"github.com/ryanmoran/contagent/internal"
"github.com/ryanmoran/contagent/internal/runtime"
)

const MaxSleepTime = math.MaxInt32

// Compile-time check that Runtime implements runtime.Runtime.
var _ runtime.Runtime = (*Runtime)(nil)

Expand Down Expand Up @@ -63,7 +59,7 @@ func (r *Runtime) CreateContainer(ctx context.Context, opts runtime.CreateContai
args = append(args, "--workdir", opts.WorkingDir)
}

args = append(args, opts.Image.Name, "sleep", strconv.Itoa(MaxSleepTime))
args = append(args, opts.Image.Name, "sleep", "infinity")

err := r.runner.Run(ctx, nil, os.Stdout, os.Stderr, "container", args...)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/apple/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func TestRuntimeCreateContainer(t *testing.T) {
require.Contains(t, call.Args, "/app")
require.Contains(t, call.Args, "myimage:latest")
require.Contains(t, call.Args, "sleep")
require.Contains(t, call.Args, "2147483647")
require.Contains(t, call.Args, "infinity")
})

t.Run("returns error on create failure", func(t *testing.T) {
Expand Down
13 changes: 4 additions & 9 deletions internal/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package internal

import (
"fmt"
"os"
"os/exec"
goruntime "runtime"
"strings"
Expand Down Expand Up @@ -53,12 +52,7 @@ type GitUserConfig struct {
// the configuration for running a container. It uses the new config package to load
// and merge configuration from multiple sources (defaults, config files, CLI flags).
// Returns an error if config loading fails (e.g., invalid config file, bad flags).
func ParseConfig(args []string, environment []string) (Config, error) {
startDir, err := os.Getwd()
if err != nil {
startDir = "."
}

func ParseConfig(args []string, environment []string, startDir string) (Config, error) {
// Use the new config package to load and parse configuration
cfg, programArgs, err := config.Load(args, environment, startDir)
if err != nil {
Expand Down Expand Up @@ -152,8 +146,9 @@ func buildEnvironment(environment []string, configEnv map[string]string, rt stri
env = append(env, fmt.Sprintf("COLORTERM=%s", value))

// Add ANTHROPIC_API_KEY if present
value = lookup["ANTHROPIC_API_KEY"]
env = append(env, fmt.Sprintf("ANTHROPIC_API_KEY=%s", value))
if value := lookup["ANTHROPIC_API_KEY"]; value != "" {
env = append(env, fmt.Sprintf("ANTHROPIC_API_KEY=%s", value))
}

// Set SSH_AUTH_SOCK for Docker runtime only (Apple uses --ssh flag natively)
if rt == "docker" {
Expand Down
5 changes: 3 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,9 @@ func Load(cliArgs []string, environment []string, startDir string) (Config, []st
fs.Var(&envFlags, "env", "Environment variable (KEY=VALUE)")
fs.Var(&volumeFlags, "volume", "Volume mount")

// Ignore parse errors since we want to handle remaining args separately
_ = fs.Parse(cliArgs) //nolint:errcheck // Intentionally parsing what we can and handling remaining args
if err := fs.Parse(cliArgs); err != nil {
return Config{}, nil, err
}

// Extract remaining program arguments
programArgs := fs.Args()
Expand Down
19 changes: 9 additions & 10 deletions internal/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func TestConfig(t *testing.T) {
"OTHER_KEY=other-value",
}

config, err := internal.ParseConfig(args, env)
config, err := internal.ParseConfig(args, env, ".")
require.NoError(t, err)
require.Equal(t, internal.Command([]string{"some-command", "--some-option"}), config.Args)
require.Equal(t, internal.Environment([]string{
Expand All @@ -50,7 +50,7 @@ func TestConfig(t *testing.T) {
"ANTHROPIC_API_KEY=some-api-key",
}

config, err := internal.ParseConfig(args, env)
config, err := internal.ParseConfig(args, env, ".")
require.NoError(t, err)
require.Equal(t, internal.Command([]string{"some-program", "--arg"}), config.Args)
require.ElementsMatch(t, []string{
Expand All @@ -72,7 +72,7 @@ func TestConfig(t *testing.T) {
"TERM=some-term",
}

config, err := internal.ParseConfig(args, env)
config, err := internal.ParseConfig(args, env, ".")
require.NoError(t, err)
require.Equal(t, internal.Command([]string{"some-program"}), config.Args)
require.Equal(t, []string{
Expand All @@ -93,7 +93,7 @@ func TestConfig(t *testing.T) {
"TERM=some-term",
}

config, err := internal.ParseConfig(args, env)
config, err := internal.ParseConfig(args, env, ".")
require.NoError(t, err)
require.Equal(t, internal.Command([]string{"some-program"}), config.Args)
require.Equal(t, []string{
Expand All @@ -115,13 +115,12 @@ func TestConfig(t *testing.T) {
"TERM=some-term",
}

config, err := internal.ParseConfig(args, env)
config, err := internal.ParseConfig(args, env, ".")
require.NoError(t, err)
require.Equal(t, internal.Command([]string{"some-program", "--arg"}), config.Args)
require.ElementsMatch(t, []string{
"TERM=some-term",
"COLORTERM=truecolor",
"ANTHROPIC_API_KEY=",
"SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock",
"VAR1=value1",
"VAR2=value2",
Expand All @@ -142,7 +141,7 @@ func TestConfig(t *testing.T) {
"TERM=some-term",
}

config, err := internal.ParseConfig(args, env)
config, err := internal.ParseConfig(args, env, ".")
require.Error(t, err)
require.Contains(t, err.Error(), "time: invalid duration")
require.Equal(t, internal.Config{}, config)
Expand All @@ -155,7 +154,7 @@ func TestConfig(t *testing.T) {
"TERM=some-term",
}

config, err := internal.ParseConfig(args, env)
config, err := internal.ParseConfig(args, env, ".")
require.Error(t, err)
require.Contains(t, err.Error(), "file does not exist")
require.Equal(t, internal.Config{}, config)
Expand All @@ -170,7 +169,7 @@ func TestConfig(t *testing.T) {
"TERM=some-term",
}

config, err := internal.ParseConfig(args, env)
config, err := internal.ParseConfig(args, env, ".")
require.NoError(t, err)
require.Equal(t, "/some/path/to/a/Dockerfile", config.DockerfilePath)
})
Expand All @@ -184,7 +183,7 @@ func TestConfig(t *testing.T) {
"TERM=some-term",
}

config, err := internal.ParseConfig(args, env)
config, err := internal.ParseConfig(args, env, ".")
require.NoError(t, err)
require.Equal(t, "some-network", config.Network)
})
Expand Down
1 change: 0 additions & 1 deletion internal/docker/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,6 @@ func (c Client) BuildImage(ctx context.Context, dockerfilePath string, imageName
}
case <-ctx.Done():
return runtime.Image{}, ctx.Err()
default:
}

decoder := json.NewDecoder(response.Body)
Expand Down
4 changes: 4 additions & 0 deletions internal/docker/client_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func TestBuildImageWithMock(t *testing.T) {

mock := &mockDockerClient{
imageBuildFunc: func(ctx context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error) {
io.Copy(io.Discard, buildContext) //nolint:errcheck // draining pipe for goroutine completion
return client.ImageBuildResult{
Body: io.NopCloser(bytes.NewReader(outputBytes)),
}, nil
Expand Down Expand Up @@ -69,6 +70,7 @@ func TestBuildImageWithMock(t *testing.T) {

mock := &mockDockerClient{
imageBuildFunc: func(ctx context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error) {
io.Copy(io.Discard, buildContext) //nolint:errcheck // draining pipe for goroutine completion
return client.ImageBuildResult{}, errors.New("build failed")
},
}
Expand Down Expand Up @@ -103,6 +105,7 @@ func TestBuildImageWithMock(t *testing.T) {

mock := &mockDockerClient{
imageBuildFunc: func(ctx context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error) {
io.Copy(io.Discard, buildContext) //nolint:errcheck // draining pipe for goroutine completion
return client.ImageBuildResult{
Body: io.NopCloser(bytes.NewReader(outputBytes)),
}, nil
Expand All @@ -129,6 +132,7 @@ func TestBuildImageWithMock(t *testing.T) {

mock := &mockDockerClient{
imageBuildFunc: func(ctx context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error) {
io.Copy(io.Discard, buildContext) //nolint:errcheck // draining pipe for goroutine completion
return client.ImageBuildResult{}, context.Canceled
},
}
Expand Down
20 changes: 7 additions & 13 deletions internal/docker/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ import (
"errors"
"fmt"
"io"
"os"
"os/signal"
"sync"
"syscall"
"time"

"github.com/docker/cli/cli/streams"
Expand Down Expand Up @@ -65,7 +62,7 @@ func (c Container) Start(ctx context.Context) error {
// It sets the terminal to raw mode, monitors terminal resize events, and forwards I/O between
// the local terminal and the container. Returns an error if terminal setup fails, TTY monitoring
// fails, or container attachment fails.
func (c Container) Attach(ctx context.Context, w internal.Writer) error {
func (c Container) Attach(ctx context.Context, cancel context.CancelFunc, w internal.Writer) error {
stdin, stdout, _ := term.StdStreams()
in := streams.NewIn(stdin)
out := streams.NewOut(stdout)
Expand All @@ -80,8 +77,8 @@ func (c Container) Attach(ctx context.Context, w internal.Writer) error {
w.Warningf("failed to resize tty: %v", err)
}

tty := NewTTY(c.client, out, c.ID, c.TTYRetries, c.RetryDelay, w)
err = tty.Monitor(ctx)
tty := NewTTY(c.client, out, c.ID, c.TTYRetries, c.RetryDelay, w, cancel)
err = tty.Monitor(ctx, cancel)
if err != nil {
return fmt.Errorf("failed to monitor tty size: %w", err)
}
Expand Down Expand Up @@ -157,28 +154,25 @@ func (c Container) Attach(ctx context.Context, w internal.Writer) error {
return nil
}

// Wait waits for the container to exit or for an interrupt signal (SIGINT, SIGTERM).
// If a signal is received, it attempts to gracefully stop the container with the configured
// Wait waits for the container to exit or for context cancellation.
// If the context is cancelled, it attempts to gracefully stop the container with the configured
// timeout. Returns an error if waiting for the container fails.
func (c Container) Wait(ctx context.Context, w internal.Writer) error {
wait := c.client.ContainerWait(ctx, c.ID, client.ContainerWaitOptions{
Condition: container.WaitConditionNotRunning,
})

sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

select {
case err := <-wait.Error:
if err != nil {
return fmt.Errorf("failed to wait for container %q: %w\nDocker daemon may have encountered an error", c.Name, err)
}
case status := <-wait.Result:
w.Printf("\nContainer exited with status: %d\n", status.StatusCode)
case <-sigChan:
case <-ctx.Done():
w.Println("\nReceived signal, stopping container...")
timeout := c.StopTimeout
_, err := c.client.ContainerStop(ctx, c.ID, client.ContainerStopOptions{Timeout: &timeout})
_, err := c.client.ContainerStop(context.Background(), c.ID, client.ContainerStopOptions{Timeout: &timeout})
if err != nil {
w.Warningf("failed to stop container: %v", err)
}
Expand Down
12 changes: 8 additions & 4 deletions internal/docker/tty.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,30 @@ type TTY struct {
maxRetries int
retryDelay time.Duration
writer internal.Writer
cancel context.CancelFunc
}

// NewTTY creates a TTY handler for monitoring and resizing the container's terminal.
// The maxRetries parameter controls how many times to retry initial resize operations,
// and retryDelay specifies the base delay between retries.
func NewTTY(client DockerClient, out *streams.Out, id string, maxRetries int, retryDelay time.Duration, writer internal.Writer) TTY {
// and retryDelay specifies the base delay between retries. The cancel function is called
// if retries are exhausted to signal context cancellation instead of calling Fatal.
func NewTTY(client DockerClient, out *streams.Out, id string, maxRetries int, retryDelay time.Duration, writer internal.Writer, cancel context.CancelFunc) TTY {
return TTY{
client: client,
out: out,
id: id,
maxRetries: maxRetries,
retryDelay: retryDelay,
writer: writer,
cancel: cancel,
}
}

// Monitor monitors the terminal for resize events (SIGWINCH) and automatically resizes
// the container's TTY to match. If the initial resize fails, it retries with exponential
// backoff up to the configured maximum retries. Returns nil after starting background
// monitoring goroutines, or an error if the context is cancelled during setup.
func (t TTY) Monitor(ctx context.Context) error {
func (t TTY) Monitor(ctx context.Context, cancel context.CancelFunc) error {
err := t.Resize(ctx)
if err != nil {
go func() {
Expand All @@ -55,7 +58,8 @@ func (t TTY) Monitor(ctx context.Context) error {
}
}
if err != nil {
t.writer.Fatalf("failed to resize tty: %v", err)
t.writer.Warningf("failed to resize tty: %v", err)
cancel()
}
}()
}
Expand Down
Loading
Loading