feat(container): implement LinuxKit container runtime

Add pkg/container for running LinuxKit VMs:
- Manager interface with Run, Stop, List, Logs, Exec
- Hypervisor abstraction (QEMU, Hyperkit)
- Auto-detect available hypervisor and image format
- State persistence in ~/.core/containers.json
- Log management in ~/.core/logs/

CLI commands:
- core run <image> - run LinuxKit image (-d for detach)
- core ps - list containers (-a for all)
- core stop <id> - stop container
- core logs <id> - view logs (-f to follow)
- core exec <id> <cmd> - execute via SSH

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-28 18:50:32 +00:00
parent d25a86feca
commit 3bd9f9bc3d
8 changed files with 1995 additions and 0 deletions

333
cmd/core/cmd/container.go Normal file
View file

@ -0,0 +1,333 @@
package cmd
import (
"context"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
"time"
"github.com/host-uk/core/pkg/container"
"github.com/leaanthony/clir"
)
// AddContainerCommands adds container-related commands to the CLI.
func AddContainerCommands(parent *clir.Cli) {
AddRunCommand(parent)
AddPsCommand(parent)
AddStopCommand(parent)
AddLogsCommand(parent)
AddExecCommand(parent)
}
// AddRunCommand adds the 'run' command.
func AddRunCommand(parent *clir.Cli) {
var (
name string
detach bool
memory int
cpus int
sshPort int
)
runCmd := parent.NewSubCommand("run", "Run a LinuxKit image")
runCmd.LongDescription("Runs a LinuxKit image as a VM using the available hypervisor.\n\n" +
"Supported image formats: .iso, .qcow2, .vmdk, .raw\n\n" +
"Examples:\n" +
" core run image.iso\n" +
" core run -d image.qcow2\n" +
" core run --name myvm --memory 2048 --cpus 4 image.iso")
runCmd.StringFlag("name", "Name for the container", &name)
runCmd.BoolFlag("d", "Run in detached mode (background)", &detach)
runCmd.IntFlag("memory", "Memory in MB (default: 1024)", &memory)
runCmd.IntFlag("cpus", "Number of CPUs (default: 1)", &cpus)
runCmd.IntFlag("ssh-port", "SSH port for exec commands (default: 2222)", &sshPort)
runCmd.Action(func() error {
args := runCmd.OtherArgs()
if len(args) == 0 {
return fmt.Errorf("image path is required")
}
image := args[0]
return runContainer(image, name, detach, memory, cpus, sshPort)
})
}
func runContainer(image, name string, detach bool, memory, cpus, sshPort int) error {
manager, err := container.NewLinuxKitManager()
if err != nil {
return fmt.Errorf("failed to initialize container manager: %w", err)
}
opts := container.RunOptions{
Name: name,
Detach: detach,
Memory: memory,
CPUs: cpus,
SSHPort: sshPort,
}
fmt.Printf("%s %s\n", dimStyle.Render("Image:"), image)
if name != "" {
fmt.Printf("%s %s\n", dimStyle.Render("Name:"), name)
}
fmt.Printf("%s %s\n", dimStyle.Render("Hypervisor:"), manager.Hypervisor().Name())
fmt.Println()
ctx := context.Background()
c, err := manager.Run(ctx, image, opts)
if err != nil {
return fmt.Errorf("failed to run container: %w", err)
}
if detach {
fmt.Printf("%s %s\n", successStyle.Render("Started:"), c.ID)
fmt.Printf("%s %d\n", dimStyle.Render("PID:"), c.PID)
fmt.Println()
fmt.Printf("Use 'core logs %s' to view output\n", c.ID[:8])
fmt.Printf("Use 'core stop %s' to stop\n", c.ID[:8])
} else {
fmt.Printf("\n%s %s\n", dimStyle.Render("Container stopped:"), c.ID)
}
return nil
}
// AddPsCommand adds the 'ps' command.
func AddPsCommand(parent *clir.Cli) {
var all bool
psCmd := parent.NewSubCommand("ps", "List running containers")
psCmd.LongDescription("Lists all containers. By default, only shows running containers.\n\n" +
"Examples:\n" +
" core ps\n" +
" core ps -a")
psCmd.BoolFlag("a", "Show all containers (including stopped)", &all)
psCmd.Action(func() error {
return listContainers(all)
})
}
func listContainers(all bool) error {
manager, err := container.NewLinuxKitManager()
if err != nil {
return fmt.Errorf("failed to initialize container manager: %w", err)
}
ctx := context.Background()
containers, err := manager.List(ctx)
if err != nil {
return fmt.Errorf("failed to list containers: %w", err)
}
// Filter if not showing all
if !all {
filtered := make([]*container.Container, 0)
for _, c := range containers {
if c.Status == container.StatusRunning {
filtered = append(filtered, c)
}
}
containers = filtered
}
if len(containers) == 0 {
if all {
fmt.Println("No containers")
} else {
fmt.Println("No running containers")
}
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNAME\tIMAGE\tSTATUS\tSTARTED\tPID")
fmt.Fprintln(w, "--\t----\t-----\t------\t-------\t---")
for _, c := range containers {
// Shorten image path
imageName := c.Image
if len(imageName) > 30 {
imageName = "..." + imageName[len(imageName)-27:]
}
// Format duration
duration := formatDuration(time.Since(c.StartedAt))
// Status with color
status := string(c.Status)
switch c.Status {
case container.StatusRunning:
status = successStyle.Render(status)
case container.StatusStopped:
status = dimStyle.Render(status)
case container.StatusError:
status = errorStyle.Render(status)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n",
c.ID[:8], c.Name, imageName, status, duration, c.PID)
}
w.Flush()
return nil
}
func formatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d.Minutes()))
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh", int(d.Hours()))
}
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
// AddStopCommand adds the 'stop' command.
func AddStopCommand(parent *clir.Cli) {
stopCmd := parent.NewSubCommand("stop", "Stop a running container")
stopCmd.LongDescription("Stops a running container by ID.\n\n" +
"Examples:\n" +
" core stop abc12345\n" +
" core stop abc1")
stopCmd.Action(func() error {
args := stopCmd.OtherArgs()
if len(args) == 0 {
return fmt.Errorf("container ID is required")
}
return stopContainer(args[0])
})
}
func stopContainer(id string) error {
manager, err := container.NewLinuxKitManager()
if err != nil {
return fmt.Errorf("failed to initialize container manager: %w", err)
}
// Support partial ID matching
fullID, err := resolveContainerID(manager, id)
if err != nil {
return err
}
fmt.Printf("%s %s\n", dimStyle.Render("Stopping:"), fullID[:8])
ctx := context.Background()
if err := manager.Stop(ctx, fullID); err != nil {
return fmt.Errorf("failed to stop container: %w", err)
}
fmt.Printf("%s\n", successStyle.Render("Stopped"))
return nil
}
// resolveContainerID resolves a partial ID to a full ID.
func resolveContainerID(manager *container.LinuxKitManager, partialID string) (string, error) {
ctx := context.Background()
containers, err := manager.List(ctx)
if err != nil {
return "", err
}
var matches []*container.Container
for _, c := range containers {
if strings.HasPrefix(c.ID, partialID) || strings.HasPrefix(c.Name, partialID) {
matches = append(matches, c)
}
}
switch len(matches) {
case 0:
return "", fmt.Errorf("no container found matching: %s", partialID)
case 1:
return matches[0].ID, nil
default:
return "", fmt.Errorf("multiple containers match '%s', be more specific", partialID)
}
}
// AddLogsCommand adds the 'logs' command.
func AddLogsCommand(parent *clir.Cli) {
var follow bool
logsCmd := parent.NewSubCommand("logs", "View container logs")
logsCmd.LongDescription("View logs from a container.\n\n" +
"Examples:\n" +
" core logs abc12345\n" +
" core logs -f abc1")
logsCmd.BoolFlag("f", "Follow log output", &follow)
logsCmd.Action(func() error {
args := logsCmd.OtherArgs()
if len(args) == 0 {
return fmt.Errorf("container ID is required")
}
return viewLogs(args[0], follow)
})
}
func viewLogs(id string, follow bool) error {
manager, err := container.NewLinuxKitManager()
if err != nil {
return fmt.Errorf("failed to initialize container manager: %w", err)
}
fullID, err := resolveContainerID(manager, id)
if err != nil {
return err
}
ctx := context.Background()
reader, err := manager.Logs(ctx, fullID, follow)
if err != nil {
return fmt.Errorf("failed to get logs: %w", err)
}
defer reader.Close()
_, err = io.Copy(os.Stdout, reader)
return err
}
// AddExecCommand adds the 'exec' command.
func AddExecCommand(parent *clir.Cli) {
execCmd := parent.NewSubCommand("exec", "Execute a command in a container")
execCmd.LongDescription("Execute a command inside a running container via SSH.\n\n" +
"Examples:\n" +
" core exec abc12345 ls -la\n" +
" core exec abc1 /bin/sh")
execCmd.Action(func() error {
args := execCmd.OtherArgs()
if len(args) < 2 {
return fmt.Errorf("container ID and command are required")
}
return execInContainer(args[0], args[1:])
})
}
func execInContainer(id string, cmd []string) error {
manager, err := container.NewLinuxKitManager()
if err != nil {
return fmt.Errorf("failed to initialize container manager: %w", err)
}
fullID, err := resolveContainerID(manager, id)
if err != nil {
return err
}
ctx := context.Background()
return manager.Exec(ctx, fullID, cmd)
}

