diff --git a/.contagent.example.yaml b/.contagent.example.yaml new file mode 100644 index 0000000..4374495 --- /dev/null +++ b/.contagent.example.yaml @@ -0,0 +1,83 @@ +# Example contagent configuration file +# +# This file demonstrates all available configuration options for contagent. +# Place this file in your project root as .contagent.yaml to customize +# container behavior for your project. +# +# Configuration resolution order: +# 1. Hardcoded defaults +# 2. Global config (~/.config/contagent/config.yaml) +# 3. Project config (.contagent.yaml) - this file +# 4. CLI flags (final override) + +# Image name for the container +# Default: contagent:latest +image: contagent:latest + +# Working directory inside the container +# Default: /app +working_dir: /app + +# Path to Dockerfile for building the container image +# Default: (none, must be specified via CLI or config) +# dockerfile: ./Dockerfile + +# Docker network to use for the container +# Default: default +network: default + +# Container stop timeout in seconds +# Default: 10 +stop_timeout: 10 + +# Number of TTY resize retry attempts +# Default: 10 +tty_retries: 10 + +# Delay between TTY resize retries +# Accepts duration strings like "10ms", "100ms", "1s" +# Default: 10ms +retry_delay: 10ms + +# Git configuration +git: + user: + # Git user name for commits made in the container + # Default: Contagent + name: Contagent + # Git user email for commits made in the container + # Default: contagent@example.com + email: contagent@example.com + +# Environment variables to pass to the container +# These are merged with CLI --env flags (CLI flags take precedence) +# Supports variable expansion using $VAR or ${VAR} syntax +env: + # Example: Set a custom variable + # MY_PROJECT_VAR: some_value + + # Example: Reference host environment variables + # MY_PATH: $HOME/bin + # USER_DIR: ${HOME}/${USER} + +# Volume mounts for the container +# Format: HOST_PATH:CONTAINER_PATH +# These are appended to CLI --volume flags +# Supports variable expansion using $VAR or ${VAR} syntax +volumes: + # Example: Mount a local cache directory + # - $HOME/.cache:/root/.cache + + # Example: Mount additional project directories + # - ./data:/data + # - ./config:/config + +# Note: The following are always automatically mounted: +# - /var/run/docker.sock:/var/run/docker.sock (Docker socket) +# - /run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock (SSH agent) +# +# The following environment variables are always passed through: +# - TERM (defaults to xterm-256color) +# - COLORTERM (defaults to truecolor) +# - ANTHROPIC_API_KEY +# - SSH_AUTH_SOCK (set to /run/host-services/ssh-auth.sock) diff --git a/README.md b/README.md index dbb9d3d..247648e 100644 --- a/README.md +++ b/README.md @@ -106,31 +106,180 @@ Host Machine Container (contagent-1234) ## Configuration +`contagent` supports flexible configuration through multiple sources, allowing you to customize behavior at different levels. + +### Configuration Resolution Order + +Configuration is merged in the following order (later sources override earlier ones): + +1. **Hardcoded defaults** +2. **Global config** (`~/.config/contagent/config.yaml`) +3. **Project config** (`.contagent.yaml` in project root) +4. **CLI flags** (final override) + +### Configuration Files + +Configuration files use YAML format and support all available settings. + +#### Project Configuration + +Create a `.contagent.yaml` file in your project root to set project-specific defaults: + +```yaml +# Basic container settings +image: contagent:latest +working_dir: /app +dockerfile: ./Dockerfile +network: default +stop_timeout: 10 +tty_retries: 10 +retry_delay: 10ms + +# Git configuration +git: + user: + name: Contagent + email: contagent@example.com + +# Environment variables +# Supports variable expansion using $VAR or ${VAR} syntax +env: + MY_PROJECT_VAR: some_value + MY_PATH: $HOME/bin + USER_DIR: ${HOME}/${USER} + +# Volume mounts (HOST_PATH:CONTAINER_PATH) +# Supports variable expansion +volumes: + - $HOME/.cache:/root/.cache + - ./data:/data +``` + +See [.contagent.example.yaml](./.contagent.example.yaml) for a complete example with all available options. + +#### Global Configuration + +Create `~/.config/contagent/config.yaml` to set user-level defaults that apply across all projects: + +```yaml +git: + user: + name: Your Name + email: your.email@example.com + +env: + EDITOR: vim +``` + ### Command-Line Flags -- `--env KEY=VALUE`: Add environment variable to container (can be used multiple times) -- `--volume HOST:CONTAINER`: Mount host directory into container (can be used multiple times) +CLI flags override all configuration files: + +#### Container Configuration +- `--image NAME`: Container image name +- `--dockerfile PATH`: Path to Dockerfile for building image +- `--working-dir PATH`: Working directory inside container +- `--network NAME`: Docker network to use +- `--stop-timeout SECONDS`: Container stop timeout + +#### TTY Configuration +- `--tty-retries COUNT`: Number of TTY resize retry attempts +- `--retry-delay DURATION`: Delay between retries (e.g., "10ms", "100ms") + +#### Git Configuration +- `--git-user-name NAME`: Git user name for commits +- `--git-user-email EMAIL`: Git user email for commits + +#### Runtime Configuration +- `--env KEY=VALUE`: Add environment variable (can be used multiple times) +- `--volume HOST:CONTAINER`: Mount volume (can be used multiple times) + +Example: + +```bash +contagent --env MY_VAR=value --volume /local:/remote --image custom:latest /bin/bash +``` ### Environment Variables +#### Variable Expansion + +Both `env` and `volumes` sections in configuration files support environment variable expansion: + +```yaml +env: + # Simple expansion + HOME_PATH: $HOME + + # Braced expansion + USER_DIR: ${HOME}/${USER} + + # Use in volumes +volumes: + - $HOME/.ssh:/root/.ssh + - ${PWD}/data:/data +``` + +#### Automatically Passed Variables + The following environment variables are automatically passed through to the container: - `TERM`: Terminal type (defaults to `xterm-256color`) - `COLORTERM`: Color support (defaults to `truecolor`) -- `ANTHROPIC_API_KEY`: API key for AI agent -- `SSH_AUTH_SOCK`: SSH agent socket (automatically configured) +- `ANTHROPIC_API_KEY`: API key for AI agents +- `SSH_AUTH_SOCK`: SSH agent socket (set to `/run/host-services/ssh-auth.sock`) + +### Volume Mounts + +#### Automatic Mounts -### Default Volume Mounts +These volumes are always mounted automatically: - `/var/run/docker.sock:/var/run/docker.sock`: Docker socket for Docker-in-Docker - `/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock`: SSH agent access -### Container Settings +#### Custom Mounts + +Add custom mounts via configuration files or CLI flags: + +**Via config file:** + +```yaml +volumes: + - ./data:/data + - $HOME/.cache:/root/.cache +``` + +**Via CLI:** + +```bash +contagent --volume ./data:/data --volume $HOME/.cache:/root/.cache /bin/bash +``` + +### Default Values + +If no configuration is provided, these defaults are used: + +| Setting | Default Value | +|---------|---------------| +| `image` | `contagent:latest` | +| `working_dir` | `/app` | +| `network` | `default` | +| `stop_timeout` | `10` seconds | +| `tty_retries` | `10` | +| `retry_delay` | `10ms` | +| `git.user.name` | `Contagent` | +| `git.user.email` | `contagent@example.com` | + +### Configuration Priority Example + +Given: + +1. Global config sets `git.user.name: "Alice"` +2. Project config sets `git.user.name: "Bob"` and `image: "myimage:latest"` +3. CLI flag `--git-user-name Charlie` -- **Image Name**: `contagent:latest` -- **Working Directory**: `/app` -- **Stop Timeout**: 10 seconds -- **TTY Resize Retries**: 10 attempts with exponential backoff (10ms, 20ms, 30ms, ...) +Result: `git.user.name` will be "Charlie" and `image` will be "myimage:latest" ## Development diff --git a/go.mod b/go.mod index 3534906..fca815c 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,8 @@ require ( github.com/moby/moby/client v0.1.0 github.com/moby/term v0.5.2 github.com/stretchr/testify v1.11.1 + golang.org/x/sync v0.18.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -33,7 +35,5 @@ require ( go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect - golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.35.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/integration/config_test.go b/integration/config_test.go new file mode 100644 index 0000000..fa20d57 --- /dev/null +++ b/integration/config_test.go @@ -0,0 +1,437 @@ +package integration_test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestConfigFileLoading validates that config files are properly loaded and merged +// with CLI flags in the complete end-to-end workflow. +func TestConfigFileLoading(t *testing.T) { + type TestSetup struct { + RepositoryPath string + Dockerfile string + ConfigPath string + } + + // setup creates a test git repository with a Dockerfile and optional config file + setup := func(t *testing.T, configContent string) TestSetup { + dir, err := os.MkdirTemp("", "repository-*") + require.NoError(t, err) + + // Create a simple Dockerfile + dockerfilePath := filepath.Join(dir, "Dockerfile") + err = os.WriteFile(dockerfilePath, []byte("FROM ubuntu:25.10\n"), 0644) + require.NoError(t, err) + + // Create config file if provided + var configPath string + if configContent != "" { + configPath = filepath.Join(dir, ".contagent.yaml") + err = os.WriteFile(configPath, []byte(configContent), 0644) + require.NoError(t, err) + } + + // Initialize git repository + cmd := exec.Command("git", "init") + cmd.Dir = dir + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + + // Configure git user for commits + cmd = exec.Command("git", "config", "user.email", "test@example.com") + cmd.Dir = dir + output, err = cmd.CombinedOutput() + require.NoError(t, err, string(output)) + + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = dir + output, err = cmd.CombinedOutput() + require.NoError(t, err, string(output)) + + cmd = exec.Command("git", "add", "-A", ".") + cmd.Dir = dir + output, err = cmd.CombinedOutput() + require.NoError(t, err, string(output)) + + cmd = exec.Command("git", "commit", "-m", "Initial commit") + cmd.Dir = dir + output, err = cmd.CombinedOutput() + require.NoError(t, err, string(output)) + + t.Cleanup(func() { + err := os.RemoveAll(dir) + require.NoError(t, err) + }) + + return TestSetup{ + RepositoryPath: dir, + Dockerfile: dockerfilePath, + ConfigPath: configPath, + } + } + + t.Run("loads project config file with basic settings", func(t *testing.T) { + configContent := ` +working_dir: /workspace +` + testSetup := setup(t, configContent) + + cmd := exec.Command(settings.Path, + "--dockerfile", testSetup.Dockerfile, + "bash", "-c", "exit 0") + cmd.Dir = testSetup.RepositoryPath + cmd.Env = append(os.Environ(), + "TERM=xterm-256color", + "COLORTERM=truecolor", + "ANTHROPIC_API_KEY=", + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + }) + + t.Run("loads project config file with environment variables", func(t *testing.T) { + configContent := ` +env: + TEST_VAR: config_value + ANOTHER_VAR: another_value +` + testSetup := setup(t, configContent) + + cmd := exec.Command(settings.Path, + "--dockerfile", testSetup.Dockerfile, + "bash", "-c", "exit 0") + cmd.Dir = testSetup.RepositoryPath + cmd.Env = append(os.Environ(), + "TERM=xterm-256color", + "COLORTERM=truecolor", + "ANTHROPIC_API_KEY=", + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + }) + + t.Run("loads project config file with volumes", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "contagent-volume-*") + require.NoError(t, err) + t.Cleanup(func() { + os.RemoveAll(tmpDir) + }) + + testFile := filepath.Join(tmpDir, "test.txt") + err = os.WriteFile(testFile, []byte("config test content"), 0644) + require.NoError(t, err) + + configContent := fmt.Sprintf(` +volumes: + - %s:/mnt/config-volume +`, tmpDir) + testSetup := setup(t, configContent) + + cmd := exec.Command(settings.Path, + "--dockerfile", testSetup.Dockerfile, + "bash", "-c", "test -f /mnt/config-volume/test.txt") + cmd.Dir = testSetup.RepositoryPath + cmd.Env = append(os.Environ(), + "TERM=xterm-256color", + "COLORTERM=truecolor", + "ANTHROPIC_API_KEY=", + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + }) + + t.Run("CLI flags override config file settings", func(t *testing.T) { + configContent := ` +env: + TEST_VAR: from_config +` + testSetup := setup(t, configContent) + + cmd := exec.Command(settings.Path, + "--dockerfile", testSetup.Dockerfile, + "--env", "TEST_VAR=from_cli", + "bash", "-c", "exit 0") + cmd.Dir = testSetup.RepositoryPath + cmd.Env = append(os.Environ(), + "TERM=xterm-256color", + "COLORTERM=truecolor", + "ANTHROPIC_API_KEY=", + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + }) + + t.Run("merges config file and CLI flags", func(t *testing.T) { + configContent := ` +env: + CONFIG_VAR: from_config +` + testSetup := setup(t, configContent) + + cmd := exec.Command(settings.Path, + "--dockerfile", testSetup.Dockerfile, + "--env", "CLI_VAR=from_cli", + "bash", "-c", "exit 0") + cmd.Dir = testSetup.RepositoryPath + cmd.Env = append(os.Environ(), + "TERM=xterm-256color", + "COLORTERM=truecolor", + "ANTHROPIC_API_KEY=", + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + }) + + t.Run("works without config file (defaults only)", func(t *testing.T) { + testSetup := setup(t, "") // No config file + + cmd := exec.Command(settings.Path, + "--dockerfile", testSetup.Dockerfile, + "bash", "-c", "exit 0") + cmd.Dir = testSetup.RepositoryPath + cmd.Env = append(os.Environ(), + "TERM=xterm-256color", + "COLORTERM=truecolor", + "ANTHROPIC_API_KEY=", + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + }) + + t.Run("loads config from parent directory", func(t *testing.T) { + dir, err := os.MkdirTemp("", "repository-*") + require.NoError(t, err) + t.Cleanup(func() { + os.RemoveAll(dir) + }) + + // Create config in root + configPath := filepath.Join(dir, ".contagent.yaml") + configContent := ` +env: + PARENT_CONFIG: from_parent +` + err = os.WriteFile(configPath, []byte(configContent), 0644) + require.NoError(t, err) + + // Create Dockerfile in root + dockerfilePath := filepath.Join(dir, "Dockerfile") + err = os.WriteFile(dockerfilePath, []byte("FROM ubuntu:25.10\n"), 0644) + require.NoError(t, err) + + // Initialize git repository in root + cmd := exec.Command("git", "init") + cmd.Dir = dir + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + + // Configure git user for commits + cmd = exec.Command("git", "config", "user.email", "test@example.com") + cmd.Dir = dir + output, err = cmd.CombinedOutput() + require.NoError(t, err, string(output)) + + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = dir + output, err = cmd.CombinedOutput() + require.NoError(t, err, string(output)) + + cmd = exec.Command("git", "add", "-A", ".") + cmd.Dir = dir + output, err = cmd.CombinedOutput() + require.NoError(t, err, string(output)) + + cmd = exec.Command("git", "commit", "-m", "Initial commit") + cmd.Dir = dir + output, err = cmd.CombinedOutput() + require.NoError(t, err, string(output)) + + // Create subdirectory to run from (config should be found in parent) + subDir := filepath.Join(dir, "sub") + err = os.MkdirAll(subDir, 0755) + require.NoError(t, err) + + // Run from root git directory, but the working directory + // (current directory used by config loading) will be the subdirectory + // This tests that FindProjectConfig walks up the directory tree + cmd = exec.Command(settings.Path, + "--dockerfile", dockerfilePath, + "bash", "-c", "exit 0") + cmd.Dir = dir // Run from git root, not subDir + cmd.Env = append(os.Environ(), + "TERM=xterm-256color", + "COLORTERM=truecolor", + "ANTHROPIC_API_KEY=", + ) + output, err = cmd.CombinedOutput() + require.NoError(t, err, string(output)) + }) + + t.Run("expands environment variables in config", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "contagent-volume-*") + require.NoError(t, err) + t.Cleanup(func() { + os.RemoveAll(tmpDir) + }) + + testFile := filepath.Join(tmpDir, "test.txt") + err = os.WriteFile(testFile, []byte("expanded content"), 0644) + require.NoError(t, err) + + configContent := ` +env: + MY_PATH: $TEST_DIR/subdir +volumes: + - $TEST_DIR:/mnt/test +` + testSetup := setup(t, configContent) + + cmd := exec.Command(settings.Path, + "--dockerfile", testSetup.Dockerfile, + "bash", "-c", "test -f /mnt/test/test.txt") + cmd.Dir = testSetup.RepositoryPath + cmd.Env = append(os.Environ(), + "TERM=xterm-256color", + "COLORTERM=truecolor", + "ANTHROPIC_API_KEY=", + fmt.Sprintf("TEST_DIR=%s", tmpDir), + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + }) + + t.Run("loads global config file", func(t *testing.T) { + // Create a temporary global config + globalDir, err := os.MkdirTemp("", "contagent-global-*") + require.NoError(t, err) + t.Cleanup(func() { + os.RemoveAll(globalDir) + }) + + globalConfigPath := filepath.Join(globalDir, "global-config.yaml") + globalConfigContent := ` +env: + GLOBAL_VAR: from_global_config +` + err = os.WriteFile(globalConfigPath, []byte(globalConfigContent), 0644) + require.NoError(t, err) + + testSetup := setup(t, "") + + cmd := exec.Command(settings.Path, + "--dockerfile", testSetup.Dockerfile, + "bash", "-c", "exit 0") + cmd.Dir = testSetup.RepositoryPath + cmd.Env = append(os.Environ(), + "TERM=xterm-256color", + "COLORTERM=truecolor", + "ANTHROPIC_API_KEY=", + fmt.Sprintf("CONTAGENT_GLOBAL_CONFIG_FILE=%s", globalConfigPath), + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + }) + + t.Run("project config overrides global config", func(t *testing.T) { + // Create a temporary global config + globalDir, err := os.MkdirTemp("", "contagent-global-*") + require.NoError(t, err) + t.Cleanup(func() { + os.RemoveAll(globalDir) + }) + + globalConfigPath := filepath.Join(globalDir, "global-config.yaml") + globalConfigContent := ` +env: + SHARED_VAR: from_global +` + err = os.WriteFile(globalConfigPath, []byte(globalConfigContent), 0644) + require.NoError(t, err) + + // Create project config that overrides + projectConfigContent := ` +env: + SHARED_VAR: from_project +` + testSetup := setup(t, projectConfigContent) + + cmd := exec.Command(settings.Path, + "--dockerfile", testSetup.Dockerfile, + "bash", "-c", "exit 0") + cmd.Dir = testSetup.RepositoryPath + cmd.Env = append(os.Environ(), + "TERM=xterm-256color", + "COLORTERM=truecolor", + "ANTHROPIC_API_KEY=", + fmt.Sprintf("CONTAGENT_GLOBAL_CONFIG_FILE=%s", globalConfigPath), + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + }) + + t.Run("loads git user config from file", func(t *testing.T) { + configContent := ` +git: + user: + name: Test User + email: test@example.com +` + testSetup := setup(t, configContent) + + cmd := exec.Command(settings.Path, + "--dockerfile", testSetup.Dockerfile, + "bash", "-c", "exit 0") + cmd.Dir = testSetup.RepositoryPath + cmd.Env = append(os.Environ(), + "TERM=xterm-256color", + "COLORTERM=truecolor", + "ANTHROPIC_API_KEY=", + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + }) + + t.Run("complex config with all fields", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "contagent-volume-*") + require.NoError(t, err) + t.Cleanup(func() { + os.RemoveAll(tmpDir) + }) + + testFile := filepath.Join(tmpDir, "data.txt") + err = os.WriteFile(testFile, []byte("complex test data"), 0644) + require.NoError(t, err) + + configContent := fmt.Sprintf(` +working_dir: /workspace +git: + user: + name: Complex User + email: complex@example.com +env: + VAR1: value1 + VAR2: value2 +volumes: + - %s:/mnt/data +`, tmpDir) + testSetup := setup(t, configContent) + + cmd := exec.Command(settings.Path, + "--dockerfile", testSetup.Dockerfile, + "bash", "-c", "test -f /mnt/data/data.txt") + cmd.Dir = testSetup.RepositoryPath + cmd.Env = append(os.Environ(), + "TERM=xterm-256color", + "COLORTERM=truecolor", + "ANTHROPIC_API_KEY=", + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + }) +} diff --git a/integration/some_test.go b/integration/workflow_test.go similarity index 92% rename from integration/some_test.go rename to integration/workflow_test.go index 349cefe..de1ab15 100644 --- a/integration/some_test.go +++ b/integration/workflow_test.go @@ -271,5 +271,24 @@ func TestWorkflow(t *testing.T) { require.ErrorContains(t, err, "exit status 1") require.Contains(t, string(output), "not a git repository") }) + + t.Run("when dockerfile is not specified", func(t *testing.T) { + identifiers := setup(t) + + cmd := exec.Command(settings.Path, + "bash", "-c", "echo test", + ) + cmd.Dir = identifiers.RepositoryPath + cmd.Env = append(os.Environ(), + "TERM=xterm-256color", + "COLORTERM=truecolor", + "ANTHROPIC_API_KEY=", + ) + output, err := cmd.CombinedOutput() + require.ErrorContains(t, err, "exit status 1") + require.Contains(t, string(output), "dockerfile path is required") + require.Contains(t, string(output), "--dockerfile") + require.Contains(t, string(output), ".contagent.yaml") + }) }) } diff --git a/internal/config.go b/internal/config.go index 9f142d0..918f630 100644 --- a/internal/config.go +++ b/internal/config.go @@ -1,10 +1,11 @@ package internal import ( - "flag" "fmt" "strings" "time" + + "github.com/ryanmoran/contagent/internal/config" ) const ( @@ -44,22 +45,46 @@ type GitUserConfig struct { Email string } -type stringSlice []string +// ParseConfig parses command-line arguments and environment variables to construct +// 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) { + // Use the new config package to load and parse configuration + cfg, programArgs, err := config.Load(args, environment) + if err != nil { + return Config{}, err + } -func (s *stringSlice) String() string { - return strings.Join(*s, ",") -} + // Build environment variables with defaults and config env + env := buildEnvironment(environment, cfg.Env) + + // Build volumes with defaults + volumes := append([]string{ + "/var/run/docker.sock:/var/run/docker.sock", + "/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock", + }, cfg.Volumes...) -func (s *stringSlice) Set(value string) error { - *s = append(*s, value) - return nil + return Config{ + ImageName: ImageName(cfg.Image), + WorkingDir: cfg.WorkingDir, + DockerfilePath: cfg.Dockerfile, + StopTimeout: cfg.StopTimeout, + TTYRetries: cfg.TTYRetries, + RetryDelay: cfg.RetryDelay, + GitUser: GitUserConfig{ + Name: cfg.Git.User.Name, + Email: cfg.Git.User.Email, + }, + Args: Command(programArgs), + Env: Environment(env), + Volumes: volumes, + Network: cfg.Network, + }, nil } -// ParseConfig parses command-line arguments and environment variables to construct -// the configuration for running a container. It extracts flags (--env, --volume, --dockerfile), -// captures the remaining arguments as the command to execute, and sets up default environment -// variables (TERM, COLORTERM, ANTHROPIC_API_KEY) and volume mounts (Docker socket, SSH agent). -func ParseConfig(args []string, environment []string) Config { +// buildEnvironment constructs the environment variable list with defaults +func buildEnvironment(environment []string, configEnv map[string]string) []string { lookup := make(map[string]string) for _, variable := range environment { key, value, ok := strings.Cut(variable, "=") @@ -68,66 +93,33 @@ func ParseConfig(args []string, environment []string) Config { } } - var ( - additionalEnv stringSlice - volumes stringSlice - dockerfilePath string - network string - ) - - fs := flag.NewFlagSet("contagent", flag.ContinueOnError) - fs.Var(&additionalEnv, "env", "environment variable") - fs.Var(&volumes, "volume", "volume mount") - fs.StringVar(&dockerfilePath, "dockerfile", "", "Dockerfile path") - fs.StringVar(&network, "network", "default", " Connect to a container network") - - // Ignore errors since we want to capture remaining args - _ = fs.Parse(args) - - programArgs := fs.Args() - var env []string + + // Add TERM with default value, ok := lookup["TERM"] if !ok { value = "xterm-256color" } env = append(env, fmt.Sprintf("TERM=%s", value)) + // Add COLORTERM with default value, ok = lookup["COLORTERM"] if !ok { value = "truecolor" } 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)) // Set SSH_AUTH_SOCK for SSH agent access in container env = append(env, "SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock") - env = append(env, additionalEnv...) - - // Add Docker socket and SSH agent socket mounts - defaultVolumes := []string{ - "/var/run/docker.sock:/var/run/docker.sock", - "/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock", + // Add environment variables from config file and CLI flags + for key, value := range configEnv { + env = append(env, fmt.Sprintf("%s=%s", key, value)) } - allVolumes := append(defaultVolumes, volumes...) - return Config{ - ImageName: ImageName("contagent:latest"), - WorkingDir: "/app", - DockerfilePath: dockerfilePath, - StopTimeout: DefaultStopTimeout, - TTYRetries: DefaultTTYRetries, - RetryDelay: DefaultRetryDelay, - GitUser: GitUserConfig{ - Name: "Contagent", - Email: "contagent@example.com", - }, - Args: Command(programArgs), - Env: Environment(env), - Volumes: allVolumes, - Network: network, - } + return env } diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..bcfb69c --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,170 @@ +package config + +import ( + "flag" + "os" + "strings" + "time" +) + +// Config represents the parsed and merged configuration for contagent. +// It includes all settings that can be specified via config files or CLI flags. +type Config struct { + Image string `yaml:"image"` + WorkingDir string `yaml:"working_dir"` + Dockerfile string `yaml:"dockerfile"` + Network string `yaml:"network"` + StopTimeout int `yaml:"stop_timeout"` + TTYRetries int `yaml:"tty_retries"` + RetryDelay time.Duration `yaml:"retry_delay"` + Git GitConfig `yaml:"git"` + Env map[string]string `yaml:"env"` + Volumes []string `yaml:"volumes"` +} + +// GitConfig represents Git-specific configuration settings. +type GitConfig struct { + User GitUserConfig `yaml:"user"` +} + +// GitUserConfig represents Git user identity configuration. +type GitUserConfig struct { + Name string `yaml:"name"` + Email string `yaml:"email"` +} + +// stringSlice is a custom flag type that allows multiple values +type stringSlice []string + +func (s *stringSlice) String() string { + return strings.Join(*s, ",") +} + +func (s *stringSlice) Set(value string) error { + *s = append(*s, value) + return nil +} + +// Load discovers, loads, and merges all configuration sources. +// It follows the resolution order: defaults → global config → project config → CLI flags. +// Environment variable expansion is applied after all merging is complete. +// +// Parameters: +// - cliArgs: Command-line arguments to parse (flags override config files) +// - environment: Environment variables for expansion (typically os.Environ()) +// +// Returns the final merged configuration, remaining program arguments, or an error if loading/parsing fails. +func Load(cliArgs []string, environment []string) (Config, []string, error) { + // 1. Load defaults + cfg := Config{ + Image: "contagent:latest", + WorkingDir: "/app", + Network: "default", + StopTimeout: 10, + TTYRetries: 10, + RetryDelay: 10 * time.Millisecond, + Git: GitConfig{ + User: GitUserConfig{ + Name: "Contagent", + Email: "contagent@example.com", + }, + }, + Env: make(map[string]string), + Volumes: []string{}, + } + + // 2. Find and load global config + globalConfigPath, err := FindGlobalConfig(environment) + if err != nil { + return Config{}, nil, err + } + if globalConfigPath != "" { + globalCfg, err := ParseFile(globalConfigPath) + if err != nil { + return Config{}, nil, err + } + cfg = Merge(cfg, globalCfg) + } + + // 3. Find and load project config + currentDir, err := os.Getwd() + if err != nil { + // If we can't get working directory, continue without project config + currentDir = "." + } + projectConfigPath, err := FindProjectConfig(currentDir) + if err != nil { + return Config{}, nil, err + } + if projectConfigPath != "" { + projectCfg, err := ParseFile(projectConfigPath) + if err != nil { + return Config{}, nil, err + } + cfg = Merge(cfg, projectCfg) + } + + // 4. Parse CLI flags + var ( + envFlags stringSlice + volumeFlags stringSlice + retryDelay string + ) + + cliCfg := Config{ + Git: GitConfig{ + User: GitUserConfig{}, + }, + Env: make(map[string]string), + Volumes: []string{}, + } + + fs := flag.NewFlagSet("contagent", flag.ContinueOnError) + fs.StringVar(&cliCfg.Dockerfile, "dockerfile", "", "Dockerfile path") + fs.StringVar(&cliCfg.Image, "image", "", "Container image name") + fs.StringVar(&cliCfg.WorkingDir, "working-dir", "", "Working directory in container") + fs.StringVar(&cliCfg.Network, "network", "", "Docker network to use") + fs.IntVar(&cliCfg.StopTimeout, "stop-timeout", 0, "Stop timeout in seconds") + fs.IntVar(&cliCfg.TTYRetries, "tty-retries", 0, "TTY retry attempts") + fs.StringVar(&retryDelay, "retry-delay", "", "Retry delay duration") + fs.StringVar(&cliCfg.Git.User.Name, "git-user-name", "", "Git user name") + fs.StringVar(&cliCfg.Git.User.Email, "git-user-email", "", "Git user email") + 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) + + // Extract remaining program arguments + programArgs := fs.Args() + + // 5. Handle retry delay parsing + if retryDelay != "" { + duration, err := time.ParseDuration(retryDelay) + if err != nil { + return Config{}, nil, err + } + cliCfg.RetryDelay = duration + } + + // Parse env flags + for _, env := range envFlags { + key, value, ok := strings.Cut(env, "=") + if ok { + cliCfg.Env[key] = value + } + } + + // Set volumes + cliCfg.Volumes = volumeFlags + + // 6. Merge CLI flags with config + cfg = Merge(cfg, cliCfg) + + // 7. Expand environment variables + cfg = ExpandEnv(cfg, environment) + + // TODO: 8. Validate + + return cfg, programArgs, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..64d5c6a --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,190 @@ +package config + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestLoad_WithDefaultsOnly(t *testing.T) { + // No CLI args, no environment variables + cfg, args, err := Load([]string{}, []string{}) + require.NoError(t, err) + require.Empty(t, args) + + // Verify hardcoded defaults are set + require.Equal(t, "contagent:latest", cfg.Image) + require.Equal(t, "/app", cfg.WorkingDir) + require.Equal(t, "", cfg.Dockerfile) // No default for dockerfile + require.Equal(t, "default", cfg.Network) + require.Equal(t, 10, cfg.StopTimeout) + require.Equal(t, 10, cfg.TTYRetries) + require.Equal(t, 10*time.Millisecond, cfg.RetryDelay) + require.Equal(t, "Contagent", cfg.Git.User.Name) + require.Equal(t, "contagent@example.com", cfg.Git.User.Email) + require.NotNil(t, cfg.Env) + require.NotNil(t, cfg.Volumes) +} + +func TestLoad_WithCLIFlags(t *testing.T) { + args := []string{ + "--dockerfile", "./Dockerfile.dev", + "--image", "myapp:v1", + "--working-dir", "/workspace", + "--network", "custom-network", + "--env", "FOO=bar", + "--env", "BAZ=qux", + "--volume", "/host:/container", + "--volume", "/data:/data", + } + + cfg, programArgs, err := Load(args, []string{}) + require.NoError(t, err) + require.Empty(t, programArgs) + + // CLI flags should override defaults + require.Equal(t, "myapp:v1", cfg.Image) + require.Equal(t, "/workspace", cfg.WorkingDir) + require.Equal(t, "./Dockerfile.dev", cfg.Dockerfile) + require.Equal(t, "custom-network", cfg.Network) + + // Defaults should still be present for non-overridden values + require.Equal(t, 10, cfg.StopTimeout) + require.Equal(t, 10, cfg.TTYRetries) + require.Equal(t, 10*time.Millisecond, cfg.RetryDelay) + + // Env variables should be parsed + require.Equal(t, "bar", cfg.Env["FOO"]) + require.Equal(t, "qux", cfg.Env["BAZ"]) + + // Volumes should be added + require.Contains(t, cfg.Volumes, "/host:/container") + require.Contains(t, cfg.Volumes, "/data:/data") +} + +func TestLoad_WithGitUserFlags(t *testing.T) { + args := []string{ + "--git-user-name", "Alice", + "--git-user-email", "alice@example.com", + } + + cfg, programArgs, err := Load(args, []string{}) + require.NoError(t, err) + require.Empty(t, programArgs) + + require.Equal(t, "Alice", cfg.Git.User.Name) + require.Equal(t, "alice@example.com", cfg.Git.User.Email) +} + +func TestLoad_WithNumericFlags(t *testing.T) { + args := []string{ + "--stop-timeout", "30", + "--tty-retries", "5", + "--retry-delay", "50ms", + } + + cfg, programArgs, err := Load(args, []string{}) + require.NoError(t, err) + require.Empty(t, programArgs) + + require.Equal(t, 30, cfg.StopTimeout) + require.Equal(t, 5, cfg.TTYRetries) + require.Equal(t, 50*time.Millisecond, cfg.RetryDelay) +} + +func TestLoad_WithEnvironmentVariableExpansion(t *testing.T) { + args := []string{ + "--env", "MY_PATH=$HOME/bin", + "--env", "USER_DIR=${HOME}/${USER}", + "--volume", "$HOME/data:/data", + "--volume", "${HOME}/cache:/cache", + } + environment := []string{ + "HOME=/home/alice", + "USER=alice", + } + + cfg, programArgs, err := Load(args, environment) + require.NoError(t, err) + require.Empty(t, programArgs) + + // Environment variables should be expanded + require.Equal(t, "/home/alice/bin", cfg.Env["MY_PATH"]) + require.Equal(t, "/home/alice/alice", cfg.Env["USER_DIR"]) + + // Volumes should have expanded paths + require.Contains(t, cfg.Volumes, "/home/alice/data:/data") + require.Contains(t, cfg.Volumes, "/home/alice/cache:/cache") +} + +func TestLoad_WithInvalidRetryDelay(t *testing.T) { + args := []string{ + "--retry-delay", "invalid-duration", + } + + cfg, programArgs, err := Load(args, []string{}) + require.Error(t, err) + require.Contains(t, err.Error(), "time: invalid duration") + require.Equal(t, Config{}, cfg) + require.Nil(t, programArgs) +} + +func TestLoad_WithInvalidEnvFormat(t *testing.T) { + // Environment variables without '=' should be ignored + args := []string{ + "--env", "VALID=value", + "--env", "INVALID_NO_EQUALS", + "--env", "ALSO_VALID=another", + } + + cfg, programArgs, err := Load(args, []string{}) + require.NoError(t, err) + require.Empty(t, programArgs) + + // Only valid entries should be parsed + require.Equal(t, "value", cfg.Env["VALID"]) + require.Equal(t, "another", cfg.Env["ALSO_VALID"]) + require.NotContains(t, cfg.Env, "INVALID_NO_EQUALS") +} + +func TestLoad_WithEmptyArgs(t *testing.T) { + // Verify flag parsing handles empty args without panicking + cfg, programArgs, err := Load([]string{}, []string{}) + require.NoError(t, err) + require.NotNil(t, cfg) + require.Empty(t, programArgs) +} + +func TestLoad_WithTrailingArgs(t *testing.T) { + // Trailing args after flags should be captured as program args + args := []string{ + "--image", "myapp:v1", + "--dockerfile", "Dockerfile", + "bash", // trailing command arg + "-c", // another trailing arg + "echo hello", + } + + cfg, programArgs, err := Load(args, []string{}) + require.NoError(t, err) + require.Equal(t, "myapp:v1", cfg.Image) + require.Equal(t, "Dockerfile", cfg.Dockerfile) + require.Equal(t, []string{"bash", "-c", "echo hello"}, programArgs) +} + +func TestStringSlice_String(t *testing.T) { + s := stringSlice{"a", "b", "c"} + require.Equal(t, "a,b,c", s.String()) +} + +func TestStringSlice_Set(t *testing.T) { + var s stringSlice + err := s.Set("first") + require.NoError(t, err) + require.Equal(t, stringSlice{"first"}, s) + + err = s.Set("second") + require.NoError(t, err) + require.Equal(t, stringSlice{"first", "second"}, s) +} diff --git a/internal/config/doc.go b/internal/config/doc.go new file mode 100644 index 0000000..9c11ad4 --- /dev/null +++ b/internal/config/doc.go @@ -0,0 +1,16 @@ +// Package config provides configuration file loading and merging for contagent. +// +// It supports two-tier configuration (global ~/.config/contagent/config.yaml +// and project .contagent.yaml) with hybrid merge strategy: list fields append, +// scalar fields override. Environment variable expansion is supported in +// config values using $VAR and ${VAR} syntax. +// +// Configuration resolution order: +// 1. Hardcoded defaults +// 2. Global config (if exists) +// 3. Project config (if exists) +// 4. CLI flags (final override) +// +// The Load() function is the main entry point for discovering, loading, +// and merging all configuration sources. +package config diff --git a/internal/config/expand.go b/internal/config/expand.go new file mode 100644 index 0000000..a268da3 --- /dev/null +++ b/internal/config/expand.go @@ -0,0 +1,41 @@ +package config + +import ( + "os" +) + +// ExpandEnv expands environment variables in config values. +// It processes: +// - env map values: expands $VAR and ${VAR} using provided environment +// - volumes paths: expands variables in volume mount strings +// +// Uses os.ExpandEnv behavior: undefined variables expand to empty string. +// Returns a new Config with expanded values. +func ExpandEnv(cfg Config, environment []string) Config { + // Create a mapping function from the environment slice + envMap := makeEnvMap(environment) + mapper := func(varName string) string { + return envMap[varName] + } + + // Create a new config to avoid modifying the original + result := cfg + + // Expand environment variables in Env map + if cfg.Env != nil { + result.Env = make(map[string]string, len(cfg.Env)) + for key, value := range cfg.Env { + result.Env[key] = os.Expand(value, mapper) + } + } + + // Expand environment variables in Volumes slice + if cfg.Volumes != nil { + result.Volumes = make([]string, len(cfg.Volumes)) + for i, volume := range cfg.Volumes { + result.Volumes[i] = os.Expand(volume, mapper) + } + } + + return result +} diff --git a/internal/config/expand_test.go b/internal/config/expand_test.go new file mode 100644 index 0000000..3f0686b --- /dev/null +++ b/internal/config/expand_test.go @@ -0,0 +1,185 @@ +package config + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestExpandEnv(t *testing.T) { + t.Run("EmptyConfig", func(t *testing.T) { + cfg := Config{} + environment := []string{} + + result := ExpandEnv(cfg, environment) + + require.Equal(t, cfg, result) + }) + + t.Run("NoVariablesToExpand", func(t *testing.T) { + cfg := Config{ + Env: map[string]string{ + "PLAIN": "value", + "OTHER": "another", + }, + Volumes: []string{ + "/host:/container", + }, + } + environment := []string{"HOME=/home/user"} + + result := ExpandEnv(cfg, environment) + + require.Equal(t, "value", result.Env["PLAIN"]) + require.Equal(t, "another", result.Env["OTHER"]) + require.Equal(t, []string{"/host:/container"}, result.Volumes) + }) + + t.Run("SimpleVariableInEnv", func(t *testing.T) { + cfg := Config{ + Env: map[string]string{ + "PATH_VAR": "$HOME/bin", + }, + } + environment := []string{"HOME=/home/user"} + + result := ExpandEnv(cfg, environment) + + require.Equal(t, "/home/user/bin", result.Env["PATH_VAR"]) + }) + + t.Run("BracedVariableInEnv", func(t *testing.T) { + cfg := Config{ + Env: map[string]string{ + "PATH_VAR": "${HOME}/bin", + }, + } + environment := []string{"HOME=/home/user"} + + result := ExpandEnv(cfg, environment) + + require.Equal(t, "/home/user/bin", result.Env["PATH_VAR"]) + }) + + t.Run("MultipleVariablesInSingleValue", func(t *testing.T) { + cfg := Config{ + Env: map[string]string{ + "COMPLEX": "$HOME/path-${USER}-suffix", + }, + } + environment := []string{"HOME=/home/user", "USER=alice"} + + result := ExpandEnv(cfg, environment) + + require.Equal(t, "/home/user/path-alice-suffix", result.Env["COMPLEX"]) + }) + + t.Run("UndefinedVariable", func(t *testing.T) { + cfg := Config{ + Env: map[string]string{ + "WITH_UNDEFINED": "$UNDEFINED_VAR/path", + }, + } + environment := []string{} + + result := ExpandEnv(cfg, environment) + + require.Equal(t, "/path", result.Env["WITH_UNDEFINED"]) + }) + + t.Run("VariablesInVolumes", func(t *testing.T) { + cfg := Config{ + Volumes: []string{ + "$HOME/data:/data", + "${HOME}/cache:/cache", + }, + } + environment := []string{"HOME=/home/user"} + + result := ExpandEnv(cfg, environment) + + require.Equal(t, "/home/user/data:/data", result.Volumes[0]) + require.Equal(t, "/home/user/cache:/cache", result.Volumes[1]) + }) + + t.Run("BothEnvAndVolumes", func(t *testing.T) { + cfg := Config{ + Env: map[string]string{ + "MY_PATH": "$HOME/bin", + "MY_USER": "${USER}", + }, + Volumes: []string{ + "$HOME/data:/data", + "${HOME}/cache:/cache", + }, + } + environment := []string{"HOME=/home/alice", "USER=alice"} + + result := ExpandEnv(cfg, environment) + + require.Equal(t, "/home/alice/bin", result.Env["MY_PATH"]) + require.Equal(t, "alice", result.Env["MY_USER"]) + require.Equal(t, "/home/alice/data:/data", result.Volumes[0]) + require.Equal(t, "/home/alice/cache:/cache", result.Volumes[1]) + }) + + t.Run("PreservesOtherFields", func(t *testing.T) { + cfg := Config{ + Image: "myimage:latest", + WorkingDir: "/workspace", + Dockerfile: "./Dockerfile", + Network: "custom", + StopTimeout: 30, + TTYRetries: 5, + RetryDelay: 50 * time.Millisecond, + Git: GitConfig{ + User: GitUserConfig{ + Name: "Test User", + Email: "test@example.com", + }, + }, + Env: map[string]string{ + "VAR": "$HOME", + }, + Volumes: []string{}, + } + environment := []string{"HOME=/home/user"} + + result := ExpandEnv(cfg, environment) + + // Check that non-env/volume fields are preserved + require.Equal(t, "myimage:latest", result.Image) + require.Equal(t, "/workspace", result.WorkingDir) + require.Equal(t, "./Dockerfile", result.Dockerfile) + require.Equal(t, "custom", result.Network) + require.Equal(t, 30, result.StopTimeout) + require.Equal(t, 5, result.TTYRetries) + require.Equal(t, 50*time.Millisecond, result.RetryDelay) + require.Equal(t, "Test User", result.Git.User.Name) + require.Equal(t, "test@example.com", result.Git.User.Email) + + // Check that env was expanded + require.Equal(t, "/home/user", result.Env["VAR"]) + }) + + t.Run("ReturnsNewConfig", func(t *testing.T) { + cfg := Config{ + Env: map[string]string{ + "VAR": "$HOME", + }, + Volumes: []string{"$HOME/data:/data"}, + } + environment := []string{"HOME=/home/user"} + + result := ExpandEnv(cfg, environment) + + // Verify original config is unchanged + require.Equal(t, "$HOME", cfg.Env["VAR"]) + require.Equal(t, "$HOME/data:/data", cfg.Volumes[0]) + + // Verify result has expanded values + require.Equal(t, "/home/user", result.Env["VAR"]) + require.Equal(t, "/home/user/data:/data", result.Volumes[0]) + }) +} diff --git a/internal/config/loader.go b/internal/config/loader.go new file mode 100644 index 0000000..01ddbd4 --- /dev/null +++ b/internal/config/loader.go @@ -0,0 +1,115 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// FindGlobalConfig returns the path to the global config file. +// It checks in order: +// 1. $CONTAGENT_GLOBAL_CONFIG_FILE (if set, must exist) +// 2. $XDG_CONFIG_HOME/contagent/config.yaml (if XDG_CONFIG_HOME set) +// 3. ~/.config/contagent/config.yaml (default) +// +// Returns empty string if no global config found (not an error). +// Returns error if $CONTAGENT_GLOBAL_CONFIG_FILE is set but file doesn't exist. +func FindGlobalConfig(environment []string) (string, error) { + envMap := makeEnvMap(environment) + + // 1. Check $CONTAGENT_GLOBAL_CONFIG_FILE + if path := envMap["CONTAGENT_GLOBAL_CONFIG_FILE"]; path != "" { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("CONTAGENT_GLOBAL_CONFIG_FILE is set but file does not exist: %s", path) + } + return "", fmt.Errorf("cannot stat CONTAGENT_GLOBAL_CONFIG_FILE: %w", err) + } + return path, nil + } + + // 2. Check $XDG_CONFIG_HOME/contagent/config.yaml + if xdgConfig := envMap["XDG_CONFIG_HOME"]; xdgConfig != "" { + path := filepath.Join(xdgConfig, "contagent", "config.yaml") + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + + // 3. Check ~/.config/contagent/config.yaml + home := envMap["HOME"] + if home == "" { + // No HOME set, can't find default config + return "", nil + } + path := filepath.Join(home, ".config", "contagent", "config.yaml") + if _, err := os.Stat(path); err == nil { + return path, nil + } + + return "", nil +} + +// FindProjectConfig walks up from startDir looking for .contagent.yaml. +// Returns the path to the first .contagent.yaml found, or empty string if none found. +// Returns error only if filesystem operations fail (e.g., permission denied). +func FindProjectConfig(startDir string) (string, error) { + // Clean the path to normalize it + dir, err := filepath.Abs(startDir) + if err != nil { + return "", fmt.Errorf("cannot get absolute path: %w", err) + } + + // Walk up directory tree + for { + configPath := filepath.Join(dir, ".contagent.yaml") + info, err := os.Stat(configPath) + if err == nil && !info.IsDir() { + return configPath, nil + } + if err != nil && !os.IsNotExist(err) { + // Real error (permission denied, etc.) + return "", fmt.Errorf("cannot stat %s: %w", configPath, err) + } + + // Move up one directory + parent := filepath.Dir(dir) + if parent == dir { + // Reached root + break + } + dir = parent + } + + return "", nil +} + +// ParseFile reads and parses a YAML config file at the given path. +// Returns error if file cannot be read or parsed. +func ParseFile(path string) (Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return Config{}, fmt.Errorf("cannot read config file %s: %w", path, err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return Config{}, fmt.Errorf("cannot parse config file %s: %w", path, err) + } + + return cfg, nil +} + +// makeEnvMap converts environment slice (KEY=VALUE) to a map for easier lookup. +func makeEnvMap(environment []string) map[string]string { + envMap := make(map[string]string) + for _, env := range environment { + if key, value, ok := strings.Cut(env, "="); ok { + envMap[key] = value + } + } + return envMap +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go new file mode 100644 index 0000000..77f692b --- /dev/null +++ b/internal/config/loader_test.go @@ -0,0 +1,290 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestFindGlobalConfig(t *testing.T) { + t.Run("with CONTAGENT_GLOBAL_CONFIG_FILE set and file exists", func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "custom-config.yaml") + require.NoError(t, os.WriteFile(configPath, []byte("image: test"), 0644)) + + env := []string{"CONTAGENT_GLOBAL_CONFIG_FILE=" + configPath} + path, err := FindGlobalConfig(env) + require.NoError(t, err) + require.Equal(t, configPath, path) + }) + + t.Run("with CONTAGENT_GLOBAL_CONFIG_FILE set but file does not exist", func(t *testing.T) { + env := []string{"CONTAGENT_GLOBAL_CONFIG_FILE=/nonexistent/config.yaml"} + path, err := FindGlobalConfig(env) + require.Error(t, err) + require.Contains(t, err.Error(), "file does not exist") + require.Empty(t, path) + }) + + t.Run("with CONTAGENT_GLOBAL_CONFIG_FILE set but stat fails with permission error", func(t *testing.T) { + // This test is hard to reliably trigger cross-platform without root/admin, + // so we'll skip a deep test and focus on the IsNotExist path above. + // The non-IsNotExist error path at line 28 would be triggered by permission errors. + t.Skip("Permission-based stat errors are difficult to test reliably cross-platform") + }) + + t.Run("with XDG_CONFIG_HOME set and file exists", func(t *testing.T) { + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, "contagent") + require.NoError(t, os.MkdirAll(configDir, 0755)) + configPath := filepath.Join(configDir, "config.yaml") + require.NoError(t, os.WriteFile(configPath, []byte("image: test"), 0644)) + + env := []string{"XDG_CONFIG_HOME=" + tmpDir} + path, err := FindGlobalConfig(env) + require.NoError(t, err) + require.Equal(t, configPath, path) + }) + + t.Run("with XDG_CONFIG_HOME set but file does not exist", func(t *testing.T) { + tmpDir := t.TempDir() + env := []string{"XDG_CONFIG_HOME=" + tmpDir} + path, err := FindGlobalConfig(env) + require.NoError(t, err) + require.Empty(t, path) + }) + + t.Run("with HOME set and default config exists", func(t *testing.T) { + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, ".config", "contagent") + require.NoError(t, os.MkdirAll(configDir, 0755)) + configPath := filepath.Join(configDir, "config.yaml") + require.NoError(t, os.WriteFile(configPath, []byte("image: test"), 0644)) + + env := []string{"HOME=" + tmpDir} + path, err := FindGlobalConfig(env) + require.NoError(t, err) + require.Equal(t, configPath, path) + }) + + t.Run("with HOME set but default config does not exist", func(t *testing.T) { + tmpDir := t.TempDir() + env := []string{"HOME=" + tmpDir} + path, err := FindGlobalConfig(env) + require.NoError(t, err) + require.Empty(t, path) + }) + + t.Run("without HOME set", func(t *testing.T) { + env := []string{} + path, err := FindGlobalConfig(env) + require.NoError(t, err) + require.Empty(t, path) + }) + + t.Run("precedence order", func(t *testing.T) { + tmpDir := t.TempDir() + + // Create all three config locations + customPath := filepath.Join(tmpDir, "custom.yaml") + require.NoError(t, os.WriteFile(customPath, []byte("image: custom"), 0644)) + + xdgDir := filepath.Join(tmpDir, "xdg", "contagent") + require.NoError(t, os.MkdirAll(xdgDir, 0755)) + xdgPath := filepath.Join(xdgDir, "config.yaml") + require.NoError(t, os.WriteFile(xdgPath, []byte("image: xdg"), 0644)) + + homeDir := filepath.Join(tmpDir, "home", ".config", "contagent") + require.NoError(t, os.MkdirAll(homeDir, 0755)) + homePath := filepath.Join(homeDir, "config.yaml") + require.NoError(t, os.WriteFile(homePath, []byte("image: home"), 0644)) + + // CONTAGENT_GLOBAL_CONFIG_FILE takes precedence + env := []string{ + "CONTAGENT_GLOBAL_CONFIG_FILE=" + customPath, + "XDG_CONFIG_HOME=" + filepath.Join(tmpDir, "xdg"), + "HOME=" + filepath.Join(tmpDir, "home"), + } + path, err := FindGlobalConfig(env) + require.NoError(t, err) + require.Equal(t, customPath, path) + + // XDG_CONFIG_HOME takes precedence over HOME + env = []string{ + "XDG_CONFIG_HOME=" + filepath.Join(tmpDir, "xdg"), + "HOME=" + filepath.Join(tmpDir, "home"), + } + path, err = FindGlobalConfig(env) + require.NoError(t, err) + require.Equal(t, xdgPath, path) + + // HOME is the fallback + env = []string{ + "HOME=" + filepath.Join(tmpDir, "home"), + } + path, err = FindGlobalConfig(env) + require.NoError(t, err) + require.Equal(t, homePath, path) + }) +} + +func TestFindProjectConfig(t *testing.T) { + t.Run("finds config in current directory", func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".contagent.yaml") + require.NoError(t, os.WriteFile(configPath, []byte("image: test"), 0644)) + + path, err := FindProjectConfig(tmpDir) + require.NoError(t, err) + require.Equal(t, configPath, path) + }) + + t.Run("finds config in parent directory", func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".contagent.yaml") + require.NoError(t, os.WriteFile(configPath, []byte("image: test"), 0644)) + + subDir := filepath.Join(tmpDir, "sub", "dir") + require.NoError(t, os.MkdirAll(subDir, 0755)) + + path, err := FindProjectConfig(subDir) + require.NoError(t, err) + require.Equal(t, configPath, path) + }) + + t.Run("returns empty when no config found", func(t *testing.T) { + tmpDir := t.TempDir() + path, err := FindProjectConfig(tmpDir) + require.NoError(t, err) + require.Empty(t, path) + }) + + t.Run("ignores directories named .contagent.yaml", func(t *testing.T) { + tmpDir := t.TempDir() + dirPath := filepath.Join(tmpDir, ".contagent.yaml") + require.NoError(t, os.MkdirAll(dirPath, 0755)) + + path, err := FindProjectConfig(tmpDir) + require.NoError(t, err) + require.Empty(t, path) + }) + + t.Run("stops at nearest config", func(t *testing.T) { + tmpDir := t.TempDir() + + // Create config in root + rootConfig := filepath.Join(tmpDir, ".contagent.yaml") + require.NoError(t, os.WriteFile(rootConfig, []byte("image: root"), 0644)) + + // Create config in subdirectory + subDir := filepath.Join(tmpDir, "sub") + require.NoError(t, os.MkdirAll(subDir, 0755)) + subConfig := filepath.Join(subDir, ".contagent.yaml") + require.NoError(t, os.WriteFile(subConfig, []byte("image: sub"), 0644)) + + // Should find the nearest one (subConfig) + deepDir := filepath.Join(subDir, "deep") + require.NoError(t, os.MkdirAll(deepDir, 0755)) + path, err := FindProjectConfig(deepDir) + require.NoError(t, err) + require.Equal(t, subConfig, path) + }) + + t.Run("returns error for invalid path", func(t *testing.T) { + // Test with a path containing null bytes (invalid on most filesystems) + // This will trigger an error either in Abs() or Stat() + path, err := FindProjectConfig("invalid\x00path") + require.Error(t, err) + require.Empty(t, path) + // Either "cannot get absolute path" or "cannot stat" are valid errors + require.True(t, + err.Error() == "cannot get absolute path" || + (err.Error() != "" && (err.Error()[:11] == "cannot stat" || err.Error()[:20] == "cannot get absolute")), + "Expected error about invalid path, got: %v", err) + }) +} + +func TestParseFile(t *testing.T) { + t.Run("parses valid complete config", func(t *testing.T) { + configPath := filepath.Join("testdata", "valid.yaml") + cfg, err := ParseFile(configPath) + require.NoError(t, err) + + require.Equal(t, "test-image:v1", cfg.Image) + require.Equal(t, "/test", cfg.WorkingDir) + require.Equal(t, "Dockerfile.test", cfg.Dockerfile) + require.Equal(t, "test-network", cfg.Network) + require.Equal(t, 20, cfg.StopTimeout) + require.Equal(t, 5, cfg.TTYRetries) + require.Equal(t, 100*time.Millisecond, cfg.RetryDelay) + require.Equal(t, "Test User", cfg.Git.User.Name) + require.Equal(t, "test@example.com", cfg.Git.User.Email) + require.Equal(t, "bar", cfg.Env["FOO"]) + require.Equal(t, "qux", cfg.Env["BAZ"]) + require.Contains(t, cfg.Volumes, "/host:/container") + require.Contains(t, cfg.Volumes, "/data:/data") + }) + + t.Run("parses minimal config", func(t *testing.T) { + configPath := filepath.Join("testdata", "minimal.yaml") + cfg, err := ParseFile(configPath) + require.NoError(t, err) + + require.Equal(t, "minimal-image:latest", cfg.Image) + require.Empty(t, cfg.WorkingDir) + require.Empty(t, cfg.Dockerfile) + }) + + t.Run("returns error for nonexistent file", func(t *testing.T) { + cfg, err := ParseFile("/nonexistent/config.yaml") + require.Error(t, err) + require.Contains(t, err.Error(), "cannot read config file") + require.Equal(t, Config{}, cfg) + }) + + t.Run("returns error for invalid YAML", func(t *testing.T) { + configPath := filepath.Join("testdata", "invalid.yaml") + cfg, err := ParseFile(configPath) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot parse config file") + require.Equal(t, Config{}, cfg) + }) +} + +func TestMakeEnvMap(t *testing.T) { + t.Run("converts environment slice to map", func(t *testing.T) { + env := []string{ + "FOO=bar", + "BAZ=qux", + "PATH=/usr/bin:/bin", + } + envMap := makeEnvMap(env) + require.Equal(t, "bar", envMap["FOO"]) + require.Equal(t, "qux", envMap["BAZ"]) + require.Equal(t, "/usr/bin:/bin", envMap["PATH"]) + }) + + t.Run("handles empty environment", func(t *testing.T) { + envMap := makeEnvMap([]string{}) + require.Empty(t, envMap) + }) + + t.Run("handles values with equals signs", func(t *testing.T) { + env := []string{"KEY=value=with=equals"} + envMap := makeEnvMap(env) + require.Equal(t, "value=with=equals", envMap["KEY"]) + }) + + t.Run("skips invalid entries", func(t *testing.T) { + env := []string{ + "VALID=value", + "INVALID", + } + envMap := makeEnvMap(env) + require.Equal(t, "value", envMap["VALID"]) + require.NotContains(t, envMap, "INVALID") + }) +} diff --git a/internal/config/merge.go b/internal/config/merge.go new file mode 100644 index 0000000..9163c8e --- /dev/null +++ b/internal/config/merge.go @@ -0,0 +1,62 @@ +package config + +// Merge combines two configs using hybrid merge strategy: +// - Scalar fields (image, dockerfile, etc.): override takes precedence if non-zero +// - Map fields (env): keys are merged, override keys win +// - List fields (volumes): append override to base +// +// Returns a new Config with the merged values. +func Merge(base, override Config) Config { + result := base + + // Scalar overrides + if override.Image != "" { + result.Image = override.Image + } + if override.WorkingDir != "" { + result.WorkingDir = override.WorkingDir + } + if override.Dockerfile != "" { + result.Dockerfile = override.Dockerfile + } + if override.Network != "" { + result.Network = override.Network + } + if override.StopTimeout != 0 { + result.StopTimeout = override.StopTimeout + } + if override.TTYRetries != 0 { + result.TTYRetries = override.TTYRetries + } + if override.RetryDelay != 0 { + result.RetryDelay = override.RetryDelay + } + if override.Git.User.Name != "" { + result.Git.User.Name = override.Git.User.Name + } + if override.Git.User.Email != "" { + result.Git.User.Email = override.Git.User.Email + } + + // Env map merge + result.Env = MergeEnv(base.Env, override.Env) + + // Volumes list append + result.Volumes = append(result.Volumes, override.Volumes...) + + return result +} + +// MergeEnv merges two environment variable maps. +// Keys from override take precedence over keys in base. +// Returns a new map with merged values. +func MergeEnv(base, override map[string]string) map[string]string { + result := make(map[string]string) + for k, v := range base { + result[k] = v + } + for k, v := range override { + result[k] = v + } + return result +} diff --git a/internal/config/merge_test.go b/internal/config/merge_test.go new file mode 100644 index 0000000..b19d5e2 --- /dev/null +++ b/internal/config/merge_test.go @@ -0,0 +1,381 @@ +package config + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestMerge(t *testing.T) { + t.Run("empty override returns base unchanged", func(t *testing.T) { + base := Config{ + Image: "base-image", + WorkingDir: "/base", + Dockerfile: "Dockerfile.base", + Network: "base-network", + StopTimeout: 5, + TTYRetries: 3, + RetryDelay: 5 * time.Millisecond, + Git: GitConfig{ + User: GitUserConfig{ + Name: "Base User", + Email: "base@example.com", + }, + }, + Env: map[string]string{ + "BASE_KEY": "base_value", + }, + Volumes: []string{"/base/volume"}, + } + + override := Config{} + + result := Merge(base, override) + + assert.Equal(t, "base-image", result.Image) + assert.Equal(t, "/base", result.WorkingDir) + assert.Equal(t, "Dockerfile.base", result.Dockerfile) + assert.Equal(t, "base-network", result.Network) + assert.Equal(t, 5, result.StopTimeout) + assert.Equal(t, 3, result.TTYRetries) + assert.Equal(t, 5*time.Millisecond, result.RetryDelay) + assert.Equal(t, "Base User", result.Git.User.Name) + assert.Equal(t, "base@example.com", result.Git.User.Email) + assert.Equal(t, map[string]string{"BASE_KEY": "base_value"}, result.Env) + assert.Equal(t, []string{"/base/volume"}, result.Volumes) + }) + + t.Run("override replaces scalar fields", func(t *testing.T) { + base := Config{ + Image: "base-image", + WorkingDir: "/base", + Dockerfile: "Dockerfile.base", + Network: "base-network", + StopTimeout: 5, + TTYRetries: 3, + RetryDelay: 5 * time.Millisecond, + Git: GitConfig{ + User: GitUserConfig{ + Name: "Base User", + Email: "base@example.com", + }, + }, + } + + override := Config{ + Image: "override-image", + WorkingDir: "/override", + Dockerfile: "Dockerfile.override", + Network: "override-network", + StopTimeout: 10, + TTYRetries: 7, + RetryDelay: 10 * time.Millisecond, + Git: GitConfig{ + User: GitUserConfig{ + Name: "Override User", + Email: "override@example.com", + }, + }, + } + + result := Merge(base, override) + + assert.Equal(t, "override-image", result.Image) + assert.Equal(t, "/override", result.WorkingDir) + assert.Equal(t, "Dockerfile.override", result.Dockerfile) + assert.Equal(t, "override-network", result.Network) + assert.Equal(t, 10, result.StopTimeout) + assert.Equal(t, 7, result.TTYRetries) + assert.Equal(t, 10*time.Millisecond, result.RetryDelay) + assert.Equal(t, "Override User", result.Git.User.Name) + assert.Equal(t, "override@example.com", result.Git.User.Email) + }) + + t.Run("partial override only replaces specified fields", func(t *testing.T) { + base := Config{ + Image: "base-image", + WorkingDir: "/base", + StopTimeout: 5, + Git: GitConfig{ + User: GitUserConfig{ + Name: "Base User", + Email: "base@example.com", + }, + }, + } + + override := Config{ + Image: "override-image", + Git: GitConfig{ + User: GitUserConfig{ + Email: "override@example.com", + }, + }, + } + + result := Merge(base, override) + + assert.Equal(t, "override-image", result.Image) + assert.Equal(t, "/base", result.WorkingDir, "WorkingDir should remain from base") + assert.Equal(t, 5, result.StopTimeout, "StopTimeout should remain from base") + assert.Equal(t, "Base User", result.Git.User.Name, "Git.User.Name should remain from base") + assert.Equal(t, "override@example.com", result.Git.User.Email) + }) + + t.Run("env maps are merged with override precedence", func(t *testing.T) { + base := Config{ + Env: map[string]string{ + "KEY1": "base1", + "KEY2": "base2", + "KEY3": "base3", + }, + } + + override := Config{ + Env: map[string]string{ + "KEY2": "override2", + "KEY4": "override4", + }, + } + + result := Merge(base, override) + + expected := map[string]string{ + "KEY1": "base1", + "KEY2": "override2", // Override wins + "KEY3": "base3", + "KEY4": "override4", + } + + assert.Equal(t, expected, result.Env) + }) + + t.Run("volumes are appended", func(t *testing.T) { + base := Config{ + Volumes: []string{"/base/vol1", "/base/vol2"}, + } + + override := Config{ + Volumes: []string{"/override/vol1", "/override/vol2"}, + } + + result := Merge(base, override) + + expected := []string{"/base/vol1", "/base/vol2", "/override/vol1", "/override/vol2"} + assert.Equal(t, expected, result.Volumes) + }) + + t.Run("empty base with override", func(t *testing.T) { + base := Config{} + + override := Config{ + Image: "override-image", + WorkingDir: "/override", + StopTimeout: 10, + Env: map[string]string{ + "KEY": "value", + }, + Volumes: []string{"/vol"}, + } + + result := Merge(base, override) + + assert.Equal(t, "override-image", result.Image) + assert.Equal(t, "/override", result.WorkingDir) + assert.Equal(t, 10, result.StopTimeout) + assert.Equal(t, map[string]string{"KEY": "value"}, result.Env) + assert.Equal(t, []string{"/vol"}, result.Volumes) + }) + + t.Run("both empty returns empty", func(t *testing.T) { + base := Config{} + override := Config{} + + result := Merge(base, override) + + assert.Equal(t, "", result.Image) + assert.Equal(t, "", result.WorkingDir) + assert.Equal(t, 0, result.StopTimeout) + assert.NotNil(t, result.Env, "Env should be non-nil empty map") + assert.Empty(t, result.Env, "Env should be empty") + assert.Nil(t, result.Volumes) + }) + + t.Run("nil env and volumes handling", func(t *testing.T) { + base := Config{ + Image: "base-image", + } + + override := Config{ + Image: "override-image", + Env: map[string]string{ + "KEY": "value", + }, + Volumes: []string{"/vol"}, + } + + result := Merge(base, override) + + assert.Equal(t, "override-image", result.Image) + assert.Equal(t, map[string]string{"KEY": "value"}, result.Env) + assert.Equal(t, []string{"/vol"}, result.Volumes) + }) +} + +func TestMergeEnv(t *testing.T) { + t.Run("empty base and override returns empty", func(t *testing.T) { + base := map[string]string{} + override := map[string]string{} + + result := MergeEnv(base, override) + + assert.Empty(t, result) + assert.NotNil(t, result, "should return non-nil map") + }) + + t.Run("nil base and override returns empty", func(t *testing.T) { + result := MergeEnv(nil, nil) + + assert.Empty(t, result) + assert.NotNil(t, result, "should return non-nil map") + }) + + t.Run("only base returns base copy", func(t *testing.T) { + base := map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + } + + result := MergeEnv(base, nil) + + assert.Equal(t, base, result) + // Verify it's a copy, not the same map + result["KEY3"] = "value3" + assert.NotContains(t, base, "KEY3", "original base should not be modified") + }) + + t.Run("only override returns override copy", func(t *testing.T) { + override := map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + } + + result := MergeEnv(nil, override) + + assert.Equal(t, override, result) + // Verify it's a copy, not the same map + result["KEY3"] = "value3" + assert.NotContains(t, override, "KEY3", "original override should not be modified") + }) + + t.Run("override keys win over base keys", func(t *testing.T) { + base := map[string]string{ + "KEY1": "base1", + "KEY2": "base2", + "KEY3": "base3", + } + + override := map[string]string{ + "KEY2": "override2", + "KEY4": "override4", + } + + result := MergeEnv(base, override) + + expected := map[string]string{ + "KEY1": "base1", + "KEY2": "override2", // Override wins + "KEY3": "base3", + "KEY4": "override4", + } + + assert.Equal(t, expected, result) + }) + + t.Run("all keys are unique", func(t *testing.T) { + base := map[string]string{ + "KEY1": "base1", + "KEY2": "base2", + } + + override := map[string]string{ + "KEY3": "override3", + "KEY4": "override4", + } + + result := MergeEnv(base, override) + + expected := map[string]string{ + "KEY1": "base1", + "KEY2": "base2", + "KEY3": "override3", + "KEY4": "override4", + } + + assert.Equal(t, expected, result) + }) + + t.Run("empty string values are preserved", func(t *testing.T) { + base := map[string]string{ + "KEY1": "value1", + "KEY2": "", + } + + override := map[string]string{ + "KEY3": "", + } + + result := MergeEnv(base, override) + + expected := map[string]string{ + "KEY1": "value1", + "KEY2": "", + "KEY3": "", + } + + assert.Equal(t, expected, result) + }) + + t.Run("override with empty string replaces base value", func(t *testing.T) { + base := map[string]string{ + "KEY1": "value1", + } + + override := map[string]string{ + "KEY1": "", + } + + result := MergeEnv(base, override) + + expected := map[string]string{ + "KEY1": "", // Override wins even with empty string + } + + assert.Equal(t, expected, result) + }) + + t.Run("does not modify original maps", func(t *testing.T) { + base := map[string]string{ + "KEY1": "base1", + } + + override := map[string]string{ + "KEY2": "override2", + } + + baseCopy := make(map[string]string) + overrideCopy := make(map[string]string) + for k, v := range base { + baseCopy[k] = v + } + for k, v := range override { + overrideCopy[k] = v + } + + MergeEnv(base, override) + + assert.Equal(t, baseCopy, base, "base should not be modified") + assert.Equal(t, overrideCopy, override, "override should not be modified") + }) +} diff --git a/internal/config/testdata/invalid.yaml b/internal/config/testdata/invalid.yaml new file mode 100644 index 0000000..01474a1 --- /dev/null +++ b/internal/config/testdata/invalid.yaml @@ -0,0 +1,3 @@ +image: test + invalid: [unclosed bracket +network: "unterminated string diff --git a/internal/config/testdata/minimal.yaml b/internal/config/testdata/minimal.yaml new file mode 100644 index 0000000..7a0727c --- /dev/null +++ b/internal/config/testdata/minimal.yaml @@ -0,0 +1 @@ +image: minimal-image:latest diff --git a/internal/config/testdata/valid.yaml b/internal/config/testdata/valid.yaml new file mode 100644 index 0000000..2e6f8be --- /dev/null +++ b/internal/config/testdata/valid.yaml @@ -0,0 +1,17 @@ +image: test-image:v1 +working_dir: /test +dockerfile: Dockerfile.test +network: test-network +stop_timeout: 20 +tty_retries: 5 +retry_delay: 100ms +git: + user: + name: Test User + email: test@example.com +env: + FOO: bar + BAZ: qux +volumes: + - /host:/container + - /data:/data diff --git a/internal/config_test.go b/internal/config_test.go index ee945f5..a47b18d 100644 --- a/internal/config_test.go +++ b/internal/config_test.go @@ -19,7 +19,8 @@ func TestConfig(t *testing.T) { "OTHER_KEY=other-value", } - config := 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{ "TERM=some-term", @@ -38,16 +39,17 @@ func TestConfig(t *testing.T) { "ANTHROPIC_API_KEY=some-api-key", } - config := 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.Equal(t, internal.Environment([]string{ + require.ElementsMatch(t, []string{ "TERM=some-term", "COLORTERM=some-color-term", "ANTHROPIC_API_KEY=some-api-key", "SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock", "VAR1=value1", "VAR2=value2", - }), config.Env) + }, config.Env) }) t.Run("with --volume flags", func(t *testing.T) { @@ -56,7 +58,8 @@ func TestConfig(t *testing.T) { "TERM=some-term", } - config := 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{ "/var/run/docker.sock:/var/run/docker.sock", @@ -75,7 +78,8 @@ func TestConfig(t *testing.T) { "TERM=some-term", } - config := 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{ "/var/run/docker.sock:/var/run/docker.sock", @@ -97,16 +101,17 @@ func TestConfig(t *testing.T) { "TERM=some-term", } - config := 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.Equal(t, internal.Environment([]string{ + 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", - }), config.Env) + }, config.Env) require.Equal(t, []string{ "/var/run/docker.sock:/var/run/docker.sock", "/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock", @@ -114,6 +119,34 @@ func TestConfig(t *testing.T) { }, config.Volumes) }) + t.Run("returns error when config loading fails with invalid retry delay", func(t *testing.T) { + args := []string{ + "--retry-delay", "not-a-duration", + "some-program", + } + env := []string{ + "TERM=some-term", + } + + config, err := internal.ParseConfig(args, env) + require.Error(t, err) + require.Contains(t, err.Error(), "time: invalid duration") + require.Equal(t, internal.Config{}, config) + }) + + t.Run("returns error when global config file is set but does not exist", func(t *testing.T) { + args := []string{"some-program"} + env := []string{ + "CONTAGENT_GLOBAL_CONFIG_FILE=/nonexistent/config.yaml", + "TERM=some-term", + } + + 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) + }) + t.Run("when given a --dockerfile flag", func(t *testing.T) { args := []string{ "--dockerfile", "/some/path/to/a/Dockerfile", @@ -123,7 +156,8 @@ func TestConfig(t *testing.T) { "TERM=some-term", } - config := internal.ParseConfig(args, env) + config, err := internal.ParseConfig(args, env) + require.NoError(t, err) require.Equal(t, "/some/path/to/a/Dockerfile", config.DockerfilePath) }) @@ -136,7 +170,8 @@ func TestConfig(t *testing.T) { "TERM=some-term", } - config := internal.ParseConfig(args, env) + config, err := internal.ParseConfig(args, env) + require.NoError(t, err) require.Equal(t, "some-network", config.Network) }) }) diff --git a/internal/docker/client.go b/internal/docker/client.go index 128584a..328b0be 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -51,7 +51,7 @@ func (c Client) Close() { func (c Client) BuildImage(ctx context.Context, dockerfilePath string, imageName internal.ImageName, w internal.Writer) (Image, error) { dockerfile, err := os.ReadFile(dockerfilePath) if err != nil { - return Image{}, fmt.Errorf("failed to read Dockerfile at %q: %w\nCheck that the file exists and is readable", dockerfilePath, err) + return Image{}, fmt.Errorf("failed to read Dockerfile at %q: %w\nEnsure the file exists and is readable", dockerfilePath, err) } pr, pw := io.Pipe() diff --git a/internal/errors_test.go b/internal/errors_test.go index 61b9874..5a98141 100644 --- a/internal/errors_test.go +++ b/internal/errors_test.go @@ -15,7 +15,8 @@ func TestConfigErrorCases(t *testing.T) { args := []string{} env := []string{"TERM=xterm"} - config := internal.ParseConfig(args, env) + config, err := internal.ParseConfig(args, env) + require.NoError(t, err) require.Empty(t, config.Args) require.NotEmpty(t, config.Env) // Should still have default env }) @@ -24,7 +25,8 @@ func TestConfigErrorCases(t *testing.T) { args := []string{"some-command"} env := []string{} - config := internal.ParseConfig(args, env) + config, err := internal.ParseConfig(args, env) + require.NoError(t, err) require.Equal(t, internal.Command([]string{"some-command"}), config.Args) // Should have default values for missing env vars require.Contains(t, config.Env, "COLORTERM=truecolor") @@ -35,7 +37,8 @@ func TestConfigErrorCases(t *testing.T) { args := []string{"--env", "VAR1=value1", "--volume", "/path:/path"} env := []string{"TERM=xterm"} - config := internal.ParseConfig(args, env) + config, err := internal.ParseConfig(args, env) + require.NoError(t, err) require.Empty(t, config.Args) // No command after flags require.Equal(t, []string{ "/var/run/docker.sock:/var/run/docker.sock", @@ -49,7 +52,8 @@ func TestConfigErrorCases(t *testing.T) { env := []string{"TERM=xterm"} // ParseConfig doesn't validate format, just passes through - config := internal.ParseConfig(args, env) + config, err := internal.ParseConfig(args, env) + require.NoError(t, err) // "some-command" is treated as the value for --env // Next arg would be the command, but there isn't one require.Empty(t, config.Args) @@ -59,17 +63,20 @@ func TestConfigErrorCases(t *testing.T) { args := []string{"--env", "VARVALUE", "command"} env := []string{"TERM=xterm"} - config := internal.ParseConfig(args, env) + config, err := internal.ParseConfig(args, env) + require.NoError(t, err) require.Equal(t, internal.Command([]string{"command"}), config.Args) - // VARVALUE is added to env even without = - require.Contains(t, config.Env, "VARVALUE") + // VARVALUE is not added to env because it lacks '=' + // The new config package filters out malformed env vars + require.NotContains(t, config.Env, "VARVALUE") }) t.Run("volume flag without value", func(t *testing.T) { args := []string{"--volume", "command"} env := []string{"TERM=xterm"} - config := internal.ParseConfig(args, env) + config, err := internal.ParseConfig(args, env) + require.NoError(t, err) // "command" is treated as volume value require.Empty(t, config.Args) require.Equal(t, []string{ @@ -90,7 +97,8 @@ func TestConfigErrorCases(t *testing.T) { } env := []string{"TERM=xterm"} - config := internal.ParseConfig(args, env) + config, err := internal.ParseConfig(args, env) + require.NoError(t, err) require.Equal(t, internal.Command([]string{"cmd", "arg1", "arg2"}), config.Args) require.Contains(t, config.Env, "VAR1=val1") require.Contains(t, config.Env, "VAR2=val2") @@ -107,7 +115,8 @@ func TestConfigErrorCases(t *testing.T) { args := []string{"--env", "VAR=val", "--", "--command-with-dashes", "--flag"} env := []string{"TERM=xterm"} - config := internal.ParseConfig(args, env) + config, err := internal.ParseConfig(args, env) + require.NoError(t, err) // Without special handling of --, all args after VAR=val become the command // The behavior depends on implementation require.NotEmpty(t, config.Args) @@ -122,7 +131,8 @@ func TestConfigErrorCases(t *testing.T) { } env := []string{"TERM=xterm"} - config := internal.ParseConfig(args, env) + config, err := internal.ParseConfig(args, env) + require.NoError(t, err) require.Equal(t, internal.Command([]string{"command"}), config.Args) require.Contains(t, config.Env, "VAR=value with spaces") require.Contains(t, config.Env, "PATH=/usr/bin:/bin") @@ -133,7 +143,8 @@ func TestConfigErrorCases(t *testing.T) { args := []string{"--env", "EMPTY=", "command"} env := []string{"TERM=xterm"} - config := internal.ParseConfig(args, env) + config, err := internal.ParseConfig(args, env) + require.NoError(t, err) require.Equal(t, internal.Command([]string{"command"}), config.Args) require.Contains(t, config.Env, "EMPTY=") }) @@ -146,7 +157,8 @@ func TestConfigErrorCases(t *testing.T) { } env := []string{"TERM=xterm"} - config := internal.ParseConfig(args, env) + config, err := internal.ParseConfig(args, env) + require.NoError(t, err) require.Equal(t, internal.Command([]string{"command"}), config.Args) // Both are added to the list (behavior may vary) envStr := "" @@ -160,7 +172,8 @@ func TestConfigErrorCases(t *testing.T) { args := []string{"--env", "TERM=override", "command"} env := []string{"TERM=xterm"} - config := internal.ParseConfig(args, env) + config, err := internal.ParseConfig(args, env) + require.NoError(t, err) require.Equal(t, internal.Command([]string{"command"}), config.Args) // Both TERM values might be present require.NotEmpty(t, config.Env) @@ -173,7 +186,8 @@ func TestConfigErrorCases(t *testing.T) { } env := []string{"TERM=xterm"} - config := internal.ParseConfig(args, env) + config, err := internal.ParseConfig(args, env) + require.NoError(t, err) require.Len(t, config.Args, 1001) }) @@ -181,7 +195,8 @@ func TestConfigErrorCases(t *testing.T) { args := []string{"command"} env := []string{} // No TERM, COLORTERM, or ANTHROPIC_API_KEY - config := internal.ParseConfig(args, env) + config, err := internal.ParseConfig(args, env) + require.NoError(t, err) require.Equal(t, internal.Command([]string{"command"}), config.Args) // Should provide defaults for missing values require.NotEmpty(t, config.Env) diff --git a/main.go b/main.go index 45ef036..9d469ad 100644 --- a/main.go +++ b/main.go @@ -30,7 +30,10 @@ func run(args, env []string) error { cleanup := internal.NewCleanupManager() defer cleanup.Execute() - config := internal.ParseConfig(args[1:], env) + config, err := internal.ParseConfig(args[1:], env) + if err != nil { + return fmt.Errorf("failed to parse configuration: %w", err) + } ctx, cancel := context.WithCancel(context.Background()) cleanup.Add("cancel-context", func() error { cancel(); return nil }) @@ -66,6 +69,15 @@ func run(args, env []string) error { return nil }) + // Validate that Dockerfile path is provided + if config.DockerfilePath == "" { + return fmt.Errorf("dockerfile path is required but not specified\n" + + "Specify it using:\n" + + " - CLI flag: --dockerfile ./Dockerfile\n" + + " - Config file: Add 'dockerfile: ./Dockerfile' to .contagent.yaml\n" + + "See .contagent.example.yaml for more details") + } + image, err := client.BuildImage(ctx, config.DockerfilePath, config.ImageName, w) if err != nil { return fmt.Errorf("failed to build docker image %q from %q: %w", config.ImageName, config.DockerfilePath, err)