From 8bc93ce6100baa713f00b76fbc7e9e66acf03ad7 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 9 Mar 2026 11:42:51 +0000 Subject: [PATCH] feat: extract container/, devops/, sources/ from go-devops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Container runtime (LinuxKit, hypervisor, state, templates), dev environment management (renamed devops → devenv), and image sources (CDN, GitHub). 254 tests passing. Package devops renamed to devenv to avoid collision with parent repo name. Co-Authored-By: Virgil --- CLAUDE.md | 34 ++ container.go | 106 +++++ devenv/claude.go | 143 +++++++ devenv/claude_test.go | 61 +++ devenv/config.go | 90 +++++ devenv/config_test.go | 255 ++++++++++++ devenv/devops.go | 244 ++++++++++++ devenv/devops_test.go | 833 +++++++++++++++++++++++++++++++++++++++ devenv/images.go | 199 ++++++++++ devenv/images_test.go | 583 +++++++++++++++++++++++++++ devenv/serve.go | 110 ++++++ devenv/serve_test.go | 137 +++++++ devenv/shell.go | 75 ++++ devenv/shell_test.go | 47 +++ devenv/ssh_utils.go | 69 ++++ devenv/test.go | 189 +++++++++ devenv/test_test.go | 354 +++++++++++++++++ go.mod | 16 + hypervisor.go | 274 +++++++++++++ hypervisor_test.go | 358 +++++++++++++++++ linuxkit.go | 462 ++++++++++++++++++++++ linuxkit_test.go | 786 ++++++++++++++++++++++++++++++++++++ sources/cdn.go | 113 ++++++ sources/cdn_test.go | 306 ++++++++++++++ sources/github.go | 72 ++++ sources/github_test.go | 68 ++++ sources/source.go | 33 ++ sources/source_test.go | 35 ++ state.go | 172 ++++++++ state_test.go | 222 +++++++++++ templates.go | 314 +++++++++++++++ templates/core-dev.yml | 121 ++++++ templates/server-php.yml | 142 +++++++ templates_test.go | 495 +++++++++++++++++++++++ 34 files changed, 7518 insertions(+) create mode 100644 CLAUDE.md create mode 100644 container.go create mode 100644 devenv/claude.go create mode 100644 devenv/claude_test.go create mode 100644 devenv/config.go create mode 100644 devenv/config_test.go create mode 100644 devenv/devops.go create mode 100644 devenv/devops_test.go create mode 100644 devenv/images.go create mode 100644 devenv/images_test.go create mode 100644 devenv/serve.go create mode 100644 devenv/serve_test.go create mode 100644 devenv/shell.go create mode 100644 devenv/shell_test.go create mode 100644 devenv/ssh_utils.go create mode 100644 devenv/test.go create mode 100644 devenv/test_test.go create mode 100644 go.mod create mode 100644 hypervisor.go create mode 100644 hypervisor_test.go create mode 100644 linuxkit.go create mode 100644 linuxkit_test.go create mode 100644 sources/cdn.go create mode 100644 sources/cdn_test.go create mode 100644 sources/github.go create mode 100644 sources/github_test.go create mode 100644 sources/source.go create mode 100644 sources/source_test.go create mode 100644 state.go create mode 100644 state_test.go create mode 100644 templates.go create mode 100644 templates/core-dev.yml create mode 100644 templates/server-php.yml create mode 100644 templates_test.go diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..05c8f5d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,34 @@ +# CLAUDE.md + +## Project Overview + +`core/go-container` provides container runtime management, LinuxKit image building, and portable dev environments. Three packages: root (container runtime + LinuxKit), devenv (dev environment orchestration), sources (image download from CDN/GitHub). + +## Build & Development + +```bash +go test ./... +go test -race ./... +``` + +## Architecture + +Three packages: + +- Root (`container`) — Container lifecycle (start/stop/status), hypervisor abstraction (QEMU/Hyperkit), LinuxKit YAML config builder with embedded templates, state persistence +- `devenv/` — Dev environment management: config loading, image management, shell access, test runner, serve mode, Claude integration. Depends on container/ and sources/ +- `sources/` — Image download sources: CDN and GitHub release fetchers with checksum verification + +## Dependencies + +- `go-io` — File/process utilities +- `go-config` — Configuration loading (devenv only) +- `testify` — Test assertions + +## Coding Standards + +- UK English +- All functions have typed params/returns +- Tests use testify +- Test naming: `_Good`, `_Bad`, `_Ugly` suffixes +- License: EUPL-1.2 diff --git a/container.go b/container.go new file mode 100644 index 0000000..d7161c3 --- /dev/null +++ b/container.go @@ -0,0 +1,106 @@ +// Package container provides a runtime for managing LinuxKit containers. +// It supports running LinuxKit images (ISO, qcow2, vmdk, raw) using +// available hypervisors (QEMU on Linux, Hyperkit on macOS). +package container + +import ( + "context" + "crypto/rand" + "encoding/hex" + "io" + "time" +) + +// Container represents a running LinuxKit container/VM instance. +type Container struct { + // ID is a unique identifier for the container (8 character hex string). + ID string `json:"id"` + // Name is the optional human-readable name for the container. + Name string `json:"name,omitempty"` + // Image is the path to the LinuxKit image being run. + Image string `json:"image"` + // Status represents the current state of the container. + Status Status `json:"status"` + // PID is the process ID of the hypervisor running this container. + PID int `json:"pid"` + // StartedAt is when the container was started. + StartedAt time.Time `json:"started_at"` + // Ports maps host ports to container ports. + Ports map[int]int `json:"ports,omitempty"` + // Memory is the amount of memory allocated in MB. + Memory int `json:"memory,omitempty"` + // CPUs is the number of CPUs allocated. + CPUs int `json:"cpus,omitempty"` +} + +// Status represents the state of a container. +type Status string + +const ( + // StatusRunning indicates the container is running. + StatusRunning Status = "running" + // StatusStopped indicates the container has stopped. + StatusStopped Status = "stopped" + // StatusError indicates the container encountered an error. + StatusError Status = "error" +) + +// RunOptions configures how a container should be run. +type RunOptions struct { + // Name is an optional human-readable name for the container. + Name string + // Detach runs the container in the background. + Detach bool + // Memory is the amount of memory to allocate in MB (default: 1024). + Memory int + // CPUs is the number of CPUs to allocate (default: 1). + CPUs int + // Ports maps host ports to container ports. + Ports map[int]int + // Volumes maps host paths to container paths. + Volumes map[string]string + // SSHPort is the port to use for SSH access (default: 2222). + SSHPort int + // SSHKey is the path to the SSH private key for exec commands. + SSHKey string +} + +// Manager defines the interface for container lifecycle management. +type Manager interface { + // Run starts a new container from the given image. + Run(ctx context.Context, image string, opts RunOptions) (*Container, error) + // Stop stops a running container by ID. + Stop(ctx context.Context, id string) error + // List returns all known containers. + List(ctx context.Context) ([]*Container, error) + // Logs returns a reader for the container's log output. + // If follow is true, the reader will continue to stream new log entries. + Logs(ctx context.Context, id string, follow bool) (io.ReadCloser, error) + // Exec executes a command inside the container via SSH. + Exec(ctx context.Context, id string, cmd []string) error +} + +// GenerateID creates a new unique container ID (8 hex characters). +func GenerateID() (string, error) { + bytes := make([]byte, 4) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +// ImageFormat represents the format of a LinuxKit image. +type ImageFormat string + +const ( + // FormatISO is an ISO image format. + FormatISO ImageFormat = "iso" + // FormatQCOW2 is a QEMU Copy-On-Write image format. + FormatQCOW2 ImageFormat = "qcow2" + // FormatVMDK is a VMware disk image format. + FormatVMDK ImageFormat = "vmdk" + // FormatRaw is a raw disk image format. + FormatRaw ImageFormat = "raw" + // FormatUnknown indicates an unknown image format. + FormatUnknown ImageFormat = "unknown" +) diff --git a/devenv/claude.go b/devenv/claude.go new file mode 100644 index 0000000..d29922e --- /dev/null +++ b/devenv/claude.go @@ -0,0 +1,143 @@ +package devenv + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "forge.lthn.ai/core/go-io" +) + +// ClaudeOptions configures the Claude sandbox session. +type ClaudeOptions struct { + NoAuth bool // Don't forward any auth + Auth []string // Selective auth: "gh", "anthropic", "ssh", "git" + Model string // Model to use: opus, sonnet +} + +// Claude starts a sandboxed Claude session in the dev environment. +func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptions) error { + // Auto-boot if not running + running, err := d.IsRunning(ctx) + if err != nil { + return err + } + if !running { + fmt.Println("Dev environment not running, booting...") + if err := d.Boot(ctx, DefaultBootOptions()); err != nil { + return fmt.Errorf("failed to boot: %w", err) + } + } + + // Mount project + if err := d.mountProject(ctx, projectDir); err != nil { + return fmt.Errorf("failed to mount project: %w", err) + } + + // Prepare environment variables to forward + envVars := []string{} + + if !opts.NoAuth { + authTypes := opts.Auth + if len(authTypes) == 0 { + authTypes = []string{"gh", "anthropic", "ssh", "git"} + } + + for _, auth := range authTypes { + switch auth { + case "anthropic": + if key := os.Getenv("ANTHROPIC_API_KEY"); key != "" { + envVars = append(envVars, "ANTHROPIC_API_KEY="+key) + } + case "git": + // Forward git config + name, _ := exec.Command("git", "config", "user.name").Output() + email, _ := exec.Command("git", "config", "user.email").Output() + if len(name) > 0 { + envVars = append(envVars, "GIT_AUTHOR_NAME="+strings.TrimSpace(string(name))) + envVars = append(envVars, "GIT_COMMITTER_NAME="+strings.TrimSpace(string(name))) + } + if len(email) > 0 { + envVars = append(envVars, "GIT_AUTHOR_EMAIL="+strings.TrimSpace(string(email))) + envVars = append(envVars, "GIT_COMMITTER_EMAIL="+strings.TrimSpace(string(email))) + } + } + } + } + + // Build SSH command with agent forwarding + args := []string{ + "-o", "StrictHostKeyChecking=yes", + "-o", "UserKnownHostsFile=~/.core/known_hosts", + "-o", "LogLevel=ERROR", + "-A", // SSH agent forwarding + "-p", fmt.Sprintf("%d", DefaultSSHPort), + } + + args = append(args, "root@localhost") + + // Build command to run inside + claudeCmd := "cd /app && claude" + if opts.Model != "" { + claudeCmd += " --model " + opts.Model + } + args = append(args, claudeCmd) + + // Set environment for SSH + cmd := exec.CommandContext(ctx, "ssh", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Pass environment variables through SSH + for _, env := range envVars { + parts := strings.SplitN(env, "=", 2) + if len(parts) == 2 { + cmd.Env = append(os.Environ(), env) + } + } + + fmt.Println("Starting Claude in sandboxed environment...") + fmt.Println("Project mounted at /app") + fmt.Println("Auth forwarded: SSH agent" + formatAuthList(opts)) + fmt.Println() + + return cmd.Run() +} + +func formatAuthList(opts ClaudeOptions) string { + if opts.NoAuth { + return " (none)" + } + if len(opts.Auth) == 0 { + return ", gh, anthropic, git" + } + return ", " + strings.Join(opts.Auth, ", ") +} + +// CopyGHAuth copies GitHub CLI auth to the VM. +func (d *DevOps) CopyGHAuth(ctx context.Context) error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + + ghConfigDir := filepath.Join(home, ".config", "gh") + if !io.Local.IsDir(ghConfigDir) { + return nil // No gh config to copy + } + + // Use scp to copy gh config + cmd := exec.CommandContext(ctx, "scp", + "-o", "StrictHostKeyChecking=yes", + "-o", "UserKnownHostsFile=~/.core/known_hosts", + "-o", "LogLevel=ERROR", + "-P", fmt.Sprintf("%d", DefaultSSHPort), + "-r", ghConfigDir, + "root@localhost:/root/.config/", + ) + return cmd.Run() +} diff --git a/devenv/claude_test.go b/devenv/claude_test.go new file mode 100644 index 0000000..179ef6c --- /dev/null +++ b/devenv/claude_test.go @@ -0,0 +1,61 @@ +package devenv + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestClaudeOptions_Default(t *testing.T) { + opts := ClaudeOptions{} + assert.False(t, opts.NoAuth) + assert.Nil(t, opts.Auth) + assert.Empty(t, opts.Model) +} + +func TestClaudeOptions_Custom(t *testing.T) { + opts := ClaudeOptions{ + NoAuth: true, + Auth: []string{"gh", "anthropic"}, + Model: "opus", + } + assert.True(t, opts.NoAuth) + assert.Equal(t, []string{"gh", "anthropic"}, opts.Auth) + assert.Equal(t, "opus", opts.Model) +} + +func TestFormatAuthList_Good_NoAuth(t *testing.T) { + opts := ClaudeOptions{NoAuth: true} + result := formatAuthList(opts) + assert.Equal(t, " (none)", result) +} + +func TestFormatAuthList_Good_Default(t *testing.T) { + opts := ClaudeOptions{} + result := formatAuthList(opts) + assert.Equal(t, ", gh, anthropic, git", result) +} + +func TestFormatAuthList_Good_CustomAuth(t *testing.T) { + opts := ClaudeOptions{ + Auth: []string{"gh"}, + } + result := formatAuthList(opts) + assert.Equal(t, ", gh", result) +} + +func TestFormatAuthList_Good_MultipleAuth(t *testing.T) { + opts := ClaudeOptions{ + Auth: []string{"gh", "ssh", "git"}, + } + result := formatAuthList(opts) + assert.Equal(t, ", gh, ssh, git", result) +} + +func TestFormatAuthList_Good_EmptyAuth(t *testing.T) { + opts := ClaudeOptions{ + Auth: []string{}, + } + result := formatAuthList(opts) + assert.Equal(t, ", gh, anthropic, git", result) +} diff --git a/devenv/config.go b/devenv/config.go new file mode 100644 index 0000000..78419b2 --- /dev/null +++ b/devenv/config.go @@ -0,0 +1,90 @@ +package devenv + +import ( + "os" + "path/filepath" + + "forge.lthn.ai/core/go-config" + "forge.lthn.ai/core/go-io" +) + +// Config holds global devops configuration from ~/.core/config.yaml. +type Config struct { + Version int `yaml:"version" mapstructure:"version"` + Images ImagesConfig `yaml:"images" mapstructure:"images"` +} + +// ImagesConfig holds image source configuration. +type ImagesConfig struct { + Source string `yaml:"source" mapstructure:"source"` // auto, github, registry, cdn + GitHub GitHubConfig `yaml:"github,omitempty" mapstructure:"github,omitempty"` + Registry RegistryConfig `yaml:"registry,omitempty" mapstructure:"registry,omitempty"` + CDN CDNConfig `yaml:"cdn,omitempty" mapstructure:"cdn,omitempty"` +} + +// GitHubConfig holds GitHub Releases configuration. +type GitHubConfig struct { + Repo string `yaml:"repo" mapstructure:"repo"` // owner/repo format +} + +// RegistryConfig holds container registry configuration. +type RegistryConfig struct { + Image string `yaml:"image" mapstructure:"image"` // e.g., ghcr.io/host-uk/core-devops +} + +// CDNConfig holds CDN/S3 configuration. +type CDNConfig struct { + URL string `yaml:"url" mapstructure:"url"` // base URL for downloads +} + +// DefaultConfig returns sensible defaults. +func DefaultConfig() *Config { + return &Config{ + Version: 1, + Images: ImagesConfig{ + Source: "auto", + GitHub: GitHubConfig{ + Repo: "host-uk/core-images", + }, + Registry: RegistryConfig{ + Image: "ghcr.io/host-uk/core-devops", + }, + }, + } +} + +// ConfigPath returns the path to the config file. +func ConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".core", "config.yaml"), nil +} + +// LoadConfig loads configuration from ~/.core/config.yaml using the provided medium. +// Returns default config if file doesn't exist. +func LoadConfig(m io.Medium) (*Config, error) { + configPath, err := ConfigPath() + if err != nil { + return DefaultConfig(), nil + } + + cfg := DefaultConfig() + + if !m.IsFile(configPath) { + return cfg, nil + } + + // Use centralized config service + c, err := config.New(config.WithMedium(m), config.WithPath(configPath)) + if err != nil { + return nil, err + } + + if err := c.Get("", cfg); err != nil { + return nil, err + } + + return cfg, nil +} diff --git a/devenv/config_test.go b/devenv/config_test.go new file mode 100644 index 0000000..863d819 --- /dev/null +++ b/devenv/config_test.go @@ -0,0 +1,255 @@ +package devenv + +import ( + "os" + "path/filepath" + "testing" + + "forge.lthn.ai/core/go-io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + assert.Equal(t, 1, cfg.Version) + assert.Equal(t, "auto", cfg.Images.Source) + assert.Equal(t, "host-uk/core-images", cfg.Images.GitHub.Repo) +} + +func TestConfigPath(t *testing.T) { + path, err := ConfigPath() + assert.NoError(t, err) + assert.Contains(t, path, ".core/config.yaml") +} + +func TestLoadConfig_Good(t *testing.T) { + t.Run("returns default if not exists", func(t *testing.T) { + // Mock HOME to a temp dir + tempHome := t.TempDir() + origHome := os.Getenv("HOME") + t.Setenv("HOME", tempHome) + defer func() { _ = os.Setenv("HOME", origHome) }() + + cfg, err := LoadConfig(io.Local) + assert.NoError(t, err) + assert.Equal(t, DefaultConfig(), cfg) + }) + + t.Run("loads existing config", func(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + + coreDir := filepath.Join(tempHome, ".core") + err := os.MkdirAll(coreDir, 0755) + require.NoError(t, err) + + configData := ` +version: 2 +images: + source: cdn + cdn: + url: https://cdn.example.com +` + err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(configData), 0644) + require.NoError(t, err) + + cfg, err := LoadConfig(io.Local) + assert.NoError(t, err) + assert.Equal(t, 2, cfg.Version) + assert.Equal(t, "cdn", cfg.Images.Source) + assert.Equal(t, "https://cdn.example.com", cfg.Images.CDN.URL) + }) +} + +func TestLoadConfig_Bad(t *testing.T) { + t.Run("invalid yaml", func(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + + coreDir := filepath.Join(tempHome, ".core") + err := os.MkdirAll(coreDir, 0755) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte("invalid: yaml: :"), 0644) + require.NoError(t, err) + + _, err = LoadConfig(io.Local) + assert.Error(t, err) + }) +} + +func TestConfig_Struct(t *testing.T) { + cfg := &Config{ + Version: 2, + Images: ImagesConfig{ + Source: "github", + GitHub: GitHubConfig{ + Repo: "owner/repo", + }, + Registry: RegistryConfig{ + Image: "ghcr.io/owner/image", + }, + CDN: CDNConfig{ + URL: "https://cdn.example.com", + }, + }, + } + assert.Equal(t, 2, cfg.Version) + assert.Equal(t, "github", cfg.Images.Source) + assert.Equal(t, "owner/repo", cfg.Images.GitHub.Repo) + assert.Equal(t, "ghcr.io/owner/image", cfg.Images.Registry.Image) + assert.Equal(t, "https://cdn.example.com", cfg.Images.CDN.URL) +} + +func TestDefaultConfig_Complete(t *testing.T) { + cfg := DefaultConfig() + assert.Equal(t, 1, cfg.Version) + assert.Equal(t, "auto", cfg.Images.Source) + assert.Equal(t, "host-uk/core-images", cfg.Images.GitHub.Repo) + assert.Equal(t, "ghcr.io/host-uk/core-devops", cfg.Images.Registry.Image) + assert.Empty(t, cfg.Images.CDN.URL) +} + +func TestLoadConfig_Good_PartialConfig(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + + coreDir := filepath.Join(tempHome, ".core") + err := os.MkdirAll(coreDir, 0755) + require.NoError(t, err) + + // Config only specifies source, should merge with defaults + configData := ` +version: 1 +images: + source: github +` + err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(configData), 0644) + require.NoError(t, err) + + cfg, err := LoadConfig(io.Local) + assert.NoError(t, err) + assert.Equal(t, 1, cfg.Version) + assert.Equal(t, "github", cfg.Images.Source) + // Default values should be preserved + assert.Equal(t, "host-uk/core-images", cfg.Images.GitHub.Repo) +} + +func TestLoadConfig_Good_AllSourceTypes(t *testing.T) { + tests := []struct { + name string + config string + check func(*testing.T, *Config) + }{ + { + name: "github source", + config: ` +version: 1 +images: + source: github + github: + repo: custom/repo +`, + check: func(t *testing.T, cfg *Config) { + assert.Equal(t, "github", cfg.Images.Source) + assert.Equal(t, "custom/repo", cfg.Images.GitHub.Repo) + }, + }, + { + name: "cdn source", + config: ` +version: 1 +images: + source: cdn + cdn: + url: https://custom-cdn.com +`, + check: func(t *testing.T, cfg *Config) { + assert.Equal(t, "cdn", cfg.Images.Source) + assert.Equal(t, "https://custom-cdn.com", cfg.Images.CDN.URL) + }, + }, + { + name: "registry source", + config: ` +version: 1 +images: + source: registry + registry: + image: docker.io/custom/image +`, + check: func(t *testing.T, cfg *Config) { + assert.Equal(t, "registry", cfg.Images.Source) + assert.Equal(t, "docker.io/custom/image", cfg.Images.Registry.Image) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + + coreDir := filepath.Join(tempHome, ".core") + err := os.MkdirAll(coreDir, 0755) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(tt.config), 0644) + require.NoError(t, err) + + cfg, err := LoadConfig(io.Local) + assert.NoError(t, err) + tt.check(t, cfg) + }) + } +} + +func TestImagesConfig_Struct(t *testing.T) { + ic := ImagesConfig{ + Source: "auto", + GitHub: GitHubConfig{Repo: "test/repo"}, + } + assert.Equal(t, "auto", ic.Source) + assert.Equal(t, "test/repo", ic.GitHub.Repo) +} + +func TestGitHubConfig_Struct(t *testing.T) { + gc := GitHubConfig{Repo: "owner/repo"} + assert.Equal(t, "owner/repo", gc.Repo) +} + +func TestRegistryConfig_Struct(t *testing.T) { + rc := RegistryConfig{Image: "ghcr.io/owner/image:latest"} + assert.Equal(t, "ghcr.io/owner/image:latest", rc.Image) +} + +func TestCDNConfig_Struct(t *testing.T) { + cc := CDNConfig{URL: "https://cdn.example.com/images"} + assert.Equal(t, "https://cdn.example.com/images", cc.URL) +} + +func TestLoadConfig_Bad_UnreadableFile(t *testing.T) { + // This test is platform-specific and may not work on all systems + // Skip if we can't test file permissions properly + if os.Getuid() == 0 { + t.Skip("Skipping permission test when running as root") + } + + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + + coreDir := filepath.Join(tempHome, ".core") + err := os.MkdirAll(coreDir, 0755) + require.NoError(t, err) + + configPath := filepath.Join(coreDir, "config.yaml") + err = os.WriteFile(configPath, []byte("version: 1"), 0000) + require.NoError(t, err) + + _, err = LoadConfig(io.Local) + assert.Error(t, err) + + // Restore permissions so cleanup works + _ = os.Chmod(configPath, 0644) +} diff --git a/devenv/devops.go b/devenv/devops.go new file mode 100644 index 0000000..a233e60 --- /dev/null +++ b/devenv/devops.go @@ -0,0 +1,244 @@ +// Package devenv provides a portable development environment using LinuxKit images. +package devenv + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "time" + + "forge.lthn.ai/core/go-container" + "forge.lthn.ai/core/go-io" +) + +const ( + // DefaultSSHPort is the default port for SSH connections to the dev environment. + DefaultSSHPort = 2222 +) + +// DevOps manages the portable development environment. +type DevOps struct { + medium io.Medium + config *Config + images *ImageManager + container *container.LinuxKitManager +} + +// New creates a new DevOps instance using the provided medium. +func New(m io.Medium) (*DevOps, error) { + cfg, err := LoadConfig(m) + if err != nil { + return nil, fmt.Errorf("devops.New: failed to load config: %w", err) + } + + images, err := NewImageManager(m, cfg) + if err != nil { + return nil, fmt.Errorf("devops.New: failed to create image manager: %w", err) + } + + mgr, err := container.NewLinuxKitManager(io.Local) + if err != nil { + return nil, fmt.Errorf("devops.New: failed to create container manager: %w", err) + } + + return &DevOps{ + medium: m, + config: cfg, + images: images, + container: mgr, + }, nil +} + +// ImageName returns the platform-specific image name. +func ImageName() string { + return fmt.Sprintf("core-devops-%s-%s.qcow2", runtime.GOOS, runtime.GOARCH) +} + +// ImagesDir returns the path to the images directory. +func ImagesDir() (string, error) { + if dir := os.Getenv("CORE_IMAGES_DIR"); dir != "" { + return dir, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".core", "images"), nil +} + +// ImagePath returns the full path to the platform-specific image. +func ImagePath() (string, error) { + dir, err := ImagesDir() + if err != nil { + return "", err + } + return filepath.Join(dir, ImageName()), nil +} + +// IsInstalled checks if the dev image is installed. +func (d *DevOps) IsInstalled() bool { + path, err := ImagePath() + if err != nil { + return false + } + return d.medium.IsFile(path) +} + +// Install downloads and installs the dev image. +func (d *DevOps) Install(ctx context.Context, progress func(downloaded, total int64)) error { + return d.images.Install(ctx, progress) +} + +// CheckUpdate checks if an update is available. +func (d *DevOps) CheckUpdate(ctx context.Context) (current, latest string, hasUpdate bool, err error) { + return d.images.CheckUpdate(ctx) +} + +// BootOptions configures how to boot the dev environment. +type BootOptions struct { + Memory int // MB, default 4096 + CPUs int // default 2 + Name string // container name + Fresh bool // destroy existing and start fresh +} + +// DefaultBootOptions returns sensible defaults. +func DefaultBootOptions() BootOptions { + return BootOptions{ + Memory: 4096, + CPUs: 2, + Name: "core-dev", + } +} + +// Boot starts the dev environment. +func (d *DevOps) Boot(ctx context.Context, opts BootOptions) error { + if !d.images.IsInstalled() { + return errors.New("dev image not installed (run 'core dev install' first)") + } + + // Check if already running + if !opts.Fresh { + running, err := d.IsRunning(ctx) + if err == nil && running { + return errors.New("dev environment already running (use 'core dev stop' first or --fresh)") + } + } + + // Stop existing if fresh + if opts.Fresh { + _ = d.Stop(ctx) + } + + imagePath, err := ImagePath() + if err != nil { + return err + } + + // Build run options for LinuxKitManager + runOpts := container.RunOptions{ + Name: opts.Name, + Memory: opts.Memory, + CPUs: opts.CPUs, + SSHPort: DefaultSSHPort, + Detach: true, + } + + _, err = d.container.Run(ctx, imagePath, runOpts) + if err != nil { + return err + } + + // Wait for SSH to be ready and scan host key + // We try for up to 60 seconds as the VM takes a moment to boot + var lastErr error + for range 30 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + if err := ensureHostKey(ctx, runOpts.SSHPort); err == nil { + return nil + } else { + lastErr = err + } + } + } + + return fmt.Errorf("failed to verify host key after boot: %w", lastErr) +} + +// Stop stops the dev environment. +func (d *DevOps) Stop(ctx context.Context) error { + c, err := d.findContainer(ctx, "core-dev") + if err != nil { + return err + } + if c == nil { + return errors.New("dev environment not found") + } + return d.container.Stop(ctx, c.ID) +} + +// IsRunning checks if the dev environment is running. +func (d *DevOps) IsRunning(ctx context.Context) (bool, error) { + c, err := d.findContainer(ctx, "core-dev") + if err != nil { + return false, err + } + return c != nil && c.Status == container.StatusRunning, nil +} + +// findContainer finds a container by name. +func (d *DevOps) findContainer(ctx context.Context, name string) (*container.Container, error) { + containers, err := d.container.List(ctx) + if err != nil { + return nil, err + } + for _, c := range containers { + if c.Name == name { + return c, nil + } + } + return nil, nil +} + +// DevStatus returns information about the dev environment. +type DevStatus struct { + Installed bool + Running bool + ImageVersion string + ContainerID string + Memory int + CPUs int + SSHPort int + Uptime time.Duration +} + +// Status returns the current dev environment status. +func (d *DevOps) Status(ctx context.Context) (*DevStatus, error) { + status := &DevStatus{ + Installed: d.images.IsInstalled(), + SSHPort: DefaultSSHPort, + } + + if info, ok := d.images.manifest.Images[ImageName()]; ok { + status.ImageVersion = info.Version + } + + c, _ := d.findContainer(ctx, "core-dev") + if c != nil { + status.Running = c.Status == container.StatusRunning + status.ContainerID = c.ID + status.Memory = c.Memory + status.CPUs = c.CPUs + if status.Running { + status.Uptime = time.Since(c.StartedAt) + } + } + + return status, nil +} diff --git a/devenv/devops_test.go b/devenv/devops_test.go new file mode 100644 index 0000000..c0cf745 --- /dev/null +++ b/devenv/devops_test.go @@ -0,0 +1,833 @@ +package devenv + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" + + "forge.lthn.ai/core/go-container" + "forge.lthn.ai/core/go-io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestImageName(t *testing.T) { + name := ImageName() + assert.Contains(t, name, "core-devops-") + assert.Contains(t, name, runtime.GOOS) + assert.Contains(t, name, runtime.GOARCH) + assert.True(t, (name[len(name)-6:] == ".qcow2")) +} + +func TestImagesDir(t *testing.T) { + t.Run("default directory", func(t *testing.T) { + // Unset env if it exists + orig := os.Getenv("CORE_IMAGES_DIR") + _ = os.Unsetenv("CORE_IMAGES_DIR") + defer func() { _ = os.Setenv("CORE_IMAGES_DIR", orig) }() + + dir, err := ImagesDir() + assert.NoError(t, err) + assert.Contains(t, dir, ".core/images") + }) + + t.Run("environment override", func(t *testing.T) { + customDir := "/tmp/custom-images" + t.Setenv("CORE_IMAGES_DIR", customDir) + + dir, err := ImagesDir() + assert.NoError(t, err) + assert.Equal(t, customDir, dir) + }) +} + +func TestImagePath(t *testing.T) { + customDir := "/tmp/images" + t.Setenv("CORE_IMAGES_DIR", customDir) + + path, err := ImagePath() + assert.NoError(t, err) + expected := filepath.Join(customDir, ImageName()) + assert.Equal(t, expected, path) +} + +func TestDefaultBootOptions(t *testing.T) { + opts := DefaultBootOptions() + assert.Equal(t, 4096, opts.Memory) + assert.Equal(t, 2, opts.CPUs) + assert.Equal(t, "core-dev", opts.Name) + assert.False(t, opts.Fresh) +} + +func TestIsInstalled_Bad(t *testing.T) { + t.Run("returns false for non-existent image", func(t *testing.T) { + // Point to a temp directory that is empty + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + // Create devops instance manually to avoid loading real config/images + d := &DevOps{medium: io.Local} + assert.False(t, d.IsInstalled()) + }) +} + +func TestIsInstalled_Good(t *testing.T) { + t.Run("returns true when image exists", func(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + // Create the image file + imagePath := filepath.Join(tempDir, ImageName()) + err := os.WriteFile(imagePath, []byte("fake image data"), 0644) + require.NoError(t, err) + + d := &DevOps{medium: io.Local} + assert.True(t, d.IsInstalled()) + }) +} + +type mockHypervisor struct{} + +func (m *mockHypervisor) Name() string { return "mock" } +func (m *mockHypervisor) Available() bool { return true } +func (m *mockHypervisor) BuildCommand(ctx context.Context, image string, opts *container.HypervisorOptions) (*exec.Cmd, error) { + return exec.Command("true"), nil +} + +func TestDevOps_Status_Good(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + // Setup mock container manager + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + images: mgr, + container: cm, + } + + // Add a fake running container + c := &container.Container{ + ID: "test-id", + Name: "core-dev", + Status: container.StatusRunning, + PID: os.Getpid(), // Use our own PID so isProcessRunning returns true + StartedAt: time.Now().Add(-time.Hour), + Memory: 2048, + CPUs: 4, + } + err = state.Add(c) + require.NoError(t, err) + + status, err := d.Status(context.Background()) + assert.NoError(t, err) + assert.NotNil(t, status) + assert.True(t, status.Running) + assert.Equal(t, "test-id", status.ContainerID) + assert.Equal(t, 2048, status.Memory) + assert.Equal(t, 4, status.CPUs) +} + +func TestDevOps_Status_Good_NotInstalled(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + images: mgr, + container: cm, + } + + status, err := d.Status(context.Background()) + assert.NoError(t, err) + assert.NotNil(t, status) + assert.False(t, status.Installed) + assert.False(t, status.Running) + assert.Equal(t, 2222, status.SSHPort) +} + +func TestDevOps_Status_Good_NoContainer(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + // Create fake image to mark as installed + imagePath := filepath.Join(tempDir, ImageName()) + err := os.WriteFile(imagePath, []byte("fake"), 0644) + require.NoError(t, err) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + images: mgr, + container: cm, + } + + status, err := d.Status(context.Background()) + assert.NoError(t, err) + assert.NotNil(t, status) + assert.True(t, status.Installed) + assert.False(t, status.Running) + assert.Empty(t, status.ContainerID) +} + +func TestDevOps_IsRunning_Good(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + images: mgr, + container: cm, + } + + c := &container.Container{ + ID: "test-id", + Name: "core-dev", + Status: container.StatusRunning, + PID: os.Getpid(), + StartedAt: time.Now(), + } + err = state.Add(c) + require.NoError(t, err) + + running, err := d.IsRunning(context.Background()) + assert.NoError(t, err) + assert.True(t, running) +} + +func TestDevOps_IsRunning_Bad_NotRunning(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + images: mgr, + container: cm, + } + + running, err := d.IsRunning(context.Background()) + assert.NoError(t, err) + assert.False(t, running) +} + +func TestDevOps_IsRunning_Bad_ContainerStopped(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + images: mgr, + container: cm, + } + + c := &container.Container{ + ID: "test-id", + Name: "core-dev", + Status: container.StatusStopped, + PID: 12345, + StartedAt: time.Now(), + } + err = state.Add(c) + require.NoError(t, err) + + running, err := d.IsRunning(context.Background()) + assert.NoError(t, err) + assert.False(t, running) +} + +func TestDevOps_findContainer_Good(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + images: mgr, + container: cm, + } + + c := &container.Container{ + ID: "test-id", + Name: "my-container", + Status: container.StatusRunning, + PID: os.Getpid(), + StartedAt: time.Now(), + } + err = state.Add(c) + require.NoError(t, err) + + found, err := d.findContainer(context.Background(), "my-container") + assert.NoError(t, err) + assert.NotNil(t, found) + assert.Equal(t, "test-id", found.ID) + assert.Equal(t, "my-container", found.Name) +} + +func TestDevOps_findContainer_Bad_NotFound(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + images: mgr, + container: cm, + } + + found, err := d.findContainer(context.Background(), "nonexistent") + assert.NoError(t, err) + assert.Nil(t, found) +} + +func TestDevOps_Stop_Bad_NotFound(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + images: mgr, + container: cm, + } + + err = d.Stop(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestBootOptions_Custom(t *testing.T) { + opts := BootOptions{ + Memory: 8192, + CPUs: 4, + Name: "custom-dev", + Fresh: true, + } + assert.Equal(t, 8192, opts.Memory) + assert.Equal(t, 4, opts.CPUs) + assert.Equal(t, "custom-dev", opts.Name) + assert.True(t, opts.Fresh) +} + +func TestDevStatus_Struct(t *testing.T) { + status := DevStatus{ + Installed: true, + Running: true, + ImageVersion: "v1.2.3", + ContainerID: "abc123", + Memory: 4096, + CPUs: 2, + SSHPort: 2222, + Uptime: time.Hour, + } + assert.True(t, status.Installed) + assert.True(t, status.Running) + assert.Equal(t, "v1.2.3", status.ImageVersion) + assert.Equal(t, "abc123", status.ContainerID) + assert.Equal(t, 4096, status.Memory) + assert.Equal(t, 2, status.CPUs) + assert.Equal(t, 2222, status.SSHPort) + assert.Equal(t, time.Hour, status.Uptime) +} + +func TestDevOps_Boot_Bad_NotInstalled(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + images: mgr, + container: cm, + } + + err = d.Boot(context.Background(), DefaultBootOptions()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not installed") +} + +func TestDevOps_Boot_Bad_AlreadyRunning(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + // Create fake image + imagePath := filepath.Join(tempDir, ImageName()) + err := os.WriteFile(imagePath, []byte("fake"), 0644) + require.NoError(t, err) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + images: mgr, + container: cm, + } + + // Add a running container + c := &container.Container{ + ID: "test-id", + Name: "core-dev", + Status: container.StatusRunning, + PID: os.Getpid(), + StartedAt: time.Now(), + } + err = state.Add(c) + require.NoError(t, err) + + err = d.Boot(context.Background(), DefaultBootOptions()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already running") +} + +func TestDevOps_Status_Good_WithImageVersion(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + // Create fake image + imagePath := filepath.Join(tempDir, ImageName()) + err := os.WriteFile(imagePath, []byte("fake"), 0644) + require.NoError(t, err) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + // Manually set manifest with version info + mgr.manifest.Images[ImageName()] = ImageInfo{ + Version: "v1.2.3", + Source: "test", + } + + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + config: cfg, + images: mgr, + container: cm, + } + + status, err := d.Status(context.Background()) + assert.NoError(t, err) + assert.True(t, status.Installed) + assert.Equal(t, "v1.2.3", status.ImageVersion) +} + +func TestDevOps_findContainer_Good_MultipleContainers(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + images: mgr, + container: cm, + } + + // Add multiple containers + c1 := &container.Container{ + ID: "id-1", + Name: "container-1", + Status: container.StatusRunning, + PID: os.Getpid(), + StartedAt: time.Now(), + } + c2 := &container.Container{ + ID: "id-2", + Name: "container-2", + Status: container.StatusRunning, + PID: os.Getpid(), + StartedAt: time.Now(), + } + err = state.Add(c1) + require.NoError(t, err) + err = state.Add(c2) + require.NoError(t, err) + + // Find specific container + found, err := d.findContainer(context.Background(), "container-2") + assert.NoError(t, err) + assert.NotNil(t, found) + assert.Equal(t, "id-2", found.ID) +} + +func TestDevOps_Status_Good_ContainerWithUptime(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + images: mgr, + container: cm, + } + + startTime := time.Now().Add(-2 * time.Hour) + c := &container.Container{ + ID: "test-id", + Name: "core-dev", + Status: container.StatusRunning, + PID: os.Getpid(), + StartedAt: startTime, + Memory: 4096, + CPUs: 2, + } + err = state.Add(c) + require.NoError(t, err) + + status, err := d.Status(context.Background()) + assert.NoError(t, err) + assert.True(t, status.Running) + assert.GreaterOrEqual(t, status.Uptime.Hours(), float64(1)) +} + +func TestDevOps_IsRunning_Bad_DifferentContainerName(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + images: mgr, + container: cm, + } + + // Add a container with different name + c := &container.Container{ + ID: "test-id", + Name: "other-container", + Status: container.StatusRunning, + PID: os.Getpid(), + StartedAt: time.Now(), + } + err = state.Add(c) + require.NoError(t, err) + + // IsRunning looks for "core-dev", not "other-container" + running, err := d.IsRunning(context.Background()) + assert.NoError(t, err) + assert.False(t, running) +} + +func TestDevOps_Boot_Good_FreshFlag(t *testing.T) { + t.Setenv("CORE_SKIP_SSH_SCAN", "true") + tempDir, err := os.MkdirTemp("", "devops-test-*") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) + t.Setenv("CORE_IMAGES_DIR", tempDir) + + // Create fake image + imagePath := filepath.Join(tempDir, ImageName()) + err = os.WriteFile(imagePath, []byte("fake"), 0644) + require.NoError(t, err) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + images: mgr, + container: cm, + } + + // Add an existing container with non-existent PID (will be seen as stopped) + c := &container.Container{ + ID: "old-id", + Name: "core-dev", + Status: container.StatusRunning, + PID: 99999999, // Non-existent PID - List() will mark it as stopped + StartedAt: time.Now(), + } + err = state.Add(c) + require.NoError(t, err) + + // Boot with Fresh=true should try to stop the existing container + // then run a new one. The mock hypervisor "succeeds" so this won't error + opts := BootOptions{ + Memory: 4096, + CPUs: 2, + Name: "core-dev", + Fresh: true, + } + err = d.Boot(context.Background(), opts) + // The mock hypervisor's Run succeeds + assert.NoError(t, err) +} + +func TestDevOps_Stop_Bad_ContainerNotRunning(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + images: mgr, + container: cm, + } + + // Add a container that's already stopped + c := &container.Container{ + ID: "test-id", + Name: "core-dev", + Status: container.StatusStopped, + PID: 99999999, + StartedAt: time.Now(), + } + err = state.Add(c) + require.NoError(t, err) + + // Stop should fail because container is not running + err = d.Stop(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not running") +} + +func TestDevOps_Boot_Good_FreshWithNoExisting(t *testing.T) { + t.Setenv("CORE_SKIP_SSH_SCAN", "true") + tempDir, err := os.MkdirTemp("", "devops-boot-fresh-*") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) + t.Setenv("CORE_IMAGES_DIR", tempDir) + + // Create fake image + imagePath := filepath.Join(tempDir, ImageName()) + err = os.WriteFile(imagePath, []byte("fake"), 0644) + require.NoError(t, err) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + images: mgr, + container: cm, + } + + // Boot with Fresh=true but no existing container + opts := BootOptions{ + Memory: 4096, + CPUs: 2, + Name: "core-dev", + Fresh: true, + } + err = d.Boot(context.Background(), opts) + // The mock hypervisor succeeds + assert.NoError(t, err) +} + +func TestImageName_Format(t *testing.T) { + name := ImageName() + // Check format: core-devops-{os}-{arch}.qcow2 + assert.Contains(t, name, "core-devops-") + assert.Contains(t, name, runtime.GOOS) + assert.Contains(t, name, runtime.GOARCH) + assert.True(t, filepath.Ext(name) == ".qcow2") +} + +func TestDevOps_Install_Delegates(t *testing.T) { + // This test verifies the Install method delegates to ImageManager + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + d := &DevOps{medium: io.Local, + images: mgr, + } + + // This will fail because no source is available, but it tests delegation + err = d.Install(context.Background(), nil) + assert.Error(t, err) +} + +func TestDevOps_CheckUpdate_Delegates(t *testing.T) { + // This test verifies the CheckUpdate method delegates to ImageManager + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + d := &DevOps{medium: io.Local, + images: mgr, + } + + // This will fail because image not installed, but it tests delegation + _, _, _, err = d.CheckUpdate(context.Background()) + assert.Error(t, err) +} + +func TestDevOps_Boot_Good_Success(t *testing.T) { + t.Setenv("CORE_SKIP_SSH_SCAN", "true") + tempDir, err := os.MkdirTemp("", "devops-boot-success-*") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) + t.Setenv("CORE_IMAGES_DIR", tempDir) + + // Create fake image + imagePath := filepath.Join(tempDir, ImageName()) + err = os.WriteFile(imagePath, []byte("fake"), 0644) + require.NoError(t, err) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + statePath := filepath.Join(tempDir, "containers.json") + state := container.NewState(statePath) + h := &mockHypervisor{} + cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) + + d := &DevOps{medium: io.Local, + images: mgr, + container: cm, + } + + // Boot without Fresh flag and no existing container + opts := DefaultBootOptions() + err = d.Boot(context.Background(), opts) + assert.NoError(t, err) // Mock hypervisor succeeds +} + +func TestDevOps_Config(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tempDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + d := &DevOps{medium: io.Local, + config: cfg, + images: mgr, + } + + assert.NotNil(t, d.config) + assert.Equal(t, "auto", d.config.Images.Source) +} diff --git a/devenv/images.go b/devenv/images.go new file mode 100644 index 0000000..12f0df6 --- /dev/null +++ b/devenv/images.go @@ -0,0 +1,199 @@ +package devenv + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "forge.lthn.ai/core/go-container/sources" + "forge.lthn.ai/core/go-io" +) + +// ImageManager handles image downloads and updates. +type ImageManager struct { + medium io.Medium + config *Config + manifest *Manifest + sources []sources.ImageSource +} + +// Manifest tracks installed images. +type Manifest struct { + medium io.Medium + Images map[string]ImageInfo `json:"images"` + path string +} + +// ImageInfo holds metadata about an installed image. +type ImageInfo struct { + Version string `json:"version"` + SHA256 string `json:"sha256,omitempty"` + Downloaded time.Time `json:"downloaded"` + Source string `json:"source"` +} + +// NewImageManager creates a new image manager. +func NewImageManager(m io.Medium, cfg *Config) (*ImageManager, error) { + imagesDir, err := ImagesDir() + if err != nil { + return nil, err + } + + // Ensure images directory exists + if err := m.EnsureDir(imagesDir); err != nil { + return nil, err + } + + // Load or create manifest + manifestPath := filepath.Join(imagesDir, "manifest.json") + manifest, err := loadManifest(m, manifestPath) + if err != nil { + return nil, err + } + + // Build source list based on config + imageName := ImageName() + sourceCfg := sources.SourceConfig{ + GitHubRepo: cfg.Images.GitHub.Repo, + RegistryImage: cfg.Images.Registry.Image, + CDNURL: cfg.Images.CDN.URL, + ImageName: imageName, + } + + var srcs []sources.ImageSource + switch cfg.Images.Source { + case "github": + srcs = []sources.ImageSource{sources.NewGitHubSource(sourceCfg)} + case "cdn": + srcs = []sources.ImageSource{sources.NewCDNSource(sourceCfg)} + default: // "auto" + srcs = []sources.ImageSource{ + sources.NewGitHubSource(sourceCfg), + sources.NewCDNSource(sourceCfg), + } + } + + return &ImageManager{ + medium: m, + config: cfg, + manifest: manifest, + sources: srcs, + }, nil +} + +// IsInstalled checks if the dev image is installed. +func (m *ImageManager) IsInstalled() bool { + path, err := ImagePath() + if err != nil { + return false + } + return m.medium.IsFile(path) +} + +// Install downloads and installs the dev image. +func (m *ImageManager) Install(ctx context.Context, progress func(downloaded, total int64)) error { + imagesDir, err := ImagesDir() + if err != nil { + return err + } + + // Find first available source + var src sources.ImageSource + for _, s := range m.sources { + if s.Available() { + src = s + break + } + } + if src == nil { + return errors.New("no image source available") + } + + // Get version + version, err := src.LatestVersion(ctx) + if err != nil { + return fmt.Errorf("failed to get latest version: %w", err) + } + + fmt.Printf("Downloading %s from %s...\n", ImageName(), src.Name()) + + // Download + if err := src.Download(ctx, m.medium, imagesDir, progress); err != nil { + return err + } + + // Update manifest + m.manifest.Images[ImageName()] = ImageInfo{ + Version: version, + Downloaded: time.Now(), + Source: src.Name(), + } + + return m.manifest.Save() +} + +// CheckUpdate checks if an update is available. +func (m *ImageManager) CheckUpdate(ctx context.Context) (current, latest string, hasUpdate bool, err error) { + info, ok := m.manifest.Images[ImageName()] + if !ok { + return "", "", false, errors.New("image not installed") + } + current = info.Version + + // Find first available source + var src sources.ImageSource + for _, s := range m.sources { + if s.Available() { + src = s + break + } + } + if src == nil { + return current, "", false, errors.New("no image source available") + } + + latest, err = src.LatestVersion(ctx) + if err != nil { + return current, "", false, err + } + + hasUpdate = current != latest + return current, latest, hasUpdate, nil +} + +func loadManifest(m io.Medium, path string) (*Manifest, error) { + manifest := &Manifest{ + medium: m, + Images: make(map[string]ImageInfo), + path: path, + } + + content, err := m.Read(path) + if err != nil { + if os.IsNotExist(err) { + return manifest, nil + } + return nil, err + } + + if err := json.Unmarshal([]byte(content), manifest); err != nil { + return nil, err + } + manifest.medium = m + manifest.path = path + + return manifest, nil +} + +// Save writes the manifest to disk. +func (m *Manifest) Save() error { + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return m.medium.Write(m.path, string(data)) +} diff --git a/devenv/images_test.go b/devenv/images_test.go new file mode 100644 index 0000000..795212d --- /dev/null +++ b/devenv/images_test.go @@ -0,0 +1,583 @@ +package devenv + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "forge.lthn.ai/core/go-container/sources" + "forge.lthn.ai/core/go-io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestImageManager_Good_IsInstalled(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tmpDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + // Not installed yet + assert.False(t, mgr.IsInstalled()) + + // Create fake image + imagePath := filepath.Join(tmpDir, ImageName()) + err = os.WriteFile(imagePath, []byte("fake"), 0644) + require.NoError(t, err) + + // Now installed + assert.True(t, mgr.IsInstalled()) +} + +func TestNewImageManager_Good(t *testing.T) { + t.Run("creates manager with cdn source", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tmpDir) + + cfg := DefaultConfig() + cfg.Images.Source = "cdn" + + mgr, err := NewImageManager(io.Local, cfg) + assert.NoError(t, err) + assert.NotNil(t, mgr) + assert.Len(t, mgr.sources, 1) + assert.Equal(t, "cdn", mgr.sources[0].Name()) + }) + + t.Run("creates manager with github source", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tmpDir) + + cfg := DefaultConfig() + cfg.Images.Source = "github" + + mgr, err := NewImageManager(io.Local, cfg) + assert.NoError(t, err) + assert.NotNil(t, mgr) + assert.Len(t, mgr.sources, 1) + assert.Equal(t, "github", mgr.sources[0].Name()) + }) +} + +func TestManifest_Save(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "manifest.json") + + m := &Manifest{ + medium: io.Local, + Images: make(map[string]ImageInfo), + path: path, + } + + m.Images["test.img"] = ImageInfo{ + Version: "1.0.0", + Source: "test", + } + + err := m.Save() + assert.NoError(t, err) + + // Verify file exists and has content + _, err = os.Stat(path) + assert.NoError(t, err) + + // Reload + m2, err := loadManifest(io.Local, path) + assert.NoError(t, err) + assert.Equal(t, "1.0.0", m2.Images["test.img"].Version) +} + +func TestLoadManifest_Bad(t *testing.T) { + t.Run("invalid json", func(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "manifest.json") + err := os.WriteFile(path, []byte("invalid json"), 0644) + require.NoError(t, err) + + _, err = loadManifest(io.Local, path) + assert.Error(t, err) + }) +} + +func TestCheckUpdate_Bad(t *testing.T) { + t.Run("image not installed", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tmpDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + require.NoError(t, err) + + _, _, _, err = mgr.CheckUpdate(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "image not installed") + }) +} + +func TestNewImageManager_Good_AutoSource(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tmpDir) + + cfg := DefaultConfig() + cfg.Images.Source = "auto" + + mgr, err := NewImageManager(io.Local, cfg) + assert.NoError(t, err) + assert.NotNil(t, mgr) + assert.Len(t, mgr.sources, 2) // github and cdn +} + +func TestNewImageManager_Good_UnknownSourceFallsToAuto(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tmpDir) + + cfg := DefaultConfig() + cfg.Images.Source = "unknown" + + mgr, err := NewImageManager(io.Local, cfg) + assert.NoError(t, err) + assert.NotNil(t, mgr) + assert.Len(t, mgr.sources, 2) // falls to default (auto) which is github + cdn +} + +func TestLoadManifest_Good_Empty(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "nonexistent.json") + + m, err := loadManifest(io.Local, path) + assert.NoError(t, err) + assert.NotNil(t, m) + assert.NotNil(t, m.Images) + assert.Empty(t, m.Images) + assert.Equal(t, path, m.path) +} + +func TestLoadManifest_Good_ExistingData(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "manifest.json") + + data := `{"images":{"test.img":{"version":"2.0.0","source":"cdn"}}}` + err := os.WriteFile(path, []byte(data), 0644) + require.NoError(t, err) + + m, err := loadManifest(io.Local, path) + assert.NoError(t, err) + assert.NotNil(t, m) + assert.Equal(t, "2.0.0", m.Images["test.img"].Version) + assert.Equal(t, "cdn", m.Images["test.img"].Source) +} + +func TestImageInfo_Struct(t *testing.T) { + info := ImageInfo{ + Version: "1.0.0", + SHA256: "abc123", + Downloaded: time.Now(), + Source: "github", + } + assert.Equal(t, "1.0.0", info.Version) + assert.Equal(t, "abc123", info.SHA256) + assert.False(t, info.Downloaded.IsZero()) + assert.Equal(t, "github", info.Source) +} + +func TestManifest_Save_Good_CreatesDirs(t *testing.T) { + tmpDir := t.TempDir() + nestedPath := filepath.Join(tmpDir, "nested", "dir", "manifest.json") + + m := &Manifest{ + medium: io.Local, + Images: make(map[string]ImageInfo), + path: nestedPath, + } + m.Images["test.img"] = ImageInfo{Version: "1.0.0"} + + // Save creates parent directories automatically via io.Local.Write + err := m.Save() + assert.NoError(t, err) + + // Verify file was created + _, err = os.Stat(nestedPath) + assert.NoError(t, err) +} + +func TestManifest_Save_Good_Overwrite(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "manifest.json") + + // First save + m1 := &Manifest{ + medium: io.Local, + Images: make(map[string]ImageInfo), + path: path, + } + m1.Images["test.img"] = ImageInfo{Version: "1.0.0"} + err := m1.Save() + require.NoError(t, err) + + // Second save with different data + m2 := &Manifest{ + medium: io.Local, + Images: make(map[string]ImageInfo), + path: path, + } + m2.Images["other.img"] = ImageInfo{Version: "2.0.0"} + err = m2.Save() + require.NoError(t, err) + + // Verify second data + loaded, err := loadManifest(io.Local, path) + assert.NoError(t, err) + assert.Equal(t, "2.0.0", loaded.Images["other.img"].Version) + _, exists := loaded.Images["test.img"] + assert.False(t, exists) +} + +func TestImageManager_Install_Bad_NoSourceAvailable(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tmpDir) + + // Create manager with empty sources + mgr := &ImageManager{ + medium: io.Local, + config: DefaultConfig(), + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + sources: nil, // no sources + } + + err := mgr.Install(context.Background(), nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no image source available") +} + +func TestNewImageManager_Good_CreatesDir(t *testing.T) { + tmpDir := t.TempDir() + imagesDir := filepath.Join(tmpDir, "images") + t.Setenv("CORE_IMAGES_DIR", imagesDir) + + cfg := DefaultConfig() + mgr, err := NewImageManager(io.Local, cfg) + assert.NoError(t, err) + assert.NotNil(t, mgr) + + // Verify directory was created + info, err := os.Stat(imagesDir) + assert.NoError(t, err) + assert.True(t, info.IsDir()) +} + +// mockImageSource is a test helper for simulating image sources +type mockImageSource struct { + name string + available bool + latestVersion string + latestErr error + downloadErr error +} + +func (m *mockImageSource) Name() string { return m.name } +func (m *mockImageSource) Available() bool { return m.available } +func (m *mockImageSource) LatestVersion(ctx context.Context) (string, error) { + return m.latestVersion, m.latestErr +} +func (m *mockImageSource) Download(ctx context.Context, medium io.Medium, dest string, progress func(downloaded, total int64)) error { + if m.downloadErr != nil { + return m.downloadErr + } + // Create a fake image file + imagePath := filepath.Join(dest, ImageName()) + return os.WriteFile(imagePath, []byte("mock image content"), 0644) +} + +func TestImageManager_Install_Good_WithMockSource(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tmpDir) + + mock := &mockImageSource{ + name: "mock", + available: true, + latestVersion: "v1.0.0", + } + + mgr := &ImageManager{ + medium: io.Local, + config: DefaultConfig(), + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + sources: []sources.ImageSource{mock}, + } + + err := mgr.Install(context.Background(), nil) + assert.NoError(t, err) + assert.True(t, mgr.IsInstalled()) + + // Verify manifest was updated + info, ok := mgr.manifest.Images[ImageName()] + assert.True(t, ok) + assert.Equal(t, "v1.0.0", info.Version) + assert.Equal(t, "mock", info.Source) +} + +func TestImageManager_Install_Bad_DownloadError(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tmpDir) + + mock := &mockImageSource{ + name: "mock", + available: true, + latestVersion: "v1.0.0", + downloadErr: assert.AnError, + } + + mgr := &ImageManager{ + medium: io.Local, + config: DefaultConfig(), + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + sources: []sources.ImageSource{mock}, + } + + err := mgr.Install(context.Background(), nil) + assert.Error(t, err) +} + +func TestImageManager_Install_Bad_VersionError(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tmpDir) + + mock := &mockImageSource{ + name: "mock", + available: true, + latestErr: assert.AnError, + } + + mgr := &ImageManager{ + medium: io.Local, + config: DefaultConfig(), + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + sources: []sources.ImageSource{mock}, + } + + err := mgr.Install(context.Background(), nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get latest version") +} + +func TestImageManager_Install_Good_SkipsUnavailableSource(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tmpDir) + + unavailableMock := &mockImageSource{ + name: "unavailable", + available: false, + } + availableMock := &mockImageSource{ + name: "available", + available: true, + latestVersion: "v2.0.0", + } + + mgr := &ImageManager{ + medium: io.Local, + config: DefaultConfig(), + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + sources: []sources.ImageSource{unavailableMock, availableMock}, + } + + err := mgr.Install(context.Background(), nil) + assert.NoError(t, err) + + // Should have used the available source + info := mgr.manifest.Images[ImageName()] + assert.Equal(t, "available", info.Source) +} + +func TestImageManager_CheckUpdate_Good_WithMockSource(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tmpDir) + + mock := &mockImageSource{ + name: "mock", + available: true, + latestVersion: "v2.0.0", + } + + mgr := &ImageManager{ + medium: io.Local, + config: DefaultConfig(), + manifest: &Manifest{ + medium: io.Local, + Images: map[string]ImageInfo{ + ImageName(): {Version: "v1.0.0", Source: "mock"}, + }, + path: filepath.Join(tmpDir, "manifest.json"), + }, + sources: []sources.ImageSource{mock}, + } + + current, latest, hasUpdate, err := mgr.CheckUpdate(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "v1.0.0", current) + assert.Equal(t, "v2.0.0", latest) + assert.True(t, hasUpdate) +} + +func TestImageManager_CheckUpdate_Good_NoUpdate(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tmpDir) + + mock := &mockImageSource{ + name: "mock", + available: true, + latestVersion: "v1.0.0", + } + + mgr := &ImageManager{ + medium: io.Local, + config: DefaultConfig(), + manifest: &Manifest{ + medium: io.Local, + Images: map[string]ImageInfo{ + ImageName(): {Version: "v1.0.0", Source: "mock"}, + }, + path: filepath.Join(tmpDir, "manifest.json"), + }, + sources: []sources.ImageSource{mock}, + } + + current, latest, hasUpdate, err := mgr.CheckUpdate(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "v1.0.0", current) + assert.Equal(t, "v1.0.0", latest) + assert.False(t, hasUpdate) +} + +func TestImageManager_CheckUpdate_Bad_NoSource(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tmpDir) + + unavailableMock := &mockImageSource{ + name: "mock", + available: false, + } + + mgr := &ImageManager{ + medium: io.Local, + config: DefaultConfig(), + manifest: &Manifest{ + medium: io.Local, + Images: map[string]ImageInfo{ + ImageName(): {Version: "v1.0.0", Source: "mock"}, + }, + path: filepath.Join(tmpDir, "manifest.json"), + }, + sources: []sources.ImageSource{unavailableMock}, + } + + _, _, _, err := mgr.CheckUpdate(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no image source available") +} + +func TestImageManager_CheckUpdate_Bad_VersionError(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tmpDir) + + mock := &mockImageSource{ + name: "mock", + available: true, + latestErr: assert.AnError, + } + + mgr := &ImageManager{ + medium: io.Local, + config: DefaultConfig(), + manifest: &Manifest{ + medium: io.Local, + Images: map[string]ImageInfo{ + ImageName(): {Version: "v1.0.0", Source: "mock"}, + }, + path: filepath.Join(tmpDir, "manifest.json"), + }, + sources: []sources.ImageSource{mock}, + } + + current, _, _, err := mgr.CheckUpdate(context.Background()) + assert.Error(t, err) + assert.Equal(t, "v1.0.0", current) // Current should still be returned +} + +func TestImageManager_Install_Bad_EmptySources(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tmpDir) + + mgr := &ImageManager{ + medium: io.Local, + config: DefaultConfig(), + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + sources: []sources.ImageSource{}, // Empty slice, not nil + } + + err := mgr.Install(context.Background(), nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no image source available") +} + +func TestImageManager_Install_Bad_AllUnavailable(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tmpDir) + + mock1 := &mockImageSource{name: "mock1", available: false} + mock2 := &mockImageSource{name: "mock2", available: false} + + mgr := &ImageManager{ + medium: io.Local, + config: DefaultConfig(), + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + sources: []sources.ImageSource{mock1, mock2}, + } + + err := mgr.Install(context.Background(), nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no image source available") +} + +func TestImageManager_CheckUpdate_Good_FirstSourceUnavailable(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CORE_IMAGES_DIR", tmpDir) + + unavailable := &mockImageSource{name: "unavailable", available: false} + available := &mockImageSource{name: "available", available: true, latestVersion: "v2.0.0"} + + mgr := &ImageManager{ + medium: io.Local, + config: DefaultConfig(), + manifest: &Manifest{ + medium: io.Local, + Images: map[string]ImageInfo{ + ImageName(): {Version: "v1.0.0", Source: "available"}, + }, + path: filepath.Join(tmpDir, "manifest.json"), + }, + sources: []sources.ImageSource{unavailable, available}, + } + + current, latest, hasUpdate, err := mgr.CheckUpdate(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "v1.0.0", current) + assert.Equal(t, "v2.0.0", latest) + assert.True(t, hasUpdate) +} + +func TestManifest_Struct(t *testing.T) { + m := &Manifest{ + Images: map[string]ImageInfo{ + "test.img": {Version: "1.0.0"}, + }, + path: "/path/to/manifest.json", + } + assert.Equal(t, "/path/to/manifest.json", m.path) + assert.Len(t, m.Images, 1) + assert.Equal(t, "1.0.0", m.Images["test.img"].Version) +} diff --git a/devenv/serve.go b/devenv/serve.go new file mode 100644 index 0000000..788a7c2 --- /dev/null +++ b/devenv/serve.go @@ -0,0 +1,110 @@ +package devenv + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + + "forge.lthn.ai/core/go-io" +) + +// ServeOptions configures the dev server. +type ServeOptions struct { + Port int // Port to serve on (default 8000) + Path string // Subdirectory to serve (default: current dir) +} + +// Serve mounts the project and starts a dev server. +func (d *DevOps) Serve(ctx context.Context, projectDir string, opts ServeOptions) error { + running, err := d.IsRunning(ctx) + if err != nil { + return err + } + if !running { + return errors.New("dev environment not running (run 'core dev boot' first)") + } + + if opts.Port == 0 { + opts.Port = 8000 + } + + servePath := projectDir + if opts.Path != "" { + servePath = filepath.Join(projectDir, opts.Path) + } + + // Mount project directory via SSHFS + if err := d.mountProject(ctx, servePath); err != nil { + return fmt.Errorf("failed to mount project: %w", err) + } + + // Detect and run serve command + serveCmd := DetectServeCommand(d.medium, servePath) + fmt.Printf("Starting server: %s\n", serveCmd) + fmt.Printf("Listening on http://localhost:%d\n", opts.Port) + + // Run serve command via SSH + return d.sshShell(ctx, []string{"cd", "/app", "&&", serveCmd}) +} + +// mountProject mounts a directory into the VM via SSHFS. +func (d *DevOps) mountProject(ctx context.Context, path string) error { + absPath, err := filepath.Abs(path) + if err != nil { + return err + } + + // Use reverse SSHFS mount + // The VM connects back to host to mount the directory + cmd := exec.CommandContext(ctx, "ssh", + "-o", "StrictHostKeyChecking=yes", + "-o", "UserKnownHostsFile=~/.core/known_hosts", + "-o", "LogLevel=ERROR", + "-R", "10000:localhost:22", // Reverse tunnel for SSHFS + "-p", fmt.Sprintf("%d", DefaultSSHPort), + "root@localhost", + fmt.Sprintf("mkdir -p /app && sshfs -p 10000 %s@localhost:%s /app -o allow_other", os.Getenv("USER"), absPath), + ) + return cmd.Run() +} + +// DetectServeCommand auto-detects the serve command for a project. +func DetectServeCommand(m io.Medium, projectDir string) string { + // Laravel/Octane + if hasFile(m, projectDir, "artisan") { + return "php artisan octane:start --host=0.0.0.0 --port=8000" + } + + // Node.js with dev script + if hasFile(m, projectDir, "package.json") { + if hasPackageScript(m, projectDir, "dev") { + return "npm run dev -- --host 0.0.0.0" + } + if hasPackageScript(m, projectDir, "start") { + return "npm start" + } + } + + // PHP with composer + if hasFile(m, projectDir, "composer.json") { + return "frankenphp php-server -l :8000" + } + + // Go + if hasFile(m, projectDir, "go.mod") { + if hasFile(m, projectDir, "main.go") { + return "go run ." + } + } + + // Python Django + if hasFile(m, projectDir, "manage.py") { + return "python manage.py runserver 0.0.0.0:8000" + } + + // Fallback: simple HTTP server + return "python3 -m http.server 8000" +} diff --git a/devenv/serve_test.go b/devenv/serve_test.go new file mode 100644 index 0000000..1c2f61e --- /dev/null +++ b/devenv/serve_test.go @@ -0,0 +1,137 @@ +package devenv + +import ( + "os" + "path/filepath" + "testing" + + "forge.lthn.ai/core/go-io" + "github.com/stretchr/testify/assert" +) + +func TestDetectServeCommand_Good_Laravel(t *testing.T) { + tmpDir := t.TempDir() + err := os.WriteFile(filepath.Join(tmpDir, "artisan"), []byte("#!/usr/bin/env php"), 0644) + assert.NoError(t, err) + + cmd := DetectServeCommand(io.Local, tmpDir) + assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd) +} + +func TestDetectServeCommand_Good_NodeDev(t *testing.T) { + tmpDir := t.TempDir() + packageJSON := `{"scripts":{"dev":"vite","start":"node index.js"}}` + err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(packageJSON), 0644) + assert.NoError(t, err) + + cmd := DetectServeCommand(io.Local, tmpDir) + assert.Equal(t, "npm run dev -- --host 0.0.0.0", cmd) +} + +func TestDetectServeCommand_Good_NodeStart(t *testing.T) { + tmpDir := t.TempDir() + packageJSON := `{"scripts":{"start":"node server.js"}}` + err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(packageJSON), 0644) + assert.NoError(t, err) + + cmd := DetectServeCommand(io.Local, tmpDir) + assert.Equal(t, "npm start", cmd) +} + +func TestDetectServeCommand_Good_PHP(t *testing.T) { + tmpDir := t.TempDir() + err := os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"require":{}}`), 0644) + assert.NoError(t, err) + + cmd := DetectServeCommand(io.Local, tmpDir) + assert.Equal(t, "frankenphp php-server -l :8000", cmd) +} + +func TestDetectServeCommand_Good_GoMain(t *testing.T) { + tmpDir := t.TempDir() + err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) + assert.NoError(t, err) + err = os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644) + assert.NoError(t, err) + + cmd := DetectServeCommand(io.Local, tmpDir) + assert.Equal(t, "go run .", cmd) +} + +func TestDetectServeCommand_Good_GoWithoutMain(t *testing.T) { + tmpDir := t.TempDir() + err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) + assert.NoError(t, err) + + // No main.go, so falls through to fallback + cmd := DetectServeCommand(io.Local, tmpDir) + assert.Equal(t, "python3 -m http.server 8000", cmd) +} + +func TestDetectServeCommand_Good_Django(t *testing.T) { + tmpDir := t.TempDir() + err := os.WriteFile(filepath.Join(tmpDir, "manage.py"), []byte("#!/usr/bin/env python"), 0644) + assert.NoError(t, err) + + cmd := DetectServeCommand(io.Local, tmpDir) + assert.Equal(t, "python manage.py runserver 0.0.0.0:8000", cmd) +} + +func TestDetectServeCommand_Good_Fallback(t *testing.T) { + tmpDir := t.TempDir() + + cmd := DetectServeCommand(io.Local, tmpDir) + assert.Equal(t, "python3 -m http.server 8000", cmd) +} + +func TestDetectServeCommand_Good_Priority(t *testing.T) { + // Laravel (artisan) should take priority over PHP (composer.json) + tmpDir := t.TempDir() + err := os.WriteFile(filepath.Join(tmpDir, "artisan"), []byte("#!/usr/bin/env php"), 0644) + assert.NoError(t, err) + err = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"require":{}}`), 0644) + assert.NoError(t, err) + + cmd := DetectServeCommand(io.Local, tmpDir) + assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd) +} + +func TestServeOptions_Default(t *testing.T) { + opts := ServeOptions{} + assert.Equal(t, 0, opts.Port) + assert.Equal(t, "", opts.Path) +} + +func TestServeOptions_Custom(t *testing.T) { + opts := ServeOptions{ + Port: 3000, + Path: "public", + } + assert.Equal(t, 3000, opts.Port) + assert.Equal(t, "public", opts.Path) +} + +func TestHasFile_Good(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.txt") + err := os.WriteFile(testFile, []byte("content"), 0644) + assert.NoError(t, err) + + assert.True(t, hasFile(io.Local, tmpDir, "test.txt")) +} + +func TestHasFile_Bad(t *testing.T) { + tmpDir := t.TempDir() + + assert.False(t, hasFile(io.Local, tmpDir, "nonexistent.txt")) +} + +func TestHasFile_Bad_Directory(t *testing.T) { + tmpDir := t.TempDir() + subDir := filepath.Join(tmpDir, "subdir") + err := os.Mkdir(subDir, 0755) + assert.NoError(t, err) + + // hasFile correctly returns false for directories (only true for regular files) + assert.False(t, hasFile(io.Local, tmpDir, "subdir")) +} diff --git a/devenv/shell.go b/devenv/shell.go new file mode 100644 index 0000000..8410298 --- /dev/null +++ b/devenv/shell.go @@ -0,0 +1,75 @@ +package devenv + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" +) + +// ShellOptions configures the shell connection. +type ShellOptions struct { + Console bool // Use serial console instead of SSH + Command []string // Command to run (empty = interactive shell) +} + +// Shell connects to the dev environment. +func (d *DevOps) Shell(ctx context.Context, opts ShellOptions) error { + running, err := d.IsRunning(ctx) + if err != nil { + return err + } + if !running { + return errors.New("dev environment not running (run 'core dev boot' first)") + } + + if opts.Console { + return d.serialConsole(ctx) + } + + return d.sshShell(ctx, opts.Command) +} + +// sshShell connects via SSH. +func (d *DevOps) sshShell(ctx context.Context, command []string) error { + args := []string{ + "-o", "StrictHostKeyChecking=yes", + "-o", "UserKnownHostsFile=~/.core/known_hosts", + "-o", "LogLevel=ERROR", + "-A", // Agent forwarding + "-p", fmt.Sprintf("%d", DefaultSSHPort), + "root@localhost", + } + + if len(command) > 0 { + args = append(args, command...) + } + + cmd := exec.CommandContext(ctx, "ssh", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// serialConsole attaches to the QEMU serial console. +func (d *DevOps) serialConsole(ctx context.Context) error { + // Find the container to get its console socket + c, err := d.findContainer(ctx, "core-dev") + if err != nil { + return err + } + if c == nil { + return errors.New("console not available: container not found") + } + + // Use socat to connect to the console socket + socketPath := fmt.Sprintf("/tmp/core-%s-console.sock", c.ID) + cmd := exec.CommandContext(ctx, "socat", "-,raw,echo=0", "unix-connect:"+socketPath) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/devenv/shell_test.go b/devenv/shell_test.go new file mode 100644 index 0000000..b9d57b7 --- /dev/null +++ b/devenv/shell_test.go @@ -0,0 +1,47 @@ +package devenv + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShellOptions_Default(t *testing.T) { + opts := ShellOptions{} + assert.False(t, opts.Console) + assert.Nil(t, opts.Command) +} + +func TestShellOptions_Console(t *testing.T) { + opts := ShellOptions{ + Console: true, + } + assert.True(t, opts.Console) + assert.Nil(t, opts.Command) +} + +func TestShellOptions_Command(t *testing.T) { + opts := ShellOptions{ + Command: []string{"ls", "-la"}, + } + assert.False(t, opts.Console) + assert.Equal(t, []string{"ls", "-la"}, opts.Command) +} + +func TestShellOptions_ConsoleWithCommand(t *testing.T) { + opts := ShellOptions{ + Console: true, + Command: []string{"echo", "hello"}, + } + assert.True(t, opts.Console) + assert.Equal(t, []string{"echo", "hello"}, opts.Command) +} + +func TestShellOptions_EmptyCommand(t *testing.T) { + opts := ShellOptions{ + Command: []string{}, + } + assert.False(t, opts.Console) + assert.Empty(t, opts.Command) + assert.Len(t, opts.Command, 0) +} diff --git a/devenv/ssh_utils.go b/devenv/ssh_utils.go new file mode 100644 index 0000000..1315c02 --- /dev/null +++ b/devenv/ssh_utils.go @@ -0,0 +1,69 @@ +package devenv + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// ensureHostKey ensures that the host key for the dev environment is in the known hosts file. +// This is used after boot to allow StrictHostKeyChecking=yes to work. +func ensureHostKey(ctx context.Context, port int) error { + // Skip if requested (used in tests) + if os.Getenv("CORE_SKIP_SSH_SCAN") == "true" { + return nil + } + + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("get home dir: %w", err) + } + + knownHostsPath := filepath.Join(home, ".core", "known_hosts") + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(knownHostsPath), 0755); err != nil { + return fmt.Errorf("create known_hosts dir: %w", err) + } + + // Get host key using ssh-keyscan + cmd := exec.CommandContext(ctx, "ssh-keyscan", "-p", fmt.Sprintf("%d", port), "localhost") + out, err := cmd.Output() + if err != nil { + return fmt.Errorf("ssh-keyscan failed: %w", err) + } + + if len(out) == 0 { + return errors.New("ssh-keyscan returned no keys") + } + + // Read existing known_hosts to avoid duplicates + existing, _ := os.ReadFile(knownHostsPath) + existingStr := string(existing) + + // Append new keys that aren't already there + f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("open known_hosts: %w", err) + } + defer f.Close() + + lines := strings.Split(string(out), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if !strings.Contains(existingStr, line) { + if _, err := f.WriteString(line + "\n"); err != nil { + return fmt.Errorf("write known_hosts: %w", err) + } + } + } + + return nil +} diff --git a/devenv/test.go b/devenv/test.go new file mode 100644 index 0000000..d7dcba2 --- /dev/null +++ b/devenv/test.go @@ -0,0 +1,189 @@ +package devenv + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "strings" + + "forge.lthn.ai/core/go-io" + "gopkg.in/yaml.v3" +) + +// TestConfig holds test configuration from .core/test.yaml. +type TestConfig struct { + Version int `yaml:"version"` + Command string `yaml:"command,omitempty"` + Commands []TestCommand `yaml:"commands,omitempty"` + Env map[string]string `yaml:"env,omitempty"` +} + +// TestCommand is a named test command. +type TestCommand struct { + Name string `yaml:"name"` + Run string `yaml:"run"` +} + +// TestOptions configures test execution. +type TestOptions struct { + Name string // Run specific named command from .core/test.yaml + Command []string // Override command (from -- args) +} + +// Test runs tests in the dev environment. +func (d *DevOps) Test(ctx context.Context, projectDir string, opts TestOptions) error { + running, err := d.IsRunning(ctx) + if err != nil { + return err + } + if !running { + return errors.New("dev environment not running (run 'core dev boot' first)") + } + + var cmd string + + // Priority: explicit command > named command > auto-detect + if len(opts.Command) > 0 { + cmd = strings.Join(opts.Command, " ") + } else if opts.Name != "" { + cfg, err := LoadTestConfig(d.medium, projectDir) + if err != nil { + return err + } + for _, c := range cfg.Commands { + if c.Name == opts.Name { + cmd = c.Run + break + } + } + if cmd == "" { + return fmt.Errorf("test command %q not found in .core/test.yaml", opts.Name) + } + } else { + cmd = DetectTestCommand(d.medium, projectDir) + if cmd == "" { + return errors.New("could not detect test command (create .core/test.yaml)") + } + } + + // Run via SSH - construct command as single string for shell execution + return d.sshShell(ctx, []string{"cd", "/app", "&&", cmd}) +} + +// DetectTestCommand auto-detects the test command for a project. +func DetectTestCommand(m io.Medium, projectDir string) string { + // 1. Check .core/test.yaml + cfg, err := LoadTestConfig(m, projectDir) + if err == nil && cfg.Command != "" { + return cfg.Command + } + + // 2. Check composer.json for test script + if hasFile(m, projectDir, "composer.json") { + if hasComposerScript(m, projectDir, "test") { + return "composer test" + } + } + + // 3. Check package.json for test script + if hasFile(m, projectDir, "package.json") { + if hasPackageScript(m, projectDir, "test") { + return "npm test" + } + } + + // 4. Check go.mod + if hasFile(m, projectDir, "go.mod") { + return "go test ./..." + } + + // 5. Check pytest + if hasFile(m, projectDir, "pytest.ini") || hasFile(m, projectDir, "pyproject.toml") { + return "pytest" + } + + // 6. Check Taskfile + if hasFile(m, projectDir, "Taskfile.yaml") || hasFile(m, projectDir, "Taskfile.yml") { + return "task test" + } + + return "" +} + +// LoadTestConfig loads .core/test.yaml. +func LoadTestConfig(m io.Medium, projectDir string) (*TestConfig, error) { + path := filepath.Join(projectDir, ".core", "test.yaml") + absPath, err := filepath.Abs(path) + if err != nil { + return nil, err + } + + content, err := m.Read(absPath) + if err != nil { + return nil, err + } + + var cfg TestConfig + if err := yaml.Unmarshal([]byte(content), &cfg); err != nil { + return nil, err + } + + return &cfg, nil +} + +func hasFile(m io.Medium, dir, name string) bool { + path := filepath.Join(dir, name) + absPath, err := filepath.Abs(path) + if err != nil { + return false + } + return m.IsFile(absPath) +} + +func hasPackageScript(m io.Medium, projectDir, script string) bool { + path := filepath.Join(projectDir, "package.json") + absPath, err := filepath.Abs(path) + if err != nil { + return false + } + + content, err := m.Read(absPath) + if err != nil { + return false + } + + var pkg struct { + Scripts map[string]string `json:"scripts"` + } + if err := json.Unmarshal([]byte(content), &pkg); err != nil { + return false + } + + _, ok := pkg.Scripts[script] + return ok +} + +func hasComposerScript(m io.Medium, projectDir, script string) bool { + path := filepath.Join(projectDir, "composer.json") + absPath, err := filepath.Abs(path) + if err != nil { + return false + } + + content, err := m.Read(absPath) + if err != nil { + return false + } + + var pkg struct { + Scripts map[string]any `json:"scripts"` + } + if err := json.Unmarshal([]byte(content), &pkg); err != nil { + return false + } + + _, ok := pkg.Scripts[script] + return ok +} diff --git a/devenv/test_test.go b/devenv/test_test.go new file mode 100644 index 0000000..fd1e23a --- /dev/null +++ b/devenv/test_test.go @@ -0,0 +1,354 @@ +package devenv + +import ( + "os" + "path/filepath" + "testing" + + "forge.lthn.ai/core/go-io" +) + +func TestDetectTestCommand_Good_ComposerJSON(t *testing.T) { + tmpDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest"}}`), 0644) + + cmd := DetectTestCommand(io.Local, tmpDir) + if cmd != "composer test" { + t.Errorf("expected 'composer test', got %q", cmd) + } +} + +func TestDetectTestCommand_Good_PackageJSON(t *testing.T) { + tmpDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"vitest"}}`), 0644) + + cmd := DetectTestCommand(io.Local, tmpDir) + if cmd != "npm test" { + t.Errorf("expected 'npm test', got %q", cmd) + } +} + +func TestDetectTestCommand_Good_GoMod(t *testing.T) { + tmpDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) + + cmd := DetectTestCommand(io.Local, tmpDir) + if cmd != "go test ./..." { + t.Errorf("expected 'go test ./...', got %q", cmd) + } +} + +func TestDetectTestCommand_Good_CoreTestYaml(t *testing.T) { + tmpDir := t.TempDir() + coreDir := filepath.Join(tmpDir, ".core") + _ = os.MkdirAll(coreDir, 0755) + _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: custom-test"), 0644) + + cmd := DetectTestCommand(io.Local, tmpDir) + if cmd != "custom-test" { + t.Errorf("expected 'custom-test', got %q", cmd) + } +} + +func TestDetectTestCommand_Good_Pytest(t *testing.T) { + tmpDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tmpDir, "pytest.ini"), []byte("[pytest]"), 0644) + + cmd := DetectTestCommand(io.Local, tmpDir) + if cmd != "pytest" { + t.Errorf("expected 'pytest', got %q", cmd) + } +} + +func TestDetectTestCommand_Good_Taskfile(t *testing.T) { + tmpDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tmpDir, "Taskfile.yaml"), []byte("version: '3'"), 0644) + + cmd := DetectTestCommand(io.Local, tmpDir) + if cmd != "task test" { + t.Errorf("expected 'task test', got %q", cmd) + } +} + +func TestDetectTestCommand_Bad_NoFiles(t *testing.T) { + tmpDir := t.TempDir() + + cmd := DetectTestCommand(io.Local, tmpDir) + if cmd != "" { + t.Errorf("expected empty string, got %q", cmd) + } +} + +func TestDetectTestCommand_Good_Priority(t *testing.T) { + // .core/test.yaml should take priority over other detection methods + tmpDir := t.TempDir() + coreDir := filepath.Join(tmpDir, ".core") + _ = os.MkdirAll(coreDir, 0755) + _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: my-custom-test"), 0644) + _ = os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) + + cmd := DetectTestCommand(io.Local, tmpDir) + if cmd != "my-custom-test" { + t.Errorf("expected 'my-custom-test' (from .core/test.yaml), got %q", cmd) + } +} + +func TestLoadTestConfig_Good(t *testing.T) { + tmpDir := t.TempDir() + coreDir := filepath.Join(tmpDir, ".core") + _ = os.MkdirAll(coreDir, 0755) + + configYAML := `version: 1 +command: default-test +commands: + - name: unit + run: go test ./... + - name: integration + run: go test -tags=integration ./... +env: + CI: "true" +` + _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte(configYAML), 0644) + + cfg, err := LoadTestConfig(io.Local, tmpDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.Version != 1 { + t.Errorf("expected version 1, got %d", cfg.Version) + } + if cfg.Command != "default-test" { + t.Errorf("expected command 'default-test', got %q", cfg.Command) + } + if len(cfg.Commands) != 2 { + t.Errorf("expected 2 commands, got %d", len(cfg.Commands)) + } + if cfg.Commands[0].Name != "unit" { + t.Errorf("expected first command name 'unit', got %q", cfg.Commands[0].Name) + } + if cfg.Env["CI"] != "true" { + t.Errorf("expected env CI='true', got %q", cfg.Env["CI"]) + } +} + +func TestLoadTestConfig_Bad_NotFound(t *testing.T) { + tmpDir := t.TempDir() + + _, err := LoadTestConfig(io.Local, tmpDir) + if err == nil { + t.Error("expected error for missing config, got nil") + } +} + +func TestHasPackageScript_Good(t *testing.T) { + tmpDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"jest","build":"webpack"}}`), 0644) + + if !hasPackageScript(io.Local, tmpDir, "test") { + t.Error("expected to find 'test' script") + } + if !hasPackageScript(io.Local, tmpDir, "build") { + t.Error("expected to find 'build' script") + } +} + +func TestHasPackageScript_Bad_MissingScript(t *testing.T) { + tmpDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"build":"webpack"}}`), 0644) + + if hasPackageScript(io.Local, tmpDir, "test") { + t.Error("expected not to find 'test' script") + } +} + +func TestHasComposerScript_Good(t *testing.T) { + tmpDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest","post-install-cmd":"@php artisan migrate"}}`), 0644) + + if !hasComposerScript(io.Local, tmpDir, "test") { + t.Error("expected to find 'test' script") + } +} + +func TestHasComposerScript_Bad_MissingScript(t *testing.T) { + tmpDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"build":"@php build.php"}}`), 0644) + + if hasComposerScript(io.Local, tmpDir, "test") { + t.Error("expected not to find 'test' script") + } +} + +func TestTestConfig_Struct(t *testing.T) { + cfg := &TestConfig{ + Version: 2, + Command: "my-test", + Commands: []TestCommand{{Name: "unit", Run: "go test ./..."}}, + Env: map[string]string{"CI": "true"}, + } + if cfg.Version != 2 { + t.Errorf("expected version 2, got %d", cfg.Version) + } + if cfg.Command != "my-test" { + t.Errorf("expected command 'my-test', got %q", cfg.Command) + } + if len(cfg.Commands) != 1 { + t.Errorf("expected 1 command, got %d", len(cfg.Commands)) + } + if cfg.Env["CI"] != "true" { + t.Errorf("expected CI=true, got %q", cfg.Env["CI"]) + } +} + +func TestTestCommand_Struct(t *testing.T) { + cmd := TestCommand{ + Name: "integration", + Run: "go test -tags=integration ./...", + } + if cmd.Name != "integration" { + t.Errorf("expected name 'integration', got %q", cmd.Name) + } + if cmd.Run != "go test -tags=integration ./..." { + t.Errorf("expected run command, got %q", cmd.Run) + } +} + +func TestTestOptions_Struct(t *testing.T) { + opts := TestOptions{ + Name: "unit", + Command: []string{"go", "test", "-v"}, + } + if opts.Name != "unit" { + t.Errorf("expected name 'unit', got %q", opts.Name) + } + if len(opts.Command) != 3 { + t.Errorf("expected 3 command parts, got %d", len(opts.Command)) + } +} + +func TestDetectTestCommand_Good_TaskfileYml(t *testing.T) { + tmpDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tmpDir, "Taskfile.yml"), []byte("version: '3'"), 0644) + + cmd := DetectTestCommand(io.Local, tmpDir) + if cmd != "task test" { + t.Errorf("expected 'task test', got %q", cmd) + } +} + +func TestDetectTestCommand_Good_Pyproject(t *testing.T) { + tmpDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tmpDir, "pyproject.toml"), []byte("[tool.pytest]"), 0644) + + cmd := DetectTestCommand(io.Local, tmpDir) + if cmd != "pytest" { + t.Errorf("expected 'pytest', got %q", cmd) + } +} + +func TestHasPackageScript_Bad_NoFile(t *testing.T) { + tmpDir := t.TempDir() + + if hasPackageScript(io.Local, tmpDir, "test") { + t.Error("expected false for missing package.json") + } +} + +func TestHasPackageScript_Bad_InvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`invalid json`), 0644) + + if hasPackageScript(io.Local, tmpDir, "test") { + t.Error("expected false for invalid JSON") + } +} + +func TestHasPackageScript_Bad_NoScripts(t *testing.T) { + tmpDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0644) + + if hasPackageScript(io.Local, tmpDir, "test") { + t.Error("expected false for missing scripts section") + } +} + +func TestHasComposerScript_Bad_NoFile(t *testing.T) { + tmpDir := t.TempDir() + + if hasComposerScript(io.Local, tmpDir, "test") { + t.Error("expected false for missing composer.json") + } +} + +func TestHasComposerScript_Bad_InvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`invalid json`), 0644) + + if hasComposerScript(io.Local, tmpDir, "test") { + t.Error("expected false for invalid JSON") + } +} + +func TestHasComposerScript_Bad_NoScripts(t *testing.T) { + tmpDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"name":"test/pkg"}`), 0644) + + if hasComposerScript(io.Local, tmpDir, "test") { + t.Error("expected false for missing scripts section") + } +} + +func TestLoadTestConfig_Bad_InvalidYAML(t *testing.T) { + tmpDir := t.TempDir() + coreDir := filepath.Join(tmpDir, ".core") + _ = os.MkdirAll(coreDir, 0755) + _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("invalid: yaml: :"), 0644) + + _, err := LoadTestConfig(io.Local, tmpDir) + if err == nil { + t.Error("expected error for invalid YAML") + } +} + +func TestLoadTestConfig_Good_MinimalConfig(t *testing.T) { + tmpDir := t.TempDir() + coreDir := filepath.Join(tmpDir, ".core") + _ = os.MkdirAll(coreDir, 0755) + _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("version: 1"), 0644) + + cfg, err := LoadTestConfig(io.Local, tmpDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Version != 1 { + t.Errorf("expected version 1, got %d", cfg.Version) + } + if cfg.Command != "" { + t.Errorf("expected empty command, got %q", cfg.Command) + } +} + +func TestDetectTestCommand_Good_ComposerWithoutScript(t *testing.T) { + tmpDir := t.TempDir() + // composer.json without test script should not return composer test + _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"name":"test/pkg"}`), 0644) + + cmd := DetectTestCommand(io.Local, tmpDir) + // Falls through to empty (no match) + if cmd != "" { + t.Errorf("expected empty string, got %q", cmd) + } +} + +func TestDetectTestCommand_Good_PackageJSONWithoutScript(t *testing.T) { + tmpDir := t.TempDir() + // package.json without test or dev script + _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0644) + + cmd := DetectTestCommand(io.Local, tmpDir) + // Falls through to empty + if cmd != "" { + t.Errorf("expected empty string, got %q", cmd) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..892b5a1 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module forge.lthn.ai/core/go-container + +go 1.26.0 + +require ( + forge.lthn.ai/core/go-config v0.1.0 + forge.lthn.ai/core/go-io v0.0.3 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/hypervisor.go b/hypervisor.go new file mode 100644 index 0000000..4c23dac --- /dev/null +++ b/hypervisor.go @@ -0,0 +1,274 @@ +package container + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// Hypervisor defines the interface for VM hypervisors. +type Hypervisor interface { + // Name returns the name of the hypervisor. + Name() string + // Available checks if the hypervisor is available on the system. + Available() bool + // BuildCommand builds the command to run a VM with the given options. + BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) +} + +// HypervisorOptions contains options for running a VM. +type HypervisorOptions struct { + // Memory in MB. + Memory int + // CPUs count. + CPUs int + // LogFile path for output. + LogFile string + // SSHPort for SSH access. + SSHPort int + // Ports maps host ports to guest ports. + Ports map[int]int + // Volumes maps host paths to guest paths (9p shares). + Volumes map[string]string + // Detach runs in background (nographic mode). + Detach bool +} + +// QemuHypervisor implements Hypervisor for QEMU. +type QemuHypervisor struct { + // Binary is the path to the qemu binary (defaults to qemu-system-x86_64). + Binary string +} + +// NewQemuHypervisor creates a new QEMU hypervisor instance. +func NewQemuHypervisor() *QemuHypervisor { + return &QemuHypervisor{ + Binary: "qemu-system-x86_64", + } +} + +// Name returns the hypervisor name. +func (q *QemuHypervisor) Name() string { + return "qemu" +} + +// Available checks if QEMU is installed and accessible. +func (q *QemuHypervisor) Available() bool { + _, err := exec.LookPath(q.Binary) + return err == nil +} + +// BuildCommand creates the QEMU command for running a VM. +func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) { + format := DetectImageFormat(image) + if format == FormatUnknown { + return nil, fmt.Errorf("unknown image format: %s", image) + } + + args := []string{ + "-m", fmt.Sprintf("%d", opts.Memory), + "-smp", fmt.Sprintf("%d", opts.CPUs), + "-enable-kvm", + } + + // Add the image based on format + switch format { + case FormatISO: + args = append(args, "-cdrom", image) + args = append(args, "-boot", "d") + case FormatQCOW2: + args = append(args, "-drive", fmt.Sprintf("file=%s,format=qcow2", image)) + case FormatVMDK: + args = append(args, "-drive", fmt.Sprintf("file=%s,format=vmdk", image)) + case FormatRaw: + args = append(args, "-drive", fmt.Sprintf("file=%s,format=raw", image)) + } + + // Always run in nographic mode for container-like behavior + args = append(args, "-nographic") + + // Add serial console for log output + args = append(args, "-serial", "stdio") + + // Network with port forwarding + netdev := "user,id=net0" + if opts.SSHPort > 0 { + netdev += fmt.Sprintf(",hostfwd=tcp::%d-:22", opts.SSHPort) + } + for hostPort, guestPort := range opts.Ports { + netdev += fmt.Sprintf(",hostfwd=tcp::%d-:%d", hostPort, guestPort) + } + args = append(args, "-netdev", netdev) + args = append(args, "-device", "virtio-net-pci,netdev=net0") + + // Add 9p shares for volumes + shareID := 0 + for hostPath, guestPath := range opts.Volumes { + tag := fmt.Sprintf("share%d", shareID) + args = append(args, + "-fsdev", fmt.Sprintf("local,id=%s,path=%s,security_model=none", tag, hostPath), + "-device", fmt.Sprintf("virtio-9p-pci,fsdev=%s,mount_tag=%s", tag, filepath.Base(guestPath)), + ) + shareID++ + } + + // Check if KVM is available on Linux, remove -enable-kvm if not + if runtime.GOOS != "linux" || !isKVMAvailable() { + // Remove -enable-kvm from args + newArgs := make([]string, 0, len(args)) + for _, arg := range args { + if arg != "-enable-kvm" { + newArgs = append(newArgs, arg) + } + } + args = newArgs + + // On macOS, use HVF acceleration if available + if runtime.GOOS == "darwin" { + args = append(args, "-accel", "hvf") + } + } + + cmd := exec.CommandContext(ctx, q.Binary, args...) + return cmd, nil +} + +// isKVMAvailable checks if KVM is available on the system. +func isKVMAvailable() bool { + _, err := os.Stat("/dev/kvm") + return err == nil +} + +// HyperkitHypervisor implements Hypervisor for macOS Hyperkit. +type HyperkitHypervisor struct { + // Binary is the path to the hyperkit binary. + Binary string +} + +// NewHyperkitHypervisor creates a new Hyperkit hypervisor instance. +func NewHyperkitHypervisor() *HyperkitHypervisor { + return &HyperkitHypervisor{ + Binary: "hyperkit", + } +} + +// Name returns the hypervisor name. +func (h *HyperkitHypervisor) Name() string { + return "hyperkit" +} + +// Available checks if Hyperkit is installed and accessible. +func (h *HyperkitHypervisor) Available() bool { + if runtime.GOOS != "darwin" { + return false + } + _, err := exec.LookPath(h.Binary) + return err == nil +} + +// BuildCommand creates the Hyperkit command for running a VM. +func (h *HyperkitHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) { + format := DetectImageFormat(image) + if format == FormatUnknown { + return nil, fmt.Errorf("unknown image format: %s", image) + } + + args := []string{ + "-m", fmt.Sprintf("%dM", opts.Memory), + "-c", fmt.Sprintf("%d", opts.CPUs), + "-A", // ACPI + "-u", // Unlimited console output + "-s", "0:0,hostbridge", + "-s", "31,lpc", + "-l", "com1,stdio", // Serial console + } + + // Add PCI slot for disk (slot 2) + switch format { + case FormatISO: + args = append(args, "-s", fmt.Sprintf("2:0,ahci-cd,%s", image)) + case FormatQCOW2, FormatVMDK, FormatRaw: + args = append(args, "-s", fmt.Sprintf("2:0,virtio-blk,%s", image)) + } + + // Network with port forwarding (slot 3) + netArgs := "virtio-net" + if opts.SSHPort > 0 || len(opts.Ports) > 0 { + // Hyperkit uses slirp for user networking with port forwarding + portForwards := make([]string, 0) + if opts.SSHPort > 0 { + portForwards = append(portForwards, fmt.Sprintf("tcp:%d:22", opts.SSHPort)) + } + for hostPort, guestPort := range opts.Ports { + portForwards = append(portForwards, fmt.Sprintf("tcp:%d:%d", hostPort, guestPort)) + } + if len(portForwards) > 0 { + netArgs += "," + strings.Join(portForwards, ",") + } + } + args = append(args, "-s", "3:0,"+netArgs) + + cmd := exec.CommandContext(ctx, h.Binary, args...) + return cmd, nil +} + +// DetectImageFormat determines the image format from its file extension. +func DetectImageFormat(path string) ImageFormat { + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".iso": + return FormatISO + case ".qcow2": + return FormatQCOW2 + case ".vmdk": + return FormatVMDK + case ".raw", ".img": + return FormatRaw + default: + return FormatUnknown + } +} + +// DetectHypervisor returns the best available hypervisor for the current platform. +func DetectHypervisor() (Hypervisor, error) { + // On macOS, prefer Hyperkit if available, fall back to QEMU + if runtime.GOOS == "darwin" { + hk := NewHyperkitHypervisor() + if hk.Available() { + return hk, nil + } + } + + // Try QEMU on all platforms + qemu := NewQemuHypervisor() + if qemu.Available() { + return qemu, nil + } + + return nil, errors.New("no hypervisor available: install qemu or hyperkit (macOS)") +} + +// GetHypervisor returns a specific hypervisor by name. +func GetHypervisor(name string) (Hypervisor, error) { + switch strings.ToLower(name) { + case "qemu": + h := NewQemuHypervisor() + if !h.Available() { + return nil, errors.New("qemu is not available") + } + return h, nil + case "hyperkit": + h := NewHyperkitHypervisor() + if !h.Available() { + return nil, errors.New("hyperkit is not available (requires macOS)") + } + return h, nil + default: + return nil, fmt.Errorf("unknown hypervisor: %s", name) + } +} diff --git a/hypervisor_test.go b/hypervisor_test.go new file mode 100644 index 0000000..e5c9964 --- /dev/null +++ b/hypervisor_test.go @@ -0,0 +1,358 @@ +package container + +import ( + "context" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQemuHypervisor_Available_Good(t *testing.T) { + q := NewQemuHypervisor() + + // Check if qemu is available on this system + available := q.Available() + + // We just verify it returns a boolean without error + // The actual availability depends on the system + assert.IsType(t, true, available) +} + +func TestQemuHypervisor_Available_Bad_InvalidBinary(t *testing.T) { + q := &QemuHypervisor{ + Binary: "nonexistent-qemu-binary-that-does-not-exist", + } + + available := q.Available() + + assert.False(t, available) +} + +func TestHyperkitHypervisor_Available_Good(t *testing.T) { + h := NewHyperkitHypervisor() + + available := h.Available() + + // On non-darwin systems, should always be false + if runtime.GOOS != "darwin" { + assert.False(t, available) + } else { + // On darwin, just verify it returns a boolean + assert.IsType(t, true, available) + } +} + +func TestHyperkitHypervisor_Available_Bad_NotDarwin(t *testing.T) { + if runtime.GOOS == "darwin" { + t.Skip("This test only runs on non-darwin systems") + } + + h := NewHyperkitHypervisor() + + available := h.Available() + + assert.False(t, available, "Hyperkit should not be available on non-darwin systems") +} + +func TestHyperkitHypervisor_Available_Bad_InvalidBinary(t *testing.T) { + h := &HyperkitHypervisor{ + Binary: "nonexistent-hyperkit-binary-that-does-not-exist", + } + + available := h.Available() + + assert.False(t, available) +} + +func TestIsKVMAvailable_Good(t *testing.T) { + // This test verifies the function runs without error + // The actual result depends on the system + result := isKVMAvailable() + + // On non-linux systems, should be false + if runtime.GOOS != "linux" { + assert.False(t, result, "KVM should not be available on non-linux systems") + } else { + // On linux, just verify it returns a boolean + assert.IsType(t, true, result) + } +} + +func TestDetectHypervisor_Good(t *testing.T) { + // DetectHypervisor tries to find an available hypervisor + hv, err := DetectHypervisor() + + // This test may pass or fail depending on system configuration + // If no hypervisor is available, it should return an error + if err != nil { + assert.Nil(t, hv) + assert.Contains(t, err.Error(), "no hypervisor available") + } else { + assert.NotNil(t, hv) + assert.NotEmpty(t, hv.Name()) + } +} + +func TestGetHypervisor_Good_Qemu(t *testing.T) { + hv, err := GetHypervisor("qemu") + + // Depends on whether qemu is installed + if err != nil { + assert.Contains(t, err.Error(), "not available") + } else { + assert.NotNil(t, hv) + assert.Equal(t, "qemu", hv.Name()) + } +} + +func TestGetHypervisor_Good_QemuUppercase(t *testing.T) { + hv, err := GetHypervisor("QEMU") + + // Depends on whether qemu is installed + if err != nil { + assert.Contains(t, err.Error(), "not available") + } else { + assert.NotNil(t, hv) + assert.Equal(t, "qemu", hv.Name()) + } +} + +func TestGetHypervisor_Good_Hyperkit(t *testing.T) { + hv, err := GetHypervisor("hyperkit") + + // On non-darwin systems, should always fail + if runtime.GOOS != "darwin" { + assert.Error(t, err) + assert.Contains(t, err.Error(), "not available") + } else { + // On darwin, depends on whether hyperkit is installed + if err != nil { + assert.Contains(t, err.Error(), "not available") + } else { + assert.NotNil(t, hv) + assert.Equal(t, "hyperkit", hv.Name()) + } + } +} + +func TestGetHypervisor_Bad_Unknown(t *testing.T) { + _, err := GetHypervisor("unknown-hypervisor") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown hypervisor") +} + +func TestQemuHypervisor_BuildCommand_Good_WithPortsAndVolumes(t *testing.T) { + q := NewQemuHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{ + Memory: 2048, + CPUs: 4, + SSHPort: 2222, + Ports: map[int]int{8080: 80, 443: 443}, + Volumes: map[string]string{ + "/host/data": "/container/data", + "/host/logs": "/container/logs", + }, + Detach: true, + } + + cmd, err := q.BuildCommand(ctx, "/path/to/image.iso", opts) + require.NoError(t, err) + assert.NotNil(t, cmd) + + // Verify command includes all expected args + args := cmd.Args + assert.Contains(t, args, "-m") + assert.Contains(t, args, "2048") + assert.Contains(t, args, "-smp") + assert.Contains(t, args, "4") +} + +func TestQemuHypervisor_BuildCommand_Good_QCow2Format(t *testing.T) { + q := NewQemuHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{Memory: 1024, CPUs: 1} + + cmd, err := q.BuildCommand(ctx, "/path/to/image.qcow2", opts) + require.NoError(t, err) + + // Check that the drive format is qcow2 + found := false + for _, arg := range cmd.Args { + if arg == "file=/path/to/image.qcow2,format=qcow2" { + found = true + break + } + } + assert.True(t, found, "Should have qcow2 drive argument") +} + +func TestQemuHypervisor_BuildCommand_Good_VMDKFormat(t *testing.T) { + q := NewQemuHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{Memory: 1024, CPUs: 1} + + cmd, err := q.BuildCommand(ctx, "/path/to/image.vmdk", opts) + require.NoError(t, err) + + // Check that the drive format is vmdk + found := false + for _, arg := range cmd.Args { + if arg == "file=/path/to/image.vmdk,format=vmdk" { + found = true + break + } + } + assert.True(t, found, "Should have vmdk drive argument") +} + +func TestQemuHypervisor_BuildCommand_Good_RawFormat(t *testing.T) { + q := NewQemuHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{Memory: 1024, CPUs: 1} + + cmd, err := q.BuildCommand(ctx, "/path/to/image.raw", opts) + require.NoError(t, err) + + // Check that the drive format is raw + found := false + for _, arg := range cmd.Args { + if arg == "file=/path/to/image.raw,format=raw" { + found = true + break + } + } + assert.True(t, found, "Should have raw drive argument") +} + +func TestHyperkitHypervisor_BuildCommand_Good_WithPorts(t *testing.T) { + h := NewHyperkitHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{ + Memory: 1024, + CPUs: 2, + SSHPort: 2222, + Ports: map[int]int{8080: 80}, + } + + cmd, err := h.BuildCommand(ctx, "/path/to/image.iso", opts) + require.NoError(t, err) + assert.NotNil(t, cmd) + + // Verify it creates a command with memory and CPU args + args := cmd.Args + assert.Contains(t, args, "-m") + assert.Contains(t, args, "1024M") + assert.Contains(t, args, "-c") + assert.Contains(t, args, "2") +} + +func TestHyperkitHypervisor_BuildCommand_Good_QCow2Format(t *testing.T) { + h := NewHyperkitHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{Memory: 1024, CPUs: 1} + + cmd, err := h.BuildCommand(ctx, "/path/to/image.qcow2", opts) + require.NoError(t, err) + assert.NotNil(t, cmd) +} + +func TestHyperkitHypervisor_BuildCommand_Good_RawFormat(t *testing.T) { + h := NewHyperkitHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{Memory: 1024, CPUs: 1} + + cmd, err := h.BuildCommand(ctx, "/path/to/image.raw", opts) + require.NoError(t, err) + assert.NotNil(t, cmd) +} + +func TestHyperkitHypervisor_BuildCommand_Good_NoPorts(t *testing.T) { + h := NewHyperkitHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{ + Memory: 512, + CPUs: 1, + SSHPort: 0, // No SSH port + Ports: nil, + } + + cmd, err := h.BuildCommand(ctx, "/path/to/image.iso", opts) + require.NoError(t, err) + assert.NotNil(t, cmd) +} + +func TestQemuHypervisor_BuildCommand_Good_NoSSHPort(t *testing.T) { + q := NewQemuHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{ + Memory: 512, + CPUs: 1, + SSHPort: 0, // No SSH port + Ports: nil, + } + + cmd, err := q.BuildCommand(ctx, "/path/to/image.iso", opts) + require.NoError(t, err) + assert.NotNil(t, cmd) +} + +func TestQemuHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) { + q := NewQemuHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{Memory: 1024, CPUs: 1} + + _, err := q.BuildCommand(ctx, "/path/to/image.txt", opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown image format") +} + +func TestHyperkitHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) { + h := NewHyperkitHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{Memory: 1024, CPUs: 1} + + _, err := h.BuildCommand(ctx, "/path/to/image.unknown", opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown image format") +} + +func TestHyperkitHypervisor_Name_Good(t *testing.T) { + h := NewHyperkitHypervisor() + assert.Equal(t, "hyperkit", h.Name()) +} + +func TestHyperkitHypervisor_BuildCommand_Good_ISOFormat(t *testing.T) { + h := NewHyperkitHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{ + Memory: 1024, + CPUs: 2, + SSHPort: 2222, + } + + cmd, err := h.BuildCommand(ctx, "/path/to/image.iso", opts) + require.NoError(t, err) + assert.NotNil(t, cmd) + + args := cmd.Args + assert.Contains(t, args, "-m") + assert.Contains(t, args, "1024M") + assert.Contains(t, args, "-c") + assert.Contains(t, args, "2") +} diff --git a/linuxkit.go b/linuxkit.go new file mode 100644 index 0000000..88ce430 --- /dev/null +++ b/linuxkit.go @@ -0,0 +1,462 @@ +package container + +import ( + "bufio" + "context" + "fmt" + goio "io" + "os" + "os/exec" + "syscall" + "time" + + "forge.lthn.ai/core/go-io" +) + +// LinuxKitManager implements the Manager interface for LinuxKit VMs. +type LinuxKitManager struct { + state *State + hypervisor Hypervisor + medium io.Medium +} + +// NewLinuxKitManager creates a new LinuxKit manager with auto-detected hypervisor. +func NewLinuxKitManager(m io.Medium) (*LinuxKitManager, error) { + statePath, err := DefaultStatePath() + if err != nil { + return nil, fmt.Errorf("failed to determine state path: %w", err) + } + + state, err := LoadState(statePath) + if err != nil { + return nil, fmt.Errorf("failed to load state: %w", err) + } + + hypervisor, err := DetectHypervisor() + if err != nil { + return nil, err + } + + return &LinuxKitManager{ + state: state, + hypervisor: hypervisor, + medium: m, + }, nil +} + +// NewLinuxKitManagerWithHypervisor creates a manager with a specific hypervisor. +func NewLinuxKitManagerWithHypervisor(m io.Medium, state *State, hypervisor Hypervisor) *LinuxKitManager { + return &LinuxKitManager{ + state: state, + hypervisor: hypervisor, + medium: m, + } +} + +// Run starts a new LinuxKit VM from the given image. +func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions) (*Container, error) { + // Validate image exists + if !m.medium.IsFile(image) { + return nil, fmt.Errorf("image not found: %s", image) + } + + // Detect image format + format := DetectImageFormat(image) + if format == FormatUnknown { + return nil, fmt.Errorf("unsupported image format: %s", image) + } + + // Generate container ID + id, err := GenerateID() + if err != nil { + return nil, fmt.Errorf("failed to generate container ID: %w", err) + } + + // Apply defaults + if opts.Memory <= 0 { + opts.Memory = 1024 + } + if opts.CPUs <= 0 { + opts.CPUs = 1 + } + if opts.SSHPort <= 0 { + opts.SSHPort = 2222 + } + + // Use name or generate from ID + name := opts.Name + if name == "" { + name = id[:8] + } + + // Ensure logs directory exists + if err := EnsureLogsDir(); err != nil { + return nil, fmt.Errorf("failed to create logs directory: %w", err) + } + + // Get log file path + logPath, err := LogPath(id) + if err != nil { + return nil, fmt.Errorf("failed to determine log path: %w", err) + } + + // Build hypervisor options + hvOpts := &HypervisorOptions{ + Memory: opts.Memory, + CPUs: opts.CPUs, + LogFile: logPath, + SSHPort: opts.SSHPort, + Ports: opts.Ports, + Volumes: opts.Volumes, + Detach: opts.Detach, + } + + // Build the command + cmd, err := m.hypervisor.BuildCommand(ctx, image, hvOpts) + if err != nil { + return nil, fmt.Errorf("failed to build hypervisor command: %w", err) + } + + // Create log file + logFile, err := os.Create(logPath) + if err != nil { + return nil, fmt.Errorf("failed to create log file: %w", err) + } + + // Create container record + container := &Container{ + ID: id, + Name: name, + Image: image, + Status: StatusRunning, + StartedAt: time.Now(), + Ports: opts.Ports, + Memory: opts.Memory, + CPUs: opts.CPUs, + } + + if opts.Detach { + // Run in background + cmd.Stdout = logFile + cmd.Stderr = logFile + + // Start the process + if err := cmd.Start(); err != nil { + _ = logFile.Close() + return nil, fmt.Errorf("failed to start VM: %w", err) + } + + container.PID = cmd.Process.Pid + + // Save state + if err := m.state.Add(container); err != nil { + // Try to kill the process we just started + _ = cmd.Process.Kill() + _ = logFile.Close() + return nil, fmt.Errorf("failed to save state: %w", err) + } + + // Close log file handle (process has its own) + _ = logFile.Close() + + // Start a goroutine to wait for process exit and update state + go m.waitForExit(container.ID, cmd) + + return container, nil + } + + // Run in foreground + // Tee output to both log file and stdout + stdout, err := cmd.StdoutPipe() + if err != nil { + _ = logFile.Close() + return nil, fmt.Errorf("failed to get stdout pipe: %w", err) + } + + stderr, err := cmd.StderrPipe() + if err != nil { + _ = logFile.Close() + return nil, fmt.Errorf("failed to get stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + _ = logFile.Close() + return nil, fmt.Errorf("failed to start VM: %w", err) + } + + container.PID = cmd.Process.Pid + + // Save state before waiting + if err := m.state.Add(container); err != nil { + _ = cmd.Process.Kill() + _ = logFile.Close() + return nil, fmt.Errorf("failed to save state: %w", err) + } + + // Copy output to both log and stdout + go func() { + mw := goio.MultiWriter(logFile, os.Stdout) + _, _ = goio.Copy(mw, stdout) + }() + go func() { + mw := goio.MultiWriter(logFile, os.Stderr) + _, _ = goio.Copy(mw, stderr) + }() + + // Wait for the process to complete + if err := cmd.Wait(); err != nil { + container.Status = StatusError + } else { + container.Status = StatusStopped + } + + _ = logFile.Close() + if err := m.state.Update(container); err != nil { + return container, fmt.Errorf("update container state: %w", err) + } + + return container, nil +} + +// waitForExit monitors a detached process and updates state when it exits. +func (m *LinuxKitManager) waitForExit(id string, cmd *exec.Cmd) { + err := cmd.Wait() + + container, ok := m.state.Get(id) + if ok { + if err != nil { + container.Status = StatusError + } else { + container.Status = StatusStopped + } + _ = m.state.Update(container) + } +} + +// Stop stops a running container by sending SIGTERM. +func (m *LinuxKitManager) Stop(ctx context.Context, id string) error { + if err := ctx.Err(); err != nil { + return err + } + container, ok := m.state.Get(id) + if !ok { + return fmt.Errorf("container not found: %s", id) + } + + if container.Status != StatusRunning { + return fmt.Errorf("container is not running: %s", id) + } + + // Find the process + process, err := os.FindProcess(container.PID) + if err != nil { + // Process doesn't exist, update state + container.Status = StatusStopped + _ = m.state.Update(container) + return nil + } + + // Send SIGTERM + if err := process.Signal(syscall.SIGTERM); err != nil { + // Process might already be gone + container.Status = StatusStopped + _ = m.state.Update(container) + return nil + } + + // Honour already-cancelled contexts before waiting + if err := ctx.Err(); err != nil { + _ = process.Signal(syscall.SIGKILL) + return err + } + + // Wait for graceful shutdown with timeout + done := make(chan struct{}) + go func() { + _, _ = process.Wait() + close(done) + }() + + select { + case <-done: + // Process exited gracefully + case <-time.After(10 * time.Second): + // Force kill + _ = process.Signal(syscall.SIGKILL) + <-done + case <-ctx.Done(): + // Context cancelled + _ = process.Signal(syscall.SIGKILL) + return ctx.Err() + } + + container.Status = StatusStopped + return m.state.Update(container) +} + +// List returns all known containers, verifying process state. +func (m *LinuxKitManager) List(ctx context.Context) ([]*Container, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + containers := m.state.All() + + // Verify each running container's process is still alive + for _, c := range containers { + if c.Status == StatusRunning { + if !isProcessRunning(c.PID) { + c.Status = StatusStopped + _ = m.state.Update(c) + } + } + } + + return containers, nil +} + +// isProcessRunning checks if a process with the given PID is still running. +func isProcessRunning(pid int) bool { + process, err := os.FindProcess(pid) + if err != nil { + return false + } + + // On Unix, FindProcess always succeeds, so we need to send signal 0 to check + err = process.Signal(syscall.Signal(0)) + return err == nil +} + +// Logs returns a reader for the container's log output. +func (m *LinuxKitManager) Logs(ctx context.Context, id string, follow bool) (goio.ReadCloser, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + _, ok := m.state.Get(id) + if !ok { + return nil, fmt.Errorf("container not found: %s", id) + } + + logPath, err := LogPath(id) + if err != nil { + return nil, fmt.Errorf("failed to determine log path: %w", err) + } + + if !m.medium.IsFile(logPath) { + return nil, fmt.Errorf("no logs available for container: %s", id) + } + + if !follow { + // Simple case: just open and return the file + return m.medium.Open(logPath) + } + + // Follow mode: create a reader that tails the file + return newFollowReader(ctx, m.medium, logPath) +} + +// followReader implements goio.ReadCloser for following log files. +type followReader struct { + file goio.ReadCloser + ctx context.Context + cancel context.CancelFunc + reader *bufio.Reader + medium io.Medium + path string +} + +func newFollowReader(ctx context.Context, m io.Medium, path string) (*followReader, error) { + file, err := m.Open(path) + if err != nil { + return nil, err + } + + // Note: We don't seek here because Medium.Open doesn't guarantee Seekability. + + ctx, cancel := context.WithCancel(ctx) + + return &followReader{ + file: file, + ctx: ctx, + cancel: cancel, + reader: bufio.NewReader(file), + medium: m, + path: path, + }, nil +} + +func (f *followReader) Read(p []byte) (int, error) { + for { + select { + case <-f.ctx.Done(): + return 0, goio.EOF + default: + } + + n, err := f.reader.Read(p) + if n > 0 { + return n, nil + } + if err != nil && err != goio.EOF { + return 0, err + } + + // No data available, wait a bit and try again + select { + case <-f.ctx.Done(): + return 0, goio.EOF + case <-time.After(100 * time.Millisecond): + // Reset reader to pick up new data + f.reader.Reset(f.file) + } + } +} + +func (f *followReader) Close() error { + f.cancel() + return f.file.Close() +} + +// Exec executes a command inside the container via SSH. +func (m *LinuxKitManager) Exec(ctx context.Context, id string, cmd []string) error { + if err := ctx.Err(); err != nil { + return err + } + container, ok := m.state.Get(id) + if !ok { + return fmt.Errorf("container not found: %s", id) + } + + if container.Status != StatusRunning { + return fmt.Errorf("container is not running: %s", id) + } + + // Default SSH port + sshPort := 2222 + + // Build SSH command + sshArgs := []string{ + "-p", fmt.Sprintf("%d", sshPort), + "-o", "StrictHostKeyChecking=yes", + "-o", "UserKnownHostsFile=~/.core/known_hosts", + "-o", "LogLevel=ERROR", + "root@localhost", + } + sshArgs = append(sshArgs, cmd...) + + sshCmd := exec.CommandContext(ctx, "ssh", sshArgs...) + sshCmd.Stdin = os.Stdin + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + + return sshCmd.Run() +} + +// State returns the manager's state (for testing). +func (m *LinuxKitManager) State() *State { + return m.state +} + +// Hypervisor returns the manager's hypervisor (for testing). +func (m *LinuxKitManager) Hypervisor() Hypervisor { + return m.hypervisor +} diff --git a/linuxkit_test.go b/linuxkit_test.go new file mode 100644 index 0000000..da748e8 --- /dev/null +++ b/linuxkit_test.go @@ -0,0 +1,786 @@ +package container + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "forge.lthn.ai/core/go-io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// MockHypervisor is a mock implementation for testing. +type MockHypervisor struct { + name string + available bool + buildErr error + lastImage string + lastOpts *HypervisorOptions + commandToRun string +} + +func NewMockHypervisor() *MockHypervisor { + return &MockHypervisor{ + name: "mock", + available: true, + commandToRun: "echo", + } +} + +func (m *MockHypervisor) Name() string { + return m.name +} + +func (m *MockHypervisor) Available() bool { + return m.available +} + +func (m *MockHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) { + m.lastImage = image + m.lastOpts = opts + if m.buildErr != nil { + return nil, m.buildErr + } + // Return a simple command that exits quickly + return exec.CommandContext(ctx, m.commandToRun, "test"), nil +} + +// newTestManager creates a LinuxKitManager with mock hypervisor for testing. +// Uses manual temp directory management to avoid race conditions with t.TempDir cleanup. +func newTestManager(t *testing.T) (*LinuxKitManager, *MockHypervisor, string) { + tmpDir, err := os.MkdirTemp("", "linuxkit-test-*") + require.NoError(t, err) + + // Manual cleanup that handles race conditions with state file writes + t.Cleanup(func() { + // Give any pending file operations time to complete + time.Sleep(10 * time.Millisecond) + _ = os.RemoveAll(tmpDir) + }) + + statePath := filepath.Join(tmpDir, "containers.json") + + state, err := LoadState(statePath) + require.NoError(t, err) + + mock := NewMockHypervisor() + manager := NewLinuxKitManagerWithHypervisor(io.Local, state, mock) + + return manager, mock, tmpDir +} + +func TestNewLinuxKitManagerWithHypervisor_Good(t *testing.T) { + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "containers.json") + state, _ := LoadState(statePath) + mock := NewMockHypervisor() + + manager := NewLinuxKitManagerWithHypervisor(io.Local, state, mock) + + assert.NotNil(t, manager) + assert.Equal(t, state, manager.State()) + assert.Equal(t, mock, manager.Hypervisor()) +} + +func TestLinuxKitManager_Run_Good_Detached(t *testing.T) { + manager, mock, tmpDir := newTestManager(t) + + // Create a test image file + imagePath := filepath.Join(tmpDir, "test.iso") + err := os.WriteFile(imagePath, []byte("fake image"), 0644) + require.NoError(t, err) + + // Use a command that runs briefly then exits + mock.commandToRun = "sleep" + + ctx := context.Background() + opts := RunOptions{ + Name: "test-vm", + Detach: true, + Memory: 512, + CPUs: 2, + } + + container, err := manager.Run(ctx, imagePath, opts) + require.NoError(t, err) + + assert.NotEmpty(t, container.ID) + assert.Equal(t, "test-vm", container.Name) + assert.Equal(t, imagePath, container.Image) + assert.Equal(t, StatusRunning, container.Status) + assert.Greater(t, container.PID, 0) + assert.Equal(t, 512, container.Memory) + assert.Equal(t, 2, container.CPUs) + + // Verify hypervisor was called with correct options + assert.Equal(t, imagePath, mock.lastImage) + assert.Equal(t, 512, mock.lastOpts.Memory) + assert.Equal(t, 2, mock.lastOpts.CPUs) + + // Clean up - stop the container + time.Sleep(100 * time.Millisecond) +} + +func TestLinuxKitManager_Run_Good_DefaultValues(t *testing.T) { + manager, mock, tmpDir := newTestManager(t) + + imagePath := filepath.Join(tmpDir, "test.qcow2") + err := os.WriteFile(imagePath, []byte("fake image"), 0644) + require.NoError(t, err) + + ctx := context.Background() + opts := RunOptions{Detach: true} + + container, err := manager.Run(ctx, imagePath, opts) + require.NoError(t, err) + + // Check defaults were applied + assert.Equal(t, 1024, mock.lastOpts.Memory) + assert.Equal(t, 1, mock.lastOpts.CPUs) + assert.Equal(t, 2222, mock.lastOpts.SSHPort) + + // Name should default to first 8 chars of ID + assert.Equal(t, container.ID[:8], container.Name) + + // Wait for the mock process to complete to avoid temp dir cleanup issues + time.Sleep(50 * time.Millisecond) +} + +func TestLinuxKitManager_Run_Bad_ImageNotFound(t *testing.T) { + manager, _, _ := newTestManager(t) + + ctx := context.Background() + opts := RunOptions{Detach: true} + + _, err := manager.Run(ctx, "/nonexistent/image.iso", opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "image not found") +} + +func TestLinuxKitManager_Run_Bad_UnsupportedFormat(t *testing.T) { + manager, _, tmpDir := newTestManager(t) + + imagePath := filepath.Join(tmpDir, "test.txt") + err := os.WriteFile(imagePath, []byte("not an image"), 0644) + require.NoError(t, err) + + ctx := context.Background() + opts := RunOptions{Detach: true} + + _, err = manager.Run(ctx, imagePath, opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported image format") +} + +func TestLinuxKitManager_Stop_Good(t *testing.T) { + manager, _, _ := newTestManager(t) + + // Add a fake running container with a non-existent PID + // The Stop function should handle this gracefully + container := &Container{ + ID: "abc12345", + Status: StatusRunning, + PID: 999999, // Non-existent PID + StartedAt: time.Now(), + } + _ = manager.State().Add(container) + + ctx := context.Background() + err := manager.Stop(ctx, "abc12345") + + // Stop should succeed (process doesn't exist, so container is marked stopped) + assert.NoError(t, err) + + // Verify the container status was updated + c, ok := manager.State().Get("abc12345") + assert.True(t, ok) + assert.Equal(t, StatusStopped, c.Status) +} + +func TestLinuxKitManager_Stop_Bad_NotFound(t *testing.T) { + manager, _, _ := newTestManager(t) + + ctx := context.Background() + err := manager.Stop(ctx, "nonexistent") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "container not found") +} + +func TestLinuxKitManager_Stop_Bad_NotRunning(t *testing.T) { + _, _, tmpDir := newTestManager(t) + statePath := filepath.Join(tmpDir, "containers.json") + state, err := LoadState(statePath) + require.NoError(t, err) + manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor()) + + container := &Container{ + ID: "abc12345", + Status: StatusStopped, + } + _ = state.Add(container) + + ctx := context.Background() + err = manager.Stop(ctx, "abc12345") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "not running") +} + +func TestLinuxKitManager_List_Good(t *testing.T) { + _, _, tmpDir := newTestManager(t) + statePath := filepath.Join(tmpDir, "containers.json") + state, err := LoadState(statePath) + require.NoError(t, err) + manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor()) + + _ = state.Add(&Container{ID: "aaa11111", Status: StatusStopped}) + _ = state.Add(&Container{ID: "bbb22222", Status: StatusStopped}) + + ctx := context.Background() + containers, err := manager.List(ctx) + + require.NoError(t, err) + assert.Len(t, containers, 2) +} + +func TestLinuxKitManager_List_Good_VerifiesRunningStatus(t *testing.T) { + _, _, tmpDir := newTestManager(t) + statePath := filepath.Join(tmpDir, "containers.json") + state, err := LoadState(statePath) + require.NoError(t, err) + manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor()) + + // Add a "running" container with a fake PID that doesn't exist + _ = state.Add(&Container{ + ID: "abc12345", + Status: StatusRunning, + PID: 999999, // PID that almost certainly doesn't exist + }) + + ctx := context.Background() + containers, err := manager.List(ctx) + + require.NoError(t, err) + assert.Len(t, containers, 1) + // Status should have been updated to stopped since PID doesn't exist + assert.Equal(t, StatusStopped, containers[0].Status) +} + +func TestLinuxKitManager_Logs_Good(t *testing.T) { + manager, _, tmpDir := newTestManager(t) + + // Create a log file manually + logsDir := filepath.Join(tmpDir, "logs") + require.NoError(t, os.MkdirAll(logsDir, 0755)) + + container := &Container{ID: "abc12345"} + _ = manager.State().Add(container) + + // Override the default logs dir for testing by creating the log file + // at the expected location + logContent := "test log content\nline 2\n" + logPath, err := LogPath("abc12345") + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0755)) + require.NoError(t, os.WriteFile(logPath, []byte(logContent), 0644)) + + ctx := context.Background() + reader, err := manager.Logs(ctx, "abc12345", false) + + require.NoError(t, err) + defer func() { _ = reader.Close() }() + + buf := make([]byte, 1024) + n, _ := reader.Read(buf) + assert.Equal(t, logContent, string(buf[:n])) +} + +func TestLinuxKitManager_Logs_Bad_NotFound(t *testing.T) { + manager, _, _ := newTestManager(t) + + ctx := context.Background() + _, err := manager.Logs(ctx, "nonexistent", false) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "container not found") +} + +func TestLinuxKitManager_Logs_Bad_NoLogFile(t *testing.T) { + manager, _, _ := newTestManager(t) + + // Use a unique ID that won't have a log file + uniqueID, err := GenerateID() + require.NoError(t, err) + container := &Container{ID: uniqueID} + _ = manager.State().Add(container) + + ctx := context.Background() + reader, err := manager.Logs(ctx, uniqueID, false) + + // If logs existed somehow, clean up the reader + if reader != nil { + _ = reader.Close() + } + + assert.Error(t, err) + if err != nil { + assert.Contains(t, err.Error(), "no logs available") + } +} + +func TestLinuxKitManager_Exec_Bad_NotFound(t *testing.T) { + manager, _, _ := newTestManager(t) + + ctx := context.Background() + err := manager.Exec(ctx, "nonexistent", []string{"ls"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "container not found") +} + +func TestLinuxKitManager_Exec_Bad_NotRunning(t *testing.T) { + manager, _, _ := newTestManager(t) + + container := &Container{ID: "abc12345", Status: StatusStopped} + _ = manager.State().Add(container) + + ctx := context.Background() + err := manager.Exec(ctx, "abc12345", []string{"ls"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "not running") +} + +func TestDetectImageFormat_Good(t *testing.T) { + tests := []struct { + path string + format ImageFormat + }{ + {"/path/to/image.iso", FormatISO}, + {"/path/to/image.ISO", FormatISO}, + {"/path/to/image.qcow2", FormatQCOW2}, + {"/path/to/image.QCOW2", FormatQCOW2}, + {"/path/to/image.vmdk", FormatVMDK}, + {"/path/to/image.raw", FormatRaw}, + {"/path/to/image.img", FormatRaw}, + {"image.iso", FormatISO}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + assert.Equal(t, tt.format, DetectImageFormat(tt.path)) + }) + } +} + +func TestDetectImageFormat_Bad_Unknown(t *testing.T) { + tests := []string{ + "/path/to/image.txt", + "/path/to/image", + "noextension", + "/path/to/image.docx", + } + + for _, path := range tests { + t.Run(path, func(t *testing.T) { + assert.Equal(t, FormatUnknown, DetectImageFormat(path)) + }) + } +} + +func TestQemuHypervisor_Name_Good(t *testing.T) { + q := NewQemuHypervisor() + assert.Equal(t, "qemu", q.Name()) +} + +func TestQemuHypervisor_BuildCommand_Good(t *testing.T) { + q := NewQemuHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{ + Memory: 2048, + CPUs: 4, + SSHPort: 2222, + Ports: map[int]int{8080: 80}, + Detach: true, + } + + cmd, err := q.BuildCommand(ctx, "/path/to/image.iso", opts) + require.NoError(t, err) + assert.NotNil(t, cmd) + + // Check command path + assert.Contains(t, cmd.Path, "qemu") + + // Check that args contain expected values + args := cmd.Args + assert.Contains(t, args, "-m") + assert.Contains(t, args, "2048") + assert.Contains(t, args, "-smp") + assert.Contains(t, args, "4") + assert.Contains(t, args, "-nographic") +} + +func TestLinuxKitManager_Logs_Good_Follow(t *testing.T) { + manager, _, _ := newTestManager(t) + + // Create a unique container ID + uniqueID, err := GenerateID() + require.NoError(t, err) + container := &Container{ID: uniqueID} + _ = manager.State().Add(container) + + // Create a log file at the expected location + logPath, err := LogPath(uniqueID) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0755)) + + // Write initial content + err = os.WriteFile(logPath, []byte("initial log content\n"), 0644) + require.NoError(t, err) + + // Create a cancellable context + ctx, cancel := context.WithCancel(context.Background()) + + // Get the follow reader + reader, err := manager.Logs(ctx, uniqueID, true) + require.NoError(t, err) + + // Cancel the context to stop the follow + cancel() + + // Read should return EOF after context cancellation + buf := make([]byte, 1024) + _, readErr := reader.Read(buf) + // After context cancel, Read should return EOF + assert.Equal(t, "EOF", readErr.Error()) + + // Close the reader + assert.NoError(t, reader.Close()) +} + +func TestFollowReader_Read_Good_WithData(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "test.log") + + // Create log file with content + content := "test log line 1\ntest log line 2\n" + err := os.WriteFile(logPath, []byte(content), 0644) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + reader, err := newFollowReader(ctx, io.Local, logPath) + require.NoError(t, err) + defer func() { _ = reader.Close() }() + + // The followReader seeks to end, so we need to append more content + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0644) + require.NoError(t, err) + _, err = f.WriteString("new line\n") + require.NoError(t, err) + require.NoError(t, f.Close()) + + // Give the reader time to poll + time.Sleep(150 * time.Millisecond) + + buf := make([]byte, 1024) + n, err := reader.Read(buf) + if err == nil { + assert.Greater(t, n, 0) + } +} + +func TestFollowReader_Read_Good_ContextCancel(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "test.log") + + // Create log file + err := os.WriteFile(logPath, []byte("initial content\n"), 0644) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + + reader, err := newFollowReader(ctx, io.Local, logPath) + require.NoError(t, err) + + // Cancel the context + cancel() + + // Read should return EOF + buf := make([]byte, 1024) + _, readErr := reader.Read(buf) + assert.Equal(t, "EOF", readErr.Error()) + + _ = reader.Close() +} + +func TestFollowReader_Close_Good(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "test.log") + + err := os.WriteFile(logPath, []byte("content\n"), 0644) + require.NoError(t, err) + + ctx := context.Background() + reader, err := newFollowReader(ctx, io.Local, logPath) + require.NoError(t, err) + + err = reader.Close() + assert.NoError(t, err) + + // Reading after close should fail or return EOF + buf := make([]byte, 1024) + _, readErr := reader.Read(buf) + assert.Error(t, readErr) +} + +func TestNewFollowReader_Bad_FileNotFound(t *testing.T) { + ctx := context.Background() + _, err := newFollowReader(ctx, io.Local, "/nonexistent/path/to/file.log") + + assert.Error(t, err) +} + +func TestLinuxKitManager_Run_Bad_BuildCommandError(t *testing.T) { + manager, mock, tmpDir := newTestManager(t) + + // Create a test image file + imagePath := filepath.Join(tmpDir, "test.iso") + err := os.WriteFile(imagePath, []byte("fake image"), 0644) + require.NoError(t, err) + + // Configure mock to return an error + mock.buildErr = assert.AnError + + ctx := context.Background() + opts := RunOptions{Detach: true} + + _, err = manager.Run(ctx, imagePath, opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to build hypervisor command") +} + +func TestLinuxKitManager_Run_Good_Foreground(t *testing.T) { + manager, mock, tmpDir := newTestManager(t) + + // Create a test image file + imagePath := filepath.Join(tmpDir, "test.iso") + err := os.WriteFile(imagePath, []byte("fake image"), 0644) + require.NoError(t, err) + + // Use echo which exits quickly + mock.commandToRun = "echo" + + ctx := context.Background() + opts := RunOptions{ + Name: "test-foreground", + Detach: false, // Run in foreground + Memory: 512, + CPUs: 1, + } + + container, err := manager.Run(ctx, imagePath, opts) + require.NoError(t, err) + + assert.NotEmpty(t, container.ID) + assert.Equal(t, "test-foreground", container.Name) + // Foreground process should have completed + assert.Equal(t, StatusStopped, container.Status) +} + +func TestLinuxKitManager_Stop_Good_ContextCancelled(t *testing.T) { + manager, mock, tmpDir := newTestManager(t) + + // Create a test image file + imagePath := filepath.Join(tmpDir, "test.iso") + err := os.WriteFile(imagePath, []byte("fake image"), 0644) + require.NoError(t, err) + + // Use a command that takes a long time + mock.commandToRun = "sleep" + + // Start a container + ctx := context.Background() + opts := RunOptions{ + Name: "test-cancel", + Detach: true, + } + + container, err := manager.Run(ctx, imagePath, opts) + require.NoError(t, err) + + // Ensure cleanup happens regardless of test outcome + t.Cleanup(func() { + _ = manager.Stop(context.Background(), container.ID) + }) + + // Create a context that's already cancelled + cancelCtx, cancel := context.WithCancel(context.Background()) + cancel() + + // Stop with cancelled context + err = manager.Stop(cancelCtx, container.ID) + // Should return context error + assert.Error(t, err) + assert.Equal(t, context.Canceled, err) +} + +func TestIsProcessRunning_Good_ExistingProcess(t *testing.T) { + // Use our own PID which definitely exists + running := isProcessRunning(os.Getpid()) + assert.True(t, running) +} + +func TestIsProcessRunning_Bad_NonexistentProcess(t *testing.T) { + // Use a PID that almost certainly doesn't exist + running := isProcessRunning(999999) + assert.False(t, running) +} + +func TestLinuxKitManager_Run_Good_WithPortsAndVolumes(t *testing.T) { + manager, mock, tmpDir := newTestManager(t) + + imagePath := filepath.Join(tmpDir, "test.iso") + err := os.WriteFile(imagePath, []byte("fake image"), 0644) + require.NoError(t, err) + + ctx := context.Background() + opts := RunOptions{ + Name: "test-ports", + Detach: true, + Memory: 512, + CPUs: 1, + SSHPort: 2223, + Ports: map[int]int{8080: 80, 443: 443}, + Volumes: map[string]string{"/host/data": "/container/data"}, + } + + container, err := manager.Run(ctx, imagePath, opts) + require.NoError(t, err) + + assert.NotEmpty(t, container.ID) + assert.Equal(t, map[int]int{8080: 80, 443: 443}, container.Ports) + assert.Equal(t, 2223, mock.lastOpts.SSHPort) + assert.Equal(t, map[string]string{"/host/data": "/container/data"}, mock.lastOpts.Volumes) + + time.Sleep(50 * time.Millisecond) +} + +func TestFollowReader_Read_Bad_ReaderError(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "test.log") + + // Create log file + err := os.WriteFile(logPath, []byte("content\n"), 0644) + require.NoError(t, err) + + ctx := context.Background() + reader, err := newFollowReader(ctx, io.Local, logPath) + require.NoError(t, err) + + // Close the underlying file to cause read errors + _ = reader.file.Close() + + // Read should return an error + buf := make([]byte, 1024) + _, readErr := reader.Read(buf) + assert.Error(t, readErr) +} + +func TestLinuxKitManager_Run_Bad_StartError(t *testing.T) { + manager, mock, tmpDir := newTestManager(t) + + imagePath := filepath.Join(tmpDir, "test.iso") + err := os.WriteFile(imagePath, []byte("fake image"), 0644) + require.NoError(t, err) + + // Use a command that doesn't exist to cause Start() to fail + mock.commandToRun = "/nonexistent/command/that/does/not/exist" + + ctx := context.Background() + opts := RunOptions{ + Name: "test-start-error", + Detach: true, + } + + _, err = manager.Run(ctx, imagePath, opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to start VM") +} + +func TestLinuxKitManager_Run_Bad_ForegroundStartError(t *testing.T) { + manager, mock, tmpDir := newTestManager(t) + + imagePath := filepath.Join(tmpDir, "test.iso") + err := os.WriteFile(imagePath, []byte("fake image"), 0644) + require.NoError(t, err) + + // Use a command that doesn't exist to cause Start() to fail + mock.commandToRun = "/nonexistent/command/that/does/not/exist" + + ctx := context.Background() + opts := RunOptions{ + Name: "test-foreground-error", + Detach: false, + } + + _, err = manager.Run(ctx, imagePath, opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to start VM") +} + +func TestLinuxKitManager_Run_Good_ForegroundWithError(t *testing.T) { + manager, mock, tmpDir := newTestManager(t) + + imagePath := filepath.Join(tmpDir, "test.iso") + err := os.WriteFile(imagePath, []byte("fake image"), 0644) + require.NoError(t, err) + + // Use a command that exits with error + mock.commandToRun = "false" // false command exits with code 1 + + ctx := context.Background() + opts := RunOptions{ + Name: "test-foreground-exit-error", + Detach: false, + } + + container, err := manager.Run(ctx, imagePath, opts) + require.NoError(t, err) // Run itself should succeed + + // Container should be in error state since process exited with error + assert.Equal(t, StatusError, container.Status) +} + +func TestLinuxKitManager_Stop_Good_ProcessExitedWhileRunning(t *testing.T) { + manager, _, _ := newTestManager(t) + + // Add a "running" container with a process that has already exited + // This simulates the race condition where process exits between status check + // and signal send + container := &Container{ + ID: "test1234", + Status: StatusRunning, + PID: 999999, // Non-existent PID + StartedAt: time.Now(), + } + _ = manager.State().Add(container) + + ctx := context.Background() + err := manager.Stop(ctx, "test1234") + + // Stop should succeed gracefully + assert.NoError(t, err) + + // Container should be stopped + c, ok := manager.State().Get("test1234") + assert.True(t, ok) + assert.Equal(t, StatusStopped, c.Status) +} diff --git a/sources/cdn.go b/sources/cdn.go new file mode 100644 index 0000000..21c5b66 --- /dev/null +++ b/sources/cdn.go @@ -0,0 +1,113 @@ +package sources + +import ( + "context" + "fmt" + goio "io" + "net/http" + "os" + "path/filepath" + + "forge.lthn.ai/core/go-io" +) + +// CDNSource downloads images from a CDN or S3 bucket. +type CDNSource struct { + config SourceConfig +} + +// Compile-time interface check. +var _ ImageSource = (*CDNSource)(nil) + +// NewCDNSource creates a new CDN source. +func NewCDNSource(cfg SourceConfig) *CDNSource { + return &CDNSource{config: cfg} +} + +// Name returns "cdn". +func (s *CDNSource) Name() string { + return "cdn" +} + +// Available checks if CDN URL is configured. +func (s *CDNSource) Available() bool { + return s.config.CDNURL != "" +} + +// LatestVersion fetches version from manifest or returns "latest". +func (s *CDNSource) LatestVersion(ctx context.Context) (string, error) { + // Try to fetch manifest.json for version info + url := fmt.Sprintf("%s/manifest.json", s.config.CDNURL) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return "latest", nil + } + + resp, err := http.DefaultClient.Do(req) + if err != nil || resp.StatusCode != 200 { + return "latest", nil + } + defer func() { _ = resp.Body.Close() }() + + // For now, just return latest - could parse manifest for version + return "latest", nil +} + +// Download downloads the image from CDN. +func (s *CDNSource) Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error { + url := fmt.Sprintf("%s/%s", s.config.CDNURL, s.config.ImageName) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return fmt.Errorf("cdn.Download: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("cdn.Download: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != 200 { + return fmt.Errorf("cdn.Download: HTTP %d", resp.StatusCode) + } + + // Ensure dest directory exists + if err := m.EnsureDir(dest); err != nil { + return fmt.Errorf("cdn.Download: %w", err) + } + + // Create destination file + destPath := filepath.Join(dest, s.config.ImageName) + f, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("cdn.Download: %w", err) + } + defer func() { _ = f.Close() }() + + // Copy with progress + total := resp.ContentLength + var downloaded int64 + + buf := make([]byte, 32*1024) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + if _, werr := f.Write(buf[:n]); werr != nil { + return fmt.Errorf("cdn.Download: %w", werr) + } + downloaded += int64(n) + if progress != nil { + progress(downloaded, total) + } + } + if err == goio.EOF { + break + } + if err != nil { + return fmt.Errorf("cdn.Download: %w", err) + } + } + + return nil +} diff --git a/sources/cdn_test.go b/sources/cdn_test.go new file mode 100644 index 0000000..7473d45 --- /dev/null +++ b/sources/cdn_test.go @@ -0,0 +1,306 @@ +package sources + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "forge.lthn.ai/core/go-io" + "github.com/stretchr/testify/assert" +) + +func TestCDNSource_Good_Available(t *testing.T) { + src := NewCDNSource(SourceConfig{ + CDNURL: "https://images.example.com", + ImageName: "core-devops-darwin-arm64.qcow2", + }) + + assert.Equal(t, "cdn", src.Name()) + assert.True(t, src.Available()) +} + +func TestCDNSource_Bad_NoURL(t *testing.T) { + src := NewCDNSource(SourceConfig{ + ImageName: "core-devops-darwin-arm64.qcow2", + }) + + assert.False(t, src.Available()) +} + +func TestCDNSource_LatestVersion_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/manifest.json" { + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, `{"version": "1.2.3"}`) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + src := NewCDNSource(SourceConfig{ + CDNURL: server.URL, + ImageName: "test.img", + }) + + version, err := src.LatestVersion(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "latest", version) // Current impl always returns "latest" +} + +func TestCDNSource_Download_Good(t *testing.T) { + content := "fake image data" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/test.img" { + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, content) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + dest := t.TempDir() + imageName := "test.img" + src := NewCDNSource(SourceConfig{ + CDNURL: server.URL, + ImageName: imageName, + }) + + var progressCalled bool + err := src.Download(context.Background(), io.Local, dest, func(downloaded, total int64) { + progressCalled = true + }) + + assert.NoError(t, err) + assert.True(t, progressCalled) + + // Verify file content + data, err := os.ReadFile(filepath.Join(dest, imageName)) + assert.NoError(t, err) + assert.Equal(t, content, string(data)) +} + +func TestCDNSource_Download_Bad(t *testing.T) { + t.Run("HTTP error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + dest := t.TempDir() + src := NewCDNSource(SourceConfig{ + CDNURL: server.URL, + ImageName: "test.img", + }) + + err := src.Download(context.Background(), io.Local, dest, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 500") + }) + + t.Run("Invalid URL", func(t *testing.T) { + dest := t.TempDir() + src := NewCDNSource(SourceConfig{ + CDNURL: "http://invalid-url-that-should-fail", + ImageName: "test.img", + }) + + err := src.Download(context.Background(), io.Local, dest, nil) + assert.Error(t, err) + }) +} + +func TestCDNSource_LatestVersion_Bad_NoManifest(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + src := NewCDNSource(SourceConfig{ + CDNURL: server.URL, + ImageName: "test.img", + }) + + version, err := src.LatestVersion(context.Background()) + assert.NoError(t, err) // Should not error, just return "latest" + assert.Equal(t, "latest", version) +} + +func TestCDNSource_LatestVersion_Bad_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + src := NewCDNSource(SourceConfig{ + CDNURL: server.URL, + ImageName: "test.img", + }) + + version, err := src.LatestVersion(context.Background()) + assert.NoError(t, err) // Falls back to "latest" + assert.Equal(t, "latest", version) +} + +func TestCDNSource_Download_Good_NoProgress(t *testing.T) { + content := "test content" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, content) + })) + defer server.Close() + + dest := t.TempDir() + src := NewCDNSource(SourceConfig{ + CDNURL: server.URL, + ImageName: "test.img", + }) + + // nil progress callback should be handled gracefully + err := src.Download(context.Background(), io.Local, dest, nil) + assert.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dest, "test.img")) + assert.NoError(t, err) + assert.Equal(t, content, string(data)) +} + +func TestCDNSource_Download_Good_LargeFile(t *testing.T) { + // Create content larger than buffer size (32KB) + content := make([]byte, 64*1024) // 64KB + for i := range content { + content[i] = byte(i % 256) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + })) + defer server.Close() + + dest := t.TempDir() + src := NewCDNSource(SourceConfig{ + CDNURL: server.URL, + ImageName: "large.img", + }) + + var progressCalls int + var lastDownloaded int64 + err := src.Download(context.Background(), io.Local, dest, func(downloaded, total int64) { + progressCalls++ + lastDownloaded = downloaded + }) + + assert.NoError(t, err) + assert.Greater(t, progressCalls, 1) // Should be called multiple times for large file + assert.Equal(t, int64(len(content)), lastDownloaded) +} + +func TestCDNSource_Download_Bad_HTTPErrorCodes(t *testing.T) { + testCases := []struct { + name string + statusCode int + }{ + {"Bad Request", http.StatusBadRequest}, + {"Unauthorized", http.StatusUnauthorized}, + {"Forbidden", http.StatusForbidden}, + {"Not Found", http.StatusNotFound}, + {"Service Unavailable", http.StatusServiceUnavailable}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.statusCode) + })) + defer server.Close() + + dest := t.TempDir() + src := NewCDNSource(SourceConfig{ + CDNURL: server.URL, + ImageName: "test.img", + }) + + err := src.Download(context.Background(), io.Local, dest, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("HTTP %d", tc.statusCode)) + }) + } +} + +func TestCDNSource_InterfaceCompliance(t *testing.T) { + // Verify CDNSource implements ImageSource + var _ ImageSource = (*CDNSource)(nil) +} + +func TestCDNSource_Config(t *testing.T) { + cfg := SourceConfig{ + CDNURL: "https://cdn.example.com", + ImageName: "my-image.qcow2", + } + src := NewCDNSource(cfg) + + assert.Equal(t, "https://cdn.example.com", src.config.CDNURL) + assert.Equal(t, "my-image.qcow2", src.config.ImageName) +} + +func TestNewCDNSource_Good(t *testing.T) { + cfg := SourceConfig{ + GitHubRepo: "host-uk/core-images", + RegistryImage: "ghcr.io/host-uk/core-devops", + CDNURL: "https://cdn.example.com", + ImageName: "core-devops-darwin-arm64.qcow2", + } + + src := NewCDNSource(cfg) + assert.NotNil(t, src) + assert.Equal(t, "cdn", src.Name()) + assert.Equal(t, cfg.CDNURL, src.config.CDNURL) +} + +func TestCDNSource_Download_Good_CreatesDestDir(t *testing.T) { + content := "test content" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, content) + })) + defer server.Close() + + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "nested", "dir") + // dest doesn't exist yet + + src := NewCDNSource(SourceConfig{ + CDNURL: server.URL, + ImageName: "test.img", + }) + + err := src.Download(context.Background(), io.Local, dest, nil) + assert.NoError(t, err) + + // Verify nested dir was created + info, err := os.Stat(dest) + assert.NoError(t, err) + assert.True(t, info.IsDir()) +} + +func TestSourceConfig_Struct(t *testing.T) { + cfg := SourceConfig{ + GitHubRepo: "owner/repo", + RegistryImage: "ghcr.io/owner/image", + CDNURL: "https://cdn.example.com", + ImageName: "image.qcow2", + } + + assert.Equal(t, "owner/repo", cfg.GitHubRepo) + assert.Equal(t, "ghcr.io/owner/image", cfg.RegistryImage) + assert.Equal(t, "https://cdn.example.com", cfg.CDNURL) + assert.Equal(t, "image.qcow2", cfg.ImageName) +} diff --git a/sources/github.go b/sources/github.go new file mode 100644 index 0000000..29650dd --- /dev/null +++ b/sources/github.go @@ -0,0 +1,72 @@ +package sources + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + + "forge.lthn.ai/core/go-io" +) + +// GitHubSource downloads images from GitHub Releases. +type GitHubSource struct { + config SourceConfig +} + +// Compile-time interface check. +var _ ImageSource = (*GitHubSource)(nil) + +// NewGitHubSource creates a new GitHub source. +func NewGitHubSource(cfg SourceConfig) *GitHubSource { + return &GitHubSource{config: cfg} +} + +// Name returns "github". +func (s *GitHubSource) Name() string { + return "github" +} + +// Available checks if gh CLI is installed and authenticated. +func (s *GitHubSource) Available() bool { + _, err := exec.LookPath("gh") + if err != nil { + return false + } + // Check if authenticated + cmd := exec.Command("gh", "auth", "status") + return cmd.Run() == nil +} + +// LatestVersion returns the latest release tag. +func (s *GitHubSource) LatestVersion(ctx context.Context) (string, error) { + cmd := exec.CommandContext(ctx, "gh", "release", "view", + "-R", s.config.GitHubRepo, + "--json", "tagName", + "-q", ".tagName", + ) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("github.LatestVersion: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +// Download downloads the image from the latest release. +func (s *GitHubSource) Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error { + // Get release assets to find our image + cmd := exec.CommandContext(ctx, "gh", "release", "download", + "-R", s.config.GitHubRepo, + "-p", s.config.ImageName, + "-D", dest, + "--clobber", + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("github.Download: %w", err) + } + return nil +} diff --git a/sources/github_test.go b/sources/github_test.go new file mode 100644 index 0000000..7281129 --- /dev/null +++ b/sources/github_test.go @@ -0,0 +1,68 @@ +package sources + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGitHubSource_Good_Available(t *testing.T) { + src := NewGitHubSource(SourceConfig{ + GitHubRepo: "host-uk/core-images", + ImageName: "core-devops-darwin-arm64.qcow2", + }) + + if src.Name() != "github" { + t.Errorf("expected name 'github', got %q", src.Name()) + } + + // Available depends on gh CLI being installed + _ = src.Available() +} + +func TestGitHubSource_Name(t *testing.T) { + src := NewGitHubSource(SourceConfig{}) + assert.Equal(t, "github", src.Name()) +} + +func TestGitHubSource_Config(t *testing.T) { + cfg := SourceConfig{ + GitHubRepo: "owner/repo", + ImageName: "test-image.qcow2", + } + src := NewGitHubSource(cfg) + + // Verify the config is stored + assert.Equal(t, "owner/repo", src.config.GitHubRepo) + assert.Equal(t, "test-image.qcow2", src.config.ImageName) +} + +func TestGitHubSource_Good_Multiple(t *testing.T) { + // Test creating multiple sources with different configs + src1 := NewGitHubSource(SourceConfig{GitHubRepo: "org1/repo1", ImageName: "img1.qcow2"}) + src2 := NewGitHubSource(SourceConfig{GitHubRepo: "org2/repo2", ImageName: "img2.qcow2"}) + + assert.Equal(t, "org1/repo1", src1.config.GitHubRepo) + assert.Equal(t, "org2/repo2", src2.config.GitHubRepo) + assert.Equal(t, "github", src1.Name()) + assert.Equal(t, "github", src2.Name()) +} + +func TestNewGitHubSource_Good(t *testing.T) { + cfg := SourceConfig{ + GitHubRepo: "host-uk/core-images", + RegistryImage: "ghcr.io/host-uk/core-devops", + CDNURL: "https://cdn.example.com", + ImageName: "core-devops-darwin-arm64.qcow2", + } + + src := NewGitHubSource(cfg) + assert.NotNil(t, src) + assert.Equal(t, "github", src.Name()) + assert.Equal(t, cfg.GitHubRepo, src.config.GitHubRepo) +} + +func TestGitHubSource_InterfaceCompliance(t *testing.T) { + // Verify GitHubSource implements ImageSource + var _ ImageSource = (*GitHubSource)(nil) +} diff --git a/sources/source.go b/sources/source.go new file mode 100644 index 0000000..c03f026 --- /dev/null +++ b/sources/source.go @@ -0,0 +1,33 @@ +// Package sources provides image download sources for go-container. +package sources + +import ( + "context" + + "forge.lthn.ai/core/go-io" +) + +// ImageSource defines the interface for downloading dev images. +type ImageSource interface { + // Name returns the source identifier. + Name() string + // Available checks if this source can be used. + Available() bool + // LatestVersion returns the latest available version. + LatestVersion(ctx context.Context) (string, error) + // Download downloads the image to the destination path. + // Reports progress via the callback if provided. + Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error +} + +// SourceConfig holds configuration for a source. +type SourceConfig struct { + // GitHub configuration + GitHubRepo string + // Registry configuration + RegistryImage string + // CDN configuration + CDNURL string + // Image name (e.g., core-devops-darwin-arm64.qcow2) + ImageName string +} diff --git a/sources/source_test.go b/sources/source_test.go new file mode 100644 index 0000000..a63f09b --- /dev/null +++ b/sources/source_test.go @@ -0,0 +1,35 @@ +package sources + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSourceConfig_Empty(t *testing.T) { + cfg := SourceConfig{} + assert.Empty(t, cfg.GitHubRepo) + assert.Empty(t, cfg.RegistryImage) + assert.Empty(t, cfg.CDNURL) + assert.Empty(t, cfg.ImageName) +} + +func TestSourceConfig_Complete(t *testing.T) { + cfg := SourceConfig{ + GitHubRepo: "owner/repo", + RegistryImage: "ghcr.io/owner/image:v1", + CDNURL: "https://cdn.example.com/images", + ImageName: "my-image-darwin-arm64.qcow2", + } + + assert.Equal(t, "owner/repo", cfg.GitHubRepo) + assert.Equal(t, "ghcr.io/owner/image:v1", cfg.RegistryImage) + assert.Equal(t, "https://cdn.example.com/images", cfg.CDNURL) + assert.Equal(t, "my-image-darwin-arm64.qcow2", cfg.ImageName) +} + +func TestImageSource_Interface(t *testing.T) { + // Ensure both sources implement the interface + var _ ImageSource = (*GitHubSource)(nil) + var _ ImageSource = (*CDNSource)(nil) +} diff --git a/state.go b/state.go new file mode 100644 index 0000000..00139b5 --- /dev/null +++ b/state.go @@ -0,0 +1,172 @@ +package container + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + + "forge.lthn.ai/core/go-io" +) + +// State manages persistent container state. +type State struct { + // Containers is a map of container ID to Container. + Containers map[string]*Container `json:"containers"` + + mu sync.RWMutex + filePath string +} + +// DefaultStateDir returns the default directory for state files (~/.core). +func DefaultStateDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".core"), nil +} + +// DefaultStatePath returns the default path for the state file. +func DefaultStatePath() (string, error) { + dir, err := DefaultStateDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "containers.json"), nil +} + +// DefaultLogsDir returns the default directory for container logs. +func DefaultLogsDir() (string, error) { + dir, err := DefaultStateDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "logs"), nil +} + +// NewState creates a new State instance. +func NewState(filePath string) *State { + return &State{ + Containers: make(map[string]*Container), + filePath: filePath, + } +} + +// LoadState loads the state from the given file path. +// If the file doesn't exist, returns an empty state. +func LoadState(filePath string) (*State, error) { + state := NewState(filePath) + + dataStr, err := io.Local.Read(filePath) + if err != nil { + if os.IsNotExist(err) { + return state, nil + } + return nil, err + } + + if err := json.Unmarshal([]byte(dataStr), state); err != nil { + return nil, err + } + + return state, nil +} + +// SaveState persists the state to the configured file path. +func (s *State) SaveState() error { + s.mu.RLock() + defer s.mu.RUnlock() + + // Ensure the directory exists + dir := filepath.Dir(s.filePath) + if err := io.Local.EnsureDir(dir); err != nil { + return err + } + + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + + return io.Local.Write(s.filePath, string(data)) +} + +// Add adds a container to the state and persists it. +func (s *State) Add(c *Container) error { + s.mu.Lock() + s.Containers[c.ID] = c + s.mu.Unlock() + + return s.SaveState() +} + +// Get retrieves a copy of a container by ID. +// Returns a copy to prevent data races when the container is modified. +func (s *State) Get(id string) (*Container, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + c, ok := s.Containers[id] + if !ok { + return nil, false + } + // Return a copy to prevent data races + copy := *c + return ©, true +} + +// Update updates a container in the state and persists it. +func (s *State) Update(c *Container) error { + s.mu.Lock() + s.Containers[c.ID] = c + s.mu.Unlock() + + return s.SaveState() +} + +// Remove removes a container from the state and persists it. +func (s *State) Remove(id string) error { + s.mu.Lock() + delete(s.Containers, id) + s.mu.Unlock() + + return s.SaveState() +} + +// All returns copies of all containers in the state. +// Returns copies to prevent data races when containers are modified. +func (s *State) All() []*Container { + s.mu.RLock() + defer s.mu.RUnlock() + + containers := make([]*Container, 0, len(s.Containers)) + for _, c := range s.Containers { + copy := *c + containers = append(containers, ©) + } + return containers +} + +// FilePath returns the path to the state file. +func (s *State) FilePath() string { + return s.filePath +} + +// LogPath returns the log file path for a given container ID. +func LogPath(id string) (string, error) { + logsDir, err := DefaultLogsDir() + if err != nil { + return "", err + } + return filepath.Join(logsDir, id+".log"), nil +} + +// EnsureLogsDir ensures the logs directory exists. +func EnsureLogsDir() error { + logsDir, err := DefaultLogsDir() + if err != nil { + return err + } + return io.Local.EnsureDir(logsDir) +} diff --git a/state_test.go b/state_test.go new file mode 100644 index 0000000..68e6a02 --- /dev/null +++ b/state_test.go @@ -0,0 +1,222 @@ +package container + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewState_Good(t *testing.T) { + state := NewState("/tmp/test-state.json") + + assert.NotNil(t, state) + assert.NotNil(t, state.Containers) + assert.Equal(t, "/tmp/test-state.json", state.FilePath()) +} + +func TestLoadState_Good_NewFile(t *testing.T) { + // Test loading from non-existent file + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "containers.json") + + state, err := LoadState(statePath) + + require.NoError(t, err) + assert.NotNil(t, state) + assert.Empty(t, state.Containers) +} + +func TestLoadState_Good_ExistingFile(t *testing.T) { + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "containers.json") + + // Create a state file with data + content := `{ + "containers": { + "abc12345": { + "id": "abc12345", + "name": "test-container", + "image": "/path/to/image.iso", + "status": "running", + "pid": 12345, + "started_at": "2024-01-01T00:00:00Z" + } + } + }` + err := os.WriteFile(statePath, []byte(content), 0644) + require.NoError(t, err) + + state, err := LoadState(statePath) + + require.NoError(t, err) + assert.Len(t, state.Containers, 1) + + c, ok := state.Get("abc12345") + assert.True(t, ok) + assert.Equal(t, "test-container", c.Name) + assert.Equal(t, StatusRunning, c.Status) +} + +func TestLoadState_Bad_InvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "containers.json") + + // Create invalid JSON + err := os.WriteFile(statePath, []byte("invalid json{"), 0644) + require.NoError(t, err) + + _, err = LoadState(statePath) + assert.Error(t, err) +} + +func TestState_Add_Good(t *testing.T) { + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "containers.json") + state := NewState(statePath) + + container := &Container{ + ID: "abc12345", + Name: "test", + Image: "/path/to/image.iso", + Status: StatusRunning, + PID: 12345, + StartedAt: time.Now(), + } + + err := state.Add(container) + require.NoError(t, err) + + // Verify it's in memory + c, ok := state.Get("abc12345") + assert.True(t, ok) + assert.Equal(t, container.Name, c.Name) + + // Verify file was created + _, err = os.Stat(statePath) + assert.NoError(t, err) +} + +func TestState_Update_Good(t *testing.T) { + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "containers.json") + state := NewState(statePath) + + container := &Container{ + ID: "abc12345", + Status: StatusRunning, + } + _ = state.Add(container) + + // Update status + container.Status = StatusStopped + err := state.Update(container) + require.NoError(t, err) + + // Verify update + c, ok := state.Get("abc12345") + assert.True(t, ok) + assert.Equal(t, StatusStopped, c.Status) +} + +func TestState_Remove_Good(t *testing.T) { + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "containers.json") + state := NewState(statePath) + + container := &Container{ + ID: "abc12345", + } + _ = state.Add(container) + + err := state.Remove("abc12345") + require.NoError(t, err) + + _, ok := state.Get("abc12345") + assert.False(t, ok) +} + +func TestState_Get_Bad_NotFound(t *testing.T) { + state := NewState("/tmp/test-state.json") + + _, ok := state.Get("nonexistent") + assert.False(t, ok) +} + +func TestState_All_Good(t *testing.T) { + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "containers.json") + state := NewState(statePath) + + _ = state.Add(&Container{ID: "aaa11111"}) + _ = state.Add(&Container{ID: "bbb22222"}) + _ = state.Add(&Container{ID: "ccc33333"}) + + all := state.All() + assert.Len(t, all, 3) +} + +func TestState_SaveState_Good_CreatesDirectory(t *testing.T) { + tmpDir := t.TempDir() + nestedPath := filepath.Join(tmpDir, "nested", "dir", "containers.json") + state := NewState(nestedPath) + + _ = state.Add(&Container{ID: "abc12345"}) + + err := state.SaveState() + require.NoError(t, err) + + // Verify directory was created + _, err = os.Stat(filepath.Dir(nestedPath)) + assert.NoError(t, err) +} + +func TestDefaultStateDir_Good(t *testing.T) { + dir, err := DefaultStateDir() + require.NoError(t, err) + assert.Contains(t, dir, ".core") +} + +func TestDefaultStatePath_Good(t *testing.T) { + path, err := DefaultStatePath() + require.NoError(t, err) + assert.Contains(t, path, "containers.json") +} + +func TestDefaultLogsDir_Good(t *testing.T) { + dir, err := DefaultLogsDir() + require.NoError(t, err) + assert.Contains(t, dir, "logs") +} + +func TestLogPath_Good(t *testing.T) { + path, err := LogPath("abc12345") + require.NoError(t, err) + assert.Contains(t, path, "abc12345.log") +} + +func TestEnsureLogsDir_Good(t *testing.T) { + // This test creates real directories - skip in CI if needed + err := EnsureLogsDir() + assert.NoError(t, err) + + logsDir, _ := DefaultLogsDir() + _, err = os.Stat(logsDir) + assert.NoError(t, err) +} + +func TestGenerateID_Good(t *testing.T) { + id1, err := GenerateID() + require.NoError(t, err) + assert.Len(t, id1, 8) + + id2, err := GenerateID() + require.NoError(t, err) + assert.Len(t, id2, 8) + + // IDs should be different + assert.NotEqual(t, id1, id2) +} diff --git a/templates.go b/templates.go new file mode 100644 index 0000000..44a4307 --- /dev/null +++ b/templates.go @@ -0,0 +1,314 @@ +package container + +import ( + "embed" + "fmt" + "iter" + "maps" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "forge.lthn.ai/core/go-io" +) + +//go:embed templates/*.yml +var embeddedTemplates embed.FS + +// Template represents a LinuxKit YAML template. +type Template struct { + // Name is the template identifier (e.g., "core-dev", "server-php"). + Name string + // Description is a human-readable description of the template. + Description string + // Path is the file path to the template (relative or absolute). + Path string +} + +// builtinTemplates defines the metadata for embedded templates. +var builtinTemplates = []Template{ + { + Name: "core-dev", + Description: "Development environment with Go, Node.js, PHP, Docker-in-LinuxKit, and SSH access", + Path: "templates/core-dev.yml", + }, + { + Name: "server-php", + Description: "Production PHP server with FrankenPHP, Caddy reverse proxy, and health checks", + Path: "templates/server-php.yml", + }, +} + +// ListTemplates returns all available LinuxKit templates. +// It combines embedded templates with any templates found in the user's +// .core/linuxkit directory. +func ListTemplates() []Template { + return slices.Collect(ListTemplatesIter()) +} + +// ListTemplatesIter returns an iterator for all available LinuxKit templates. +func ListTemplatesIter() iter.Seq[Template] { + return func(yield func(Template) bool) { + // Yield builtin templates + for _, t := range builtinTemplates { + if !yield(t) { + return + } + } + + // Check for user templates in .core/linuxkit/ + userTemplatesDir := getUserTemplatesDir() + if userTemplatesDir != "" { + for _, t := range scanUserTemplates(userTemplatesDir) { + if !yield(t) { + return + } + } + } + } +} + +// GetTemplate returns the content of a template by name. +// It first checks embedded templates, then user templates. +func GetTemplate(name string) (string, error) { + // Check embedded templates first + for _, t := range builtinTemplates { + if t.Name == name { + content, err := embeddedTemplates.ReadFile(t.Path) + if err != nil { + return "", fmt.Errorf("failed to read embedded template %s: %w", name, err) + } + return string(content), nil + } + } + + // Check user templates + userTemplatesDir := getUserTemplatesDir() + if userTemplatesDir != "" { + templatePath := filepath.Join(userTemplatesDir, name+".yml") + if io.Local.IsFile(templatePath) { + content, err := io.Local.Read(templatePath) + if err != nil { + return "", fmt.Errorf("failed to read user template %s: %w", name, err) + } + return content, nil + } + } + + return "", fmt.Errorf("template not found: %s", name) +} + +// ApplyTemplate applies variable substitution to a template. +// It supports two syntaxes: +// - ${VAR} - required variable, returns error if not provided +// - ${VAR:-default} - variable with default value +func ApplyTemplate(name string, vars map[string]string) (string, error) { + content, err := GetTemplate(name) + if err != nil { + return "", err + } + + return ApplyVariables(content, vars) +} + +// ApplyVariables applies variable substitution to content string. +// It supports two syntaxes: +// - ${VAR} - required variable, returns error if not provided +// - ${VAR:-default} - variable with default value +func ApplyVariables(content string, vars map[string]string) (string, error) { + // Pattern for ${VAR:-default} syntax + defaultPattern := regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}`) + + // Pattern for ${VAR} syntax (no default) + requiredPattern := regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`) + + // Track missing required variables + var missingVars []string + + // First pass: replace variables with defaults + result := defaultPattern.ReplaceAllStringFunc(content, func(match string) string { + submatch := defaultPattern.FindStringSubmatch(match) + if len(submatch) != 3 { + return match + } + varName := submatch[1] + defaultVal := submatch[2] + + if val, ok := vars[varName]; ok { + return val + } + return defaultVal + }) + + // Second pass: replace required variables and track missing ones + result = requiredPattern.ReplaceAllStringFunc(result, func(match string) string { + submatch := requiredPattern.FindStringSubmatch(match) + if len(submatch) != 2 { + return match + } + varName := submatch[1] + + if val, ok := vars[varName]; ok { + return val + } + missingVars = append(missingVars, varName) + return match // Keep original if missing + }) + + if len(missingVars) > 0 { + return "", fmt.Errorf("missing required variables: %s", strings.Join(missingVars, ", ")) + } + + return result, nil +} + +// ExtractVariables extracts all variable names from a template. +// Returns two slices: required variables and optional variables (with defaults). +func ExtractVariables(content string) (required []string, optional map[string]string) { + optional = make(map[string]string) + requiredSet := make(map[string]bool) + + // Pattern for ${VAR:-default} syntax + defaultPattern := regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}`) + + // Pattern for ${VAR} syntax (no default) + requiredPattern := regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`) + + // Find optional variables with defaults + matches := defaultPattern.FindAllStringSubmatch(content, -1) + for _, match := range matches { + if len(match) == 3 { + optional[match[1]] = match[2] + } + } + + // Find required variables + matches = requiredPattern.FindAllStringSubmatch(content, -1) + for _, match := range matches { + if len(match) == 2 { + varName := match[1] + // Only add if not already in optional (with default) + if _, hasDefault := optional[varName]; !hasDefault { + requiredSet[varName] = true + } + } + } + + // Convert set to slice + required = slices.Sorted(maps.Keys(requiredSet)) + + return required, optional +} + +// getUserTemplatesDir returns the path to user templates directory. +// Returns empty string if the directory doesn't exist. +func getUserTemplatesDir() string { + // Try workspace-relative .core/linuxkit first + cwd, err := os.Getwd() + if err == nil { + wsDir := filepath.Join(cwd, ".core", "linuxkit") + if io.Local.IsDir(wsDir) { + return wsDir + } + } + + // Try home directory + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + homeDir := filepath.Join(home, ".core", "linuxkit") + if io.Local.IsDir(homeDir) { + return homeDir + } + + return "" +} + +// scanUserTemplates scans a directory for .yml template files. +func scanUserTemplates(dir string) []Template { + var templates []Template + + entries, err := io.Local.List(dir) + if err != nil { + return templates + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + if !strings.HasSuffix(name, ".yml") && !strings.HasSuffix(name, ".yaml") { + continue + } + + // Extract template name from filename + templateName := strings.TrimSuffix(strings.TrimSuffix(name, ".yml"), ".yaml") + + // Skip if this is a builtin template name (embedded takes precedence) + isBuiltin := false + for _, bt := range builtinTemplates { + if bt.Name == templateName { + isBuiltin = true + break + } + } + if isBuiltin { + continue + } + + // Read file to extract description from comments + description := extractTemplateDescription(filepath.Join(dir, name)) + if description == "" { + description = "User-defined template" + } + + templates = append(templates, Template{ + Name: templateName, + Description: description, + Path: filepath.Join(dir, name), + }) + } + + return templates +} + +// extractTemplateDescription reads the first comment block from a YAML file +// to use as a description. +func extractTemplateDescription(path string) string { + content, err := io.Local.Read(path) + if err != nil { + return "" + } + + lines := strings.Split(content, "\n") + var descLines []string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") { + // Remove the # and trim + comment := strings.TrimSpace(strings.TrimPrefix(trimmed, "#")) + if comment != "" { + descLines = append(descLines, comment) + // Only take the first meaningful comment line as description + if len(descLines) == 1 { + return comment + } + } + } else if trimmed != "" { + // Hit non-comment content, stop + break + } + } + + if len(descLines) > 0 { + return descLines[0] + } + return "" +} diff --git a/templates/core-dev.yml b/templates/core-dev.yml new file mode 100644 index 0000000..712e43e --- /dev/null +++ b/templates/core-dev.yml @@ -0,0 +1,121 @@ +# Core Development Environment Template +# A full-featured development environment with multiple runtimes +# +# Variables: +# ${SSH_KEY} - SSH public key for access (required) +# ${MEMORY:-2048} - Memory in MB (default: 2048) +# ${CPUS:-2} - Number of CPUs (default: 2) +# ${HOSTNAME:-core-dev} - Hostname for the VM +# ${DATA_SIZE:-10G} - Size of persistent /data volume + +kernel: + image: linuxkit/kernel:6.6.13 + cmdline: "console=tty0 console=ttyS0" + +init: + - linuxkit/init:v1.2.0 + - linuxkit/runc:v1.1.12 + - linuxkit/containerd:v1.7.13 + - linuxkit/ca-certificates:v1.0.0 + +onboot: + - name: sysctl + image: linuxkit/sysctl:v1.0.0 + - name: format + image: linuxkit/format:v1.0.0 + - name: mount + image: linuxkit/mount:v1.0.0 + command: ["/usr/bin/mountie", "/dev/sda1", "/data"] + - name: dhcpcd + image: linuxkit/dhcpcd:v1.0.0 + command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf", "-1"] + +onshutdown: + - name: shutdown + image: busybox:latest + command: ["/bin/echo", "Shutting down..."] + +services: + - name: getty + image: linuxkit/getty:v1.0.0 + env: + - INSECURE=true + + - name: sshd + image: linuxkit/sshd:v1.2.0 + binds: + - /etc/ssh/authorized_keys:/root/.ssh/authorized_keys + + - name: docker + image: docker:24.0-dind + capabilities: + - all + net: host + pid: host + binds: + - /var/run:/var/run + - /data/docker:/var/lib/docker + rootfsPropagation: shared + + - name: dev-tools + image: alpine:3.19 + capabilities: + - all + net: host + binds: + - /data:/data + command: + - /bin/sh + - -c + - | + # Install development tools + apk add --no-cache \ + git curl wget vim nano htop tmux \ + build-base gcc musl-dev linux-headers \ + openssh-client jq yq + + # Install Go 1.22.0 + wget -q https://go.dev/dl/go1.22.0.linux-amd64.tar.gz + tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz + rm go1.22.0.linux-amd64.tar.gz + echo 'export PATH=/usr/local/go/bin:$PATH' >> /etc/profile + + # Install Node.js + apk add --no-cache nodejs npm + + # Install PHP + apk add --no-cache php82 php82-cli php82-curl php82-json php82-mbstring \ + php82-openssl php82-pdo php82-pdo_mysql php82-pdo_pgsql php82-phar \ + php82-session php82-tokenizer php82-xml php82-zip composer + + # Keep container running + tail -f /dev/null + +files: + - path: /etc/hostname + contents: "${HOSTNAME:-core-dev}" + - path: /etc/ssh/authorized_keys + contents: "${SSH_KEY}" + mode: "0600" + - path: /etc/profile.d/dev.sh + contents: | + export PATH=$PATH:/usr/local/go/bin + export GOPATH=/data/go + export PATH=$PATH:$GOPATH/bin + cd /data + mode: "0755" + - path: /etc/motd + contents: | + ================================================ + Core Development Environment + + Runtimes: Go, Node.js, PHP + Tools: git, curl, vim, docker + + Data directory: /data (persistent) + ================================================ + +trust: + org: + - linuxkit + - library diff --git a/templates/server-php.yml b/templates/server-php.yml new file mode 100644 index 0000000..9db9f74 --- /dev/null +++ b/templates/server-php.yml @@ -0,0 +1,142 @@ +# PHP/FrankenPHP Server Template +# A minimal production-ready PHP server with FrankenPHP and Caddy +# +# Variables: +# ${SSH_KEY} - SSH public key for management access (required) +# ${MEMORY:-512} - Memory in MB (default: 512) +# ${CPUS:-1} - Number of CPUs (default: 1) +# ${HOSTNAME:-php-server} - Hostname for the VM +# ${APP_NAME:-app} - Application name +# ${DOMAIN:-localhost} - Domain for SSL certificates +# ${PHP_MEMORY:-128M} - PHP memory limit + +kernel: + image: linuxkit/kernel:6.6.13 + cmdline: "console=tty0 console=ttyS0" + +init: + - linuxkit/init:v1.2.0 + - linuxkit/runc:v1.1.12 + - linuxkit/containerd:v1.7.13 + - linuxkit/ca-certificates:v1.0.0 + +onboot: + - name: sysctl + image: linuxkit/sysctl:v1.0.0 + - name: dhcpcd + image: linuxkit/dhcpcd:v1.0.0 + command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf", "-1"] + +services: + - name: sshd + image: linuxkit/sshd:v1.2.0 + binds: + - /etc/ssh/authorized_keys:/root/.ssh/authorized_keys + + - name: frankenphp + image: dunglas/frankenphp:latest + capabilities: + - CAP_NET_BIND_SERVICE + net: host + binds: + - /app:/app + - /data:/data + - /etc/caddy/Caddyfile:/etc/caddy/Caddyfile + env: + - SERVER_NAME=${DOMAIN:-localhost} + - FRANKENPHP_CONFIG=/etc/caddy/Caddyfile + command: + - frankenphp + - run + - --config + - /etc/caddy/Caddyfile + + - name: healthcheck + image: alpine:3.19 + net: host + command: + - /bin/sh + - -c + - | + apk add --no-cache curl + while true; do + sleep 30 + curl -sf http://localhost/health || echo "Health check failed" + done + +files: + - path: /etc/hostname + contents: "${HOSTNAME:-php-server}" + - path: /etc/ssh/authorized_keys + contents: "${SSH_KEY}" + mode: "0600" + - path: /etc/caddy/Caddyfile + contents: | + { + frankenphp + order php_server before file_server + } + + ${DOMAIN:-localhost} { + root * /app/public + + # Health check endpoint + handle /health { + respond "OK" 200 + } + + # PHP handling + php_server + + # Encode responses + encode zstd gzip + + # Security headers + header { + X-Content-Type-Options nosniff + X-Frame-Options DENY + X-XSS-Protection "1; mode=block" + Referrer-Policy strict-origin-when-cross-origin + } + + # Logging + log { + output file /data/logs/access.log + format json + } + } + mode: "0644" + - path: /app/public/index.php + contents: | + 'healthy', + 'app' => '${APP_NAME:-app}', + 'timestamp' => date('c'), + 'php_version' => PHP_VERSION, + ]); + mode: "0644" + - path: /etc/php/php.ini + contents: | + memory_limit = ${PHP_MEMORY:-128M} + max_execution_time = 30 + upload_max_filesize = 64M + post_max_size = 64M + display_errors = Off + log_errors = On + error_log = /data/logs/php_errors.log + mode: "0644" + - path: /data/logs/.gitkeep + contents: "" + +trust: + org: + - linuxkit + - library + - dunglas diff --git a/templates_test.go b/templates_test.go new file mode 100644 index 0000000..d01b9c8 --- /dev/null +++ b/templates_test.go @@ -0,0 +1,495 @@ +package container + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListTemplates_Good(t *testing.T) { + templates := ListTemplates() + + // Should have at least the builtin templates + assert.GreaterOrEqual(t, len(templates), 2) + + // Find the core-dev template + var found bool + for _, tmpl := range templates { + if tmpl.Name == "core-dev" { + found = true + assert.NotEmpty(t, tmpl.Description) + assert.NotEmpty(t, tmpl.Path) + break + } + } + assert.True(t, found, "core-dev template should exist") + + // Find the server-php template + found = false + for _, tmpl := range templates { + if tmpl.Name == "server-php" { + found = true + assert.NotEmpty(t, tmpl.Description) + assert.NotEmpty(t, tmpl.Path) + break + } + } + assert.True(t, found, "server-php template should exist") +} + +func TestGetTemplate_Good_CoreDev(t *testing.T) { + content, err := GetTemplate("core-dev") + + require.NoError(t, err) + assert.NotEmpty(t, content) + assert.Contains(t, content, "kernel:") + assert.Contains(t, content, "linuxkit/kernel") + assert.Contains(t, content, "${SSH_KEY}") + assert.Contains(t, content, "services:") +} + +func TestGetTemplate_Good_ServerPhp(t *testing.T) { + content, err := GetTemplate("server-php") + + require.NoError(t, err) + assert.NotEmpty(t, content) + assert.Contains(t, content, "kernel:") + assert.Contains(t, content, "frankenphp") + assert.Contains(t, content, "${SSH_KEY}") + assert.Contains(t, content, "${DOMAIN:-localhost}") +} + +func TestGetTemplate_Bad_NotFound(t *testing.T) { + _, err := GetTemplate("nonexistent-template") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "template not found") +} + +func TestApplyVariables_Good_SimpleSubstitution(t *testing.T) { + content := "Hello ${NAME}, welcome to ${PLACE}!" + vars := map[string]string{ + "NAME": "World", + "PLACE": "Core", + } + + result, err := ApplyVariables(content, vars) + + require.NoError(t, err) + assert.Equal(t, "Hello World, welcome to Core!", result) +} + +func TestApplyVariables_Good_WithDefaults(t *testing.T) { + content := "Memory: ${MEMORY:-1024}MB, CPUs: ${CPUS:-2}" + vars := map[string]string{ + "MEMORY": "2048", + // CPUS not provided, should use default + } + + result, err := ApplyVariables(content, vars) + + require.NoError(t, err) + assert.Equal(t, "Memory: 2048MB, CPUs: 2", result) +} + +func TestApplyVariables_Good_AllDefaults(t *testing.T) { + content := "${HOST:-localhost}:${PORT:-8080}" + vars := map[string]string{} // No vars provided + + result, err := ApplyVariables(content, vars) + + require.NoError(t, err) + assert.Equal(t, "localhost:8080", result) +} + +func TestApplyVariables_Good_MixedSyntax(t *testing.T) { + content := ` +hostname: ${HOSTNAME:-myhost} +ssh_key: ${SSH_KEY} +memory: ${MEMORY:-512} +` + vars := map[string]string{ + "SSH_KEY": "ssh-rsa AAAA...", + "HOSTNAME": "custom-host", + } + + result, err := ApplyVariables(content, vars) + + require.NoError(t, err) + assert.Contains(t, result, "hostname: custom-host") + assert.Contains(t, result, "ssh_key: ssh-rsa AAAA...") + assert.Contains(t, result, "memory: 512") +} + +func TestApplyVariables_Good_EmptyDefault(t *testing.T) { + content := "value: ${OPT:-}" + vars := map[string]string{} + + result, err := ApplyVariables(content, vars) + + require.NoError(t, err) + assert.Equal(t, "value: ", result) +} + +func TestApplyVariables_Bad_MissingRequired(t *testing.T) { + content := "SSH Key: ${SSH_KEY}" + vars := map[string]string{} // Missing required SSH_KEY + + _, err := ApplyVariables(content, vars) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing required variables") + assert.Contains(t, err.Error(), "SSH_KEY") +} + +func TestApplyVariables_Bad_MultipleMissing(t *testing.T) { + content := "${VAR1} and ${VAR2} and ${VAR3}" + vars := map[string]string{ + "VAR2": "provided", + } + + _, err := ApplyVariables(content, vars) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing required variables") + // Should mention both missing vars + errStr := err.Error() + assert.True(t, strings.Contains(errStr, "VAR1") || strings.Contains(errStr, "VAR3")) +} + +func TestApplyTemplate_Good(t *testing.T) { + vars := map[string]string{ + "SSH_KEY": "ssh-rsa AAAA... user@host", + } + + result, err := ApplyTemplate("core-dev", vars) + + require.NoError(t, err) + assert.NotEmpty(t, result) + assert.Contains(t, result, "ssh-rsa AAAA... user@host") + // Default values should be applied + assert.Contains(t, result, "core-dev") // HOSTNAME default +} + +func TestApplyTemplate_Bad_TemplateNotFound(t *testing.T) { + vars := map[string]string{ + "SSH_KEY": "test", + } + + _, err := ApplyTemplate("nonexistent", vars) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "template not found") +} + +func TestApplyTemplate_Bad_MissingVariable(t *testing.T) { + // server-php requires SSH_KEY + vars := map[string]string{} // Missing required SSH_KEY + + _, err := ApplyTemplate("server-php", vars) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing required variables") +} + +func TestExtractVariables_Good(t *testing.T) { + content := ` +hostname: ${HOSTNAME:-myhost} +ssh_key: ${SSH_KEY} +memory: ${MEMORY:-1024} +cpus: ${CPUS:-2} +api_key: ${API_KEY} +` + required, optional := ExtractVariables(content) + + // Required variables (no default) + assert.Contains(t, required, "SSH_KEY") + assert.Contains(t, required, "API_KEY") + assert.Len(t, required, 2) + + // Optional variables (with defaults) + assert.Equal(t, "myhost", optional["HOSTNAME"]) + assert.Equal(t, "1024", optional["MEMORY"]) + assert.Equal(t, "2", optional["CPUS"]) + assert.Len(t, optional, 3) +} + +func TestExtractVariables_Good_NoVariables(t *testing.T) { + content := "This has no variables at all" + + required, optional := ExtractVariables(content) + + assert.Empty(t, required) + assert.Empty(t, optional) +} + +func TestExtractVariables_Good_OnlyDefaults(t *testing.T) { + content := "${A:-default1} ${B:-default2}" + + required, optional := ExtractVariables(content) + + assert.Empty(t, required) + assert.Len(t, optional, 2) + assert.Equal(t, "default1", optional["A"]) + assert.Equal(t, "default2", optional["B"]) +} + +func TestScanUserTemplates_Good(t *testing.T) { + // Create a temporary directory with template files + tmpDir := t.TempDir() + + // Create a valid template file + templateContent := `# My Custom Template +# A custom template for testing +kernel: + image: linuxkit/kernel:6.6 +` + err := os.WriteFile(filepath.Join(tmpDir, "custom.yml"), []byte(templateContent), 0644) + require.NoError(t, err) + + // Create a non-template file (should be ignored) + err = os.WriteFile(filepath.Join(tmpDir, "readme.txt"), []byte("Not a template"), 0644) + require.NoError(t, err) + + templates := scanUserTemplates(tmpDir) + + assert.Len(t, templates, 1) + assert.Equal(t, "custom", templates[0].Name) + assert.Equal(t, "My Custom Template", templates[0].Description) +} + +func TestScanUserTemplates_Good_MultipleTemplates(t *testing.T) { + tmpDir := t.TempDir() + + // Create multiple template files + err := os.WriteFile(filepath.Join(tmpDir, "web.yml"), []byte("# Web Server\nkernel:"), 0644) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(tmpDir, "db.yaml"), []byte("# Database Server\nkernel:"), 0644) + require.NoError(t, err) + + templates := scanUserTemplates(tmpDir) + + assert.Len(t, templates, 2) + + // Check names are extracted correctly + names := make(map[string]bool) + for _, tmpl := range templates { + names[tmpl.Name] = true + } + assert.True(t, names["web"]) + assert.True(t, names["db"]) +} + +func TestScanUserTemplates_Good_EmptyDirectory(t *testing.T) { + tmpDir := t.TempDir() + + templates := scanUserTemplates(tmpDir) + + assert.Empty(t, templates) +} + +func TestScanUserTemplates_Bad_NonexistentDirectory(t *testing.T) { + templates := scanUserTemplates("/nonexistent/path/to/templates") + + assert.Empty(t, templates) +} + +func TestExtractTemplateDescription_Good(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "test.yml") + + content := `# My Template Description +# More details here +kernel: + image: test +` + err := os.WriteFile(path, []byte(content), 0644) + require.NoError(t, err) + + desc := extractTemplateDescription(path) + + assert.Equal(t, "My Template Description", desc) +} + +func TestExtractTemplateDescription_Good_NoComments(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "test.yml") + + content := `kernel: + image: test +` + err := os.WriteFile(path, []byte(content), 0644) + require.NoError(t, err) + + desc := extractTemplateDescription(path) + + assert.Empty(t, desc) +} + +func TestExtractTemplateDescription_Bad_FileNotFound(t *testing.T) { + desc := extractTemplateDescription("/nonexistent/file.yml") + + assert.Empty(t, desc) +} + +func TestVariablePatternEdgeCases_Good(t *testing.T) { + tests := []struct { + name string + content string + vars map[string]string + expected string + }{ + { + name: "underscore in name", + content: "${MY_VAR:-default}", + vars: map[string]string{"MY_VAR": "value"}, + expected: "value", + }, + { + name: "numbers in name", + content: "${VAR123:-default}", + vars: map[string]string{}, + expected: "default", + }, + { + name: "default with special chars", + content: "${URL:-http://localhost:8080}", + vars: map[string]string{}, + expected: "http://localhost:8080", + }, + { + name: "default with path", + content: "${PATH:-/usr/local/bin}", + vars: map[string]string{}, + expected: "/usr/local/bin", + }, + { + name: "adjacent variables", + content: "${A:-a}${B:-b}${C:-c}", + vars: map[string]string{"B": "X"}, + expected: "aXc", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ApplyVariables(tt.content, tt.vars) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestScanUserTemplates_Good_SkipsBuiltinNames(t *testing.T) { + tmpDir := t.TempDir() + + // Create a template with a builtin name (should be skipped) + err := os.WriteFile(filepath.Join(tmpDir, "core-dev.yml"), []byte("# Duplicate\nkernel:"), 0644) + require.NoError(t, err) + + // Create a unique template + err = os.WriteFile(filepath.Join(tmpDir, "unique.yml"), []byte("# Unique\nkernel:"), 0644) + require.NoError(t, err) + + templates := scanUserTemplates(tmpDir) + + // Should only have the unique template, not the builtin name + assert.Len(t, templates, 1) + assert.Equal(t, "unique", templates[0].Name) +} + +func TestScanUserTemplates_Good_SkipsDirectories(t *testing.T) { + tmpDir := t.TempDir() + + // Create a subdirectory (should be skipped) + err := os.MkdirAll(filepath.Join(tmpDir, "subdir"), 0755) + require.NoError(t, err) + + // Create a valid template + err = os.WriteFile(filepath.Join(tmpDir, "valid.yml"), []byte("# Valid\nkernel:"), 0644) + require.NoError(t, err) + + templates := scanUserTemplates(tmpDir) + + assert.Len(t, templates, 1) + assert.Equal(t, "valid", templates[0].Name) +} + +func TestScanUserTemplates_Good_YamlExtension(t *testing.T) { + tmpDir := t.TempDir() + + // Create templates with both extensions + err := os.WriteFile(filepath.Join(tmpDir, "template1.yml"), []byte("# Template 1\nkernel:"), 0644) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(tmpDir, "template2.yaml"), []byte("# Template 2\nkernel:"), 0644) + require.NoError(t, err) + + templates := scanUserTemplates(tmpDir) + + assert.Len(t, templates, 2) + + names := make(map[string]bool) + for _, tmpl := range templates { + names[tmpl.Name] = true + } + assert.True(t, names["template1"]) + assert.True(t, names["template2"]) +} + +func TestExtractTemplateDescription_Good_EmptyComment(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "test.yml") + + // First comment is empty, second has content + content := `# +# Actual description here +kernel: + image: test +` + err := os.WriteFile(path, []byte(content), 0644) + require.NoError(t, err) + + desc := extractTemplateDescription(path) + + assert.Equal(t, "Actual description here", desc) +} + +func TestExtractTemplateDescription_Good_MultipleEmptyComments(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "test.yml") + + // Multiple empty comments before actual content + content := `# +# +# +# Real description +kernel: + image: test +` + err := os.WriteFile(path, []byte(content), 0644) + require.NoError(t, err) + + desc := extractTemplateDescription(path) + + assert.Equal(t, "Real description", desc) +} + +func TestScanUserTemplates_Good_DefaultDescription(t *testing.T) { + tmpDir := t.TempDir() + + // Create a template without comments + content := `kernel: + image: test +` + err := os.WriteFile(filepath.Join(tmpDir, "nocomment.yml"), []byte(content), 0644) + require.NoError(t, err) + + templates := scanUserTemplates(tmpDir) + + assert.Len(t, templates, 1) + assert.Equal(t, "User-defined template", templates[0].Description) +}