View file

@ -85,6 +85,7 @@ func Execute() error {
AddSearchCommand(app) AddSearchCommand(app)
AddInstallCommand(app) AddInstallCommand(app)
AddReleaseCommand(app) AddReleaseCommand(app)
AddContainerCommands(app)
// Run the application // Run the application
return app.Run() return app.Run()
} }

106
pkg/container/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"
)

273
pkg/container/hypervisor.go Normal file
View file

@ -0,0 +1,273 @@
package container
import (
"context"
"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, fmt.Errorf("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, fmt.Errorf("qemu is not available")
}
return h, nil
case "hyperkit":
h := NewHyperkitHypervisor()
if !h.Available() {
return nil, fmt.Errorf("hyperkit is not available (requires macOS)")
}
return h, nil
default:
return nil, fmt.Errorf("unknown hypervisor: %s", name)
}
}

433
pkg/container/linuxkit.go Normal file
View file

@ -0,0 +1,433 @@
package container
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"syscall"
"time"
)
// LinuxKitManager implements the Manager interface for LinuxKit VMs.
type LinuxKitManager struct {
state *State
hypervisor Hypervisor
}
// NewLinuxKitManager creates a new LinuxKit manager with auto-detected hypervisor.
func NewLinuxKitManager() (*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,
}, nil
}
// NewLinuxKitManagerWithHypervisor creates a manager with a specific hypervisor.
func NewLinuxKitManagerWithHypervisor(state *State, hypervisor Hypervisor) *LinuxKitManager {
return &LinuxKitManager{
state: state,
hypervisor: hypervisor,
}
}
// 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 _, err := os.Stat(image); err != nil {
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 := io.MultiWriter(logFile, os.Stdout)
io.Copy(mw, stdout)
}()
go func() {
mw := io.MultiWriter(logFile, os.Stderr)
io.Copy(mw, stderr)
}()
// Wait for the process to complete
if err := cmd.Wait(); err != nil {
container.Status = StatusError
} else {
container.Status = StatusStopped
}
logFile.Close()
m.state.Update(container)
return container, nil
}
// waitForExit monitors a detached process and updates state when it exits.
func (m *LinuxKitManager) waitForExit(id string, cmd *exec.Cmd) {
cmd.Wait()
container, ok := m.state.Get(id)
if ok {
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 {
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
}
// 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) {
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) (io.ReadCloser, error) {
_, 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 _, err := os.Stat(logPath); err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("no logs available for container: %s", id)
}
return nil, err
}
if !follow {
// Simple case: just open and return the file
return os.Open(logPath)
}
// Follow mode: create a reader that tails the file
return newFollowReader(ctx, logPath)
}
// followReader implements io.ReadCloser for following log files.
type followReader struct {
file *os.File
ctx context.Context
cancel context.CancelFunc
reader *bufio.Reader
}
func newFollowReader(ctx context.Context, path string) (*followReader, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
// Seek to end
file.Seek(0, io.SeekEnd)
ctx, cancel := context.WithCancel(ctx)
return &followReader{
file: file,
ctx: ctx,
cancel: cancel,
reader: bufio.NewReader(file),
}, nil
}
func (f *followReader) Read(p []byte) (int, error) {
for {
select {
case <-f.ctx.Done():
return 0, io.EOF
default:
}
n, err := f.reader.Read(p)
if n > 0 {
return n, nil
}
if err != nil && err != io.EOF {
return 0, err
}
// No data available, wait a bit and try again
select {
case <-f.ctx.Done():
return 0, io.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 {
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=no",
"-o", "UserKnownHostsFile=/dev/null",
"-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
}

View file

@ -0,0 +1,465 @@
package container
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"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.
func newTestManager(t *testing.T) (*LinuxKitManager, *MockHypervisor, string) {
tmpDir := t.TempDir()
statePath := filepath.Join(tmpDir, "containers.json")
state, err := LoadState(statePath)
require.NoError(t, err)
mock := NewMockHypervisor()
manager := NewLinuxKitManagerWithHypervisor(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(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) {
manager, _, tmpDir := newTestManager(t)
statePath := filepath.Join(tmpDir, "containers.json")
state, _ := LoadState(statePath)
manager = NewLinuxKitManagerWithHypervisor(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) {
manager, _, tmpDir := newTestManager(t)
statePath := filepath.Join(tmpDir, "containers.json")
state, _ := LoadState(statePath)
manager = NewLinuxKitManagerWithHypervisor(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) {
manager, _, tmpDir := newTestManager(t)
statePath := filepath.Join(tmpDir, "containers.json")
state, _ := LoadState(statePath)
manager = NewLinuxKitManagerWithHypervisor(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")
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, _ := LogPath("abc12345")
os.MkdirAll(filepath.Dir(logPath), 0755)
os.WriteFile(logPath, []byte(logContent), 0644)
ctx := context.Background()
reader, err := manager.Logs(ctx, "abc12345", false)
require.NoError(t, err)
defer 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, _ := GenerateID()
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 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_Name_Good(t *testing.T) {
h := NewHyperkitHypervisor()
assert.Equal(t, "hyperkit", h.Name())
}
func TestHyperkitHypervisor_BuildCommand_Good(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")
}
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 TestGetHypervisor_Bad_Unknown(t *testing.T) {
_, err := GetHypervisor("unknown-hypervisor")
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown hypervisor")
}

162
pkg/container/state.go Normal file
View file

@ -0,0 +1,162 @@
package container
import (
"encoding/json"
"os"
"path/filepath"
"sync"
)
// 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)
data, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
return state, nil
}
return nil, err
}
if err := json.Unmarshal(data, 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 := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.filePath, data, 0644)
}
// 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 container by ID.
func (s *State) Get(id string) (*Container, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
c, ok := s.Containers[id]
return c, ok
}
// 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 all containers in the state.
func (s *State) All() []*Container {
s.mu.RLock()
defer s.mu.RUnlock()
containers := make([]*Container, 0, len(s.Containers))
for _, c := range s.Containers {
containers = append(containers, c)
}
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 os.MkdirAll(logsDir, 0755)
}

222
pkg/container/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)
}