diff --git a/AGENTS.md b/AGENTS.md index ab6ddcd..85bda96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -434,6 +434,140 @@ func TestCreate_StepLogger(t *testing.T) { **Reference:** See `pkg/runtime/podman/steplogger_test.go` and step logger tests in `create_test.go`, `start_test.go`, `stop_test.go`, `remove_test.go`. +### Podman Runtime Configuration + +The Podman runtime supports configurable image and agent settings through JSON files stored in the runtime's storage directory. This allows customization of the base image, installed packages, sudo permissions, and agent setup. + +**Key Components:** +- **Config Interface** (`pkg/runtime/podman/config/config.go`): Interface for managing Podman runtime configuration +- **ImageConfig** (`pkg/runtime/podman/config/types.go`): Base image configuration (Fedora version, packages, sudo binaries, custom RUN commands) +- **AgentConfig** (`pkg/runtime/podman/config/types.go`): Agent-specific configuration (packages, RUN commands, terminal command) +- **Defaults** (`pkg/runtime/podman/config/defaults.go`): Default configurations for image and Claude agent + +**Configuration Storage:** + +Configuration files are stored in the runtime's storage directory: +```text +/runtimes/podman/config/ +├── image.json # Base image configuration +└── claude.json # Agent-specific configuration (e.g., for Claude Code) +``` + +**Configuration Files:** + +**image.json** - Base image configuration: +```json +{ + "version": "latest", + "packages": ["which", "procps-ng", "wget2", "@development-tools", "jq", "gh", "golang", "golangci-lint", "python3", "python3-pip"], + "sudo": ["/usr/bin/dnf", "/bin/nice", "/bin/kill", "/usr/bin/kill", "/usr/bin/killall"], + "run_commands": [] +} +``` + +Fields: +- `version` (required) - Fedora version tag (e.g., "latest", "40", "41") +- `packages` (optional) - DNF packages to install +- `sudo` (optional) - Absolute paths to binaries the user can run with sudo (creates single `ALLOWED` Cmnd_Alias) +- `run_commands` (optional) - Custom shell commands to execute during image build (before agent setup) + +**claude.json** - Agent-specific configuration: +```json +{ + "packages": [], + "run_commands": [ + "curl -fsSL --proto-redir '-all,https' --tlsv1.3 https://claude.ai/install.sh | bash", + "mkdir /home/claude/.config" + ], + "terminal_command": ["claude"] +} +``` + +Fields: +- `packages` (optional) - Additional packages for the agent (merged with image packages) +- `run_commands` (optional) - Commands to set up the agent (executed after image setup) +- `terminal_command` (required) - Command to launch the agent (must have at least one element) + +**Using the Config Interface:** + +```go +import "github.com/kortex-hub/kortex-cli/pkg/runtime/podman/config" + +// Create config manager (in Initialize method) +configDir := filepath.Join(storageDir, "config") +cfg, err := config.NewConfig(configDir) +if err != nil { + return fmt.Errorf("failed to create config: %w", err) +} + +// Generate default configs if they don't exist +if err := cfg.GenerateDefaults(); err != nil { + return fmt.Errorf("failed to generate defaults: %w", err) +} + +// Load configurations (in Create method) +imageConfig, err := cfg.LoadImage() +if err != nil { + return fmt.Errorf("failed to load image config: %w", err) +} + +agentConfig, err := cfg.LoadAgent("claude") +if err != nil { + return fmt.Errorf("failed to load agent config: %w", err) +} +``` + +**Validation:** + +The config system validates: +- Image version cannot be empty +- Sudo binaries must be absolute paths +- Terminal command must have at least one element +- All fields are optional except `version` (ImageConfig) and `terminal_command` (AgentConfig) + +**Default Generation:** + +- Default configs are auto-generated on first runtime initialization +- Existing config files are never overwritten - customizations are preserved +- Default image config includes common development tools and packages +- Default Claude config installs Claude Code from the official install script + +**Containerfile Generation:** + +The config system is used to generate Containerfiles dynamically: + +```go +import "github.com/kortex-hub/kortex-cli/pkg/runtime/podman" + +// Generate Containerfile content from configs +containerfileContent := generateContainerfile(imageConfig, agentConfig) + +// Generate sudoers file content from sudo binaries +sudoersContent := generateSudoers(imageConfig.Sudo) +``` + +The `generateContainerfile` function creates a Containerfile with: +- Base image: `registry.fedoraproject.org/fedora:` +- Merged packages from image and agent configs +- User/group setup (hardcoded as `claude:claude`) +- Sudoers configuration with single `ALLOWED` Cmnd_Alias +- Custom RUN commands from both configs (image commands first, then agent commands) + +**Hardcoded Values:** + +These values are not configurable: +- Base image registry: `registry.fedoraproject.org/fedora` (only version tag is configurable) +- Container user: `claude` +- Container group: `claude` +- User UID/GID: Matched to host user's UID/GID at build time + +**Design Principles:** +- Follows interface-based design pattern with unexported implementation +- Uses nested JSON structure for clarity +- Validates all configurations on load to catch errors early +- Separate concerns: base image vs agent-specific settings +- Extensible: easy to add new agent configurations (e.g., `goose.json`, `cursor.json`) + ### Config System The config system manages workspace configuration stored in the `.kortex` directory. It provides an interface for reading and validating workspace settings including environment variables and mount points. diff --git a/README.md b/README.md index d97e73e..9f5af1c 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,145 @@ kortex-cli start **Note:** When using `--output json`, all progress spinners are hidden to avoid polluting the JSON output. +#### Customizing Podman Runtime Configuration + +The Podman runtime is fully configurable through JSON files. When you first use the Podman runtime, default configuration files are automatically created in your storage directory. + +**Configuration Location:** + +```text +$HOME/.kortex-cli/runtimes/podman/config/ +├── image.json # Base image configuration +└── claude.json # Agent-specific configuration +``` + +Or if using a custom storage directory: + +```text +/runtimes/podman/config/ +``` + +##### Base Image Configuration (`image.json`) + +Controls the container's base image, packages, and sudo permissions. + +**Structure:** + +```json +{ + "version": "latest", + "packages": [ + "which", + "procps-ng", + "wget2", + "@development-tools", + "jq", + "gh", + "golang", + "golangci-lint", + "python3", + "python3-pip" + ], + "sudo": [ + "/usr/bin/dnf", + "/bin/nice", + "/bin/kill", + "/usr/bin/kill", + "/usr/bin/killall" + ], + "run_commands": [] +} +``` + +**Fields:** + +- `version` (required) - Fedora version tag + - Examples: `"latest"`, `"40"`, `"41"` + - The base registry `registry.fedoraproject.org/fedora` is hardcoded and cannot be changed + +- `packages` (optional) - DNF packages to install + - Array of package names + - Can include package groups with `@` prefix (e.g., `"@development-tools"`) + - Empty array is valid if no packages needed + +- `sudo` (optional) - Binaries the `claude` user can run with sudo + - Must be absolute paths (e.g., `"/usr/bin/dnf"`) + - Creates a single `ALLOWED` command alias in sudoers + - Empty array disables all sudo access + +- `run_commands` (optional) - Custom shell commands to run during image build + - Executed as RUN instructions in the Containerfile + - Run before agent-specific commands + - Useful for additional setup steps + +##### Agent Configuration (`claude.json`) + +Controls agent-specific packages and installation steps. + +**Structure:** + +```json +{ + "packages": [], + "run_commands": [ + "curl -fsSL --proto-redir '-all,https' --tlsv1.3 https://claude.ai/install.sh | bash", + "mkdir /home/claude/.config" + ], + "terminal_command": [ + "claude" + ] +} +``` + +**Fields:** + +- `packages` (optional) - Additional packages specific to this agent + - Merged with packages from `image.json` + - Useful for agent-specific dependencies + +- `run_commands` (optional) - Commands to set up the agent + - Executed after image configuration commands + - Typically used for agent installation + +- `terminal_command` (required) - Command to launch the agent + - Must have at least one element + - Can include flags: `["claude", "--verbose"]` + +##### Applying Configuration Changes + +Configuration changes take effect when you **register a new workspace with `init`**. The Containerfile is generated and the image is built during workspace registration, using the configuration files that exist at that time. + +**To apply new configuration:** + +1. Edit the configuration files: + ```bash + # Edit base image configuration + nano ~/.kortex-cli/runtimes/podman/config/image.json + + # Edit agent configuration + nano ~/.kortex-cli/runtimes/podman/config/claude.json + ``` + +2. Register a new workspace (this creates the Containerfile and builds the image): + ```bash + kortex-cli init /path/to/project --runtime podman + ``` + +3. Start the workspace: + ```bash + kortex-cli start + ``` + +**Notes:** + +- The first `init` command using Podman creates default config files automatically +- Config files are never overwritten once created - your customizations are preserved +- The Containerfile and image are built during `init`, not `start` +- Each workspace's image is built once using the configuration at registration time +- To rebuild a workspace with new config, remove and re-register it +- Validation errors in config files will cause workspace registration to fail with a descriptive message +- The generated Containerfile is automatically copied to `/home/claude/Containerfile` inside the container for reference + ## Workspace Configuration Each workspace can optionally include a configuration file that customizes the environment and mount behavior for that specific workspace. The configuration is stored in a `workspace.json` file within the workspace's configuration directory (typically `.kortex` in the sources directory). diff --git a/pkg/runtime/podman/config/config.go b/pkg/runtime/podman/config/config.go new file mode 100644 index 0000000..ee9e7fd --- /dev/null +++ b/pkg/runtime/podman/config/config.go @@ -0,0 +1,201 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" +) + +var ( + // ErrInvalidPath is returned when a configuration path is invalid or empty + ErrInvalidPath = errors.New("invalid configuration path") + // ErrConfigNotFound is returned when a configuration file is not found + ErrConfigNotFound = errors.New("configuration file not found") + // ErrInvalidConfig is returned when configuration validation fails + ErrInvalidConfig = errors.New("invalid configuration") + + // agentNamePattern matches valid agent names (alphanumeric and underscore only). + agentNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_]+$`) +) + +// Config represents a Podman runtime configuration manager. +// It manages the structure and contents of the Podman runtime configuration directory. +type Config interface { + // LoadImage reads and parses the base image configuration from image.json. + // Returns ErrConfigNotFound if the image.json file doesn't exist. + // Returns ErrInvalidConfig if the configuration is invalid. + LoadImage() (*ImageConfig, error) + + // LoadAgent reads and parses the agent-specific configuration. + // Returns ErrConfigNotFound if the agent configuration file doesn't exist. + // Returns ErrInvalidConfig if the configuration is invalid. + LoadAgent(agentName string) (*AgentConfig, error) + + // GenerateDefaults creates default configuration files if they don't exist. + // Creates the configuration directory if it doesn't exist. + // Does not overwrite existing configuration files. + GenerateDefaults() error +} + +// config is the internal implementation of Config +type config struct { + // path is the absolute path to the configuration directory + path string +} + +// Compile-time check to ensure config implements Config interface +var _ Config = (*config)(nil) + +// LoadImage reads and parses the base image configuration from image.json +func (c *config) LoadImage() (*ImageConfig, error) { + configPath := filepath.Join(c.path, ImageConfigFileName) + + // Read the file + data, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrConfigNotFound + } + return nil, err + } + + // Parse the JSON + var cfg ImageConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err) + } + + // Validate the configuration + if err := validateImageConfig(&cfg); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err) + } + + return &cfg, nil +} + +// LoadAgent reads and parses the agent-specific configuration +func (c *config) LoadAgent(agentName string) (*AgentConfig, error) { + if agentName == "" { + return nil, fmt.Errorf("%w: agent name cannot be empty", ErrInvalidConfig) + } + + // Validate agent name to prevent path traversal attacks + if !agentNamePattern.MatchString(agentName) { + return nil, fmt.Errorf("%w: agent name must contain only alphanumeric characters or underscores: %s", ErrInvalidConfig, agentName) + } + + configPath := filepath.Join(c.path, agentName+".json") + + // Read the file + data, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrConfigNotFound + } + return nil, err + } + + // Parse the JSON + var cfg AgentConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err) + } + + // Validate the configuration + if err := validateAgentConfig(&cfg); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err) + } + + return &cfg, nil +} + +// GenerateDefaults creates default configuration files if they don't exist +func (c *config) GenerateDefaults() error { + // Create the configuration directory if it doesn't exist + if err := os.MkdirAll(c.path, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // Generate default image config if it doesn't exist + imageConfigPath := filepath.Join(c.path, ImageConfigFileName) + fileInfo, err := os.Stat(imageConfigPath) + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("failed to stat %s: %w", imageConfigPath, err) + } + // File doesn't exist, generate it + imageConfig := defaultImageConfig() + data, err := json.MarshalIndent(imageConfig, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal image config: %w", err) + } + if err := os.WriteFile(imageConfigPath, data, 0644); err != nil { + return fmt.Errorf("failed to write image config: %w", err) + } + } else { + // File exists, check if it's a directory + if fileInfo.IsDir() { + return fmt.Errorf("expected file but found directory: %s", imageConfigPath) + } + } + + // Generate default Claude config if it doesn't exist + claudeConfigPath := filepath.Join(c.path, ClaudeConfigFileName) + fileInfo, err = os.Stat(claudeConfigPath) + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("failed to stat %s: %w", claudeConfigPath, err) + } + // File doesn't exist, generate it + claudeConfig := defaultClaudeConfig() + data, err := json.MarshalIndent(claudeConfig, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal claude config: %w", err) + } + if err := os.WriteFile(claudeConfigPath, data, 0644); err != nil { + return fmt.Errorf("failed to write claude config: %w", err) + } + } else { + // File exists, check if it's a directory + if fileInfo.IsDir() { + return fmt.Errorf("expected file but found directory: %s", claudeConfigPath) + } + } + + return nil +} + +// NewConfig creates a new Config for the specified configuration directory. +// The configDir is converted to an absolute path. +func NewConfig(configDir string) (Config, error) { + if configDir == "" { + return nil, ErrInvalidPath + } + + // Convert to absolute path + absPath, err := filepath.Abs(configDir) + if err != nil { + return nil, err + } + + return &config{ + path: absPath, + }, nil +} diff --git a/pkg/runtime/podman/config/config_test.go b/pkg/runtime/podman/config/config_test.go new file mode 100644 index 0000000..354d412 --- /dev/null +++ b/pkg/runtime/podman/config/config_test.go @@ -0,0 +1,600 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNewConfig(t *testing.T) { + t.Parallel() + + t.Run("creates config with valid path", func(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + if cfg == nil { + t.Fatal("Expected non-nil config") + } + }) + + t.Run("returns error for empty path", func(t *testing.T) { + t.Parallel() + + _, err := NewConfig("") + if err == nil { + t.Fatal("Expected error for empty path") + } + + if !errors.Is(err, ErrInvalidPath) { + t.Errorf("Expected ErrInvalidPath, got: %v", err) + } + }) + + t.Run("converts to absolute path", func(t *testing.T) { + t.Parallel() + + cfg, err := NewConfig(".") + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + // The internal path should be absolute + c := cfg.(*config) + if !filepath.IsAbs(c.path) { + t.Errorf("Expected absolute path, got: %s", c.path) + } + }) +} + +func TestGenerateDefaults(t *testing.T) { + t.Parallel() + + t.Run("creates config directory if missing", func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + configDir := filepath.Join(tempDir, "config") + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + err = cfg.GenerateDefaults() + if err != nil { + t.Fatalf("GenerateDefaults() failed: %v", err) + } + + // Verify directory was created + if _, err := os.Stat(configDir); os.IsNotExist(err) { + t.Error("Config directory was not created") + } + }) + + t.Run("creates default image config", func(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + err = cfg.GenerateDefaults() + if err != nil { + t.Fatalf("GenerateDefaults() failed: %v", err) + } + + // Verify image.json exists + imageConfigPath := filepath.Join(configDir, ImageConfigFileName) + if _, err := os.Stat(imageConfigPath); os.IsNotExist(err) { + t.Error("image.json was not created") + } + + // Verify content is valid JSON + data, err := os.ReadFile(imageConfigPath) + if err != nil { + t.Fatalf("Failed to read image config: %v", err) + } + + var imageConfig ImageConfig + if err := json.Unmarshal(data, &imageConfig); err != nil { + t.Fatalf("Failed to parse image config: %v", err) + } + + // Verify default values + if imageConfig.Version != DefaultVersion { + t.Errorf("Expected version %s, got: %s", DefaultVersion, imageConfig.Version) + } + }) + + t.Run("creates default claude config", func(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + err = cfg.GenerateDefaults() + if err != nil { + t.Fatalf("GenerateDefaults() failed: %v", err) + } + + // Verify claude.json exists + claudeConfigPath := filepath.Join(configDir, ClaudeConfigFileName) + if _, err := os.Stat(claudeConfigPath); os.IsNotExist(err) { + t.Error("claude.json was not created") + } + + // Verify content is valid JSON + data, err := os.ReadFile(claudeConfigPath) + if err != nil { + t.Fatalf("Failed to read claude config: %v", err) + } + + var agentConfig AgentConfig + if err := json.Unmarshal(data, &agentConfig); err != nil { + t.Fatalf("Failed to parse claude config: %v", err) + } + + // Verify terminal command is set + if len(agentConfig.TerminalCommand) == 0 { + t.Error("Expected terminal command to be set") + } + }) + + t.Run("does not overwrite existing configs", func(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + // Create a custom image config + customImageConfig := &ImageConfig{ + Version: "40", + Packages: []string{"custom-package"}, + Sudo: []string{"/usr/bin/custom"}, + } + imageConfigPath := filepath.Join(configDir, ImageConfigFileName) + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + data, _ := json.MarshalIndent(customImageConfig, "", " ") + if err := os.WriteFile(imageConfigPath, data, 0644); err != nil { + t.Fatalf("Failed to write custom config: %v", err) + } + + // Call GenerateDefaults + err = cfg.GenerateDefaults() + if err != nil { + t.Fatalf("GenerateDefaults() failed: %v", err) + } + + // Verify the custom config was not overwritten + loadedConfig, err := cfg.LoadImage() + if err != nil { + t.Fatalf("LoadImage() failed: %v", err) + } + + if loadedConfig.Version != "40" { + t.Errorf("Expected version 40, got: %s (config was overwritten)", loadedConfig.Version) + } + }) + + t.Run("returns error when image config path is a directory", func(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + // Create image.json as a directory instead of a file + imageConfigPath := filepath.Join(configDir, ImageConfigFileName) + if err := os.MkdirAll(imageConfigPath, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + // Call GenerateDefaults - should fail + err = cfg.GenerateDefaults() + if err == nil { + t.Fatal("Expected error when image config path is a directory") + } + + if !strings.Contains(err.Error(), "expected file but found directory") { + t.Errorf("Expected 'expected file but found directory' error, got: %v", err) + } + if !strings.Contains(err.Error(), imageConfigPath) { + t.Errorf("Expected error to contain path %s, got: %v", imageConfigPath, err) + } + }) + + t.Run("returns error when claude config path is a directory", func(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + // Create claude.json as a directory instead of a file + claudeConfigPath := filepath.Join(configDir, ClaudeConfigFileName) + if err := os.MkdirAll(claudeConfigPath, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + // Call GenerateDefaults - should fail + err = cfg.GenerateDefaults() + if err == nil { + t.Fatal("Expected error when claude config path is a directory") + } + + if !strings.Contains(err.Error(), "expected file but found directory") { + t.Errorf("Expected 'expected file but found directory' error, got: %v", err) + } + if !strings.Contains(err.Error(), claudeConfigPath) { + t.Errorf("Expected error to contain path %s, got: %v", claudeConfigPath, err) + } + }) +} + +func TestLoadImage(t *testing.T) { + t.Parallel() + + t.Run("loads valid image config", func(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + // Generate defaults + if err := cfg.GenerateDefaults(); err != nil { + t.Fatalf("GenerateDefaults() failed: %v", err) + } + + // Load the config + imageConfig, err := cfg.LoadImage() + if err != nil { + t.Fatalf("LoadImage() failed: %v", err) + } + + if imageConfig == nil { + t.Fatal("Expected non-nil image config") + } + + if imageConfig.Version == "" { + t.Error("Expected version to be set") + } + }) + + t.Run("returns ErrConfigNotFound if file missing", func(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + // Don't generate defaults - file doesn't exist + _, err = cfg.LoadImage() + if err == nil { + t.Fatal("Expected error for missing config file") + } + + if !errors.Is(err, ErrConfigNotFound) { + t.Errorf("Expected ErrConfigNotFound, got: %v", err) + } + }) + + t.Run("returns error for invalid JSON", func(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + // Create directory and write invalid JSON + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + + imageConfigPath := filepath.Join(configDir, ImageConfigFileName) + if err := os.WriteFile(imageConfigPath, []byte("invalid json"), 0644); err != nil { + t.Fatalf("Failed to write invalid config: %v", err) + } + + // Attempt to load + _, err = cfg.LoadImage() + if err == nil { + t.Fatal("Expected error for invalid JSON") + } + + if !errors.Is(err, ErrInvalidConfig) { + t.Errorf("Expected ErrInvalidConfig, got: %v", err) + } + }) + + t.Run("returns ErrInvalidConfig for validation failure", func(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + // Create invalid config (empty version) + invalidConfig := &ImageConfig{ + Version: "", // Invalid - empty version + Packages: []string{}, + Sudo: []string{}, + } + + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + + imageConfigPath := filepath.Join(configDir, ImageConfigFileName) + data, _ := json.MarshalIndent(invalidConfig, "", " ") + if err := os.WriteFile(imageConfigPath, data, 0644); err != nil { + t.Fatalf("Failed to write invalid config: %v", err) + } + + // Attempt to load + _, err = cfg.LoadImage() + if err == nil { + t.Fatal("Expected error for invalid config") + } + + if !errors.Is(err, ErrInvalidConfig) { + t.Errorf("Expected ErrInvalidConfig, got: %v", err) + } + }) +} + +func TestLoadAgent(t *testing.T) { + t.Parallel() + + t.Run("loads valid agent config", func(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + // Generate defaults + if err := cfg.GenerateDefaults(); err != nil { + t.Fatalf("GenerateDefaults() failed: %v", err) + } + + // Load the agent config + agentConfig, err := cfg.LoadAgent("claude") + if err != nil { + t.Fatalf("LoadAgent() failed: %v", err) + } + + if agentConfig == nil { + t.Fatal("Expected non-nil agent config") + } + + if len(agentConfig.TerminalCommand) == 0 { + t.Error("Expected terminal command to be set") + } + }) + + t.Run("returns error for empty agent name", func(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + // Try to load with empty agent name + _, err = cfg.LoadAgent("") + if err == nil { + t.Fatal("Expected error for empty agent name") + } + + if !errors.Is(err, ErrInvalidConfig) { + t.Errorf("Expected ErrInvalidConfig, got: %v", err) + } + }) + + t.Run("returns error for invalid agent name characters", func(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + // Test various invalid agent names + invalidNames := []string{ + "../etc/passwd", // Path traversal + "agent-name", // Hyphen not allowed + "agent.name", // Dot not allowed + "agent/name", // Slash not allowed + "agent\\name", // Backslash not allowed + "agent name", // Space not allowed + "agent@name", // Special char not allowed + ".", // Current directory + "..", // Parent directory + "agent-1", // Hyphen not allowed + "my-agent", // Hyphen not allowed + } + + for _, name := range invalidNames { + _, err = cfg.LoadAgent(name) + if err == nil { + t.Errorf("Expected error for invalid agent name %q", name) + continue + } + + if !errors.Is(err, ErrInvalidConfig) { + t.Errorf("Expected ErrInvalidConfig for %q, got: %v", name, err) + } + } + }) + + t.Run("accepts valid agent names", func(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + // Create config directory + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + + // Test various valid agent names + validNames := []string{ + "claude", + "goose", + "cursor", + "agent123", + "my_agent", + "AGENT", + "Agent_1", + "_agent", + "agent_", + } + + for _, name := range validNames { + // Create a valid config file for this agent + agentConfig := &AgentConfig{ + Packages: []string{}, + RunCommands: []string{}, + TerminalCommand: []string{name}, + } + agentConfigPath := filepath.Join(configDir, name+".json") + data, _ := json.MarshalIndent(agentConfig, "", " ") + if err := os.WriteFile(agentConfigPath, data, 0644); err != nil { + t.Fatalf("Failed to write config for %q: %v", name, err) + } + + // Try to load it - should succeed + _, err = cfg.LoadAgent(name) + if err != nil { + t.Errorf("Expected valid agent name %q to succeed, got error: %v", name, err) + } + } + }) + + t.Run("returns ErrConfigNotFound if file missing", func(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + // Don't generate defaults - file doesn't exist + _, err = cfg.LoadAgent("nonexistent") + if err == nil { + t.Fatal("Expected error for missing config file") + } + + if !errors.Is(err, ErrConfigNotFound) { + t.Errorf("Expected ErrConfigNotFound, got: %v", err) + } + }) + + t.Run("returns ErrInvalidConfig for validation failure", func(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + + cfg, err := NewConfig(configDir) + if err != nil { + t.Fatalf("NewConfig() failed: %v", err) + } + + // Create invalid config (empty terminal command) + invalidConfig := &AgentConfig{ + Packages: []string{}, + RunCommands: []string{}, + TerminalCommand: []string{}, // Invalid - must have at least one element + } + + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + + agentConfigPath := filepath.Join(configDir, "test.json") + data, _ := json.MarshalIndent(invalidConfig, "", " ") + if err := os.WriteFile(agentConfigPath, data, 0644); err != nil { + t.Fatalf("Failed to write invalid config: %v", err) + } + + // Attempt to load + _, err = cfg.LoadAgent("test") + if err == nil { + t.Fatal("Expected error for invalid config") + } + + if !errors.Is(err, ErrInvalidConfig) { + t.Errorf("Expected ErrInvalidConfig, got: %v", err) + } + }) +} diff --git a/pkg/runtime/podman/config/defaults.go b/pkg/runtime/podman/config/defaults.go new file mode 100644 index 0000000..2382fd1 --- /dev/null +++ b/pkg/runtime/podman/config/defaults.go @@ -0,0 +1,65 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +const ( + // DefaultVersion is the default Fedora version tag + DefaultVersion = "latest" + + // ImageConfigFileName is the filename for base image configuration + ImageConfigFileName = "image.json" + + // ClaudeConfigFileName is the filename for Claude agent configuration + ClaudeConfigFileName = "claude.json" +) + +// defaultImageConfig returns the default base image configuration. +func defaultImageConfig() *ImageConfig { + return &ImageConfig{ + Version: DefaultVersion, + Packages: []string{ + "which", + "procps-ng", + "wget2", + "@development-tools", + "jq", + "gh", + "golang", + "golangci-lint", + "python3", + "python3-pip", + }, + Sudo: []string{ + "/usr/bin/dnf", + "/bin/nice", + "/bin/kill", + "/usr/bin/kill", + "/usr/bin/killall", + }, + RunCommands: []string{}, + } +} + +// defaultClaudeConfig returns the default Claude agent configuration. +func defaultClaudeConfig() *AgentConfig { + return &AgentConfig{ + Packages: []string{}, + RunCommands: []string{ + "curl -fsSL --proto-redir '-all,https' --tlsv1.3 https://claude.ai/install.sh | bash", + "mkdir -p /home/claude/.config", + }, + TerminalCommand: []string{"claude"}, + } +} diff --git a/pkg/runtime/podman/config/types.go b/pkg/runtime/podman/config/types.go new file mode 100644 index 0000000..08d608e --- /dev/null +++ b/pkg/runtime/podman/config/types.go @@ -0,0 +1,30 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +// ImageConfig represents the base image configuration. +type ImageConfig struct { + Version string `json:"version"` + Packages []string `json:"packages"` + Sudo []string `json:"sudo"` + RunCommands []string `json:"run_commands"` +} + +// AgentConfig represents agent-specific configuration. +type AgentConfig struct { + Packages []string `json:"packages"` + RunCommands []string `json:"run_commands"` + TerminalCommand []string `json:"terminal_command"` +} diff --git a/pkg/runtime/podman/config/validation.go b/pkg/runtime/podman/config/validation.go new file mode 100644 index 0000000..0554663 --- /dev/null +++ b/pkg/runtime/podman/config/validation.go @@ -0,0 +1,65 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "fmt" + "strings" +) + +// validateImageConfig validates the image configuration. +func validateImageConfig(cfg *ImageConfig) error { + if cfg == nil { + return fmt.Errorf("image config cannot be nil") + } + + // Version cannot be empty + if cfg.Version == "" { + return fmt.Errorf("version cannot be empty") + } + + // Packages can be empty (valid use case) + + // Sudo binaries must be absolute paths (Unix-style, for container environment) + // These paths are for inside the Linux container, so they must start with / + // regardless of the host OS + for _, binary := range cfg.Sudo { + if !strings.HasPrefix(binary, "/") { + return fmt.Errorf("sudo binary must be an absolute path: %s", binary) + } + } + + // RunCommands can be empty (valid use case) + + return nil +} + +// validateAgentConfig validates the agent configuration. +func validateAgentConfig(cfg *AgentConfig) error { + if cfg == nil { + return fmt.Errorf("agent config cannot be nil") + } + + // Packages can be empty (valid use case) + + // RunCommands can be empty (valid use case) + + // Terminal command must have at least one element + if len(cfg.TerminalCommand) == 0 || cfg.TerminalCommand[0] == "" { + return fmt.Errorf("terminal command must have at least one non-empty element") + } + + return nil +} diff --git a/pkg/runtime/podman/config/validation_test.go b/pkg/runtime/podman/config/validation_test.go new file mode 100644 index 0000000..cf2a076 --- /dev/null +++ b/pkg/runtime/podman/config/validation_test.go @@ -0,0 +1,212 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "testing" +) + +func TestValidateImageConfig(t *testing.T) { + t.Parallel() + + t.Run("accepts valid config", func(t *testing.T) { + t.Parallel() + + cfg := &ImageConfig{ + Version: "latest", + Packages: []string{"package1", "package2"}, + Sudo: []string{"/usr/bin/dnf", "/bin/kill"}, + RunCommands: []string{"echo test"}, + } + + err := validateImageConfig(cfg) + if err != nil { + t.Errorf("Expected valid config, got error: %v", err) + } + }) + + t.Run("rejects nil config", func(t *testing.T) { + t.Parallel() + + err := validateImageConfig(nil) + if err == nil { + t.Error("Expected error for nil config") + } + }) + + t.Run("rejects empty version", func(t *testing.T) { + t.Parallel() + + cfg := &ImageConfig{ + Version: "", + Packages: []string{}, + Sudo: []string{}, + } + + err := validateImageConfig(cfg) + if err == nil { + t.Error("Expected error for empty version") + } + }) + + t.Run("accepts empty packages", func(t *testing.T) { + t.Parallel() + + cfg := &ImageConfig{ + Version: "latest", + Packages: []string{}, + Sudo: []string{}, + } + + err := validateImageConfig(cfg) + if err != nil { + t.Errorf("Expected empty packages to be valid, got error: %v", err) + } + }) + + t.Run("rejects non-absolute sudo paths", func(t *testing.T) { + t.Parallel() + + cfg := &ImageConfig{ + Version: "latest", + Packages: []string{}, + Sudo: []string{"relative/path"}, + } + + err := validateImageConfig(cfg) + if err == nil { + t.Error("Expected error for non-absolute sudo path") + } + }) + + t.Run("accepts absolute sudo paths", func(t *testing.T) { + t.Parallel() + + cfg := &ImageConfig{ + Version: "latest", + Packages: []string{}, + Sudo: []string{"/usr/bin/dnf", "/bin/kill"}, + } + + err := validateImageConfig(cfg) + if err != nil { + t.Errorf("Expected absolute sudo paths to be valid, got error: %v", err) + } + }) + + t.Run("accepts empty run commands", func(t *testing.T) { + t.Parallel() + + cfg := &ImageConfig{ + Version: "latest", + Packages: []string{}, + Sudo: []string{}, + RunCommands: []string{}, + } + + err := validateImageConfig(cfg) + if err != nil { + t.Errorf("Expected empty run commands to be valid, got error: %v", err) + } + }) +} + +func TestValidateAgentConfig(t *testing.T) { + t.Parallel() + + t.Run("accepts valid config", func(t *testing.T) { + t.Parallel() + + cfg := &AgentConfig{ + Packages: []string{"package1"}, + RunCommands: []string{"echo test"}, + TerminalCommand: []string{"claude"}, + } + + err := validateAgentConfig(cfg) + if err != nil { + t.Errorf("Expected valid config, got error: %v", err) + } + }) + + t.Run("rejects nil config", func(t *testing.T) { + t.Parallel() + + err := validateAgentConfig(nil) + if err == nil { + t.Error("Expected error for nil config") + } + }) + + t.Run("accepts empty packages", func(t *testing.T) { + t.Parallel() + + cfg := &AgentConfig{ + Packages: []string{}, + RunCommands: []string{}, + TerminalCommand: []string{"claude"}, + } + + err := validateAgentConfig(cfg) + if err != nil { + t.Errorf("Expected empty packages to be valid, got error: %v", err) + } + }) + + t.Run("accepts empty run commands", func(t *testing.T) { + t.Parallel() + + cfg := &AgentConfig{ + Packages: []string{}, + RunCommands: []string{}, + TerminalCommand: []string{"claude"}, + } + + err := validateAgentConfig(cfg) + if err != nil { + t.Errorf("Expected empty run commands to be valid, got error: %v", err) + } + }) + + t.Run("rejects empty terminal command", func(t *testing.T) { + t.Parallel() + + cfg := &AgentConfig{ + Packages: []string{}, + RunCommands: []string{}, + TerminalCommand: []string{}, + } + + err := validateAgentConfig(cfg) + if err == nil { + t.Error("Expected error for empty terminal command") + } + }) + + t.Run("accepts terminal command with multiple elements", func(t *testing.T) { + t.Parallel() + + cfg := &AgentConfig{ + Packages: []string{}, + RunCommands: []string{}, + TerminalCommand: []string{"claude", "--verbose"}, + } + + err := validateAgentConfig(cfg) + if err != nil { + t.Errorf("Expected multi-element terminal command to be valid, got error: %v", err) + } + }) +} diff --git a/pkg/runtime/podman/containerfile.go b/pkg/runtime/podman/containerfile.go new file mode 100644 index 0000000..6dfcc5f --- /dev/null +++ b/pkg/runtime/podman/containerfile.go @@ -0,0 +1,114 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package podman + +import ( + "fmt" + "strings" + + "github.com/kortex-hub/kortex-cli/pkg/runtime/podman/config" +) + +const ( + // BaseImageRegistry is the hardcoded base image registry + BaseImageRegistry = "registry.fedoraproject.org/fedora" + + // ContainerUser is the hardcoded container user + ContainerUser = "claude" + + // ContainerGroup is the hardcoded container group + ContainerGroup = "claude" +) + +// generateSudoers generates the sudoers file content from a list of allowed binaries. +// It creates a single ALLOWED command alias and sets up sudo rules. +func generateSudoers(sudoBinaries []string) string { + if len(sudoBinaries) == 0 { + // No sudo access if no binaries are specified + return fmt.Sprintf("%s ALL = !ALL\n", ContainerUser) + } + + var lines []string + + // Create single ALLOWED command alias + lines = append(lines, fmt.Sprintf("Cmnd_Alias ALLOWED = %s", strings.Join(sudoBinaries, ", "))) + lines = append(lines, "") + + // Create sudo rule + lines = append(lines, fmt.Sprintf("%s ALL = !ALL, NOPASSWD: ALLOWED", ContainerUser)) + + return strings.Join(lines, "\n") + "\n" +} + +// generateContainerfile generates the Containerfile content from image and agent configurations. +func generateContainerfile(imageConfig *config.ImageConfig, agentConfig *config.AgentConfig) string { + if imageConfig == nil { + return "" + } + if agentConfig == nil { + return "" + } + + var lines []string + + // FROM line with base image + baseImage := fmt.Sprintf("%s:%s", BaseImageRegistry, imageConfig.Version) + lines = append(lines, fmt.Sprintf("FROM %s", baseImage)) + lines = append(lines, "") + + // Merge packages from image and agent configs + allPackages := append([]string{}, imageConfig.Packages...) + allPackages = append(allPackages, agentConfig.Packages...) + + // Install packages if any + if len(allPackages) > 0 { + lines = append(lines, fmt.Sprintf("RUN dnf install -y %s", strings.Join(allPackages, " "))) + lines = append(lines, "") + } + + // User and group setup (hardcoded) + lines = append(lines, "ARG UID=1000") + lines = append(lines, "ARG GID=1000") + lines = append(lines, `RUN GROUPNAME=$(grep $GID /etc/group | cut -d: -f1); [ -n "$GROUPNAME" ] && groupdel $GROUPNAME || true`) + lines = append(lines, fmt.Sprintf(`RUN groupadd -g "${GID}" %s && useradd -u "${UID}" -g "${GID}" -m %s`, ContainerGroup, ContainerUser)) + lines = append(lines, "COPY sudoers /etc/sudoers.d/claude") + lines = append(lines, "RUN chmod 0440 /etc/sudoers.d/claude") + lines = append(lines, fmt.Sprintf("USER %s:%s", ContainerUser, ContainerGroup)) + lines = append(lines, "") + + // Environment PATH + lines = append(lines, fmt.Sprintf("ENV PATH=/home/%s/.local/bin:/usr/local/bin:/usr/bin", ContainerUser)) + lines = append(lines, "") + + // Copy Containerfile to home directory for reference + lines = append(lines, fmt.Sprintf("COPY Containerfile /home/%s/Containerfile", ContainerUser)) + + // Custom RUN commands from image config + for _, cmd := range imageConfig.RunCommands { + lines = append(lines, fmt.Sprintf("RUN %s", cmd)) + } + + // Custom RUN commands from agent config + for _, cmd := range agentConfig.RunCommands { + lines = append(lines, fmt.Sprintf("RUN %s", cmd)) + } + + // Add final newline if there are RUN commands + if len(imageConfig.RunCommands) > 0 || len(agentConfig.RunCommands) > 0 { + lines = append(lines, "") + } + + return strings.Join(lines, "\n") +} diff --git a/pkg/runtime/podman/containerfile_test.go b/pkg/runtime/podman/containerfile_test.go new file mode 100644 index 0000000..3d2abbc --- /dev/null +++ b/pkg/runtime/podman/containerfile_test.go @@ -0,0 +1,275 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package podman + +import ( + "strings" + "testing" + + "github.com/kortex-hub/kortex-cli/pkg/runtime/podman/config" +) + +func TestGenerateSudoers(t *testing.T) { + t.Parallel() + + t.Run("generates sudoers with single ALLOWED alias", func(t *testing.T) { + t.Parallel() + + sudoBinaries := []string{"/usr/bin/dnf", "/bin/kill", "/usr/bin/killall"} + result := generateSudoers(sudoBinaries) + + // Check for ALLOWED alias + if !strings.Contains(result, "Cmnd_Alias ALLOWED =") { + t.Error("Expected sudoers to contain 'Cmnd_Alias ALLOWED ='") + } + + // Check that all binaries are listed + for _, binary := range sudoBinaries { + if !strings.Contains(result, binary) { + t.Errorf("Expected sudoers to contain %s", binary) + } + } + + // Check for the sudo rule + if !strings.Contains(result, "claude ALL = !ALL, NOPASSWD: ALLOWED") { + t.Error("Expected sudoers to contain correct sudo rule") + } + }) + + t.Run("generates no-access sudoers when no binaries provided", func(t *testing.T) { + t.Parallel() + + result := generateSudoers([]string{}) + + // Should only have the deny-all rule + if !strings.Contains(result, "claude ALL = !ALL") { + t.Error("Expected sudoers to contain 'claude ALL = !ALL'") + } + + // Should not have ALLOWED alias + if strings.Contains(result, "ALLOWED") { + t.Error("Expected sudoers to not contain ALLOWED alias when no binaries provided") + } + }) + + t.Run("joins multiple binaries with comma separator", func(t *testing.T) { + t.Parallel() + + sudoBinaries := []string{"/usr/bin/dnf", "/bin/kill"} + result := generateSudoers(sudoBinaries) + + // Check for comma-separated list + if !strings.Contains(result, "/usr/bin/dnf, /bin/kill") { + t.Error("Expected binaries to be comma-separated") + } + }) +} + +func TestGenerateContainerfile(t *testing.T) { + t.Parallel() + + t.Run("generates containerfile with default configs", func(t *testing.T) { + t.Parallel() + + imageConfig := &config.ImageConfig{ + Version: "latest", + Packages: []string{"which", "procps-ng"}, + Sudo: []string{"/usr/bin/dnf"}, + RunCommands: []string{}, + } + + agentConfig := &config.AgentConfig{ + Packages: []string{}, + RunCommands: []string{"curl -fsSL https://claude.ai/install.sh | bash"}, + TerminalCommand: []string{"claude"}, + } + + result := generateContainerfile(imageConfig, agentConfig) + + // Check for FROM line with correct base image + expectedFrom := "FROM registry.fedoraproject.org/fedora:latest" + if !strings.Contains(result, expectedFrom) { + t.Errorf("Expected FROM line: %s", expectedFrom) + } + + // Check for package installation + if !strings.Contains(result, "RUN dnf install -y which procps-ng") { + t.Error("Expected package installation line") + } + + // Check for user/group setup + if !strings.Contains(result, "ARG UID=1000") { + t.Error("Expected UID argument") + } + if !strings.Contains(result, "ARG GID=1000") { + t.Error("Expected GID argument") + } + if !strings.Contains(result, "USER claude:claude") { + t.Error("Expected USER line") + } + + // Check for sudoers copy + if !strings.Contains(result, "COPY sudoers /etc/sudoers.d/claude") { + t.Error("Expected COPY sudoers line") + } + + // Check for sudoers chmod + if !strings.Contains(result, "RUN chmod 0440 /etc/sudoers.d/claude") { + t.Error("Expected RUN chmod for sudoers") + } + + // Check for PATH environment + if !strings.Contains(result, "ENV PATH=/home/claude/.local/bin:/usr/local/bin:/usr/bin") { + t.Error("Expected PATH environment variable") + } + + // Check for Containerfile copy + if !strings.Contains(result, "COPY Containerfile /home/claude/Containerfile") { + t.Error("Expected COPY Containerfile line") + } + + // Check for agent RUN commands + if !strings.Contains(result, "RUN curl -fsSL https://claude.ai/install.sh | bash") { + t.Error("Expected agent RUN command") + } + }) + + t.Run("uses custom fedora version", func(t *testing.T) { + t.Parallel() + + imageConfig := &config.ImageConfig{ + Version: "40", + Packages: []string{}, + Sudo: []string{}, + } + + agentConfig := &config.AgentConfig{ + Packages: []string{}, + RunCommands: []string{}, + TerminalCommand: []string{"claude"}, + } + + result := generateContainerfile(imageConfig, agentConfig) + + expectedFrom := "FROM registry.fedoraproject.org/fedora:40" + if !strings.Contains(result, expectedFrom) { + t.Errorf("Expected FROM line with custom version: %s", expectedFrom) + } + }) + + t.Run("merges packages from image and agent configs", func(t *testing.T) { + t.Parallel() + + imageConfig := &config.ImageConfig{ + Version: "latest", + Packages: []string{"package1", "package2"}, + Sudo: []string{}, + } + + agentConfig := &config.AgentConfig{ + Packages: []string{"package3", "package4"}, + RunCommands: []string{}, + TerminalCommand: []string{"claude"}, + } + + result := generateContainerfile(imageConfig, agentConfig) + + // Should have all packages in a single RUN command + if !strings.Contains(result, "RUN dnf install -y package1 package2 package3 package4") { + t.Error("Expected merged package installation with all packages") + } + }) + + t.Run("omits package installation when no packages", func(t *testing.T) { + t.Parallel() + + imageConfig := &config.ImageConfig{ + Version: "latest", + Packages: []string{}, + Sudo: []string{}, + } + + agentConfig := &config.AgentConfig{ + Packages: []string{}, + RunCommands: []string{}, + TerminalCommand: []string{"claude"}, + } + + result := generateContainerfile(imageConfig, agentConfig) + + // Should not have dnf install line + if strings.Contains(result, "dnf install") { + t.Error("Expected no dnf install line when no packages specified") + } + }) + + t.Run("includes custom RUN commands from both configs", func(t *testing.T) { + t.Parallel() + + imageConfig := &config.ImageConfig{ + Version: "latest", + Packages: []string{}, + Sudo: []string{}, + RunCommands: []string{"echo 'image setup'"}, + } + + agentConfig := &config.AgentConfig{ + Packages: []string{}, + RunCommands: []string{"echo 'agent setup'"}, + TerminalCommand: []string{"claude"}, + } + + result := generateContainerfile(imageConfig, agentConfig) + + // Should have both RUN commands + if !strings.Contains(result, "RUN echo 'image setup'") { + t.Error("Expected image RUN command") + } + if !strings.Contains(result, "RUN echo 'agent setup'") { + t.Error("Expected agent RUN command") + } + }) + + t.Run("image RUN commands come before agent RUN commands", func(t *testing.T) { + t.Parallel() + + imageConfig := &config.ImageConfig{ + Version: "latest", + Packages: []string{}, + Sudo: []string{}, + RunCommands: []string{"echo 'image'"}, + } + + agentConfig := &config.AgentConfig{ + Packages: []string{}, + RunCommands: []string{"echo 'agent'"}, + TerminalCommand: []string{"claude"}, + } + + result := generateContainerfile(imageConfig, agentConfig) + + // Find positions + imagePos := strings.Index(result, "RUN echo 'image'") + agentPos := strings.Index(result, "RUN echo 'agent'") + + if imagePos == -1 || agentPos == -1 { + t.Fatal("Both RUN commands should be present") + } + + if imagePos > agentPos { + t.Error("Image RUN commands should come before agent RUN commands") + } + }) +} diff --git a/pkg/runtime/podman/create.go b/pkg/runtime/podman/create.go index 5a90447..b69e01c 100644 --- a/pkg/runtime/podman/create.go +++ b/pkg/runtime/podman/create.go @@ -23,6 +23,7 @@ import ( "strings" "github.com/kortex-hub/kortex-cli/pkg/runtime" + "github.com/kortex-hub/kortex-cli/pkg/runtime/podman/config" "github.com/kortex-hub/kortex-cli/pkg/steplogger" ) @@ -87,37 +88,22 @@ func (p *podmanRuntime) createInstanceDirectory(name string) (string, error) { return instanceDir, nil } -// createContainerfile creates a simple Containerfile in the instance directory. -func (p *podmanRuntime) createContainerfile(instanceDir string) error { +// createContainerfile creates a Containerfile in the instance directory using the provided configs. +func (p *podmanRuntime) createContainerfile(instanceDir string, imageConfig *config.ImageConfig, agentConfig *config.AgentConfig) error { + // Generate sudoers content + sudoersContent := generateSudoers(imageConfig.Sudo) sudoersPath := filepath.Join(instanceDir, "sudoers") - sudoersContent := `Cmnd_Alias SOFTWARE = /usr/bin/dnf -Cmnd_Alias PROCESSES = /bin/nice, /bin/kill, /usr/bin/kill, /usr/bin/killall - -claude ALL = !ALL, NOPASSWD: SOFTWARE, PROCESSES -` if err := os.WriteFile(sudoersPath, []byte(sudoersContent), 0644); err != nil { return fmt.Errorf("failed to write sudoers: %w", err) } - containerfilePath := filepath.Join(instanceDir, "Containerfile") - containerfileContent := `FROM registry.fedoraproject.org/fedora:latest - -RUN dnf install -y which procps-ng wget2 @development-tools jq gh golang golangci-lint python3 python3-pip - -ARG UID=1000 -ARG GID=1000 -RUN GROUPNAME=$(grep $GID /etc/group | cut -d: -f1); [ -n "$GROUPNAME" ] && groupdel $GROUPNAME || true -RUN groupadd -g "${GID}" claude && useradd -u "${UID}" -g "${GID}" -m claude -COPY sudoers /etc/sudoers.d/claude -USER claude:claude -ENV PATH=/home/claude/.local/bin:/usr/local/bin:/usr/bin -RUN curl -fsSL --proto-redir '-all,https' --tlsv1.3 https://claude.ai/install.sh | bash - -RUN mkdir /home/claude/.config -` + // Generate Containerfile content + containerfileContent := generateContainerfile(imageConfig, agentConfig) + containerfilePath := filepath.Join(instanceDir, "Containerfile") if err := os.WriteFile(containerfilePath, []byte(containerfileContent), 0644); err != nil { return fmt.Errorf("failed to write Containerfile: %w", err) } + return nil } @@ -240,10 +226,26 @@ func (p *podmanRuntime) Create(ctx context.Context, params runtime.CreateParams) logger.Fail(err) return runtime.RuntimeInfo{}, err } + // Clean up instance directory after use (whether success or error) + // The Containerfile and sudoers are only needed during image build + defer os.RemoveAll(instanceDir) + + // Load configurations + imageConfig, err := p.config.LoadImage() + if err != nil { + return runtime.RuntimeInfo{}, fmt.Errorf("failed to load image config: %w", err) + } + + // For now, hardcode the agent name as "claude" + // In a future update, the agent name will be passed from the init command + agentConfig, err := p.config.LoadAgent("claude") + if err != nil { + return runtime.RuntimeInfo{}, fmt.Errorf("failed to load agent config: %w", err) + } // Create Containerfile logger.Start("Generating Containerfile", "Containerfile generated") - if err := p.createContainerfile(instanceDir); err != nil { + if err := p.createContainerfile(instanceDir, imageConfig, agentConfig); err != nil { logger.Fail(err) return runtime.RuntimeInfo{}, err } diff --git a/pkg/runtime/podman/create_test.go b/pkg/runtime/podman/create_test.go index c7e02eb..5fb092a 100644 --- a/pkg/runtime/podman/create_test.go +++ b/pkg/runtime/podman/create_test.go @@ -22,11 +22,14 @@ import ( "path/filepath" "strings" "testing" + "time" workspace "github.com/kortex-hub/kortex-cli-api/workspace-configuration/go" "github.com/kortex-hub/kortex-cli/pkg/runtime" + "github.com/kortex-hub/kortex-cli/pkg/runtime/podman/config" "github.com/kortex-hub/kortex-cli/pkg/runtime/podman/exec" "github.com/kortex-hub/kortex-cli/pkg/steplogger" + "github.com/kortex-hub/kortex-cli/pkg/system" ) func TestValidateDependencyPath(t *testing.T) { @@ -243,13 +246,27 @@ func TestCreateInstanceDirectory(t *testing.T) { func TestCreateContainerfile(t *testing.T) { t.Parallel() - t.Run("creates Containerfile with correct content", func(t *testing.T) { + t.Run("creates Containerfile with default configs", func(t *testing.T) { t.Parallel() instanceDir := t.TempDir() p := &podmanRuntime{} - err := p.createContainerfile(instanceDir) + // Create default configs + imageConfig := &config.ImageConfig{ + Version: "latest", + Packages: []string{"which", "procps-ng"}, + Sudo: []string{"/usr/bin/dnf"}, + RunCommands: []string{}, + } + + agentConfig := &config.AgentConfig{ + Packages: []string{}, + RunCommands: []string{"curl -fsSL https://claude.ai/install.sh | bash"}, + TerminalCommand: []string{"claude"}, + } + + err := p.createContainerfile(instanceDir, imageConfig, agentConfig) if err != nil { t.Fatalf("createContainerfile() failed: %v", err) } @@ -266,6 +283,82 @@ func TestCreateContainerfile(t *testing.T) { if len(lines) == 0 || lines[0]+"\n" != expectedFirstLine { t.Errorf("Expected Containerfile to start with:\n%s\nGot:\n%s", expectedFirstLine, lines[0]) } + + // Verify sudoers file exists + sudoersPath := filepath.Join(instanceDir, "sudoers") + sudoersContent, err := os.ReadFile(sudoersPath) + if err != nil { + t.Fatalf("Failed to read sudoers: %v", err) + } + + // Verify sudoers has ALLOWED alias + if !strings.Contains(string(sudoersContent), "Cmnd_Alias ALLOWED") { + t.Error("Expected sudoers to contain 'Cmnd_Alias ALLOWED'") + } + }) + + t.Run("creates Containerfile with custom configs", func(t *testing.T) { + t.Parallel() + + instanceDir := t.TempDir() + p := &podmanRuntime{} + + // Create custom configs + imageConfig := &config.ImageConfig{ + Version: "40", + Packages: []string{"custom-package"}, + Sudo: []string{"/usr/bin/custom"}, + RunCommands: []string{"echo 'custom setup'"}, + } + + agentConfig := &config.AgentConfig{ + Packages: []string{"agent-package"}, + RunCommands: []string{"echo 'agent setup'"}, + TerminalCommand: []string{"custom-agent"}, + } + + err := p.createContainerfile(instanceDir, imageConfig, agentConfig) + if err != nil { + t.Fatalf("createContainerfile() failed: %v", err) + } + + // Verify Containerfile contains custom version + containerfilePath := filepath.Join(instanceDir, "Containerfile") + content, err := os.ReadFile(containerfilePath) + if err != nil { + t.Fatalf("Failed to read Containerfile: %v", err) + } + + if !strings.Contains(string(content), "FROM registry.fedoraproject.org/fedora:40") { + t.Error("Expected Containerfile to use custom Fedora version 40") + } + + // Verify custom packages are installed + if !strings.Contains(string(content), "custom-package") { + t.Error("Expected Containerfile to contain custom package") + } + if !strings.Contains(string(content), "agent-package") { + t.Error("Expected Containerfile to contain agent package") + } + + // Verify custom RUN commands + if !strings.Contains(string(content), "RUN echo 'custom setup'") { + t.Error("Expected Containerfile to contain custom RUN command") + } + if !strings.Contains(string(content), "RUN echo 'agent setup'") { + t.Error("Expected Containerfile to contain agent RUN command") + } + + // Verify sudoers contains custom binary + sudoersPath := filepath.Join(instanceDir, "sudoers") + sudoersContent, err := os.ReadFile(sudoersPath) + if err != nil { + t.Fatalf("Failed to read sudoers: %v", err) + } + + if !strings.Contains(string(sudoersContent), "/usr/bin/custom") { + t.Error("Expected sudoers to contain custom binary") + } }) } @@ -554,7 +647,11 @@ func TestCreate_StepLogger_Success(t *testing.T) { } p := newWithDeps(&fakeSystem{}, fakeExec).(*podmanRuntime) - p.storageDir = storageDir + + // Initialize the runtime with storage + if err := p.Initialize(storageDir); err != nil { + t.Fatalf("Initialize() failed: %v", err) + } fakeLogger := &fakeStepLogger{} ctx := steplogger.WithLogger(context.Background(), fakeLogger) @@ -628,6 +725,7 @@ func TestCreate_StepLogger_FailOnCreateInstanceDirectory(t *testing.T) { system: &fakeSystem{}, executor: exec.NewFake(), storageDir: notADir, // Will fail when trying to create subdirectory + config: &fakeConfig{}, } fakeLogger := &fakeStepLogger{} @@ -684,6 +782,7 @@ func TestCreate_StepLogger_FailOnCreateContainerfile(t *testing.T) { system: &fakeSystem{}, executor: exec.NewFake(), storageDir: storageDir, + config: &fakeConfig{}, } fakeLogger := &fakeStepLogger{} @@ -746,7 +845,11 @@ func TestCreate_StepLogger_FailOnBuildImage(t *testing.T) { } p := newWithDeps(&fakeSystem{}, fakeExec).(*podmanRuntime) - p.storageDir = storageDir + + // Initialize the runtime with storage + if err := p.Initialize(storageDir); err != nil { + t.Fatalf("Initialize() failed: %v", err) + } fakeLogger := &fakeStepLogger{} ctx := steplogger.WithLogger(context.Background(), fakeLogger) @@ -812,7 +915,11 @@ func TestCreate_StepLogger_FailOnCreateContainer(t *testing.T) { } p := newWithDeps(&fakeSystem{}, fakeExec).(*podmanRuntime) - p.storageDir = storageDir + + // Initialize the runtime with storage + if err := p.Initialize(storageDir); err != nil { + t.Fatalf("Initialize() failed: %v", err) + } fakeLogger := &fakeStepLogger{} ctx := steplogger.WithLogger(context.Background(), fakeLogger) @@ -859,3 +966,137 @@ func TestCreate_StepLogger_FailOnCreateContainer(t *testing.T) { t.Error("Expected Fail() to be called with non-nil error") } } + +func TestCreate_CleansUpInstanceDirectory(t *testing.T) { + t.Parallel() + + t.Run("removes instance directory after successful create", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + sourcePath := t.TempDir() + + // Create a fake executor that simulates successful operations + fakeExec := &fakeExecutor{ + runErr: nil, + outputErr: nil, + output: []byte("container123"), + } + + p := newWithDeps(system.New(), fakeExec).(*podmanRuntime) + + // Initialize the runtime with storage + if err := p.Initialize(storageDir); err != nil { + t.Fatalf("Initialize() failed: %v", err) + } + + params := runtime.CreateParams{ + Name: "test-workspace", + SourcePath: sourcePath, + } + + // Before Create, verify instances directory doesn't exist yet + instancesDir := filepath.Join(storageDir, "instances") + + // Call Create + _, err := p.Create(context.Background(), params) + if err != nil { + t.Fatalf("Create() failed: %v", err) + } + + // After Create, verify the instance directory was cleaned up + // On Windows, file locks may delay cleanup, so retry with a timeout + instanceDir := filepath.Join(instancesDir, "test-workspace") + assertDirectoryRemoved(t, instanceDir) + }) + + t.Run("removes instance directory even on build failure", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + sourcePath := t.TempDir() + + // Create a fake executor that simulates build failure + fakeExec := &fakeExecutor{ + runErr: fmt.Errorf("image build failed"), + outputErr: nil, + output: nil, + } + + p := newWithDeps(system.New(), fakeExec).(*podmanRuntime) + + // Initialize the runtime with storage + if err := p.Initialize(storageDir); err != nil { + t.Fatalf("Initialize() failed: %v", err) + } + + params := runtime.CreateParams{ + Name: "test-workspace", + SourcePath: sourcePath, + } + + instancesDir := filepath.Join(storageDir, "instances") + + // Call Create (should fail on build) + _, err := p.Create(context.Background(), params) + if err == nil { + t.Fatal("Expected Create() to fail, but it succeeded") + } + + // Even after failure, verify the instance directory was cleaned up + // On Windows, file locks may delay cleanup, so retry with a timeout + instanceDir := filepath.Join(instancesDir, "test-workspace") + assertDirectoryRemoved(t, instanceDir) + }) +} + +// fakeExecutor is a test double for the exec.Executor interface +type fakeExecutor struct { + runErr error + outputErr error + output []byte +} + +func (f *fakeExecutor) Run(ctx context.Context, args ...string) error { + return f.runErr +} + +func (f *fakeExecutor) Output(ctx context.Context, args ...string) ([]byte, error) { + if f.outputErr != nil { + return nil, f.outputErr + } + return f.output, nil +} + +// assertDirectoryRemoved checks that a directory has been removed. +// On Windows, file locks may delay cleanup, so this retries with a timeout. +func assertDirectoryRemoved(t *testing.T, dir string) { + t.Helper() + + // Retry for up to 1 second with 50ms intervals (Windows file lock workaround) + maxAttempts := 20 + interval := 50 * time.Millisecond + + for attempt := 0; attempt < maxAttempts; attempt++ { + _, err := os.Stat(dir) + if os.IsNotExist(err) { + // Directory successfully removed + return + } + + if attempt < maxAttempts-1 { + time.Sleep(interval) + } + } + + // Final check after all retries + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Errorf("Expected instance directory to be removed, but it still exists: %s", dir) + + // List contents for debugging + if err == nil { + entries, _ := os.ReadDir(dir) + t.Logf("Instance directory contents: %v", entries) + } + } +} diff --git a/pkg/runtime/podman/podman.go b/pkg/runtime/podman/podman.go index 2950360..2434039 100644 --- a/pkg/runtime/podman/podman.go +++ b/pkg/runtime/podman/podman.go @@ -17,8 +17,10 @@ package podman import ( "fmt" + "path/filepath" "github.com/kortex-hub/kortex-cli/pkg/runtime" + "github.com/kortex-hub/kortex-cli/pkg/runtime/podman/config" "github.com/kortex-hub/kortex-cli/pkg/runtime/podman/exec" "github.com/kortex-hub/kortex-cli/pkg/system" ) @@ -27,7 +29,8 @@ import ( type podmanRuntime struct { system system.System executor exec.Executor - storageDir string // Directory for storing runtime-specific data + storageDir string // Directory for storing runtime-specific data + config config.Config // Configuration manager for runtime settings } // Ensure podmanRuntime implements runtime.Runtime at compile time. @@ -62,6 +65,22 @@ func (p *podmanRuntime) Initialize(storageDir string) error { return fmt.Errorf("storage directory cannot be empty") } p.storageDir = storageDir + + // Create config directory + configDir := filepath.Join(storageDir, "config") + + // Create config instance + cfg, err := config.NewConfig(configDir) + if err != nil { + return fmt.Errorf("failed to create config: %w", err) + } + p.config = cfg + + // Generate default configurations if they don't exist + if err := p.config.GenerateDefaults(); err != nil { + return fmt.Errorf("failed to generate default configs: %w", err) + } + return nil } diff --git a/pkg/runtime/podman/podman_test.go b/pkg/runtime/podman/podman_test.go index 8bd1fab..f3bee11 100644 --- a/pkg/runtime/podman/podman_test.go +++ b/pkg/runtime/podman/podman_test.go @@ -15,8 +15,11 @@ package podman import ( + "os" + "path/filepath" "testing" + "github.com/kortex-hub/kortex-cli/pkg/runtime/podman/config" "github.com/kortex-hub/kortex-cli/pkg/runtime/podman/exec" "github.com/kortex-hub/kortex-cli/pkg/system" ) @@ -78,6 +81,112 @@ func TestPodmanRuntime_Available(t *testing.T) { }) } +func TestPodmanRuntime_Initialize(t *testing.T) { + t.Parallel() + + t.Run("creates config directory and default configs", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + rt := newWithDeps(system.New(), exec.New()) + + // Type assert to StorageAware to access Initialize method + storageAware, ok := rt.(interface{ Initialize(string) error }) + if !ok { + t.Fatal("Expected runtime to implement StorageAware interface") + } + + err := storageAware.Initialize(storageDir) + if err != nil { + t.Fatalf("Initialize() failed: %v", err) + } + + // Verify config directory was created + configDir := filepath.Join(storageDir, "config") + if _, err := os.Stat(configDir); os.IsNotExist(err) { + t.Error("Config directory was not created") + } + + // Verify default image config was created + imageConfigPath := filepath.Join(configDir, config.ImageConfigFileName) + if _, err := os.Stat(imageConfigPath); os.IsNotExist(err) { + t.Error("Default image config was not created") + } + + // Verify default claude config was created + claudeConfigPath := filepath.Join(configDir, config.ClaudeConfigFileName) + if _, err := os.Stat(claudeConfigPath); os.IsNotExist(err) { + t.Error("Default claude config was not created") + } + }) + + t.Run("returns error for empty storage directory", func(t *testing.T) { + t.Parallel() + + rt := newWithDeps(system.New(), exec.New()) + + // Type assert to StorageAware to access Initialize method + storageAware, ok := rt.(interface{ Initialize(string) error }) + if !ok { + t.Fatal("Expected runtime to implement StorageAware interface") + } + + err := storageAware.Initialize("") + if err == nil { + t.Error("Expected error for empty storage directory") + } + }) + + t.Run("does not overwrite existing configs", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + rt := newWithDeps(system.New(), exec.New()) + + // Type assert to StorageAware to access Initialize method + storageAware, ok := rt.(interface{ Initialize(string) error }) + if !ok { + t.Fatal("Expected runtime to implement StorageAware interface") + } + + // Initialize once to create defaults + err := storageAware.Initialize(storageDir) + if err != nil { + t.Fatalf("First Initialize() failed: %v", err) + } + + // Modify the image config + configDir := filepath.Join(storageDir, "config") + imageConfigPath := filepath.Join(configDir, config.ImageConfigFileName) + customContent := []byte(`{"version":"40","packages":[],"sudo":[],"run_commands":[]}`) + if err := os.WriteFile(imageConfigPath, customContent, 0644); err != nil { + t.Fatalf("Failed to write custom config: %v", err) + } + + // Initialize again + rt2 := newWithDeps(system.New(), exec.New()) + storageAware2, ok := rt2.(interface{ Initialize(string) error }) + if !ok { + t.Fatal("Expected runtime to implement StorageAware interface") + } + + err = storageAware2.Initialize(storageDir) + if err != nil { + t.Fatalf("Second Initialize() failed: %v", err) + } + + // Verify custom config was not overwritten + content, err := os.ReadFile(imageConfigPath) + if err != nil { + t.Fatalf("Failed to read config: %v", err) + } + + if string(content) != string(customContent) { + t.Error("Custom config was overwritten") + } + }) +} + // fakeSystem is a fake implementation of system.System for testing. type fakeSystem struct { commandExists bool diff --git a/pkg/runtime/podman/steplogger_test.go b/pkg/runtime/podman/steplogger_test.go index 3a7d873..661350a 100644 --- a/pkg/runtime/podman/steplogger_test.go +++ b/pkg/runtime/podman/steplogger_test.go @@ -14,7 +14,10 @@ package podman -import "github.com/kortex-hub/kortex-cli/pkg/steplogger" +import ( + "github.com/kortex-hub/kortex-cli/pkg/runtime/podman/config" + "github.com/kortex-hub/kortex-cli/pkg/steplogger" +) // fakeStepLogger is a fake implementation of steplogger.StepLogger that records calls for testing. type fakeStepLogger struct { @@ -45,3 +48,30 @@ func (f *fakeStepLogger) Fail(err error) { func (f *fakeStepLogger) Complete() { f.completeCalls++ } + +// fakeConfig is a fake implementation of config.Config that returns default configurations. +type fakeConfig struct{} + +// Ensure fakeConfig implements config.Config at compile time. +var _ config.Config = (*fakeConfig)(nil) + +func (f *fakeConfig) LoadImage() (*config.ImageConfig, error) { + return &config.ImageConfig{ + Version: "latest", + Packages: []string{"which", "procps-ng"}, + Sudo: []string{"/usr/bin/dnf"}, + RunCommands: []string{}, + }, nil +} + +func (f *fakeConfig) LoadAgent(agentName string) (*config.AgentConfig, error) { + return &config.AgentConfig{ + Packages: []string{}, + RunCommands: []string{"curl -fsSL https://claude.ai/install.sh | bash"}, + TerminalCommand: []string{"claude"}, + }, nil +} + +func (f *fakeConfig) GenerateDefaults() error { + return nil +}