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)
|
||||
AddInstallCommand(app)
|
||||
AddReleaseCommand(app)
|
||||
AddContainerCommands(app)
|
||||
// Run the application
|
||||
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