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:
parent
d25a86feca
commit
3bd9f9bc3d
8 changed files with 1995 additions and 0 deletions
333
cmd/core/cmd/container.go
Normal file
333
cmd/core/cmd/container.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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
106
pkg/container/container.go
Normal 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
273
pkg/container/hypervisor.go
Normal 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
433
pkg/container/linuxkit.go
Normal 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
|
||||||
|
}
|
||||||
465
pkg/container/linuxkit_test.go
Normal file
465
pkg/container/linuxkit_test.go
Normal 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
162
pkg/container/state.go
Normal 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
222
pkg/container/state_test.go
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue