diff --git a/cmd/core/cmd/container.go b/cmd/core/cmd/container.go new file mode 100644 index 00000000..8aaec3d6 --- /dev/null +++ b/cmd/core/cmd/container.go @@ -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) +} diff --git a/cmd/core/cmd/root.go b/cmd/core/cmd/root.go index 076d1989..6d60c558 100644 --- a/cmd/core/cmd/root.go +++ b/cmd/core/cmd/root.go @@ -85,6 +85,7 @@ func Execute() error { AddSearchCommand(app) AddInstallCommand(app) AddReleaseCommand(app) + AddContainerCommands(app) // Run the application return app.Run() } diff --git a/pkg/container/container.go b/pkg/container/container.go new file mode 100644 index 00000000..d7161c30 --- /dev/null +++ b/pkg/container/container.go @@ -0,0 +1,106 @@ +// Package container provides a runtime for managing LinuxKit containers. +// It supports running LinuxKit images (ISO, qcow2, vmdk, raw) using +// available hypervisors (QEMU on Linux, Hyperkit on macOS). +package container + +import ( + "context" + "crypto/rand" + "encoding/hex" + "io" + "time" +) + +// Container represents a running LinuxKit container/VM instance. +type Container struct { + // ID is a unique identifier for the container (8 character hex string). + ID string `json:"id"` + // Name is the optional human-readable name for the container. + Name string `json:"name,omitempty"` + // Image is the path to the LinuxKit image being run. + Image string `json:"image"` + // Status represents the current state of the container. + Status Status `json:"status"` + // PID is the process ID of the hypervisor running this container. + PID int `json:"pid"` + // StartedAt is when the container was started. + StartedAt time.Time `json:"started_at"` + // Ports maps host ports to container ports. + Ports map[int]int `json:"ports,omitempty"` + // Memory is the amount of memory allocated in MB. + Memory int `json:"memory,omitempty"` + // CPUs is the number of CPUs allocated. + CPUs int `json:"cpus,omitempty"` +} + +// Status represents the state of a container. +type Status string + +const ( + // StatusRunning indicates the container is running. + StatusRunning Status = "running" + // StatusStopped indicates the container has stopped. + StatusStopped Status = "stopped" + // StatusError indicates the container encountered an error. + StatusError Status = "error" +) + +// RunOptions configures how a container should be run. +type RunOptions struct { + // Name is an optional human-readable name for the container. + Name string + // Detach runs the container in the background. + Detach bool + // Memory is the amount of memory to allocate in MB (default: 1024). + Memory int + // CPUs is the number of CPUs to allocate (default: 1). + CPUs int + // Ports maps host ports to container ports. + Ports map[int]int + // Volumes maps host paths to container paths. + Volumes map[string]string + // SSHPort is the port to use for SSH access (default: 2222). + SSHPort int + // SSHKey is the path to the SSH private key for exec commands. + SSHKey string +} + +// Manager defines the interface for container lifecycle management. +type Manager interface { + // Run starts a new container from the given image. + Run(ctx context.Context, image string, opts RunOptions) (*Container, error) + // Stop stops a running container by ID. + Stop(ctx context.Context, id string) error + // List returns all known containers. + List(ctx context.Context) ([]*Container, error) + // Logs returns a reader for the container's log output. + // If follow is true, the reader will continue to stream new log entries. + Logs(ctx context.Context, id string, follow bool) (io.ReadCloser, error) + // Exec executes a command inside the container via SSH. + Exec(ctx context.Context, id string, cmd []string) error +} + +// GenerateID creates a new unique container ID (8 hex characters). +func GenerateID() (string, error) { + bytes := make([]byte, 4) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +// ImageFormat represents the format of a LinuxKit image. +type ImageFormat string + +const ( + // FormatISO is an ISO image format. + FormatISO ImageFormat = "iso" + // FormatQCOW2 is a QEMU Copy-On-Write image format. + FormatQCOW2 ImageFormat = "qcow2" + // FormatVMDK is a VMware disk image format. + FormatVMDK ImageFormat = "vmdk" + // FormatRaw is a raw disk image format. + FormatRaw ImageFormat = "raw" + // FormatUnknown indicates an unknown image format. + FormatUnknown ImageFormat = "unknown" +) diff --git a/pkg/container/hypervisor.go b/pkg/container/hypervisor.go new file mode 100644 index 00000000..b5c1e5f3 --- /dev/null +++ b/pkg/container/hypervisor.go @@ -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) + } +} diff --git a/pkg/container/linuxkit.go b/pkg/container/linuxkit.go new file mode 100644 index 00000000..8bf34d58 --- /dev/null +++ b/pkg/container/linuxkit.go @@ -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 +} diff --git a/pkg/container/linuxkit_test.go b/pkg/container/linuxkit_test.go new file mode 100644 index 00000000..5dc2cd6a --- /dev/null +++ b/pkg/container/linuxkit_test.go @@ -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") +} diff --git a/pkg/container/state.go b/pkg/container/state.go new file mode 100644 index 00000000..53ab1e2a --- /dev/null +++ b/pkg/container/state.go @@ -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) +} diff --git a/pkg/container/state_test.go b/pkg/container/state_test.go new file mode 100644 index 00000000..cf4bf5f1 --- /dev/null +++ b/pkg/container/state_test.go @@ -0,0 +1,222 @@ +package container + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewState_Good(t *testing.T) { + state := NewState("/tmp/test-state.json") + + assert.NotNil(t, state) + assert.NotNil(t, state.Containers) + assert.Equal(t, "/tmp/test-state.json", state.FilePath()) +} + +func TestLoadState_Good_NewFile(t *testing.T) { + // Test loading from non-existent file + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "containers.json") + + state, err := LoadState(statePath) + + require.NoError(t, err) + assert.NotNil(t, state) + assert.Empty(t, state.Containers) +} + +func TestLoadState_Good_ExistingFile(t *testing.T) { + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "containers.json") + + // Create a state file with data + content := `{ + "containers": { + "abc12345": { + "id": "abc12345", + "name": "test-container", + "image": "/path/to/image.iso", + "status": "running", + "pid": 12345, + "started_at": "2024-01-01T00:00:00Z" + } + } + }` + err := os.WriteFile(statePath, []byte(content), 0644) + require.NoError(t, err) + + state, err := LoadState(statePath) + + require.NoError(t, err) + assert.Len(t, state.Containers, 1) + + c, ok := state.Get("abc12345") + assert.True(t, ok) + assert.Equal(t, "test-container", c.Name) + assert.Equal(t, StatusRunning, c.Status) +} + +func TestLoadState_Bad_InvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "containers.json") + + // Create invalid JSON + err := os.WriteFile(statePath, []byte("invalid json{"), 0644) + require.NoError(t, err) + + _, err = LoadState(statePath) + assert.Error(t, err) +} + +func TestState_Add_Good(t *testing.T) { + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "containers.json") + state := NewState(statePath) + + container := &Container{ + ID: "abc12345", + Name: "test", + Image: "/path/to/image.iso", + Status: StatusRunning, + PID: 12345, + StartedAt: time.Now(), + } + + err := state.Add(container) + require.NoError(t, err) + + // Verify it's in memory + c, ok := state.Get("abc12345") + assert.True(t, ok) + assert.Equal(t, container.Name, c.Name) + + // Verify file was created + _, err = os.Stat(statePath) + assert.NoError(t, err) +} + +func TestState_Update_Good(t *testing.T) { + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "containers.json") + state := NewState(statePath) + + container := &Container{ + ID: "abc12345", + Status: StatusRunning, + } + state.Add(container) + + // Update status + container.Status = StatusStopped + err := state.Update(container) + require.NoError(t, err) + + // Verify update + c, ok := state.Get("abc12345") + assert.True(t, ok) + assert.Equal(t, StatusStopped, c.Status) +} + +func TestState_Remove_Good(t *testing.T) { + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "containers.json") + state := NewState(statePath) + + container := &Container{ + ID: "abc12345", + } + state.Add(container) + + err := state.Remove("abc12345") + require.NoError(t, err) + + _, ok := state.Get("abc12345") + assert.False(t, ok) +} + +func TestState_Get_Bad_NotFound(t *testing.T) { + state := NewState("/tmp/test-state.json") + + _, ok := state.Get("nonexistent") + assert.False(t, ok) +} + +func TestState_All_Good(t *testing.T) { + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "containers.json") + state := NewState(statePath) + + state.Add(&Container{ID: "aaa11111"}) + state.Add(&Container{ID: "bbb22222"}) + state.Add(&Container{ID: "ccc33333"}) + + all := state.All() + assert.Len(t, all, 3) +} + +func TestState_SaveState_Good_CreatesDirectory(t *testing.T) { + tmpDir := t.TempDir() + nestedPath := filepath.Join(tmpDir, "nested", "dir", "containers.json") + state := NewState(nestedPath) + + state.Add(&Container{ID: "abc12345"}) + + err := state.SaveState() + require.NoError(t, err) + + // Verify directory was created + _, err = os.Stat(filepath.Dir(nestedPath)) + assert.NoError(t, err) +} + +func TestDefaultStateDir_Good(t *testing.T) { + dir, err := DefaultStateDir() + require.NoError(t, err) + assert.Contains(t, dir, ".core") +} + +func TestDefaultStatePath_Good(t *testing.T) { + path, err := DefaultStatePath() + require.NoError(t, err) + assert.Contains(t, path, "containers.json") +} + +func TestDefaultLogsDir_Good(t *testing.T) { + dir, err := DefaultLogsDir() + require.NoError(t, err) + assert.Contains(t, dir, "logs") +} + +func TestLogPath_Good(t *testing.T) { + path, err := LogPath("abc12345") + require.NoError(t, err) + assert.Contains(t, path, "abc12345.log") +} + +func TestEnsureLogsDir_Good(t *testing.T) { + // This test creates real directories - skip in CI if needed + err := EnsureLogsDir() + assert.NoError(t, err) + + logsDir, _ := DefaultLogsDir() + _, err = os.Stat(logsDir) + assert.NoError(t, err) +} + +func TestGenerateID_Good(t *testing.T) { + id1, err := GenerateID() + require.NoError(t, err) + assert.Len(t, id1, 8) + + id2, err := GenerateID() + require.NoError(t, err) + assert.Len(t, id2, 8) + + // IDs should be different + assert.NotEqual(t, id1, id2) +}