feat: extract container/, devops/, sources/ from go-devops

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 <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-09 11:42:51 +00:00
parent 68bac5dfb6
commit 8bc93ce610
34 changed files with 7518 additions and 0 deletions

34
CLAUDE.md Normal file
View file

@ -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

106
container.go Normal file
View file

@ -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"
)

143
devenv/claude.go Normal file
View file

@ -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()
}

61
devenv/claude_test.go Normal file
View file

@ -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)
}

90
devenv/config.go Normal file
View file

@ -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
}

255
devenv/config_test.go Normal file
View file

@ -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)
}

244
devenv/devops.go Normal file
View file

@ -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
}

833
devenv/devops_test.go Normal file
View file

@ -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)
}

199
devenv/images.go Normal file
View file

@ -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))
}

583
devenv/images_test.go Normal file
View file

@ -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)
}

110
devenv/serve.go Normal file
View file

@ -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"
}

137
devenv/serve_test.go Normal file
View file

@ -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"))
}

75
devenv/shell.go Normal file
View file

@ -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()
}

47
devenv/shell_test.go Normal file
View file

@ -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)
}

69
devenv/ssh_utils.go Normal file
View file

@ -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
}

189
devenv/test.go Normal file
View file

@ -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
}

354
devenv/test_test.go Normal file
View file

@ -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)
}
}

16
go.mod Normal file
View file

@ -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
)

274
hypervisor.go Normal file
View file

@ -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)
}
}

358
hypervisor_test.go Normal file
View file

@ -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")
}

462
linuxkit.go Normal file
View file

@ -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
}

786
linuxkit_test.go Normal file
View file

@ -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)
}

113
sources/cdn.go Normal file
View file

@ -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
}

306
sources/cdn_test.go Normal file
View file

@ -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)
}

72
sources/github.go Normal file
View file

@ -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
}

68
sources/github_test.go Normal file
View file

@ -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)
}

33
sources/source.go Normal file
View file

@ -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
}

35
sources/source_test.go Normal file
View file

@ -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)
}

172
state.go Normal file
View file

@ -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 &copy, 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, &copy)
}
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)
}

222
state_test.go Normal file
View file

@ -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)
}

314
templates.go Normal file
View file

@ -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 ""
}

121
templates/core-dev.yml Normal file
View file

@ -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

142
templates/server-php.yml Normal file
View file

@ -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: |
<?php
echo "Welcome to ${APP_NAME:-app}";
mode: "0644"
- path: /app/public/health.php
contents: |
<?php
header('Content-Type: application/json');
echo json_encode([
'status' => '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

495
templates_test.go Normal file
View file

@ -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)
}