diff --git a/container/container.go b/container/container.go deleted file mode 100644 index d7161c3..0000000 --- a/container/container.go +++ /dev/null @@ -1,106 +0,0 @@ -// 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/container/hypervisor.go b/container/hypervisor.go deleted file mode 100644 index 4c23dac..0000000 --- a/container/hypervisor.go +++ /dev/null @@ -1,274 +0,0 @@ -package container - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" -) - -// Hypervisor defines the interface for VM hypervisors. -type Hypervisor interface { - // Name returns the name of the hypervisor. - Name() string - // Available checks if the hypervisor is available on the system. - Available() bool - // BuildCommand builds the command to run a VM with the given options. - BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) -} - -// HypervisorOptions contains options for running a VM. -type HypervisorOptions struct { - // Memory in MB. - Memory int - // CPUs count. - CPUs int - // LogFile path for output. - LogFile string - // SSHPort for SSH access. - SSHPort int - // Ports maps host ports to guest ports. - Ports map[int]int - // Volumes maps host paths to guest paths (9p shares). - Volumes map[string]string - // Detach runs in background (nographic mode). - Detach bool -} - -// QemuHypervisor implements Hypervisor for QEMU. -type QemuHypervisor struct { - // Binary is the path to the qemu binary (defaults to qemu-system-x86_64). - Binary string -} - -// NewQemuHypervisor creates a new QEMU hypervisor instance. -func NewQemuHypervisor() *QemuHypervisor { - return &QemuHypervisor{ - Binary: "qemu-system-x86_64", - } -} - -// Name returns the hypervisor name. -func (q *QemuHypervisor) Name() string { - return "qemu" -} - -// Available checks if QEMU is installed and accessible. -func (q *QemuHypervisor) Available() bool { - _, err := exec.LookPath(q.Binary) - return err == nil -} - -// BuildCommand creates the QEMU command for running a VM. -func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) { - format := DetectImageFormat(image) - if format == FormatUnknown { - return nil, fmt.Errorf("unknown image format: %s", image) - } - - args := []string{ - "-m", fmt.Sprintf("%d", opts.Memory), - "-smp", fmt.Sprintf("%d", opts.CPUs), - "-enable-kvm", - } - - // Add the image based on format - switch format { - case FormatISO: - args = append(args, "-cdrom", image) - args = append(args, "-boot", "d") - case FormatQCOW2: - args = append(args, "-drive", fmt.Sprintf("file=%s,format=qcow2", image)) - case FormatVMDK: - args = append(args, "-drive", fmt.Sprintf("file=%s,format=vmdk", image)) - case FormatRaw: - args = append(args, "-drive", fmt.Sprintf("file=%s,format=raw", image)) - } - - // Always run in nographic mode for container-like behavior - args = append(args, "-nographic") - - // Add serial console for log output - args = append(args, "-serial", "stdio") - - // Network with port forwarding - netdev := "user,id=net0" - if opts.SSHPort > 0 { - netdev += fmt.Sprintf(",hostfwd=tcp::%d-:22", opts.SSHPort) - } - for hostPort, guestPort := range opts.Ports { - netdev += fmt.Sprintf(",hostfwd=tcp::%d-:%d", hostPort, guestPort) - } - args = append(args, "-netdev", netdev) - args = append(args, "-device", "virtio-net-pci,netdev=net0") - - // Add 9p shares for volumes - shareID := 0 - for hostPath, guestPath := range opts.Volumes { - tag := fmt.Sprintf("share%d", shareID) - args = append(args, - "-fsdev", fmt.Sprintf("local,id=%s,path=%s,security_model=none", tag, hostPath), - "-device", fmt.Sprintf("virtio-9p-pci,fsdev=%s,mount_tag=%s", tag, filepath.Base(guestPath)), - ) - shareID++ - } - - // Check if KVM is available on Linux, remove -enable-kvm if not - if runtime.GOOS != "linux" || !isKVMAvailable() { - // Remove -enable-kvm from args - newArgs := make([]string, 0, len(args)) - for _, arg := range args { - if arg != "-enable-kvm" { - newArgs = append(newArgs, arg) - } - } - args = newArgs - - // On macOS, use HVF acceleration if available - if runtime.GOOS == "darwin" { - args = append(args, "-accel", "hvf") - } - } - - cmd := exec.CommandContext(ctx, q.Binary, args...) - return cmd, nil -} - -// isKVMAvailable checks if KVM is available on the system. -func isKVMAvailable() bool { - _, err := os.Stat("/dev/kvm") - return err == nil -} - -// HyperkitHypervisor implements Hypervisor for macOS Hyperkit. -type HyperkitHypervisor struct { - // Binary is the path to the hyperkit binary. - Binary string -} - -// NewHyperkitHypervisor creates a new Hyperkit hypervisor instance. -func NewHyperkitHypervisor() *HyperkitHypervisor { - return &HyperkitHypervisor{ - Binary: "hyperkit", - } -} - -// Name returns the hypervisor name. -func (h *HyperkitHypervisor) Name() string { - return "hyperkit" -} - -// Available checks if Hyperkit is installed and accessible. -func (h *HyperkitHypervisor) Available() bool { - if runtime.GOOS != "darwin" { - return false - } - _, err := exec.LookPath(h.Binary) - return err == nil -} - -// BuildCommand creates the Hyperkit command for running a VM. -func (h *HyperkitHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) { - format := DetectImageFormat(image) - if format == FormatUnknown { - return nil, fmt.Errorf("unknown image format: %s", image) - } - - args := []string{ - "-m", fmt.Sprintf("%dM", opts.Memory), - "-c", fmt.Sprintf("%d", opts.CPUs), - "-A", // ACPI - "-u", // Unlimited console output - "-s", "0:0,hostbridge", - "-s", "31,lpc", - "-l", "com1,stdio", // Serial console - } - - // Add PCI slot for disk (slot 2) - switch format { - case FormatISO: - args = append(args, "-s", fmt.Sprintf("2:0,ahci-cd,%s", image)) - case FormatQCOW2, FormatVMDK, FormatRaw: - args = append(args, "-s", fmt.Sprintf("2:0,virtio-blk,%s", image)) - } - - // Network with port forwarding (slot 3) - netArgs := "virtio-net" - if opts.SSHPort > 0 || len(opts.Ports) > 0 { - // Hyperkit uses slirp for user networking with port forwarding - portForwards := make([]string, 0) - if opts.SSHPort > 0 { - portForwards = append(portForwards, fmt.Sprintf("tcp:%d:22", opts.SSHPort)) - } - for hostPort, guestPort := range opts.Ports { - portForwards = append(portForwards, fmt.Sprintf("tcp:%d:%d", hostPort, guestPort)) - } - if len(portForwards) > 0 { - netArgs += "," + strings.Join(portForwards, ",") - } - } - args = append(args, "-s", "3:0,"+netArgs) - - cmd := exec.CommandContext(ctx, h.Binary, args...) - return cmd, nil -} - -// DetectImageFormat determines the image format from its file extension. -func DetectImageFormat(path string) ImageFormat { - ext := strings.ToLower(filepath.Ext(path)) - switch ext { - case ".iso": - return FormatISO - case ".qcow2": - return FormatQCOW2 - case ".vmdk": - return FormatVMDK - case ".raw", ".img": - return FormatRaw - default: - return FormatUnknown - } -} - -// DetectHypervisor returns the best available hypervisor for the current platform. -func DetectHypervisor() (Hypervisor, error) { - // On macOS, prefer Hyperkit if available, fall back to QEMU - if runtime.GOOS == "darwin" { - hk := NewHyperkitHypervisor() - if hk.Available() { - return hk, nil - } - } - - // Try QEMU on all platforms - qemu := NewQemuHypervisor() - if qemu.Available() { - return qemu, nil - } - - return nil, errors.New("no hypervisor available: install qemu or hyperkit (macOS)") -} - -// GetHypervisor returns a specific hypervisor by name. -func GetHypervisor(name string) (Hypervisor, error) { - switch strings.ToLower(name) { - case "qemu": - h := NewQemuHypervisor() - if !h.Available() { - return nil, errors.New("qemu is not available") - } - return h, nil - case "hyperkit": - h := NewHyperkitHypervisor() - if !h.Available() { - return nil, errors.New("hyperkit is not available (requires macOS)") - } - return h, nil - default: - return nil, fmt.Errorf("unknown hypervisor: %s", name) - } -} diff --git a/container/hypervisor_test.go b/container/hypervisor_test.go deleted file mode 100644 index e5c9964..0000000 --- a/container/hypervisor_test.go +++ /dev/null @@ -1,358 +0,0 @@ -package container - -import ( - "context" - "runtime" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestQemuHypervisor_Available_Good(t *testing.T) { - q := NewQemuHypervisor() - - // Check if qemu is available on this system - available := q.Available() - - // We just verify it returns a boolean without error - // The actual availability depends on the system - assert.IsType(t, true, available) -} - -func TestQemuHypervisor_Available_Bad_InvalidBinary(t *testing.T) { - q := &QemuHypervisor{ - Binary: "nonexistent-qemu-binary-that-does-not-exist", - } - - available := q.Available() - - assert.False(t, available) -} - -func TestHyperkitHypervisor_Available_Good(t *testing.T) { - h := NewHyperkitHypervisor() - - available := h.Available() - - // On non-darwin systems, should always be false - if runtime.GOOS != "darwin" { - assert.False(t, available) - } else { - // On darwin, just verify it returns a boolean - assert.IsType(t, true, available) - } -} - -func TestHyperkitHypervisor_Available_Bad_NotDarwin(t *testing.T) { - if runtime.GOOS == "darwin" { - t.Skip("This test only runs on non-darwin systems") - } - - h := NewHyperkitHypervisor() - - available := h.Available() - - assert.False(t, available, "Hyperkit should not be available on non-darwin systems") -} - -func TestHyperkitHypervisor_Available_Bad_InvalidBinary(t *testing.T) { - h := &HyperkitHypervisor{ - Binary: "nonexistent-hyperkit-binary-that-does-not-exist", - } - - available := h.Available() - - assert.False(t, available) -} - -func TestIsKVMAvailable_Good(t *testing.T) { - // This test verifies the function runs without error - // The actual result depends on the system - result := isKVMAvailable() - - // On non-linux systems, should be false - if runtime.GOOS != "linux" { - assert.False(t, result, "KVM should not be available on non-linux systems") - } else { - // On linux, just verify it returns a boolean - assert.IsType(t, true, result) - } -} - -func TestDetectHypervisor_Good(t *testing.T) { - // DetectHypervisor tries to find an available hypervisor - hv, err := DetectHypervisor() - - // This test may pass or fail depending on system configuration - // If no hypervisor is available, it should return an error - if err != nil { - assert.Nil(t, hv) - assert.Contains(t, err.Error(), "no hypervisor available") - } else { - assert.NotNil(t, hv) - assert.NotEmpty(t, hv.Name()) - } -} - -func TestGetHypervisor_Good_Qemu(t *testing.T) { - hv, err := GetHypervisor("qemu") - - // Depends on whether qemu is installed - if err != nil { - assert.Contains(t, err.Error(), "not available") - } else { - assert.NotNil(t, hv) - assert.Equal(t, "qemu", hv.Name()) - } -} - -func TestGetHypervisor_Good_QemuUppercase(t *testing.T) { - hv, err := GetHypervisor("QEMU") - - // Depends on whether qemu is installed - if err != nil { - assert.Contains(t, err.Error(), "not available") - } else { - assert.NotNil(t, hv) - assert.Equal(t, "qemu", hv.Name()) - } -} - -func TestGetHypervisor_Good_Hyperkit(t *testing.T) { - hv, err := GetHypervisor("hyperkit") - - // On non-darwin systems, should always fail - if runtime.GOOS != "darwin" { - assert.Error(t, err) - assert.Contains(t, err.Error(), "not available") - } else { - // On darwin, depends on whether hyperkit is installed - if err != nil { - assert.Contains(t, err.Error(), "not available") - } else { - assert.NotNil(t, hv) - assert.Equal(t, "hyperkit", hv.Name()) - } - } -} - -func TestGetHypervisor_Bad_Unknown(t *testing.T) { - _, err := GetHypervisor("unknown-hypervisor") - - assert.Error(t, err) - assert.Contains(t, err.Error(), "unknown hypervisor") -} - -func TestQemuHypervisor_BuildCommand_Good_WithPortsAndVolumes(t *testing.T) { - q := NewQemuHypervisor() - - ctx := context.Background() - opts := &HypervisorOptions{ - Memory: 2048, - CPUs: 4, - SSHPort: 2222, - Ports: map[int]int{8080: 80, 443: 443}, - Volumes: map[string]string{ - "/host/data": "/container/data", - "/host/logs": "/container/logs", - }, - Detach: true, - } - - cmd, err := q.BuildCommand(ctx, "/path/to/image.iso", opts) - require.NoError(t, err) - assert.NotNil(t, cmd) - - // Verify command includes all expected args - args := cmd.Args - assert.Contains(t, args, "-m") - assert.Contains(t, args, "2048") - assert.Contains(t, args, "-smp") - assert.Contains(t, args, "4") -} - -func TestQemuHypervisor_BuildCommand_Good_QCow2Format(t *testing.T) { - q := NewQemuHypervisor() - - ctx := context.Background() - opts := &HypervisorOptions{Memory: 1024, CPUs: 1} - - cmd, err := q.BuildCommand(ctx, "/path/to/image.qcow2", opts) - require.NoError(t, err) - - // Check that the drive format is qcow2 - found := false - for _, arg := range cmd.Args { - if arg == "file=/path/to/image.qcow2,format=qcow2" { - found = true - break - } - } - assert.True(t, found, "Should have qcow2 drive argument") -} - -func TestQemuHypervisor_BuildCommand_Good_VMDKFormat(t *testing.T) { - q := NewQemuHypervisor() - - ctx := context.Background() - opts := &HypervisorOptions{Memory: 1024, CPUs: 1} - - cmd, err := q.BuildCommand(ctx, "/path/to/image.vmdk", opts) - require.NoError(t, err) - - // Check that the drive format is vmdk - found := false - for _, arg := range cmd.Args { - if arg == "file=/path/to/image.vmdk,format=vmdk" { - found = true - break - } - } - assert.True(t, found, "Should have vmdk drive argument") -} - -func TestQemuHypervisor_BuildCommand_Good_RawFormat(t *testing.T) { - q := NewQemuHypervisor() - - ctx := context.Background() - opts := &HypervisorOptions{Memory: 1024, CPUs: 1} - - cmd, err := q.BuildCommand(ctx, "/path/to/image.raw", opts) - require.NoError(t, err) - - // Check that the drive format is raw - found := false - for _, arg := range cmd.Args { - if arg == "file=/path/to/image.raw,format=raw" { - found = true - break - } - } - assert.True(t, found, "Should have raw drive argument") -} - -func TestHyperkitHypervisor_BuildCommand_Good_WithPorts(t *testing.T) { - h := NewHyperkitHypervisor() - - ctx := context.Background() - opts := &HypervisorOptions{ - Memory: 1024, - CPUs: 2, - SSHPort: 2222, - Ports: map[int]int{8080: 80}, - } - - cmd, err := h.BuildCommand(ctx, "/path/to/image.iso", opts) - require.NoError(t, err) - assert.NotNil(t, cmd) - - // Verify it creates a command with memory and CPU args - args := cmd.Args - assert.Contains(t, args, "-m") - assert.Contains(t, args, "1024M") - assert.Contains(t, args, "-c") - assert.Contains(t, args, "2") -} - -func TestHyperkitHypervisor_BuildCommand_Good_QCow2Format(t *testing.T) { - h := NewHyperkitHypervisor() - - ctx := context.Background() - opts := &HypervisorOptions{Memory: 1024, CPUs: 1} - - cmd, err := h.BuildCommand(ctx, "/path/to/image.qcow2", opts) - require.NoError(t, err) - assert.NotNil(t, cmd) -} - -func TestHyperkitHypervisor_BuildCommand_Good_RawFormat(t *testing.T) { - h := NewHyperkitHypervisor() - - ctx := context.Background() - opts := &HypervisorOptions{Memory: 1024, CPUs: 1} - - cmd, err := h.BuildCommand(ctx, "/path/to/image.raw", opts) - require.NoError(t, err) - assert.NotNil(t, cmd) -} - -func TestHyperkitHypervisor_BuildCommand_Good_NoPorts(t *testing.T) { - h := NewHyperkitHypervisor() - - ctx := context.Background() - opts := &HypervisorOptions{ - Memory: 512, - CPUs: 1, - SSHPort: 0, // No SSH port - Ports: nil, - } - - cmd, err := h.BuildCommand(ctx, "/path/to/image.iso", opts) - require.NoError(t, err) - assert.NotNil(t, cmd) -} - -func TestQemuHypervisor_BuildCommand_Good_NoSSHPort(t *testing.T) { - q := NewQemuHypervisor() - - ctx := context.Background() - opts := &HypervisorOptions{ - Memory: 512, - CPUs: 1, - SSHPort: 0, // No SSH port - Ports: nil, - } - - cmd, err := q.BuildCommand(ctx, "/path/to/image.iso", opts) - require.NoError(t, err) - assert.NotNil(t, cmd) -} - -func TestQemuHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) { - q := NewQemuHypervisor() - - ctx := context.Background() - opts := &HypervisorOptions{Memory: 1024, CPUs: 1} - - _, err := q.BuildCommand(ctx, "/path/to/image.txt", opts) - assert.Error(t, err) - assert.Contains(t, err.Error(), "unknown image format") -} - -func TestHyperkitHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) { - h := NewHyperkitHypervisor() - - ctx := context.Background() - opts := &HypervisorOptions{Memory: 1024, CPUs: 1} - - _, err := h.BuildCommand(ctx, "/path/to/image.unknown", opts) - assert.Error(t, err) - assert.Contains(t, err.Error(), "unknown image format") -} - -func TestHyperkitHypervisor_Name_Good(t *testing.T) { - h := NewHyperkitHypervisor() - assert.Equal(t, "hyperkit", h.Name()) -} - -func TestHyperkitHypervisor_BuildCommand_Good_ISOFormat(t *testing.T) { - h := NewHyperkitHypervisor() - - ctx := context.Background() - opts := &HypervisorOptions{ - Memory: 1024, - CPUs: 2, - SSHPort: 2222, - } - - cmd, err := h.BuildCommand(ctx, "/path/to/image.iso", opts) - require.NoError(t, err) - assert.NotNil(t, cmd) - - args := cmd.Args - assert.Contains(t, args, "-m") - assert.Contains(t, args, "1024M") - assert.Contains(t, args, "-c") - assert.Contains(t, args, "2") -} diff --git a/container/linuxkit.go b/container/linuxkit.go deleted file mode 100644 index 88ce430..0000000 --- a/container/linuxkit.go +++ /dev/null @@ -1,462 +0,0 @@ -package container - -import ( - "bufio" - "context" - "fmt" - goio "io" - "os" - "os/exec" - "syscall" - "time" - - "forge.lthn.ai/core/go-io" -) - -// LinuxKitManager implements the Manager interface for LinuxKit VMs. -type LinuxKitManager struct { - state *State - hypervisor Hypervisor - medium io.Medium -} - -// NewLinuxKitManager creates a new LinuxKit manager with auto-detected hypervisor. -func NewLinuxKitManager(m io.Medium) (*LinuxKitManager, error) { - statePath, err := DefaultStatePath() - if err != nil { - return nil, fmt.Errorf("failed to determine state path: %w", err) - } - - state, err := LoadState(statePath) - if err != nil { - return nil, fmt.Errorf("failed to load state: %w", err) - } - - hypervisor, err := DetectHypervisor() - if err != nil { - return nil, err - } - - return &LinuxKitManager{ - state: state, - hypervisor: hypervisor, - medium: m, - }, nil -} - -// NewLinuxKitManagerWithHypervisor creates a manager with a specific hypervisor. -func NewLinuxKitManagerWithHypervisor(m io.Medium, state *State, hypervisor Hypervisor) *LinuxKitManager { - return &LinuxKitManager{ - state: state, - hypervisor: hypervisor, - medium: m, - } -} - -// Run starts a new LinuxKit VM from the given image. -func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions) (*Container, error) { - // Validate image exists - if !m.medium.IsFile(image) { - return nil, fmt.Errorf("image not found: %s", image) - } - - // Detect image format - format := DetectImageFormat(image) - if format == FormatUnknown { - return nil, fmt.Errorf("unsupported image format: %s", image) - } - - // Generate container ID - id, err := GenerateID() - if err != nil { - return nil, fmt.Errorf("failed to generate container ID: %w", err) - } - - // Apply defaults - if opts.Memory <= 0 { - opts.Memory = 1024 - } - if opts.CPUs <= 0 { - opts.CPUs = 1 - } - if opts.SSHPort <= 0 { - opts.SSHPort = 2222 - } - - // Use name or generate from ID - name := opts.Name - if name == "" { - name = id[:8] - } - - // Ensure logs directory exists - if err := EnsureLogsDir(); err != nil { - return nil, fmt.Errorf("failed to create logs directory: %w", err) - } - - // Get log file path - logPath, err := LogPath(id) - if err != nil { - return nil, fmt.Errorf("failed to determine log path: %w", err) - } - - // Build hypervisor options - hvOpts := &HypervisorOptions{ - Memory: opts.Memory, - CPUs: opts.CPUs, - LogFile: logPath, - SSHPort: opts.SSHPort, - Ports: opts.Ports, - Volumes: opts.Volumes, - Detach: opts.Detach, - } - - // Build the command - cmd, err := m.hypervisor.BuildCommand(ctx, image, hvOpts) - if err != nil { - return nil, fmt.Errorf("failed to build hypervisor command: %w", err) - } - - // Create log file - logFile, err := os.Create(logPath) - if err != nil { - return nil, fmt.Errorf("failed to create log file: %w", err) - } - - // Create container record - container := &Container{ - ID: id, - Name: name, - Image: image, - Status: StatusRunning, - StartedAt: time.Now(), - Ports: opts.Ports, - Memory: opts.Memory, - CPUs: opts.CPUs, - } - - if opts.Detach { - // Run in background - cmd.Stdout = logFile - cmd.Stderr = logFile - - // Start the process - if err := cmd.Start(); err != nil { - _ = logFile.Close() - return nil, fmt.Errorf("failed to start VM: %w", err) - } - - container.PID = cmd.Process.Pid - - // Save state - if err := m.state.Add(container); err != nil { - // Try to kill the process we just started - _ = cmd.Process.Kill() - _ = logFile.Close() - return nil, fmt.Errorf("failed to save state: %w", err) - } - - // Close log file handle (process has its own) - _ = logFile.Close() - - // Start a goroutine to wait for process exit and update state - go m.waitForExit(container.ID, cmd) - - return container, nil - } - - // Run in foreground - // Tee output to both log file and stdout - stdout, err := cmd.StdoutPipe() - if err != nil { - _ = logFile.Close() - return nil, fmt.Errorf("failed to get stdout pipe: %w", err) - } - - stderr, err := cmd.StderrPipe() - if err != nil { - _ = logFile.Close() - return nil, fmt.Errorf("failed to get stderr pipe: %w", err) - } - - if err := cmd.Start(); err != nil { - _ = logFile.Close() - return nil, fmt.Errorf("failed to start VM: %w", err) - } - - container.PID = cmd.Process.Pid - - // Save state before waiting - if err := m.state.Add(container); err != nil { - _ = cmd.Process.Kill() - _ = logFile.Close() - return nil, fmt.Errorf("failed to save state: %w", err) - } - - // Copy output to both log and stdout - go func() { - mw := goio.MultiWriter(logFile, os.Stdout) - _, _ = goio.Copy(mw, stdout) - }() - go func() { - mw := goio.MultiWriter(logFile, os.Stderr) - _, _ = goio.Copy(mw, stderr) - }() - - // Wait for the process to complete - if err := cmd.Wait(); err != nil { - container.Status = StatusError - } else { - container.Status = StatusStopped - } - - _ = logFile.Close() - if err := m.state.Update(container); err != nil { - return container, fmt.Errorf("update container state: %w", err) - } - - return container, nil -} - -// waitForExit monitors a detached process and updates state when it exits. -func (m *LinuxKitManager) waitForExit(id string, cmd *exec.Cmd) { - err := cmd.Wait() - - container, ok := m.state.Get(id) - if ok { - if err != nil { - container.Status = StatusError - } else { - container.Status = StatusStopped - } - _ = m.state.Update(container) - } -} - -// Stop stops a running container by sending SIGTERM. -func (m *LinuxKitManager) Stop(ctx context.Context, id string) error { - if err := ctx.Err(); err != nil { - return err - } - container, ok := m.state.Get(id) - if !ok { - return fmt.Errorf("container not found: %s", id) - } - - if container.Status != StatusRunning { - return fmt.Errorf("container is not running: %s", id) - } - - // Find the process - process, err := os.FindProcess(container.PID) - if err != nil { - // Process doesn't exist, update state - container.Status = StatusStopped - _ = m.state.Update(container) - return nil - } - - // Send SIGTERM - if err := process.Signal(syscall.SIGTERM); err != nil { - // Process might already be gone - container.Status = StatusStopped - _ = m.state.Update(container) - return nil - } - - // Honour already-cancelled contexts before waiting - if err := ctx.Err(); err != nil { - _ = process.Signal(syscall.SIGKILL) - return err - } - - // Wait for graceful shutdown with timeout - done := make(chan struct{}) - go func() { - _, _ = process.Wait() - close(done) - }() - - select { - case <-done: - // Process exited gracefully - case <-time.After(10 * time.Second): - // Force kill - _ = process.Signal(syscall.SIGKILL) - <-done - case <-ctx.Done(): - // Context cancelled - _ = process.Signal(syscall.SIGKILL) - return ctx.Err() - } - - container.Status = StatusStopped - return m.state.Update(container) -} - -// List returns all known containers, verifying process state. -func (m *LinuxKitManager) List(ctx context.Context) ([]*Container, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - containers := m.state.All() - - // Verify each running container's process is still alive - for _, c := range containers { - if c.Status == StatusRunning { - if !isProcessRunning(c.PID) { - c.Status = StatusStopped - _ = m.state.Update(c) - } - } - } - - return containers, nil -} - -// isProcessRunning checks if a process with the given PID is still running. -func isProcessRunning(pid int) bool { - process, err := os.FindProcess(pid) - if err != nil { - return false - } - - // On Unix, FindProcess always succeeds, so we need to send signal 0 to check - err = process.Signal(syscall.Signal(0)) - return err == nil -} - -// Logs returns a reader for the container's log output. -func (m *LinuxKitManager) Logs(ctx context.Context, id string, follow bool) (goio.ReadCloser, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - _, ok := m.state.Get(id) - if !ok { - return nil, fmt.Errorf("container not found: %s", id) - } - - logPath, err := LogPath(id) - if err != nil { - return nil, fmt.Errorf("failed to determine log path: %w", err) - } - - if !m.medium.IsFile(logPath) { - return nil, fmt.Errorf("no logs available for container: %s", id) - } - - if !follow { - // Simple case: just open and return the file - return m.medium.Open(logPath) - } - - // Follow mode: create a reader that tails the file - return newFollowReader(ctx, m.medium, logPath) -} - -// followReader implements goio.ReadCloser for following log files. -type followReader struct { - file goio.ReadCloser - ctx context.Context - cancel context.CancelFunc - reader *bufio.Reader - medium io.Medium - path string -} - -func newFollowReader(ctx context.Context, m io.Medium, path string) (*followReader, error) { - file, err := m.Open(path) - if err != nil { - return nil, err - } - - // Note: We don't seek here because Medium.Open doesn't guarantee Seekability. - - ctx, cancel := context.WithCancel(ctx) - - return &followReader{ - file: file, - ctx: ctx, - cancel: cancel, - reader: bufio.NewReader(file), - medium: m, - path: path, - }, nil -} - -func (f *followReader) Read(p []byte) (int, error) { - for { - select { - case <-f.ctx.Done(): - return 0, goio.EOF - default: - } - - n, err := f.reader.Read(p) - if n > 0 { - return n, nil - } - if err != nil && err != goio.EOF { - return 0, err - } - - // No data available, wait a bit and try again - select { - case <-f.ctx.Done(): - return 0, goio.EOF - case <-time.After(100 * time.Millisecond): - // Reset reader to pick up new data - f.reader.Reset(f.file) - } - } -} - -func (f *followReader) Close() error { - f.cancel() - return f.file.Close() -} - -// Exec executes a command inside the container via SSH. -func (m *LinuxKitManager) Exec(ctx context.Context, id string, cmd []string) error { - if err := ctx.Err(); err != nil { - return err - } - container, ok := m.state.Get(id) - if !ok { - return fmt.Errorf("container not found: %s", id) - } - - if container.Status != StatusRunning { - return fmt.Errorf("container is not running: %s", id) - } - - // Default SSH port - sshPort := 2222 - - // Build SSH command - sshArgs := []string{ - "-p", fmt.Sprintf("%d", sshPort), - "-o", "StrictHostKeyChecking=yes", - "-o", "UserKnownHostsFile=~/.core/known_hosts", - "-o", "LogLevel=ERROR", - "root@localhost", - } - sshArgs = append(sshArgs, cmd...) - - sshCmd := exec.CommandContext(ctx, "ssh", sshArgs...) - sshCmd.Stdin = os.Stdin - sshCmd.Stdout = os.Stdout - sshCmd.Stderr = os.Stderr - - return sshCmd.Run() -} - -// State returns the manager's state (for testing). -func (m *LinuxKitManager) State() *State { - return m.state -} - -// Hypervisor returns the manager's hypervisor (for testing). -func (m *LinuxKitManager) Hypervisor() Hypervisor { - return m.hypervisor -} diff --git a/container/linuxkit_test.go b/container/linuxkit_test.go deleted file mode 100644 index da748e8..0000000 --- a/container/linuxkit_test.go +++ /dev/null @@ -1,786 +0,0 @@ -package container - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - "forge.lthn.ai/core/go-io" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// MockHypervisor is a mock implementation for testing. -type MockHypervisor struct { - name string - available bool - buildErr error - lastImage string - lastOpts *HypervisorOptions - commandToRun string -} - -func NewMockHypervisor() *MockHypervisor { - return &MockHypervisor{ - name: "mock", - available: true, - commandToRun: "echo", - } -} - -func (m *MockHypervisor) Name() string { - return m.name -} - -func (m *MockHypervisor) Available() bool { - return m.available -} - -func (m *MockHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) { - m.lastImage = image - m.lastOpts = opts - if m.buildErr != nil { - return nil, m.buildErr - } - // Return a simple command that exits quickly - return exec.CommandContext(ctx, m.commandToRun, "test"), nil -} - -// newTestManager creates a LinuxKitManager with mock hypervisor for testing. -// Uses manual temp directory management to avoid race conditions with t.TempDir cleanup. -func newTestManager(t *testing.T) (*LinuxKitManager, *MockHypervisor, string) { - tmpDir, err := os.MkdirTemp("", "linuxkit-test-*") - require.NoError(t, err) - - // Manual cleanup that handles race conditions with state file writes - t.Cleanup(func() { - // Give any pending file operations time to complete - time.Sleep(10 * time.Millisecond) - _ = os.RemoveAll(tmpDir) - }) - - statePath := filepath.Join(tmpDir, "containers.json") - - state, err := LoadState(statePath) - require.NoError(t, err) - - mock := NewMockHypervisor() - manager := NewLinuxKitManagerWithHypervisor(io.Local, state, mock) - - return manager, mock, tmpDir -} - -func TestNewLinuxKitManagerWithHypervisor_Good(t *testing.T) { - tmpDir := t.TempDir() - statePath := filepath.Join(tmpDir, "containers.json") - state, _ := LoadState(statePath) - mock := NewMockHypervisor() - - manager := NewLinuxKitManagerWithHypervisor(io.Local, state, mock) - - assert.NotNil(t, manager) - assert.Equal(t, state, manager.State()) - assert.Equal(t, mock, manager.Hypervisor()) -} - -func TestLinuxKitManager_Run_Good_Detached(t *testing.T) { - manager, mock, tmpDir := newTestManager(t) - - // Create a test image file - imagePath := filepath.Join(tmpDir, "test.iso") - err := os.WriteFile(imagePath, []byte("fake image"), 0644) - require.NoError(t, err) - - // Use a command that runs briefly then exits - mock.commandToRun = "sleep" - - ctx := context.Background() - opts := RunOptions{ - Name: "test-vm", - Detach: true, - Memory: 512, - CPUs: 2, - } - - container, err := manager.Run(ctx, imagePath, opts) - require.NoError(t, err) - - assert.NotEmpty(t, container.ID) - assert.Equal(t, "test-vm", container.Name) - assert.Equal(t, imagePath, container.Image) - assert.Equal(t, StatusRunning, container.Status) - assert.Greater(t, container.PID, 0) - assert.Equal(t, 512, container.Memory) - assert.Equal(t, 2, container.CPUs) - - // Verify hypervisor was called with correct options - assert.Equal(t, imagePath, mock.lastImage) - assert.Equal(t, 512, mock.lastOpts.Memory) - assert.Equal(t, 2, mock.lastOpts.CPUs) - - // Clean up - stop the container - time.Sleep(100 * time.Millisecond) -} - -func TestLinuxKitManager_Run_Good_DefaultValues(t *testing.T) { - manager, mock, tmpDir := newTestManager(t) - - imagePath := filepath.Join(tmpDir, "test.qcow2") - err := os.WriteFile(imagePath, []byte("fake image"), 0644) - require.NoError(t, err) - - ctx := context.Background() - opts := RunOptions{Detach: true} - - container, err := manager.Run(ctx, imagePath, opts) - require.NoError(t, err) - - // Check defaults were applied - assert.Equal(t, 1024, mock.lastOpts.Memory) - assert.Equal(t, 1, mock.lastOpts.CPUs) - assert.Equal(t, 2222, mock.lastOpts.SSHPort) - - // Name should default to first 8 chars of ID - assert.Equal(t, container.ID[:8], container.Name) - - // Wait for the mock process to complete to avoid temp dir cleanup issues - time.Sleep(50 * time.Millisecond) -} - -func TestLinuxKitManager_Run_Bad_ImageNotFound(t *testing.T) { - manager, _, _ := newTestManager(t) - - ctx := context.Background() - opts := RunOptions{Detach: true} - - _, err := manager.Run(ctx, "/nonexistent/image.iso", opts) - assert.Error(t, err) - assert.Contains(t, err.Error(), "image not found") -} - -func TestLinuxKitManager_Run_Bad_UnsupportedFormat(t *testing.T) { - manager, _, tmpDir := newTestManager(t) - - imagePath := filepath.Join(tmpDir, "test.txt") - err := os.WriteFile(imagePath, []byte("not an image"), 0644) - require.NoError(t, err) - - ctx := context.Background() - opts := RunOptions{Detach: true} - - _, err = manager.Run(ctx, imagePath, opts) - assert.Error(t, err) - assert.Contains(t, err.Error(), "unsupported image format") -} - -func TestLinuxKitManager_Stop_Good(t *testing.T) { - manager, _, _ := newTestManager(t) - - // Add a fake running container with a non-existent PID - // The Stop function should handle this gracefully - container := &Container{ - ID: "abc12345", - Status: StatusRunning, - PID: 999999, // Non-existent PID - StartedAt: time.Now(), - } - _ = manager.State().Add(container) - - ctx := context.Background() - err := manager.Stop(ctx, "abc12345") - - // Stop should succeed (process doesn't exist, so container is marked stopped) - assert.NoError(t, err) - - // Verify the container status was updated - c, ok := manager.State().Get("abc12345") - assert.True(t, ok) - assert.Equal(t, StatusStopped, c.Status) -} - -func TestLinuxKitManager_Stop_Bad_NotFound(t *testing.T) { - manager, _, _ := newTestManager(t) - - ctx := context.Background() - err := manager.Stop(ctx, "nonexistent") - - assert.Error(t, err) - assert.Contains(t, err.Error(), "container not found") -} - -func TestLinuxKitManager_Stop_Bad_NotRunning(t *testing.T) { - _, _, tmpDir := newTestManager(t) - statePath := filepath.Join(tmpDir, "containers.json") - state, err := LoadState(statePath) - require.NoError(t, err) - manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor()) - - container := &Container{ - ID: "abc12345", - Status: StatusStopped, - } - _ = state.Add(container) - - ctx := context.Background() - err = manager.Stop(ctx, "abc12345") - - assert.Error(t, err) - assert.Contains(t, err.Error(), "not running") -} - -func TestLinuxKitManager_List_Good(t *testing.T) { - _, _, tmpDir := newTestManager(t) - statePath := filepath.Join(tmpDir, "containers.json") - state, err := LoadState(statePath) - require.NoError(t, err) - manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor()) - - _ = state.Add(&Container{ID: "aaa11111", Status: StatusStopped}) - _ = state.Add(&Container{ID: "bbb22222", Status: StatusStopped}) - - ctx := context.Background() - containers, err := manager.List(ctx) - - require.NoError(t, err) - assert.Len(t, containers, 2) -} - -func TestLinuxKitManager_List_Good_VerifiesRunningStatus(t *testing.T) { - _, _, tmpDir := newTestManager(t) - statePath := filepath.Join(tmpDir, "containers.json") - state, err := LoadState(statePath) - require.NoError(t, err) - manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor()) - - // Add a "running" container with a fake PID that doesn't exist - _ = state.Add(&Container{ - ID: "abc12345", - Status: StatusRunning, - PID: 999999, // PID that almost certainly doesn't exist - }) - - ctx := context.Background() - containers, err := manager.List(ctx) - - require.NoError(t, err) - assert.Len(t, containers, 1) - // Status should have been updated to stopped since PID doesn't exist - assert.Equal(t, StatusStopped, containers[0].Status) -} - -func TestLinuxKitManager_Logs_Good(t *testing.T) { - manager, _, tmpDir := newTestManager(t) - - // Create a log file manually - logsDir := filepath.Join(tmpDir, "logs") - require.NoError(t, os.MkdirAll(logsDir, 0755)) - - container := &Container{ID: "abc12345"} - _ = manager.State().Add(container) - - // Override the default logs dir for testing by creating the log file - // at the expected location - logContent := "test log content\nline 2\n" - logPath, err := LogPath("abc12345") - require.NoError(t, err) - require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0755)) - require.NoError(t, os.WriteFile(logPath, []byte(logContent), 0644)) - - ctx := context.Background() - reader, err := manager.Logs(ctx, "abc12345", false) - - require.NoError(t, err) - defer func() { _ = reader.Close() }() - - buf := make([]byte, 1024) - n, _ := reader.Read(buf) - assert.Equal(t, logContent, string(buf[:n])) -} - -func TestLinuxKitManager_Logs_Bad_NotFound(t *testing.T) { - manager, _, _ := newTestManager(t) - - ctx := context.Background() - _, err := manager.Logs(ctx, "nonexistent", false) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "container not found") -} - -func TestLinuxKitManager_Logs_Bad_NoLogFile(t *testing.T) { - manager, _, _ := newTestManager(t) - - // Use a unique ID that won't have a log file - uniqueID, err := GenerateID() - require.NoError(t, err) - container := &Container{ID: uniqueID} - _ = manager.State().Add(container) - - ctx := context.Background() - reader, err := manager.Logs(ctx, uniqueID, false) - - // If logs existed somehow, clean up the reader - if reader != nil { - _ = reader.Close() - } - - assert.Error(t, err) - if err != nil { - assert.Contains(t, err.Error(), "no logs available") - } -} - -func TestLinuxKitManager_Exec_Bad_NotFound(t *testing.T) { - manager, _, _ := newTestManager(t) - - ctx := context.Background() - err := manager.Exec(ctx, "nonexistent", []string{"ls"}) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "container not found") -} - -func TestLinuxKitManager_Exec_Bad_NotRunning(t *testing.T) { - manager, _, _ := newTestManager(t) - - container := &Container{ID: "abc12345", Status: StatusStopped} - _ = manager.State().Add(container) - - ctx := context.Background() - err := manager.Exec(ctx, "abc12345", []string{"ls"}) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "not running") -} - -func TestDetectImageFormat_Good(t *testing.T) { - tests := []struct { - path string - format ImageFormat - }{ - {"/path/to/image.iso", FormatISO}, - {"/path/to/image.ISO", FormatISO}, - {"/path/to/image.qcow2", FormatQCOW2}, - {"/path/to/image.QCOW2", FormatQCOW2}, - {"/path/to/image.vmdk", FormatVMDK}, - {"/path/to/image.raw", FormatRaw}, - {"/path/to/image.img", FormatRaw}, - {"image.iso", FormatISO}, - } - - for _, tt := range tests { - t.Run(tt.path, func(t *testing.T) { - assert.Equal(t, tt.format, DetectImageFormat(tt.path)) - }) - } -} - -func TestDetectImageFormat_Bad_Unknown(t *testing.T) { - tests := []string{ - "/path/to/image.txt", - "/path/to/image", - "noextension", - "/path/to/image.docx", - } - - for _, path := range tests { - t.Run(path, func(t *testing.T) { - assert.Equal(t, FormatUnknown, DetectImageFormat(path)) - }) - } -} - -func TestQemuHypervisor_Name_Good(t *testing.T) { - q := NewQemuHypervisor() - assert.Equal(t, "qemu", q.Name()) -} - -func TestQemuHypervisor_BuildCommand_Good(t *testing.T) { - q := NewQemuHypervisor() - - ctx := context.Background() - opts := &HypervisorOptions{ - Memory: 2048, - CPUs: 4, - SSHPort: 2222, - Ports: map[int]int{8080: 80}, - Detach: true, - } - - cmd, err := q.BuildCommand(ctx, "/path/to/image.iso", opts) - require.NoError(t, err) - assert.NotNil(t, cmd) - - // Check command path - assert.Contains(t, cmd.Path, "qemu") - - // Check that args contain expected values - args := cmd.Args - assert.Contains(t, args, "-m") - assert.Contains(t, args, "2048") - assert.Contains(t, args, "-smp") - assert.Contains(t, args, "4") - assert.Contains(t, args, "-nographic") -} - -func TestLinuxKitManager_Logs_Good_Follow(t *testing.T) { - manager, _, _ := newTestManager(t) - - // Create a unique container ID - uniqueID, err := GenerateID() - require.NoError(t, err) - container := &Container{ID: uniqueID} - _ = manager.State().Add(container) - - // Create a log file at the expected location - logPath, err := LogPath(uniqueID) - require.NoError(t, err) - require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0755)) - - // Write initial content - err = os.WriteFile(logPath, []byte("initial log content\n"), 0644) - require.NoError(t, err) - - // Create a cancellable context - ctx, cancel := context.WithCancel(context.Background()) - - // Get the follow reader - reader, err := manager.Logs(ctx, uniqueID, true) - require.NoError(t, err) - - // Cancel the context to stop the follow - cancel() - - // Read should return EOF after context cancellation - buf := make([]byte, 1024) - _, readErr := reader.Read(buf) - // After context cancel, Read should return EOF - assert.Equal(t, "EOF", readErr.Error()) - - // Close the reader - assert.NoError(t, reader.Close()) -} - -func TestFollowReader_Read_Good_WithData(t *testing.T) { - tmpDir := t.TempDir() - logPath := filepath.Join(tmpDir, "test.log") - - // Create log file with content - content := "test log line 1\ntest log line 2\n" - err := os.WriteFile(logPath, []byte(content), 0644) - require.NoError(t, err) - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - reader, err := newFollowReader(ctx, io.Local, logPath) - require.NoError(t, err) - defer func() { _ = reader.Close() }() - - // The followReader seeks to end, so we need to append more content - f, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0644) - require.NoError(t, err) - _, err = f.WriteString("new line\n") - require.NoError(t, err) - require.NoError(t, f.Close()) - - // Give the reader time to poll - time.Sleep(150 * time.Millisecond) - - buf := make([]byte, 1024) - n, err := reader.Read(buf) - if err == nil { - assert.Greater(t, n, 0) - } -} - -func TestFollowReader_Read_Good_ContextCancel(t *testing.T) { - tmpDir := t.TempDir() - logPath := filepath.Join(tmpDir, "test.log") - - // Create log file - err := os.WriteFile(logPath, []byte("initial content\n"), 0644) - require.NoError(t, err) - - ctx, cancel := context.WithCancel(context.Background()) - - reader, err := newFollowReader(ctx, io.Local, logPath) - require.NoError(t, err) - - // Cancel the context - cancel() - - // Read should return EOF - buf := make([]byte, 1024) - _, readErr := reader.Read(buf) - assert.Equal(t, "EOF", readErr.Error()) - - _ = reader.Close() -} - -func TestFollowReader_Close_Good(t *testing.T) { - tmpDir := t.TempDir() - logPath := filepath.Join(tmpDir, "test.log") - - err := os.WriteFile(logPath, []byte("content\n"), 0644) - require.NoError(t, err) - - ctx := context.Background() - reader, err := newFollowReader(ctx, io.Local, logPath) - require.NoError(t, err) - - err = reader.Close() - assert.NoError(t, err) - - // Reading after close should fail or return EOF - buf := make([]byte, 1024) - _, readErr := reader.Read(buf) - assert.Error(t, readErr) -} - -func TestNewFollowReader_Bad_FileNotFound(t *testing.T) { - ctx := context.Background() - _, err := newFollowReader(ctx, io.Local, "/nonexistent/path/to/file.log") - - assert.Error(t, err) -} - -func TestLinuxKitManager_Run_Bad_BuildCommandError(t *testing.T) { - manager, mock, tmpDir := newTestManager(t) - - // Create a test image file - imagePath := filepath.Join(tmpDir, "test.iso") - err := os.WriteFile(imagePath, []byte("fake image"), 0644) - require.NoError(t, err) - - // Configure mock to return an error - mock.buildErr = assert.AnError - - ctx := context.Background() - opts := RunOptions{Detach: true} - - _, err = manager.Run(ctx, imagePath, opts) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to build hypervisor command") -} - -func TestLinuxKitManager_Run_Good_Foreground(t *testing.T) { - manager, mock, tmpDir := newTestManager(t) - - // Create a test image file - imagePath := filepath.Join(tmpDir, "test.iso") - err := os.WriteFile(imagePath, []byte("fake image"), 0644) - require.NoError(t, err) - - // Use echo which exits quickly - mock.commandToRun = "echo" - - ctx := context.Background() - opts := RunOptions{ - Name: "test-foreground", - Detach: false, // Run in foreground - Memory: 512, - CPUs: 1, - } - - container, err := manager.Run(ctx, imagePath, opts) - require.NoError(t, err) - - assert.NotEmpty(t, container.ID) - assert.Equal(t, "test-foreground", container.Name) - // Foreground process should have completed - assert.Equal(t, StatusStopped, container.Status) -} - -func TestLinuxKitManager_Stop_Good_ContextCancelled(t *testing.T) { - manager, mock, tmpDir := newTestManager(t) - - // Create a test image file - imagePath := filepath.Join(tmpDir, "test.iso") - err := os.WriteFile(imagePath, []byte("fake image"), 0644) - require.NoError(t, err) - - // Use a command that takes a long time - mock.commandToRun = "sleep" - - // Start a container - ctx := context.Background() - opts := RunOptions{ - Name: "test-cancel", - Detach: true, - } - - container, err := manager.Run(ctx, imagePath, opts) - require.NoError(t, err) - - // Ensure cleanup happens regardless of test outcome - t.Cleanup(func() { - _ = manager.Stop(context.Background(), container.ID) - }) - - // Create a context that's already cancelled - cancelCtx, cancel := context.WithCancel(context.Background()) - cancel() - - // Stop with cancelled context - err = manager.Stop(cancelCtx, container.ID) - // Should return context error - assert.Error(t, err) - assert.Equal(t, context.Canceled, err) -} - -func TestIsProcessRunning_Good_ExistingProcess(t *testing.T) { - // Use our own PID which definitely exists - running := isProcessRunning(os.Getpid()) - assert.True(t, running) -} - -func TestIsProcessRunning_Bad_NonexistentProcess(t *testing.T) { - // Use a PID that almost certainly doesn't exist - running := isProcessRunning(999999) - assert.False(t, running) -} - -func TestLinuxKitManager_Run_Good_WithPortsAndVolumes(t *testing.T) { - manager, mock, tmpDir := newTestManager(t) - - imagePath := filepath.Join(tmpDir, "test.iso") - err := os.WriteFile(imagePath, []byte("fake image"), 0644) - require.NoError(t, err) - - ctx := context.Background() - opts := RunOptions{ - Name: "test-ports", - Detach: true, - Memory: 512, - CPUs: 1, - SSHPort: 2223, - Ports: map[int]int{8080: 80, 443: 443}, - Volumes: map[string]string{"/host/data": "/container/data"}, - } - - container, err := manager.Run(ctx, imagePath, opts) - require.NoError(t, err) - - assert.NotEmpty(t, container.ID) - assert.Equal(t, map[int]int{8080: 80, 443: 443}, container.Ports) - assert.Equal(t, 2223, mock.lastOpts.SSHPort) - assert.Equal(t, map[string]string{"/host/data": "/container/data"}, mock.lastOpts.Volumes) - - time.Sleep(50 * time.Millisecond) -} - -func TestFollowReader_Read_Bad_ReaderError(t *testing.T) { - tmpDir := t.TempDir() - logPath := filepath.Join(tmpDir, "test.log") - - // Create log file - err := os.WriteFile(logPath, []byte("content\n"), 0644) - require.NoError(t, err) - - ctx := context.Background() - reader, err := newFollowReader(ctx, io.Local, logPath) - require.NoError(t, err) - - // Close the underlying file to cause read errors - _ = reader.file.Close() - - // Read should return an error - buf := make([]byte, 1024) - _, readErr := reader.Read(buf) - assert.Error(t, readErr) -} - -func TestLinuxKitManager_Run_Bad_StartError(t *testing.T) { - manager, mock, tmpDir := newTestManager(t) - - imagePath := filepath.Join(tmpDir, "test.iso") - err := os.WriteFile(imagePath, []byte("fake image"), 0644) - require.NoError(t, err) - - // Use a command that doesn't exist to cause Start() to fail - mock.commandToRun = "/nonexistent/command/that/does/not/exist" - - ctx := context.Background() - opts := RunOptions{ - Name: "test-start-error", - Detach: true, - } - - _, err = manager.Run(ctx, imagePath, opts) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to start VM") -} - -func TestLinuxKitManager_Run_Bad_ForegroundStartError(t *testing.T) { - manager, mock, tmpDir := newTestManager(t) - - imagePath := filepath.Join(tmpDir, "test.iso") - err := os.WriteFile(imagePath, []byte("fake image"), 0644) - require.NoError(t, err) - - // Use a command that doesn't exist to cause Start() to fail - mock.commandToRun = "/nonexistent/command/that/does/not/exist" - - ctx := context.Background() - opts := RunOptions{ - Name: "test-foreground-error", - Detach: false, - } - - _, err = manager.Run(ctx, imagePath, opts) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to start VM") -} - -func TestLinuxKitManager_Run_Good_ForegroundWithError(t *testing.T) { - manager, mock, tmpDir := newTestManager(t) - - imagePath := filepath.Join(tmpDir, "test.iso") - err := os.WriteFile(imagePath, []byte("fake image"), 0644) - require.NoError(t, err) - - // Use a command that exits with error - mock.commandToRun = "false" // false command exits with code 1 - - ctx := context.Background() - opts := RunOptions{ - Name: "test-foreground-exit-error", - Detach: false, - } - - container, err := manager.Run(ctx, imagePath, opts) - require.NoError(t, err) // Run itself should succeed - - // Container should be in error state since process exited with error - assert.Equal(t, StatusError, container.Status) -} - -func TestLinuxKitManager_Stop_Good_ProcessExitedWhileRunning(t *testing.T) { - manager, _, _ := newTestManager(t) - - // Add a "running" container with a process that has already exited - // This simulates the race condition where process exits between status check - // and signal send - container := &Container{ - ID: "test1234", - Status: StatusRunning, - PID: 999999, // Non-existent PID - StartedAt: time.Now(), - } - _ = manager.State().Add(container) - - ctx := context.Background() - err := manager.Stop(ctx, "test1234") - - // Stop should succeed gracefully - assert.NoError(t, err) - - // Container should be stopped - c, ok := manager.State().Get("test1234") - assert.True(t, ok) - assert.Equal(t, StatusStopped, c.Status) -} diff --git a/container/state.go b/container/state.go deleted file mode 100644 index 00139b5..0000000 --- a/container/state.go +++ /dev/null @@ -1,172 +0,0 @@ -package container - -import ( - "encoding/json" - "os" - "path/filepath" - "sync" - - "forge.lthn.ai/core/go-io" -) - -// State manages persistent container state. -type State struct { - // Containers is a map of container ID to Container. - Containers map[string]*Container `json:"containers"` - - mu sync.RWMutex - filePath string -} - -// DefaultStateDir returns the default directory for state files (~/.core). -func DefaultStateDir() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".core"), nil -} - -// DefaultStatePath returns the default path for the state file. -func DefaultStatePath() (string, error) { - dir, err := DefaultStateDir() - if err != nil { - return "", err - } - return filepath.Join(dir, "containers.json"), nil -} - -// DefaultLogsDir returns the default directory for container logs. -func DefaultLogsDir() (string, error) { - dir, err := DefaultStateDir() - if err != nil { - return "", err - } - return filepath.Join(dir, "logs"), nil -} - -// NewState creates a new State instance. -func NewState(filePath string) *State { - return &State{ - Containers: make(map[string]*Container), - filePath: filePath, - } -} - -// LoadState loads the state from the given file path. -// If the file doesn't exist, returns an empty state. -func LoadState(filePath string) (*State, error) { - state := NewState(filePath) - - dataStr, err := io.Local.Read(filePath) - if err != nil { - if os.IsNotExist(err) { - return state, nil - } - return nil, err - } - - if err := json.Unmarshal([]byte(dataStr), state); err != nil { - return nil, err - } - - return state, nil -} - -// SaveState persists the state to the configured file path. -func (s *State) SaveState() error { - s.mu.RLock() - defer s.mu.RUnlock() - - // Ensure the directory exists - dir := filepath.Dir(s.filePath) - if err := io.Local.EnsureDir(dir); err != nil { - return err - } - - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err - } - - return io.Local.Write(s.filePath, string(data)) -} - -// Add adds a container to the state and persists it. -func (s *State) Add(c *Container) error { - s.mu.Lock() - s.Containers[c.ID] = c - s.mu.Unlock() - - return s.SaveState() -} - -// Get retrieves a copy of a container by ID. -// Returns a copy to prevent data races when the container is modified. -func (s *State) Get(id string) (*Container, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - - c, ok := s.Containers[id] - if !ok { - return nil, false - } - // Return a copy to prevent data races - copy := *c - return ©, true -} - -// Update updates a container in the state and persists it. -func (s *State) Update(c *Container) error { - s.mu.Lock() - s.Containers[c.ID] = c - s.mu.Unlock() - - return s.SaveState() -} - -// Remove removes a container from the state and persists it. -func (s *State) Remove(id string) error { - s.mu.Lock() - delete(s.Containers, id) - s.mu.Unlock() - - return s.SaveState() -} - -// All returns copies of all containers in the state. -// Returns copies to prevent data races when containers are modified. -func (s *State) All() []*Container { - s.mu.RLock() - defer s.mu.RUnlock() - - containers := make([]*Container, 0, len(s.Containers)) - for _, c := range s.Containers { - copy := *c - containers = append(containers, ©) - } - return containers -} - -// FilePath returns the path to the state file. -func (s *State) FilePath() string { - return s.filePath -} - -// LogPath returns the log file path for a given container ID. -func LogPath(id string) (string, error) { - logsDir, err := DefaultLogsDir() - if err != nil { - return "", err - } - return filepath.Join(logsDir, id+".log"), nil -} - -// EnsureLogsDir ensures the logs directory exists. -func EnsureLogsDir() error { - logsDir, err := DefaultLogsDir() - if err != nil { - return err - } - return io.Local.EnsureDir(logsDir) -} diff --git a/container/state_test.go b/container/state_test.go deleted file mode 100644 index 68e6a02..0000000 --- a/container/state_test.go +++ /dev/null @@ -1,222 +0,0 @@ -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) -} diff --git a/container/templates.go b/container/templates.go deleted file mode 100644 index 44a4307..0000000 --- a/container/templates.go +++ /dev/null @@ -1,314 +0,0 @@ -package container - -import ( - "embed" - "fmt" - "iter" - "maps" - "os" - "path/filepath" - "regexp" - "slices" - "strings" - - "forge.lthn.ai/core/go-io" -) - -//go:embed templates/*.yml -var embeddedTemplates embed.FS - -// Template represents a LinuxKit YAML template. -type Template struct { - // Name is the template identifier (e.g., "core-dev", "server-php"). - Name string - // Description is a human-readable description of the template. - Description string - // Path is the file path to the template (relative or absolute). - Path string -} - -// builtinTemplates defines the metadata for embedded templates. -var builtinTemplates = []Template{ - { - Name: "core-dev", - Description: "Development environment with Go, Node.js, PHP, Docker-in-LinuxKit, and SSH access", - Path: "templates/core-dev.yml", - }, - { - Name: "server-php", - Description: "Production PHP server with FrankenPHP, Caddy reverse proxy, and health checks", - Path: "templates/server-php.yml", - }, -} - -// ListTemplates returns all available LinuxKit templates. -// It combines embedded templates with any templates found in the user's -// .core/linuxkit directory. -func ListTemplates() []Template { - return slices.Collect(ListTemplatesIter()) -} - -// ListTemplatesIter returns an iterator for all available LinuxKit templates. -func ListTemplatesIter() iter.Seq[Template] { - return func(yield func(Template) bool) { - // Yield builtin templates - for _, t := range builtinTemplates { - if !yield(t) { - return - } - } - - // Check for user templates in .core/linuxkit/ - userTemplatesDir := getUserTemplatesDir() - if userTemplatesDir != "" { - for _, t := range scanUserTemplates(userTemplatesDir) { - if !yield(t) { - return - } - } - } - } -} - -// GetTemplate returns the content of a template by name. -// It first checks embedded templates, then user templates. -func GetTemplate(name string) (string, error) { - // Check embedded templates first - for _, t := range builtinTemplates { - if t.Name == name { - content, err := embeddedTemplates.ReadFile(t.Path) - if err != nil { - return "", fmt.Errorf("failed to read embedded template %s: %w", name, err) - } - return string(content), nil - } - } - - // Check user templates - userTemplatesDir := getUserTemplatesDir() - if userTemplatesDir != "" { - templatePath := filepath.Join(userTemplatesDir, name+".yml") - if io.Local.IsFile(templatePath) { - content, err := io.Local.Read(templatePath) - if err != nil { - return "", fmt.Errorf("failed to read user template %s: %w", name, err) - } - return content, nil - } - } - - return "", fmt.Errorf("template not found: %s", name) -} - -// ApplyTemplate applies variable substitution to a template. -// It supports two syntaxes: -// - ${VAR} - required variable, returns error if not provided -// - ${VAR:-default} - variable with default value -func ApplyTemplate(name string, vars map[string]string) (string, error) { - content, err := GetTemplate(name) - if err != nil { - return "", err - } - - return ApplyVariables(content, vars) -} - -// ApplyVariables applies variable substitution to content string. -// It supports two syntaxes: -// - ${VAR} - required variable, returns error if not provided -// - ${VAR:-default} - variable with default value -func ApplyVariables(content string, vars map[string]string) (string, error) { - // Pattern for ${VAR:-default} syntax - defaultPattern := regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}`) - - // Pattern for ${VAR} syntax (no default) - requiredPattern := regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`) - - // Track missing required variables - var missingVars []string - - // First pass: replace variables with defaults - result := defaultPattern.ReplaceAllStringFunc(content, func(match string) string { - submatch := defaultPattern.FindStringSubmatch(match) - if len(submatch) != 3 { - return match - } - varName := submatch[1] - defaultVal := submatch[2] - - if val, ok := vars[varName]; ok { - return val - } - return defaultVal - }) - - // Second pass: replace required variables and track missing ones - result = requiredPattern.ReplaceAllStringFunc(result, func(match string) string { - submatch := requiredPattern.FindStringSubmatch(match) - if len(submatch) != 2 { - return match - } - varName := submatch[1] - - if val, ok := vars[varName]; ok { - return val - } - missingVars = append(missingVars, varName) - return match // Keep original if missing - }) - - if len(missingVars) > 0 { - return "", fmt.Errorf("missing required variables: %s", strings.Join(missingVars, ", ")) - } - - return result, nil -} - -// ExtractVariables extracts all variable names from a template. -// Returns two slices: required variables and optional variables (with defaults). -func ExtractVariables(content string) (required []string, optional map[string]string) { - optional = make(map[string]string) - requiredSet := make(map[string]bool) - - // Pattern for ${VAR:-default} syntax - defaultPattern := regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}`) - - // Pattern for ${VAR} syntax (no default) - requiredPattern := regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`) - - // Find optional variables with defaults - matches := defaultPattern.FindAllStringSubmatch(content, -1) - for _, match := range matches { - if len(match) == 3 { - optional[match[1]] = match[2] - } - } - - // Find required variables - matches = requiredPattern.FindAllStringSubmatch(content, -1) - for _, match := range matches { - if len(match) == 2 { - varName := match[1] - // Only add if not already in optional (with default) - if _, hasDefault := optional[varName]; !hasDefault { - requiredSet[varName] = true - } - } - } - - // Convert set to slice - required = slices.Sorted(maps.Keys(requiredSet)) - - return required, optional -} - -// getUserTemplatesDir returns the path to user templates directory. -// Returns empty string if the directory doesn't exist. -func getUserTemplatesDir() string { - // Try workspace-relative .core/linuxkit first - cwd, err := os.Getwd() - if err == nil { - wsDir := filepath.Join(cwd, ".core", "linuxkit") - if io.Local.IsDir(wsDir) { - return wsDir - } - } - - // Try home directory - home, err := os.UserHomeDir() - if err != nil { - return "" - } - - homeDir := filepath.Join(home, ".core", "linuxkit") - if io.Local.IsDir(homeDir) { - return homeDir - } - - return "" -} - -// scanUserTemplates scans a directory for .yml template files. -func scanUserTemplates(dir string) []Template { - var templates []Template - - entries, err := io.Local.List(dir) - if err != nil { - return templates - } - - for _, entry := range entries { - if entry.IsDir() { - continue - } - - name := entry.Name() - if !strings.HasSuffix(name, ".yml") && !strings.HasSuffix(name, ".yaml") { - continue - } - - // Extract template name from filename - templateName := strings.TrimSuffix(strings.TrimSuffix(name, ".yml"), ".yaml") - - // Skip if this is a builtin template name (embedded takes precedence) - isBuiltin := false - for _, bt := range builtinTemplates { - if bt.Name == templateName { - isBuiltin = true - break - } - } - if isBuiltin { - continue - } - - // Read file to extract description from comments - description := extractTemplateDescription(filepath.Join(dir, name)) - if description == "" { - description = "User-defined template" - } - - templates = append(templates, Template{ - Name: templateName, - Description: description, - Path: filepath.Join(dir, name), - }) - } - - return templates -} - -// extractTemplateDescription reads the first comment block from a YAML file -// to use as a description. -func extractTemplateDescription(path string) string { - content, err := io.Local.Read(path) - if err != nil { - return "" - } - - lines := strings.Split(content, "\n") - var descLines []string - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "#") { - // Remove the # and trim - comment := strings.TrimSpace(strings.TrimPrefix(trimmed, "#")) - if comment != "" { - descLines = append(descLines, comment) - // Only take the first meaningful comment line as description - if len(descLines) == 1 { - return comment - } - } - } else if trimmed != "" { - // Hit non-comment content, stop - break - } - } - - if len(descLines) > 0 { - return descLines[0] - } - return "" -} diff --git a/container/templates/core-dev.yml b/container/templates/core-dev.yml deleted file mode 100644 index 712e43e..0000000 --- a/container/templates/core-dev.yml +++ /dev/null @@ -1,121 +0,0 @@ -# Core Development Environment Template -# A full-featured development environment with multiple runtimes -# -# Variables: -# ${SSH_KEY} - SSH public key for access (required) -# ${MEMORY:-2048} - Memory in MB (default: 2048) -# ${CPUS:-2} - Number of CPUs (default: 2) -# ${HOSTNAME:-core-dev} - Hostname for the VM -# ${DATA_SIZE:-10G} - Size of persistent /data volume - -kernel: - image: linuxkit/kernel:6.6.13 - cmdline: "console=tty0 console=ttyS0" - -init: - - linuxkit/init:v1.2.0 - - linuxkit/runc:v1.1.12 - - linuxkit/containerd:v1.7.13 - - linuxkit/ca-certificates:v1.0.0 - -onboot: - - name: sysctl - image: linuxkit/sysctl:v1.0.0 - - name: format - image: linuxkit/format:v1.0.0 - - name: mount - image: linuxkit/mount:v1.0.0 - command: ["/usr/bin/mountie", "/dev/sda1", "/data"] - - name: dhcpcd - image: linuxkit/dhcpcd:v1.0.0 - command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf", "-1"] - -onshutdown: - - name: shutdown - image: busybox:latest - command: ["/bin/echo", "Shutting down..."] - -services: - - name: getty - image: linuxkit/getty:v1.0.0 - env: - - INSECURE=true - - - name: sshd - image: linuxkit/sshd:v1.2.0 - binds: - - /etc/ssh/authorized_keys:/root/.ssh/authorized_keys - - - name: docker - image: docker:24.0-dind - capabilities: - - all - net: host - pid: host - binds: - - /var/run:/var/run - - /data/docker:/var/lib/docker - rootfsPropagation: shared - - - name: dev-tools - image: alpine:3.19 - capabilities: - - all - net: host - binds: - - /data:/data - command: - - /bin/sh - - -c - - | - # Install development tools - apk add --no-cache \ - git curl wget vim nano htop tmux \ - build-base gcc musl-dev linux-headers \ - openssh-client jq yq - - # Install Go 1.22.0 - wget -q https://go.dev/dl/go1.22.0.linux-amd64.tar.gz - tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz - rm go1.22.0.linux-amd64.tar.gz - echo 'export PATH=/usr/local/go/bin:$PATH' >> /etc/profile - - # Install Node.js - apk add --no-cache nodejs npm - - # Install PHP - apk add --no-cache php82 php82-cli php82-curl php82-json php82-mbstring \ - php82-openssl php82-pdo php82-pdo_mysql php82-pdo_pgsql php82-phar \ - php82-session php82-tokenizer php82-xml php82-zip composer - - # Keep container running - tail -f /dev/null - -files: - - path: /etc/hostname - contents: "${HOSTNAME:-core-dev}" - - path: /etc/ssh/authorized_keys - contents: "${SSH_KEY}" - mode: "0600" - - path: /etc/profile.d/dev.sh - contents: | - export PATH=$PATH:/usr/local/go/bin - export GOPATH=/data/go - export PATH=$PATH:$GOPATH/bin - cd /data - mode: "0755" - - path: /etc/motd - contents: | - ================================================ - Core Development Environment - - Runtimes: Go, Node.js, PHP - Tools: git, curl, vim, docker - - Data directory: /data (persistent) - ================================================ - -trust: - org: - - linuxkit - - library diff --git a/container/templates/server-php.yml b/container/templates/server-php.yml deleted file mode 100644 index 9db9f74..0000000 --- a/container/templates/server-php.yml +++ /dev/null @@ -1,142 +0,0 @@ -# PHP/FrankenPHP Server Template -# A minimal production-ready PHP server with FrankenPHP and Caddy -# -# Variables: -# ${SSH_KEY} - SSH public key for management access (required) -# ${MEMORY:-512} - Memory in MB (default: 512) -# ${CPUS:-1} - Number of CPUs (default: 1) -# ${HOSTNAME:-php-server} - Hostname for the VM -# ${APP_NAME:-app} - Application name -# ${DOMAIN:-localhost} - Domain for SSL certificates -# ${PHP_MEMORY:-128M} - PHP memory limit - -kernel: - image: linuxkit/kernel:6.6.13 - cmdline: "console=tty0 console=ttyS0" - -init: - - linuxkit/init:v1.2.0 - - linuxkit/runc:v1.1.12 - - linuxkit/containerd:v1.7.13 - - linuxkit/ca-certificates:v1.0.0 - -onboot: - - name: sysctl - image: linuxkit/sysctl:v1.0.0 - - name: dhcpcd - image: linuxkit/dhcpcd:v1.0.0 - command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf", "-1"] - -services: - - name: sshd - image: linuxkit/sshd:v1.2.0 - binds: - - /etc/ssh/authorized_keys:/root/.ssh/authorized_keys - - - name: frankenphp - image: dunglas/frankenphp:latest - capabilities: - - CAP_NET_BIND_SERVICE - net: host - binds: - - /app:/app - - /data:/data - - /etc/caddy/Caddyfile:/etc/caddy/Caddyfile - env: - - SERVER_NAME=${DOMAIN:-localhost} - - FRANKENPHP_CONFIG=/etc/caddy/Caddyfile - command: - - frankenphp - - run - - --config - - /etc/caddy/Caddyfile - - - name: healthcheck - image: alpine:3.19 - net: host - command: - - /bin/sh - - -c - - | - apk add --no-cache curl - while true; do - sleep 30 - curl -sf http://localhost/health || echo "Health check failed" - done - -files: - - path: /etc/hostname - contents: "${HOSTNAME:-php-server}" - - path: /etc/ssh/authorized_keys - contents: "${SSH_KEY}" - mode: "0600" - - path: /etc/caddy/Caddyfile - contents: | - { - frankenphp - order php_server before file_server - } - - ${DOMAIN:-localhost} { - root * /app/public - - # Health check endpoint - handle /health { - respond "OK" 200 - } - - # PHP handling - php_server - - # Encode responses - encode zstd gzip - - # Security headers - header { - X-Content-Type-Options nosniff - X-Frame-Options DENY - X-XSS-Protection "1; mode=block" - Referrer-Policy strict-origin-when-cross-origin - } - - # Logging - log { - output file /data/logs/access.log - format json - } - } - mode: "0644" - - path: /app/public/index.php - contents: | - 'healthy', - 'app' => '${APP_NAME:-app}', - 'timestamp' => date('c'), - 'php_version' => PHP_VERSION, - ]); - mode: "0644" - - path: /etc/php/php.ini - contents: | - memory_limit = ${PHP_MEMORY:-128M} - max_execution_time = 30 - upload_max_filesize = 64M - post_max_size = 64M - display_errors = Off - log_errors = On - error_log = /data/logs/php_errors.log - mode: "0644" - - path: /data/logs/.gitkeep - contents: "" - -trust: - org: - - linuxkit - - library - - dunglas diff --git a/container/templates_test.go b/container/templates_test.go deleted file mode 100644 index d01b9c8..0000000 --- a/container/templates_test.go +++ /dev/null @@ -1,495 +0,0 @@ -package container - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestListTemplates_Good(t *testing.T) { - templates := ListTemplates() - - // Should have at least the builtin templates - assert.GreaterOrEqual(t, len(templates), 2) - - // Find the core-dev template - var found bool - for _, tmpl := range templates { - if tmpl.Name == "core-dev" { - found = true - assert.NotEmpty(t, tmpl.Description) - assert.NotEmpty(t, tmpl.Path) - break - } - } - assert.True(t, found, "core-dev template should exist") - - // Find the server-php template - found = false - for _, tmpl := range templates { - if tmpl.Name == "server-php" { - found = true - assert.NotEmpty(t, tmpl.Description) - assert.NotEmpty(t, tmpl.Path) - break - } - } - assert.True(t, found, "server-php template should exist") -} - -func TestGetTemplate_Good_CoreDev(t *testing.T) { - content, err := GetTemplate("core-dev") - - require.NoError(t, err) - assert.NotEmpty(t, content) - assert.Contains(t, content, "kernel:") - assert.Contains(t, content, "linuxkit/kernel") - assert.Contains(t, content, "${SSH_KEY}") - assert.Contains(t, content, "services:") -} - -func TestGetTemplate_Good_ServerPhp(t *testing.T) { - content, err := GetTemplate("server-php") - - require.NoError(t, err) - assert.NotEmpty(t, content) - assert.Contains(t, content, "kernel:") - assert.Contains(t, content, "frankenphp") - assert.Contains(t, content, "${SSH_KEY}") - assert.Contains(t, content, "${DOMAIN:-localhost}") -} - -func TestGetTemplate_Bad_NotFound(t *testing.T) { - _, err := GetTemplate("nonexistent-template") - - assert.Error(t, err) - assert.Contains(t, err.Error(), "template not found") -} - -func TestApplyVariables_Good_SimpleSubstitution(t *testing.T) { - content := "Hello ${NAME}, welcome to ${PLACE}!" - vars := map[string]string{ - "NAME": "World", - "PLACE": "Core", - } - - result, err := ApplyVariables(content, vars) - - require.NoError(t, err) - assert.Equal(t, "Hello World, welcome to Core!", result) -} - -func TestApplyVariables_Good_WithDefaults(t *testing.T) { - content := "Memory: ${MEMORY:-1024}MB, CPUs: ${CPUS:-2}" - vars := map[string]string{ - "MEMORY": "2048", - // CPUS not provided, should use default - } - - result, err := ApplyVariables(content, vars) - - require.NoError(t, err) - assert.Equal(t, "Memory: 2048MB, CPUs: 2", result) -} - -func TestApplyVariables_Good_AllDefaults(t *testing.T) { - content := "${HOST:-localhost}:${PORT:-8080}" - vars := map[string]string{} // No vars provided - - result, err := ApplyVariables(content, vars) - - require.NoError(t, err) - assert.Equal(t, "localhost:8080", result) -} - -func TestApplyVariables_Good_MixedSyntax(t *testing.T) { - content := ` -hostname: ${HOSTNAME:-myhost} -ssh_key: ${SSH_KEY} -memory: ${MEMORY:-512} -` - vars := map[string]string{ - "SSH_KEY": "ssh-rsa AAAA...", - "HOSTNAME": "custom-host", - } - - result, err := ApplyVariables(content, vars) - - require.NoError(t, err) - assert.Contains(t, result, "hostname: custom-host") - assert.Contains(t, result, "ssh_key: ssh-rsa AAAA...") - assert.Contains(t, result, "memory: 512") -} - -func TestApplyVariables_Good_EmptyDefault(t *testing.T) { - content := "value: ${OPT:-}" - vars := map[string]string{} - - result, err := ApplyVariables(content, vars) - - require.NoError(t, err) - assert.Equal(t, "value: ", result) -} - -func TestApplyVariables_Bad_MissingRequired(t *testing.T) { - content := "SSH Key: ${SSH_KEY}" - vars := map[string]string{} // Missing required SSH_KEY - - _, err := ApplyVariables(content, vars) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "missing required variables") - assert.Contains(t, err.Error(), "SSH_KEY") -} - -func TestApplyVariables_Bad_MultipleMissing(t *testing.T) { - content := "${VAR1} and ${VAR2} and ${VAR3}" - vars := map[string]string{ - "VAR2": "provided", - } - - _, err := ApplyVariables(content, vars) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "missing required variables") - // Should mention both missing vars - errStr := err.Error() - assert.True(t, strings.Contains(errStr, "VAR1") || strings.Contains(errStr, "VAR3")) -} - -func TestApplyTemplate_Good(t *testing.T) { - vars := map[string]string{ - "SSH_KEY": "ssh-rsa AAAA... user@host", - } - - result, err := ApplyTemplate("core-dev", vars) - - require.NoError(t, err) - assert.NotEmpty(t, result) - assert.Contains(t, result, "ssh-rsa AAAA... user@host") - // Default values should be applied - assert.Contains(t, result, "core-dev") // HOSTNAME default -} - -func TestApplyTemplate_Bad_TemplateNotFound(t *testing.T) { - vars := map[string]string{ - "SSH_KEY": "test", - } - - _, err := ApplyTemplate("nonexistent", vars) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "template not found") -} - -func TestApplyTemplate_Bad_MissingVariable(t *testing.T) { - // server-php requires SSH_KEY - vars := map[string]string{} // Missing required SSH_KEY - - _, err := ApplyTemplate("server-php", vars) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "missing required variables") -} - -func TestExtractVariables_Good(t *testing.T) { - content := ` -hostname: ${HOSTNAME:-myhost} -ssh_key: ${SSH_KEY} -memory: ${MEMORY:-1024} -cpus: ${CPUS:-2} -api_key: ${API_KEY} -` - required, optional := ExtractVariables(content) - - // Required variables (no default) - assert.Contains(t, required, "SSH_KEY") - assert.Contains(t, required, "API_KEY") - assert.Len(t, required, 2) - - // Optional variables (with defaults) - assert.Equal(t, "myhost", optional["HOSTNAME"]) - assert.Equal(t, "1024", optional["MEMORY"]) - assert.Equal(t, "2", optional["CPUS"]) - assert.Len(t, optional, 3) -} - -func TestExtractVariables_Good_NoVariables(t *testing.T) { - content := "This has no variables at all" - - required, optional := ExtractVariables(content) - - assert.Empty(t, required) - assert.Empty(t, optional) -} - -func TestExtractVariables_Good_OnlyDefaults(t *testing.T) { - content := "${A:-default1} ${B:-default2}" - - required, optional := ExtractVariables(content) - - assert.Empty(t, required) - assert.Len(t, optional, 2) - assert.Equal(t, "default1", optional["A"]) - assert.Equal(t, "default2", optional["B"]) -} - -func TestScanUserTemplates_Good(t *testing.T) { - // Create a temporary directory with template files - tmpDir := t.TempDir() - - // Create a valid template file - templateContent := `# My Custom Template -# A custom template for testing -kernel: - image: linuxkit/kernel:6.6 -` - err := os.WriteFile(filepath.Join(tmpDir, "custom.yml"), []byte(templateContent), 0644) - require.NoError(t, err) - - // Create a non-template file (should be ignored) - err = os.WriteFile(filepath.Join(tmpDir, "readme.txt"), []byte("Not a template"), 0644) - require.NoError(t, err) - - templates := scanUserTemplates(tmpDir) - - assert.Len(t, templates, 1) - assert.Equal(t, "custom", templates[0].Name) - assert.Equal(t, "My Custom Template", templates[0].Description) -} - -func TestScanUserTemplates_Good_MultipleTemplates(t *testing.T) { - tmpDir := t.TempDir() - - // Create multiple template files - err := os.WriteFile(filepath.Join(tmpDir, "web.yml"), []byte("# Web Server\nkernel:"), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(tmpDir, "db.yaml"), []byte("# Database Server\nkernel:"), 0644) - require.NoError(t, err) - - templates := scanUserTemplates(tmpDir) - - assert.Len(t, templates, 2) - - // Check names are extracted correctly - names := make(map[string]bool) - for _, tmpl := range templates { - names[tmpl.Name] = true - } - assert.True(t, names["web"]) - assert.True(t, names["db"]) -} - -func TestScanUserTemplates_Good_EmptyDirectory(t *testing.T) { - tmpDir := t.TempDir() - - templates := scanUserTemplates(tmpDir) - - assert.Empty(t, templates) -} - -func TestScanUserTemplates_Bad_NonexistentDirectory(t *testing.T) { - templates := scanUserTemplates("/nonexistent/path/to/templates") - - assert.Empty(t, templates) -} - -func TestExtractTemplateDescription_Good(t *testing.T) { - tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "test.yml") - - content := `# My Template Description -# More details here -kernel: - image: test -` - err := os.WriteFile(path, []byte(content), 0644) - require.NoError(t, err) - - desc := extractTemplateDescription(path) - - assert.Equal(t, "My Template Description", desc) -} - -func TestExtractTemplateDescription_Good_NoComments(t *testing.T) { - tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "test.yml") - - content := `kernel: - image: test -` - err := os.WriteFile(path, []byte(content), 0644) - require.NoError(t, err) - - desc := extractTemplateDescription(path) - - assert.Empty(t, desc) -} - -func TestExtractTemplateDescription_Bad_FileNotFound(t *testing.T) { - desc := extractTemplateDescription("/nonexistent/file.yml") - - assert.Empty(t, desc) -} - -func TestVariablePatternEdgeCases_Good(t *testing.T) { - tests := []struct { - name string - content string - vars map[string]string - expected string - }{ - { - name: "underscore in name", - content: "${MY_VAR:-default}", - vars: map[string]string{"MY_VAR": "value"}, - expected: "value", - }, - { - name: "numbers in name", - content: "${VAR123:-default}", - vars: map[string]string{}, - expected: "default", - }, - { - name: "default with special chars", - content: "${URL:-http://localhost:8080}", - vars: map[string]string{}, - expected: "http://localhost:8080", - }, - { - name: "default with path", - content: "${PATH:-/usr/local/bin}", - vars: map[string]string{}, - expected: "/usr/local/bin", - }, - { - name: "adjacent variables", - content: "${A:-a}${B:-b}${C:-c}", - vars: map[string]string{"B": "X"}, - expected: "aXc", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := ApplyVariables(tt.content, tt.vars) - require.NoError(t, err) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestScanUserTemplates_Good_SkipsBuiltinNames(t *testing.T) { - tmpDir := t.TempDir() - - // Create a template with a builtin name (should be skipped) - err := os.WriteFile(filepath.Join(tmpDir, "core-dev.yml"), []byte("# Duplicate\nkernel:"), 0644) - require.NoError(t, err) - - // Create a unique template - err = os.WriteFile(filepath.Join(tmpDir, "unique.yml"), []byte("# Unique\nkernel:"), 0644) - require.NoError(t, err) - - templates := scanUserTemplates(tmpDir) - - // Should only have the unique template, not the builtin name - assert.Len(t, templates, 1) - assert.Equal(t, "unique", templates[0].Name) -} - -func TestScanUserTemplates_Good_SkipsDirectories(t *testing.T) { - tmpDir := t.TempDir() - - // Create a subdirectory (should be skipped) - err := os.MkdirAll(filepath.Join(tmpDir, "subdir"), 0755) - require.NoError(t, err) - - // Create a valid template - err = os.WriteFile(filepath.Join(tmpDir, "valid.yml"), []byte("# Valid\nkernel:"), 0644) - require.NoError(t, err) - - templates := scanUserTemplates(tmpDir) - - assert.Len(t, templates, 1) - assert.Equal(t, "valid", templates[0].Name) -} - -func TestScanUserTemplates_Good_YamlExtension(t *testing.T) { - tmpDir := t.TempDir() - - // Create templates with both extensions - err := os.WriteFile(filepath.Join(tmpDir, "template1.yml"), []byte("# Template 1\nkernel:"), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(tmpDir, "template2.yaml"), []byte("# Template 2\nkernel:"), 0644) - require.NoError(t, err) - - templates := scanUserTemplates(tmpDir) - - assert.Len(t, templates, 2) - - names := make(map[string]bool) - for _, tmpl := range templates { - names[tmpl.Name] = true - } - assert.True(t, names["template1"]) - assert.True(t, names["template2"]) -} - -func TestExtractTemplateDescription_Good_EmptyComment(t *testing.T) { - tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "test.yml") - - // First comment is empty, second has content - content := `# -# Actual description here -kernel: - image: test -` - err := os.WriteFile(path, []byte(content), 0644) - require.NoError(t, err) - - desc := extractTemplateDescription(path) - - assert.Equal(t, "Actual description here", desc) -} - -func TestExtractTemplateDescription_Good_MultipleEmptyComments(t *testing.T) { - tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "test.yml") - - // Multiple empty comments before actual content - content := `# -# -# -# Real description -kernel: - image: test -` - err := os.WriteFile(path, []byte(content), 0644) - require.NoError(t, err) - - desc := extractTemplateDescription(path) - - assert.Equal(t, "Real description", desc) -} - -func TestScanUserTemplates_Good_DefaultDescription(t *testing.T) { - tmpDir := t.TempDir() - - // Create a template without comments - content := `kernel: - image: test -` - err := os.WriteFile(filepath.Join(tmpDir, "nocomment.yml"), []byte(content), 0644) - require.NoError(t, err) - - templates := scanUserTemplates(tmpDir) - - assert.Len(t, templates, 1) - assert.Equal(t, "User-defined template", templates[0].Description) -} diff --git a/devkit/complexity.go b/devkit/complexity.go deleted file mode 100644 index f532177..0000000 --- a/devkit/complexity.go +++ /dev/null @@ -1,200 +0,0 @@ -// Package devkit provides a developer toolkit for common automation commands. -// LEK-1 | lthn.ai | EUPL-1.2 -package devkit - -import ( - "fmt" - "go/ast" - "go/parser" - "go/token" - "os" - "path/filepath" - "strings" -) - -// ComplexityConfig controls cyclomatic complexity analysis. -type ComplexityConfig struct { - Threshold int // Minimum complexity to report (default 15) - Path string // Directory or file path to analyse -} - -// ComplexityResult represents a single function with its cyclomatic complexity. -type ComplexityResult struct { - FuncName string - Package string - File string - Line int - Complexity int -} - -// DefaultComplexityConfig returns a config with sensible defaults. -func DefaultComplexityConfig() ComplexityConfig { - return ComplexityConfig{ - Threshold: 15, - Path: ".", - } -} - -// AnalyseComplexity walks Go source files and returns functions exceeding the -// configured complexity threshold. Uses native go/ast parsing — no external tools. -func AnalyseComplexity(cfg ComplexityConfig) ([]ComplexityResult, error) { - if cfg.Threshold <= 0 { - cfg.Threshold = 15 - } - if cfg.Path == "" { - cfg.Path = "." - } - - var results []ComplexityResult - - info, err := os.Stat(cfg.Path) - if err != nil { - return nil, fmt.Errorf("stat %s: %w", cfg.Path, err) - } - - if !info.IsDir() { - // Single file - fileResults, err := analyseFile(cfg.Path, cfg.Threshold) - if err != nil { - return nil, err - } - results = append(results, fileResults...) - return results, nil - } - - // Walk directory for .go files - err = filepath.Walk(cfg.Path, func(path string, fi os.FileInfo, err error) error { - if err != nil { - return nil - } - if fi.IsDir() { - // Skip vendor and hidden directories - name := fi.Name() - if name == "vendor" || strings.HasPrefix(name, ".") { - return filepath.SkipDir - } - return nil - } - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { - return nil - } - fileResults, err := analyseFile(path, cfg.Threshold) - if err != nil { - return nil // Skip files that fail to parse - } - results = append(results, fileResults...) - return nil - }) - if err != nil { - return nil, fmt.Errorf("walk %s: %w", cfg.Path, err) - } - - return results, nil -} - -// AnalyseComplexitySource parses Go source code from a string and returns -// complexity results. Useful for testing without file I/O. -func AnalyseComplexitySource(src string, filename string, threshold int) ([]ComplexityResult, error) { - if threshold <= 0 { - threshold = 15 - } - - fset := token.NewFileSet() - f, err := parser.ParseFile(fset, filename, src, parser.ParseComments) - if err != nil { - return nil, fmt.Errorf("parse %s: %w", filename, err) - } - - var results []ComplexityResult - pkgName := f.Name.Name - - ast.Inspect(f, func(n ast.Node) bool { - switch fn := n.(type) { - case *ast.FuncDecl: - complexity := calculateComplexity(fn) - if complexity >= threshold { - pos := fset.Position(fn.Pos()) - funcName := fn.Name.Name - if fn.Recv != nil && len(fn.Recv.List) > 0 { - funcName = receiverType(fn.Recv.List[0].Type) + "." + funcName - } - results = append(results, ComplexityResult{ - FuncName: funcName, - Package: pkgName, - File: pos.Filename, - Line: pos.Line, - Complexity: complexity, - }) - } - } - return true - }) - - return results, nil -} - -// analyseFile parses a single Go file and returns functions exceeding the threshold. -func analyseFile(path string, threshold int) ([]ComplexityResult, error) { - src, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read %s: %w", path, err) - } - return AnalyseComplexitySource(string(src), path, threshold) -} - -// calculateComplexity computes the cyclomatic complexity of a function. -// Starts at 1, increments for each branching construct. -func calculateComplexity(fn *ast.FuncDecl) int { - if fn.Body == nil { - return 1 - } - - complexity := 1 - ast.Inspect(fn.Body, func(n ast.Node) bool { - switch node := n.(type) { - case *ast.IfStmt: - complexity++ - case *ast.ForStmt: - complexity++ - case *ast.RangeStmt: - complexity++ - case *ast.CaseClause: - // Each case adds a branch (except default, which is the "else") - if node.List != nil { - complexity++ - } - case *ast.CommClause: - // Select case - if node.Comm != nil { - complexity++ - } - case *ast.BinaryExpr: - if node.Op == token.LAND || node.Op == token.LOR { - complexity++ - } - case *ast.TypeSwitchStmt: - complexity++ - case *ast.SelectStmt: - complexity++ - } - return true - }) - - return complexity -} - -// receiverType extracts the type name from a method receiver. -func receiverType(expr ast.Expr) string { - switch t := expr.(type) { - case *ast.StarExpr: - return receiverType(t.X) - case *ast.Ident: - return t.Name - case *ast.IndexExpr: - return receiverType(t.X) - default: - return "?" - } -} - -// LEK-1 | lthn.ai | EUPL-1.2 diff --git a/devkit/complexity_test.go b/devkit/complexity_test.go deleted file mode 100644 index c95aade..0000000 --- a/devkit/complexity_test.go +++ /dev/null @@ -1,430 +0,0 @@ -// LEK-1 | lthn.ai | EUPL-1.2 -package devkit - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAnalyseComplexitySource_SimpleFunc_Good(t *testing.T) { - src := `package example - -func simple() { - x := 1 - _ = x -} -` - results, err := AnalyseComplexitySource(src, "simple.go", 1) - require.NoError(t, err) - // Complexity = 1 (just the function body, no branches), threshold = 1 - assert.Len(t, results, 1) - assert.Equal(t, "simple", results[0].FuncName) - assert.Equal(t, "example", results[0].Package) - assert.Equal(t, 1, results[0].Complexity) -} - -func TestAnalyseComplexitySource_IfElse_Good(t *testing.T) { - src := `package example - -func branches(x int) string { - if x > 0 { - return "positive" - } else if x < 0 { - return "negative" - } - return "zero" -} -` - results, err := AnalyseComplexitySource(src, "branches.go", 1) - require.NoError(t, err) - require.Len(t, results, 1) - // 1 (base) + 1 (if) + 1 (else if) = 3 - assert.Equal(t, 3, results[0].Complexity) - assert.Equal(t, "branches", results[0].FuncName) -} - -func TestAnalyseComplexitySource_ForLoop_Good(t *testing.T) { - src := `package example - -func loopy(items []int) int { - total := 0 - for _, v := range items { - total += v - } - for i := range 10 { - total += i - } - return total -} -` - results, err := AnalyseComplexitySource(src, "loops.go", 1) - require.NoError(t, err) - require.Len(t, results, 1) - // 1 (base) + 1 (range) + 1 (for) = 3 - assert.Equal(t, 3, results[0].Complexity) -} - -func TestAnalyseComplexitySource_SwitchCase_Good(t *testing.T) { - src := `package example - -func switcher(x int) string { - switch x { - case 1: - return "one" - case 2: - return "two" - case 3: - return "three" - default: - return "other" - } -} -` - results, err := AnalyseComplexitySource(src, "switch.go", 1) - require.NoError(t, err) - require.Len(t, results, 1) - // 1 (base) + 3 (case 1, 2, 3; default has nil List) = 4 - assert.Equal(t, 4, results[0].Complexity) -} - -func TestAnalyseComplexitySource_LogicalOperators_Good(t *testing.T) { - src := `package example - -func complex(a, b, c bool) bool { - if a && b || c { - return true - } - return false -} -` - results, err := AnalyseComplexitySource(src, "logical.go", 1) - require.NoError(t, err) - require.Len(t, results, 1) - // 1 (base) + 1 (if) + 1 (&&) + 1 (||) = 4 - assert.Equal(t, 4, results[0].Complexity) -} - -func TestAnalyseComplexitySource_MethodReceiver_Good(t *testing.T) { - src := `package example - -type Server struct{} - -func (s *Server) Handle(req int) string { - if req > 0 { - return "ok" - } - return "bad" -} -` - results, err := AnalyseComplexitySource(src, "method.go", 1) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, "Server.Handle", results[0].FuncName) - assert.Equal(t, 2, results[0].Complexity) -} - -func TestAnalyseComplexitySource_HighComplexity_Good(t *testing.T) { - // Build a function with high complexity to test threshold filtering. - src := `package example - -func monster(x, y, z int) int { - result := 0 - if x > 0 { - if y > 0 { - if z > 0 { - result = 1 - } else if z < -10 { - result = 2 - } - } else if y < -5 { - result = 3 - } - } else if x < -10 { - result = 4 - } - for i := range x { - for j := range y { - if i > j && j > 0 { - result += i - } else if i == j || i < 0 { - result += j - } - } - } - switch result { - case 1: - result++ - case 2: - result-- - case 3: - result *= 2 - } - return result -} -` - // With threshold 15 — should be above it - results, err := AnalyseComplexitySource(src, "monster.go", 15) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, "monster", results[0].FuncName) - assert.GreaterOrEqual(t, results[0].Complexity, 15) -} - -func TestAnalyseComplexitySource_BelowThreshold_Good(t *testing.T) { - src := `package example - -func simple() int { - return 42 -} -` - results, err := AnalyseComplexitySource(src, "simple.go", 5) - require.NoError(t, err) - assert.Empty(t, results) // Complexity 1, below threshold 5 -} - -func TestAnalyseComplexitySource_MultipleFuncs_Good(t *testing.T) { - src := `package example - -func low() { } - -func medium(x int) { - if x > 0 { - if x > 10 { - _ = x - } - } -} - -func high(a, b, c, d int) int { - if a > 0 { - if b > 0 { - if c > 0 { - if d > 0 { - return 1 - } - } - } - } - return 0 -} -` - results, err := AnalyseComplexitySource(src, "multi.go", 3) - require.NoError(t, err) - // low: 1, medium: 3, high: 5 - assert.Len(t, results, 2) // medium and high - assert.Equal(t, "medium", results[0].FuncName) - assert.Equal(t, 3, results[0].Complexity) - assert.Equal(t, "high", results[1].FuncName) - assert.Equal(t, 5, results[1].Complexity) -} - -func TestAnalyseComplexitySource_SelectStmt_Good(t *testing.T) { - src := `package example - -func selecter(ch1, ch2 chan int) int { - select { - case v := <-ch1: - return v - case v := <-ch2: - return v - } -} -` - results, err := AnalyseComplexitySource(src, "select.go", 1) - require.NoError(t, err) - require.Len(t, results, 1) - // 1 (base) + 1 (select) + 2 (comm clauses) = 4 - assert.Equal(t, 4, results[0].Complexity) -} - -func TestAnalyseComplexitySource_TypeSwitch_Good(t *testing.T) { - src := `package example - -func typeSwitch(v interface{}) string { - switch v.(type) { - case int: - return "int" - case string: - return "string" - default: - return "unknown" - } -} -` - results, err := AnalyseComplexitySource(src, "typeswitch.go", 1) - require.NoError(t, err) - require.Len(t, results, 1) - // 1 (base) + 1 (type switch) + 2 (case int, case string; default has nil List) = 4 - assert.Equal(t, 4, results[0].Complexity) -} - -func TestAnalyseComplexitySource_EmptyBody_Good(t *testing.T) { - // Interface methods or abstract funcs have nil body - src := `package example - -type Iface interface { - DoSomething(x int) error -} -` - results, err := AnalyseComplexitySource(src, "iface.go", 1) - require.NoError(t, err) - assert.Empty(t, results) // No FuncDecl in interface -} - -func TestAnalyseComplexitySource_ParseError_Bad(t *testing.T) { - src := `this is not valid go code at all!!!` - _, err := AnalyseComplexitySource(src, "bad.go", 1) - assert.Error(t, err) - assert.Contains(t, err.Error(), "parse") -} - -func TestAnalyseComplexity_DefaultThreshold_Good(t *testing.T) { - cfg := DefaultComplexityConfig() - assert.Equal(t, 15, cfg.Threshold) - assert.Equal(t, ".", cfg.Path) -} - -func TestAnalyseComplexity_ZeroThreshold_Good(t *testing.T) { - // Zero threshold should default to 15 - src := `package example -func f() { } -` - results, err := AnalyseComplexitySource(src, "zero.go", 0) - require.NoError(t, err) - assert.Empty(t, results) // complexity 1, default threshold 15 -} - -func TestAnalyseComplexity_Directory_Good(t *testing.T) { - dir := t.TempDir() - - // Write a Go file with a complex function - src := `package example - -func complex(a, b, c, d, e int) int { - if a > 0 { - if b > 0 { - if c > 0 { - return 1 - } - } - } - if d > 0 || e > 0 { - return 2 - } - return 0 -} -` - err := os.WriteFile(filepath.Join(dir, "example.go"), []byte(src), 0644) - require.NoError(t, err) - - cfg := ComplexityConfig{Threshold: 3, Path: dir} - results, err := AnalyseComplexity(cfg) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, "complex", results[0].FuncName) - // 1 (base) + 3 (if x>0, if y>0, if z>0) + 1 (if d>0||e>0) + 1 (||) = 6 - assert.Equal(t, 6, results[0].Complexity) -} - -func TestAnalyseComplexity_SingleFile_Good(t *testing.T) { - dir := t.TempDir() - src := `package example - -func branchy(x int) { - if x > 0 { } - if x > 1 { } - if x > 2 { } -} -` - path := filepath.Join(dir, "single.go") - err := os.WriteFile(path, []byte(src), 0644) - require.NoError(t, err) - - cfg := ComplexityConfig{Threshold: 1, Path: path} - results, err := AnalyseComplexity(cfg) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, 4, results[0].Complexity) // 1 + 3 ifs -} - -func TestAnalyseComplexity_SkipsTestFiles_Good(t *testing.T) { - dir := t.TempDir() - - // Production file — should be analysed - prod := `package example -func prodFunc(x int) { - if x > 0 { } - if x > 1 { } -} -` - err := os.WriteFile(filepath.Join(dir, "prod.go"), []byte(prod), 0644) - require.NoError(t, err) - - // Test file — should be skipped - test := `package example -func TestHelper(x int) { - if x > 0 { } - if x > 1 { } - if x > 2 { } - if x > 3 { } -} -` - err = os.WriteFile(filepath.Join(dir, "prod_test.go"), []byte(test), 0644) - require.NoError(t, err) - - cfg := ComplexityConfig{Threshold: 1, Path: dir} - results, err := AnalyseComplexity(cfg) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, "prodFunc", results[0].FuncName) -} - -func TestAnalyseComplexity_SkipsVendor_Good(t *testing.T) { - dir := t.TempDir() - - // Create vendor dir with a Go file - vendorDir := filepath.Join(dir, "vendor") - err := os.MkdirAll(vendorDir, 0755) - require.NoError(t, err) - - vendorSrc := `package lib -func VendorFunc(x int) { - if x > 0 { } - if x > 1 { } -} -` - err = os.WriteFile(filepath.Join(vendorDir, "lib.go"), []byte(vendorSrc), 0644) - require.NoError(t, err) - - cfg := ComplexityConfig{Threshold: 1, Path: dir} - results, err := AnalyseComplexity(cfg) - require.NoError(t, err) - assert.Empty(t, results) // vendor dir should be skipped -} - -func TestAnalyseComplexity_NonexistentPath_Bad(t *testing.T) { - cfg := ComplexityConfig{Threshold: 1, Path: "/nonexistent/path/xyz"} - _, err := AnalyseComplexity(cfg) - assert.Error(t, err) - assert.Contains(t, err.Error(), "stat") -} - -func TestAnalyseComplexitySource_NestedLogicalOps_Good(t *testing.T) { - src := `package example - -func nested(a, b, c, d bool) bool { - return (a && b) || (c && d) -} -` - results, err := AnalyseComplexitySource(src, "nested.go", 1) - require.NoError(t, err) - require.Len(t, results, 1) - // 1 (base) + 2 (&&) + 1 (||) = 4 - assert.Equal(t, 4, results[0].Complexity) -} - -// LEK-1 | lthn.ai | EUPL-1.2 diff --git a/devkit/coverage.go b/devkit/coverage.go deleted file mode 100644 index 78985ca..0000000 --- a/devkit/coverage.go +++ /dev/null @@ -1,261 +0,0 @@ -// Package devkit provides a developer toolkit for common automation commands. -// LEK-1 | lthn.ai | EUPL-1.2 -package devkit - -import ( - "bufio" - "encoding/json" - "fmt" - "math" - "os" - "regexp" - "strconv" - "strings" - "time" -) - -// CoverageSnapshot represents a point-in-time coverage measurement. -type CoverageSnapshot struct { - Timestamp time.Time `json:"timestamp"` - Packages map[string]float64 `json:"packages"` // package → coverage % - Total float64 `json:"total"` // overall coverage % - Meta map[string]string `json:"meta,omitempty"` // optional metadata (commit, branch, etc.) -} - -// CoverageRegression flags a package whose coverage dropped between runs. -type CoverageRegression struct { - Package string - Previous float64 - Current float64 - Delta float64 // Negative means regression -} - -// CoverageComparison holds the result of comparing two snapshots. -type CoverageComparison struct { - Regressions []CoverageRegression - Improvements []CoverageRegression - NewPackages []string // Packages present in current but not previous - Removed []string // Packages present in previous but not current - TotalDelta float64 // Change in overall coverage -} - -// CoverageStore persists coverage snapshots to a JSON file. -type CoverageStore struct { - Path string // File path for JSON storage -} - -// NewCoverageStore creates a store backed by the given file path. -func NewCoverageStore(path string) *CoverageStore { - return &CoverageStore{Path: path} -} - -// Append adds a snapshot to the store. -func (s *CoverageStore) Append(snap CoverageSnapshot) error { - snapshots, err := s.Load() - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("load snapshots: %w", err) - } - - snapshots = append(snapshots, snap) - - data, err := json.MarshalIndent(snapshots, "", " ") - if err != nil { - return fmt.Errorf("marshal snapshots: %w", err) - } - - if err := os.WriteFile(s.Path, data, 0644); err != nil { - return fmt.Errorf("write %s: %w", s.Path, err) - } - return nil -} - -// Load reads all snapshots from the store. -func (s *CoverageStore) Load() ([]CoverageSnapshot, error) { - data, err := os.ReadFile(s.Path) - if err != nil { - return nil, err - } - - var snapshots []CoverageSnapshot - if err := json.Unmarshal(data, &snapshots); err != nil { - return nil, fmt.Errorf("parse %s: %w", s.Path, err) - } - return snapshots, nil -} - -// Latest returns the most recent snapshot, or nil if the store is empty. -func (s *CoverageStore) Latest() (*CoverageSnapshot, error) { - snapshots, err := s.Load() - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - if len(snapshots) == 0 { - return nil, nil - } - - latest := &snapshots[0] - for i := range snapshots { - if snapshots[i].Timestamp.After(latest.Timestamp) { - latest = &snapshots[i] - } - } - return latest, nil -} - -// ParseCoverProfile parses output from `go test -coverprofile=cover.out` format. -// Each line is: mode: set/count/atomic (first line) or -// package/file.go:startLine.startCol,endLine.endCol stmts count -func ParseCoverProfile(data string) (CoverageSnapshot, error) { - snap := CoverageSnapshot{ - Timestamp: time.Now(), - Packages: make(map[string]float64), - } - - type pkgStats struct { - covered int - total int - } - packages := make(map[string]*pkgStats) - - scanner := bufio.NewScanner(strings.NewReader(data)) - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, "mode:") { - continue - } - - // Format: pkg/file.go:line.col,line.col numStmt count - parts := strings.Fields(line) - if len(parts) != 3 { - continue - } - - // Extract package from file path - filePath := parts[0] - colonIdx := strings.Index(filePath, ":") - if colonIdx < 0 { - continue - } - file := filePath[:colonIdx] - - // Package is everything up to the last / - pkg := file - if lastSlash := strings.LastIndex(file, "/"); lastSlash >= 0 { - pkg = file[:lastSlash] - } - - stmts, err := strconv.Atoi(parts[1]) - if err != nil { - continue - } - count, err := strconv.Atoi(parts[2]) - if err != nil { - continue - } - - if _, ok := packages[pkg]; !ok { - packages[pkg] = &pkgStats{} - } - packages[pkg].total += stmts - if count > 0 { - packages[pkg].covered += stmts - } - } - - totalCovered := 0 - totalStmts := 0 - - for pkg, stats := range packages { - if stats.total > 0 { - snap.Packages[pkg] = math.Round(float64(stats.covered)/float64(stats.total)*1000) / 10 - } else { - snap.Packages[pkg] = 0 - } - totalCovered += stats.covered - totalStmts += stats.total - } - - if totalStmts > 0 { - snap.Total = math.Round(float64(totalCovered)/float64(totalStmts)*1000) / 10 - } - - return snap, nil -} - -// ParseCoverOutput parses the human-readable `go test -cover ./...` output. -// Extracts lines like: ok example.com/pkg 0.5s coverage: 85.0% of statements -func ParseCoverOutput(output string) (CoverageSnapshot, error) { - snap := CoverageSnapshot{ - Timestamp: time.Now(), - Packages: make(map[string]float64), - } - - re := regexp.MustCompile(`ok\s+(\S+)\s+.*coverage:\s+([\d.]+)%`) - scanner := bufio.NewScanner(strings.NewReader(output)) - - totalPct := 0.0 - count := 0 - - for scanner.Scan() { - matches := re.FindStringSubmatch(scanner.Text()) - if len(matches) == 3 { - pct, _ := strconv.ParseFloat(matches[2], 64) - snap.Packages[matches[1]] = pct - totalPct += pct - count++ - } - } - - if count > 0 { - snap.Total = math.Round(totalPct/float64(count)*10) / 10 - } - - return snap, nil -} - -// CompareCoverage computes the difference between two snapshots. -func CompareCoverage(previous, current CoverageSnapshot) CoverageComparison { - comp := CoverageComparison{ - TotalDelta: math.Round((current.Total-previous.Total)*10) / 10, - } - - // Check each current package against previous - for pkg, curPct := range current.Packages { - prevPct, existed := previous.Packages[pkg] - if !existed { - comp.NewPackages = append(comp.NewPackages, pkg) - continue - } - - delta := math.Round((curPct-prevPct)*10) / 10 - if delta < 0 { - comp.Regressions = append(comp.Regressions, CoverageRegression{ - Package: pkg, - Previous: prevPct, - Current: curPct, - Delta: delta, - }) - } else if delta > 0 { - comp.Improvements = append(comp.Improvements, CoverageRegression{ - Package: pkg, - Previous: prevPct, - Current: curPct, - Delta: delta, - }) - } - } - - // Check for removed packages - for pkg := range previous.Packages { - if _, exists := current.Packages[pkg]; !exists { - comp.Removed = append(comp.Removed, pkg) - } - } - - return comp -} - -// LEK-1 | lthn.ai | EUPL-1.2 diff --git a/devkit/coverage_test.go b/devkit/coverage_test.go deleted file mode 100644 index 728c39e..0000000 --- a/devkit/coverage_test.go +++ /dev/null @@ -1,358 +0,0 @@ -// LEK-1 | lthn.ai | EUPL-1.2 -package devkit - -import ( - "os" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const sampleCoverProfile = `mode: set -example.com/pkg1/file1.go:10.1,20.2 5 1 -example.com/pkg1/file1.go:22.1,30.2 3 0 -example.com/pkg1/file2.go:5.1,15.2 4 1 -example.com/pkg2/main.go:1.1,10.2 10 1 -example.com/pkg2/main.go:12.1,20.2 10 0 -` - -func TestParseCoverProfile_Good(t *testing.T) { - snap, err := ParseCoverProfile(sampleCoverProfile) - require.NoError(t, err) - - assert.Len(t, snap.Packages, 2) - - // pkg1: 5+4 covered out of 5+3+4=12 => 9/12 = 75% - assert.Equal(t, 75.0, snap.Packages["example.com/pkg1"]) - - // pkg2: 10 covered out of 10+10=20 => 10/20 = 50% - assert.Equal(t, 50.0, snap.Packages["example.com/pkg2"]) - - // Total: 19/32 = 59.4% - assert.Equal(t, 59.4, snap.Total) - assert.False(t, snap.Timestamp.IsZero()) -} - -func TestParseCoverProfile_Empty_Good(t *testing.T) { - snap, err := ParseCoverProfile("") - require.NoError(t, err) - assert.Empty(t, snap.Packages) - assert.Equal(t, 0.0, snap.Total) -} - -func TestParseCoverProfile_ModeOnly_Good(t *testing.T) { - snap, err := ParseCoverProfile("mode: set\n") - require.NoError(t, err) - assert.Empty(t, snap.Packages) -} - -func TestParseCoverProfile_FullCoverage_Good(t *testing.T) { - data := `mode: set -example.com/perfect/main.go:1.1,10.2 10 1 -example.com/perfect/main.go:12.1,20.2 5 1 -` - snap, err := ParseCoverProfile(data) - require.NoError(t, err) - assert.Equal(t, 100.0, snap.Packages["example.com/perfect"]) - assert.Equal(t, 100.0, snap.Total) -} - -func TestParseCoverProfile_ZeroCoverage_Good(t *testing.T) { - data := `mode: set -example.com/zero/main.go:1.1,10.2 10 0 -example.com/zero/main.go:12.1,20.2 5 0 -` - snap, err := ParseCoverProfile(data) - require.NoError(t, err) - assert.Equal(t, 0.0, snap.Packages["example.com/zero"]) - assert.Equal(t, 0.0, snap.Total) -} - -func TestParseCoverProfile_MalformedLines_Bad(t *testing.T) { - data := `mode: set -not a valid line -example.com/pkg/file.go:1.1,10.2 5 1 -another bad line with spaces -example.com/pkg/file.go:12.1,20.2 5 0 -` - snap, err := ParseCoverProfile(data) - require.NoError(t, err) - assert.Len(t, snap.Packages, 1) - assert.Equal(t, 50.0, snap.Packages["example.com/pkg"]) -} - -func TestParseCoverOutput_Good(t *testing.T) { - output := `? example.com/skipped [no test files] -ok example.com/pkg1 0.5s coverage: 85.0% of statements -ok example.com/pkg2 0.2s coverage: 42.5% of statements -ok example.com/pkg3 1.1s coverage: 100.0% of statements -` - snap, err := ParseCoverOutput(output) - require.NoError(t, err) - - assert.Len(t, snap.Packages, 3) - assert.Equal(t, 85.0, snap.Packages["example.com/pkg1"]) - assert.Equal(t, 42.5, snap.Packages["example.com/pkg2"]) - assert.Equal(t, 100.0, snap.Packages["example.com/pkg3"]) - - // Total = avg of (85 + 42.5 + 100) / 3 = 75.8333... rounded to 75.8 - assert.Equal(t, 75.8, snap.Total) -} - -func TestParseCoverOutput_Empty_Good(t *testing.T) { - snap, err := ParseCoverOutput("") - require.NoError(t, err) - assert.Empty(t, snap.Packages) - assert.Equal(t, 0.0, snap.Total) -} - -func TestParseCoverOutput_NoTestFiles_Good(t *testing.T) { - output := `? example.com/empty [no test files] -` - snap, err := ParseCoverOutput(output) - require.NoError(t, err) - assert.Empty(t, snap.Packages) -} - -// --- CompareCoverage tests --- - -func TestCompareCoverage_Regression_Good(t *testing.T) { - prev := CoverageSnapshot{ - Packages: map[string]float64{ - "pkg1": 90.0, - "pkg2": 85.0, - "pkg3": 70.0, - }, - Total: 81.7, - } - curr := CoverageSnapshot{ - Packages: map[string]float64{ - "pkg1": 90.0, // unchanged - "pkg2": 75.0, // regression: -10 - "pkg3": 80.0, // improvement: +10 - }, - Total: 81.7, - } - - comp := CompareCoverage(prev, curr) - - assert.Len(t, comp.Regressions, 1) - assert.Equal(t, "pkg2", comp.Regressions[0].Package) - assert.Equal(t, -10.0, comp.Regressions[0].Delta) - assert.Equal(t, 85.0, comp.Regressions[0].Previous) - assert.Equal(t, 75.0, comp.Regressions[0].Current) - - assert.Len(t, comp.Improvements, 1) - assert.Equal(t, "pkg3", comp.Improvements[0].Package) - assert.Equal(t, 10.0, comp.Improvements[0].Delta) -} - -func TestCompareCoverage_NewAndRemoved_Good(t *testing.T) { - prev := CoverageSnapshot{ - Packages: map[string]float64{ - "old-pkg": 50.0, - "stable": 80.0, - }, - Total: 65.0, - } - curr := CoverageSnapshot{ - Packages: map[string]float64{ - "stable": 80.0, - "new-pkg": 60.0, - }, - Total: 70.0, - } - - comp := CompareCoverage(prev, curr) - - assert.Contains(t, comp.NewPackages, "new-pkg") - assert.Contains(t, comp.Removed, "old-pkg") - assert.Equal(t, 5.0, comp.TotalDelta) - assert.Empty(t, comp.Regressions) -} - -func TestCompareCoverage_Identical_Good(t *testing.T) { - snap := CoverageSnapshot{ - Packages: map[string]float64{ - "pkg1": 80.0, - "pkg2": 90.0, - }, - Total: 85.0, - } - - comp := CompareCoverage(snap, snap) - - assert.Empty(t, comp.Regressions) - assert.Empty(t, comp.Improvements) - assert.Empty(t, comp.NewPackages) - assert.Empty(t, comp.Removed) - assert.Equal(t, 0.0, comp.TotalDelta) -} - -func TestCompareCoverage_EmptySnapshots_Good(t *testing.T) { - prev := CoverageSnapshot{Packages: map[string]float64{}} - curr := CoverageSnapshot{Packages: map[string]float64{}} - - comp := CompareCoverage(prev, curr) - assert.Empty(t, comp.Regressions) - assert.Empty(t, comp.Improvements) - assert.Empty(t, comp.NewPackages) - assert.Empty(t, comp.Removed) -} - -func TestCompareCoverage_AllNew_Good(t *testing.T) { - prev := CoverageSnapshot{Packages: map[string]float64{}} - curr := CoverageSnapshot{ - Packages: map[string]float64{ - "new1": 50.0, - "new2": 75.0, - }, - Total: 62.5, - } - - comp := CompareCoverage(prev, curr) - assert.Len(t, comp.NewPackages, 2) - assert.Empty(t, comp.Regressions) - assert.Equal(t, 62.5, comp.TotalDelta) -} - -// --- CoverageStore tests --- - -func TestCoverageStore_AppendAndLoad_Good(t *testing.T) { - path := filepath.Join(t.TempDir(), "coverage.json") - store := NewCoverageStore(path) - - snap1 := CoverageSnapshot{ - Timestamp: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), - Packages: map[string]float64{"pkg1": 80.0}, - Total: 80.0, - } - snap2 := CoverageSnapshot{ - Timestamp: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), - Packages: map[string]float64{"pkg1": 85.0}, - Total: 85.0, - } - - require.NoError(t, store.Append(snap1)) - require.NoError(t, store.Append(snap2)) - - loaded, err := store.Load() - require.NoError(t, err) - assert.Len(t, loaded, 2) - assert.Equal(t, 80.0, loaded[0].Total) - assert.Equal(t, 85.0, loaded[1].Total) -} - -func TestCoverageStore_Latest_Good(t *testing.T) { - path := filepath.Join(t.TempDir(), "coverage.json") - store := NewCoverageStore(path) - - // Add snapshots out of chronological order - snap1 := CoverageSnapshot{ - Timestamp: time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), - Packages: map[string]float64{"pkg1": 90.0}, - Total: 90.0, - } - snap2 := CoverageSnapshot{ - Timestamp: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), - Packages: map[string]float64{"pkg1": 70.0}, - Total: 70.0, - } - - require.NoError(t, store.Append(snap2)) // older first - require.NoError(t, store.Append(snap1)) // newer second - - latest, err := store.Latest() - require.NoError(t, err) - require.NotNil(t, latest) - assert.Equal(t, 90.0, latest.Total) -} - -func TestCoverageStore_LatestEmpty_Good(t *testing.T) { - path := filepath.Join(t.TempDir(), "nonexistent.json") - store := NewCoverageStore(path) - - latest, err := store.Latest() - require.NoError(t, err) - assert.Nil(t, latest) -} - -func TestCoverageStore_LoadNonexistent_Bad(t *testing.T) { - store := NewCoverageStore("/nonexistent/path/coverage.json") - _, err := store.Load() - assert.Error(t, err) -} - -func TestCoverageStore_LoadCorrupt_Bad(t *testing.T) { - path := filepath.Join(t.TempDir(), "corrupt.json") - require.NoError(t, os.WriteFile(path, []byte("not json!!!"), 0644)) - - store := NewCoverageStore(path) - _, err := store.Load() - assert.Error(t, err) - assert.Contains(t, err.Error(), "parse") -} - -func TestCoverageStore_WithMeta_Good(t *testing.T) { - path := filepath.Join(t.TempDir(), "coverage.json") - store := NewCoverageStore(path) - - snap := CoverageSnapshot{ - Timestamp: time.Now(), - Packages: map[string]float64{"pkg1": 80.0}, - Total: 80.0, - Meta: map[string]string{ - "commit": "abc123", - "branch": "main", - }, - } - - require.NoError(t, store.Append(snap)) - - loaded, err := store.Load() - require.NoError(t, err) - require.Len(t, loaded, 1) - assert.Equal(t, "abc123", loaded[0].Meta["commit"]) - assert.Equal(t, "main", loaded[0].Meta["branch"]) -} - -func TestCoverageStore_Persistence_Good(t *testing.T) { - path := filepath.Join(t.TempDir(), "persist.json") - - // Write with one store instance - store1 := NewCoverageStore(path) - snap := CoverageSnapshot{ - Timestamp: time.Now(), - Packages: map[string]float64{"pkg1": 55.5}, - Total: 55.5, - } - require.NoError(t, store1.Append(snap)) - - // Read with a different store instance - store2 := NewCoverageStore(path) - loaded, err := store2.Load() - require.NoError(t, err) - assert.Len(t, loaded, 1) - assert.Equal(t, 55.5, loaded[0].Total) -} - -func TestCompareCoverage_SmallDelta_Good(t *testing.T) { - // Test that very small deltas (<0.05) round to 0 and are not flagged. - prev := CoverageSnapshot{ - Packages: map[string]float64{"pkg1": 80.01}, - Total: 80.01, - } - curr := CoverageSnapshot{ - Packages: map[string]float64{"pkg1": 80.04}, - Total: 80.04, - } - - comp := CompareCoverage(prev, curr) - assert.Empty(t, comp.Regressions) - assert.Empty(t, comp.Improvements) // 0.03 rounds to 0.0 -} - -// LEK-1 | lthn.ai | EUPL-1.2 diff --git a/devkit/devkit.go b/devkit/devkit.go deleted file mode 100644 index 466cafc..0000000 --- a/devkit/devkit.go +++ /dev/null @@ -1,560 +0,0 @@ -// Package devkit provides a developer toolkit for common automation commands. -// Designed by Gemini 3 Pro (Hypnos) + Claude Opus (Charon), signed LEK-1 | lthn.ai | EUPL-1.2 -package devkit - -import ( - "bufio" - "bytes" - "fmt" - "os" - "os/exec" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" -) - -// --- Code Quality --- - -// Finding represents a single issue found by a linting tool. -type Finding struct { - File string - Line int - Message string - Tool string -} - -// CoverageReport holds the test coverage percentage for a package. -type CoverageReport struct { - Package string - Percentage float64 -} - -// RaceCondition represents a data race detected by the Go race detector. -type RaceCondition struct { - File string - Line int - Desc string -} - -// TODO represents a tracked code comment like TODO, FIXME, or HACK. -type TODO struct { - File string - Line int - Type string - Message string -} - -// --- Security --- - -// Vulnerability represents a dependency vulnerability. -type Vulnerability struct { - ID string - Package string - Version string - Description string -} - -// SecretLeak represents a potential secret found in the codebase. -type SecretLeak struct { - File string - Line int - RuleID string - Match string -} - -// PermIssue represents a file permission issue. -type PermIssue struct { - File string - Permission string - Issue string -} - -// --- Git Operations --- - -// DiffSummary provides a summary of changes. -type DiffSummary struct { - FilesChanged int - Insertions int - Deletions int -} - -// Commit represents a single git commit. -type Commit struct { - Hash string - Author string - Date time.Time - Message string -} - -// --- Build & Dependencies --- - -// BuildResult holds the outcome of a single build target. -type BuildResult struct { - Target string - Path string - Error error -} - -// Graph represents a dependency graph. -type Graph struct { - Nodes []string - Edges map[string][]string -} - -// --- Metrics --- - -// ComplexFunc represents a function with its cyclomatic complexity score. -type ComplexFunc struct { - Package string - FuncName string - File string - Line int - Score int -} - -// Toolkit wraps common dev automation commands into structured Go APIs. -type Toolkit struct { - Dir string // Working directory for commands -} - -// New creates a Toolkit rooted at the given directory. -func New(dir string) *Toolkit { - return &Toolkit{Dir: dir} -} - -// Run executes a command and captures stdout, stderr, and exit code. -func (t *Toolkit) Run(name string, args ...string) (stdout, stderr string, exitCode int, err error) { - cmd := exec.Command(name, args...) - cmd.Dir = t.Dir - var stdoutBuf, stderrBuf bytes.Buffer - cmd.Stdout = &stdoutBuf - cmd.Stderr = &stderrBuf - - err = cmd.Run() - stdout = stdoutBuf.String() - stderr = stderrBuf.String() - - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } else { - exitCode = -1 - } - } - return -} - -// FindTODOs greps for TODO/FIXME/HACK comments within a directory. -func (t *Toolkit) FindTODOs(dir string) ([]TODO, error) { - pattern := `\b(TODO|FIXME|HACK)\b(\(.*\))?:` - stdout, stderr, exitCode, err := t.Run("git", "grep", "--line-number", "-E", pattern, "--", dir) - - if exitCode == 1 && stdout == "" { - return nil, nil - } - if err != nil && exitCode != 1 { - return nil, fmt.Errorf("git grep failed (exit %d): %w\n%s", exitCode, err, stderr) - } - - var todos []TODO - re := regexp.MustCompile(pattern) - - for line := range strings.SplitSeq(strings.TrimSpace(stdout), "\n") { - if line == "" { - continue - } - parts := strings.SplitN(line, ":", 3) - if len(parts) < 3 { - continue - } - lineNum, _ := strconv.Atoi(parts[1]) - match := re.FindStringSubmatch(parts[2]) - todoType := "" - if len(match) > 1 { - todoType = match[1] - } - msg := strings.TrimSpace(re.Split(parts[2], 2)[1]) - - todos = append(todos, TODO{ - File: parts[0], - Line: lineNum, - Type: todoType, - Message: msg, - }) - } - return todos, nil -} - -// AuditDeps runs govulncheck to find dependency vulnerabilities. -func (t *Toolkit) AuditDeps() ([]Vulnerability, error) { - stdout, stderr, exitCode, err := t.Run("govulncheck", "./...") - if err != nil && exitCode != 0 && !strings.Contains(stdout, "Vulnerability") { - return nil, fmt.Errorf("govulncheck failed (exit %d): %w\n%s", exitCode, err, stderr) - } - - var vulns []Vulnerability - scanner := bufio.NewScanner(strings.NewReader(stdout)) - var cur Vulnerability - inBlock := false - - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, "Vulnerability #") { - if cur.ID != "" { - vulns = append(vulns, cur) - } - fields := strings.Fields(line) - cur = Vulnerability{} - if len(fields) > 1 { - cur.ID = fields[1] - } - inBlock = true - } else if inBlock { - switch { - case strings.Contains(line, "Package:"): - cur.Package = strings.TrimSpace(strings.SplitN(line, ":", 2)[1]) - case strings.Contains(line, "Found in version:"): - cur.Version = strings.TrimSpace(strings.SplitN(line, ":", 2)[1]) - case line == "": - if cur.ID != "" { - vulns = append(vulns, cur) - cur = Vulnerability{} - } - inBlock = false - default: - if !strings.HasPrefix(line, " ") && cur.Description == "" { - cur.Description = strings.TrimSpace(line) - } - } - } - } - if cur.ID != "" { - vulns = append(vulns, cur) - } - return vulns, nil -} - -// DiffStat returns a summary of uncommitted changes. -func (t *Toolkit) DiffStat() (DiffSummary, error) { - stdout, stderr, exitCode, err := t.Run("git", "diff", "--stat") - if err != nil && exitCode != 0 { - return DiffSummary{}, fmt.Errorf("git diff failed (exit %d): %w\n%s", exitCode, err, stderr) - } - - var s DiffSummary - lines := strings.Split(strings.TrimSpace(stdout), "\n") - if len(lines) == 0 || lines[0] == "" { - return s, nil - } - - last := lines[len(lines)-1] - for _, part := range strings.Split(last, ",") { - part = strings.TrimSpace(part) - fields := strings.Fields(part) - if len(fields) < 2 { - continue - } - val, _ := strconv.Atoi(fields[0]) - switch { - case strings.Contains(part, "file"): - s.FilesChanged = val - case strings.Contains(part, "insertion"): - s.Insertions = val - case strings.Contains(part, "deletion"): - s.Deletions = val - } - } - return s, nil -} - -// UncommittedFiles returns paths of files with uncommitted changes. -func (t *Toolkit) UncommittedFiles() ([]string, error) { - stdout, stderr, exitCode, err := t.Run("git", "status", "--porcelain") - if err != nil && exitCode != 0 { - return nil, fmt.Errorf("git status failed: %w\n%s", err, stderr) - } - var files []string - for line := range strings.SplitSeq(strings.TrimSpace(stdout), "\n") { - if len(line) > 3 { - files = append(files, strings.TrimSpace(line[3:])) - } - } - return files, nil -} - -// Lint runs go vet on the given package pattern. -func (t *Toolkit) Lint(pkg string) ([]Finding, error) { - _, stderr, exitCode, err := t.Run("go", "vet", pkg) - if exitCode == 0 { - return nil, nil - } - if err != nil && exitCode != 2 { - return nil, fmt.Errorf("go vet failed: %w", err) - } - - var findings []Finding - for line := range strings.SplitSeq(strings.TrimSpace(stderr), "\n") { - if line == "" { - continue - } - parts := strings.SplitN(line, ":", 4) - if len(parts) < 4 { - continue - } - lineNum, _ := strconv.Atoi(parts[1]) - findings = append(findings, Finding{ - File: parts[0], - Line: lineNum, - Message: strings.TrimSpace(parts[3]), - Tool: "go vet", - }) - } - return findings, nil -} - -// ScanSecrets runs gitleaks to find potential secret leaks. -func (t *Toolkit) ScanSecrets(dir string) ([]SecretLeak, error) { - stdout, _, exitCode, err := t.Run("gitleaks", "detect", "--source", dir, "--report-format", "csv", "--no-git") - if exitCode == 0 { - return nil, nil - } - if err != nil && exitCode != 1 { - return nil, fmt.Errorf("gitleaks failed: %w", err) - } - - var leaks []SecretLeak - for line := range strings.SplitSeq(strings.TrimSpace(stdout), "\n") { - if line == "" || strings.HasPrefix(line, "RuleID") { - continue - } - parts := strings.SplitN(line, ",", 4) - if len(parts) < 4 { - continue - } - lineNum, _ := strconv.Atoi(parts[2]) - leaks = append(leaks, SecretLeak{ - RuleID: parts[0], - File: parts[1], - Line: lineNum, - Match: parts[3], - }) - } - return leaks, nil -} - -// ModTidy runs go mod tidy. -func (t *Toolkit) ModTidy() error { - _, stderr, exitCode, err := t.Run("go", "mod", "tidy") - if err != nil && exitCode != 0 { - return fmt.Errorf("go mod tidy failed: %s", stderr) - } - return nil -} - -// Build compiles the given targets. -func (t *Toolkit) Build(targets ...string) ([]BuildResult, error) { - var results []BuildResult - for _, target := range targets { - _, stderr, _, err := t.Run("go", "build", "-o", "/dev/null", target) - r := BuildResult{Target: target} - if err != nil { - r.Error = fmt.Errorf("%s", strings.TrimSpace(stderr)) - } - results = append(results, r) - } - return results, nil -} - -// TestCount returns the number of test functions in a package. -func (t *Toolkit) TestCount(pkg string) (int, error) { - stdout, stderr, exitCode, err := t.Run("go", "test", "-list", ".*", pkg) - if err != nil && exitCode != 0 { - return 0, fmt.Errorf("go test -list failed: %w\n%s", err, stderr) - } - count := 0 - for line := range strings.SplitSeq(strings.TrimSpace(stdout), "\n") { - if strings.HasPrefix(line, "Test") || strings.HasPrefix(line, "Benchmark") { - count++ - } - } - return count, nil -} - -// Coverage runs go test -cover and parses per-package coverage percentages. -func (t *Toolkit) Coverage(pkg string) ([]CoverageReport, error) { - if pkg == "" { - pkg = "./..." - } - stdout, stderr, exitCode, err := t.Run("go", "test", "-cover", pkg) - if err != nil && exitCode != 0 && !strings.Contains(stdout, "coverage:") { - return nil, fmt.Errorf("go test -cover failed (exit %d): %w\n%s", exitCode, err, stderr) - } - - var reports []CoverageReport - re := regexp.MustCompile(`ok\s+(\S+)\s+.*coverage:\s+([\d.]+)%`) - scanner := bufio.NewScanner(strings.NewReader(stdout)) - - for scanner.Scan() { - matches := re.FindStringSubmatch(scanner.Text()) - if len(matches) == 3 { - pct, _ := strconv.ParseFloat(matches[2], 64) - reports = append(reports, CoverageReport{ - Package: matches[1], - Percentage: pct, - }) - } - } - return reports, nil -} - -// RaceDetect runs go test -race and parses data race warnings. -func (t *Toolkit) RaceDetect(pkg string) ([]RaceCondition, error) { - if pkg == "" { - pkg = "./..." - } - _, stderr, _, err := t.Run("go", "test", "-race", pkg) - if err != nil && !strings.Contains(stderr, "WARNING: DATA RACE") { - return nil, fmt.Errorf("go test -race failed: %w", err) - } - - var races []RaceCondition - lines := strings.Split(stderr, "\n") - reFile := regexp.MustCompile(`\s+(.*\.go):(\d+)`) - - for i, line := range lines { - if strings.Contains(line, "WARNING: DATA RACE") { - rc := RaceCondition{Desc: "Data race detected"} - for j := i + 1; j < len(lines) && j < i+15; j++ { - if match := reFile.FindStringSubmatch(lines[j]); len(match) == 3 { - rc.File = strings.TrimSpace(match[1]) - rc.Line, _ = strconv.Atoi(match[2]) - break - } - } - races = append(races, rc) - } - } - return races, nil -} - -// Complexity runs gocyclo and returns functions exceeding the threshold. -func (t *Toolkit) Complexity(threshold int) ([]ComplexFunc, error) { - stdout, stderr, exitCode, err := t.Run("gocyclo", "-over", strconv.Itoa(threshold), ".") - if err != nil && exitCode == -1 { - return nil, fmt.Errorf("gocyclo not available: %w\n%s", err, stderr) - } - - var funcs []ComplexFunc - scanner := bufio.NewScanner(strings.NewReader(stdout)) - - for scanner.Scan() { - fields := strings.Fields(scanner.Text()) - if len(fields) < 4 { - continue - } - score, _ := strconv.Atoi(fields[0]) - fileParts := strings.Split(fields[3], ":") - line := 0 - if len(fileParts) > 1 { - line, _ = strconv.Atoi(fileParts[1]) - } - - funcs = append(funcs, ComplexFunc{ - Score: score, - Package: fields[1], - FuncName: fields[2], - File: fileParts[0], - Line: line, - }) - } - return funcs, nil -} - -// DepGraph runs go mod graph and builds a dependency graph. -func (t *Toolkit) DepGraph(pkg string) (*Graph, error) { - stdout, stderr, exitCode, err := t.Run("go", "mod", "graph") - if err != nil && exitCode != 0 { - return nil, fmt.Errorf("go mod graph failed (exit %d): %w\n%s", exitCode, err, stderr) - } - - graph := &Graph{Edges: make(map[string][]string)} - nodes := make(map[string]struct{}) - scanner := bufio.NewScanner(strings.NewReader(stdout)) - - for scanner.Scan() { - parts := strings.Fields(scanner.Text()) - if len(parts) >= 2 { - src, dst := parts[0], parts[1] - graph.Edges[src] = append(graph.Edges[src], dst) - nodes[src] = struct{}{} - nodes[dst] = struct{}{} - } - } - - for node := range nodes { - graph.Nodes = append(graph.Nodes, node) - } - return graph, nil -} - -// GitLog returns the last n commits from git history. -func (t *Toolkit) GitLog(n int) ([]Commit, error) { - stdout, stderr, exitCode, err := t.Run("git", "log", fmt.Sprintf("-n%d", n), "--format=%H|%an|%aI|%s") - if err != nil && exitCode != 0 { - return nil, fmt.Errorf("git log failed (exit %d): %w\n%s", exitCode, err, stderr) - } - - var commits []Commit - scanner := bufio.NewScanner(strings.NewReader(stdout)) - - for scanner.Scan() { - parts := strings.SplitN(scanner.Text(), "|", 4) - if len(parts) < 4 { - continue - } - date, _ := time.Parse(time.RFC3339, parts[2]) - commits = append(commits, Commit{ - Hash: parts[0], - Author: parts[1], - Date: date, - Message: parts[3], - }) - } - return commits, nil -} - -// CheckPerms walks a directory and flags files with overly permissive modes. -func (t *Toolkit) CheckPerms(dir string) ([]PermIssue, error) { - var issues []PermIssue - err := filepath.Walk(filepath.Join(t.Dir, dir), func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - if info.IsDir() { - return nil - } - mode := info.Mode().Perm() - if mode&0o002 != 0 { - issues = append(issues, PermIssue{ - File: path, - Permission: fmt.Sprintf("%04o", mode), - Issue: "World-writable", - }) - } else if mode&0o020 != 0 && mode&0o002 != 0 { - issues = append(issues, PermIssue{ - File: path, - Permission: fmt.Sprintf("%04o", mode), - Issue: "Group and world-writable", - }) - } - return nil - }) - if err != nil { - return nil, fmt.Errorf("walk failed: %w", err) - } - return issues, nil -} - -// LEK-1 | lthn.ai | EUPL-1.2 diff --git a/devkit/devkit_test.go b/devkit/devkit_test.go deleted file mode 100644 index ffcdecd..0000000 --- a/devkit/devkit_test.go +++ /dev/null @@ -1,270 +0,0 @@ -// Designed by Gemini 3 Pro (Hypnos) + Claude Opus (Charon), signed LEK-1 | lthn.ai | EUPL-1.2 -package devkit - -import ( - "fmt" - "os" - "path/filepath" - "testing" - "time" -) - -// setupMockCmd creates a shell script in a temp dir that echoes predetermined -// content, and prepends that dir to PATH so Run() picks it up. -func setupMockCmd(t *testing.T, name, content string) { - t.Helper() - tmpDir := t.TempDir() - scriptPath := filepath.Join(tmpDir, name) - - script := fmt.Sprintf("#!/bin/sh\ncat <<'MOCK_EOF'\n%s\nMOCK_EOF\n", content) - if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { - t.Fatalf("failed to write mock command %s: %v", name, err) - } - - oldPath := os.Getenv("PATH") - t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+oldPath) -} - -// setupMockCmdExit creates a mock that echoes to stdout/stderr and exits with a code. -func setupMockCmdExit(t *testing.T, name, stdout, stderr string, exitCode int) { - t.Helper() - tmpDir := t.TempDir() - scriptPath := filepath.Join(tmpDir, name) - - script := fmt.Sprintf("#!/bin/sh\ncat <<'MOCK_EOF'\n%s\nMOCK_EOF\ncat <<'MOCK_ERR' >&2\n%s\nMOCK_ERR\nexit %d\n", stdout, stderr, exitCode) - if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { - t.Fatalf("failed to write mock command %s: %v", name, err) - } - - oldPath := os.Getenv("PATH") - t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+oldPath) -} - -func TestCoverage_Good(t *testing.T) { - output := `? example.com/skipped [no test files] -ok example.com/pkg1 0.5s coverage: 85.0% of statements -ok example.com/pkg2 0.2s coverage: 100.0% of statements` - - setupMockCmd(t, "go", output) - - tk := New(t.TempDir()) - reports, err := tk.Coverage("./...") - if err != nil { - t.Fatalf("Coverage failed: %v", err) - } - if len(reports) != 2 { - t.Fatalf("expected 2 reports, got %d", len(reports)) - } - if reports[0].Package != "example.com/pkg1" || reports[0].Percentage != 85.0 { - t.Errorf("report 0: want pkg1@85%%, got %s@%.1f%%", reports[0].Package, reports[0].Percentage) - } - if reports[1].Package != "example.com/pkg2" || reports[1].Percentage != 100.0 { - t.Errorf("report 1: want pkg2@100%%, got %s@%.1f%%", reports[1].Package, reports[1].Percentage) - } -} - -func TestCoverage_Bad(t *testing.T) { - // No coverage lines in output - setupMockCmd(t, "go", "FAIL\texample.com/broken [build failed]") - - tk := New(t.TempDir()) - reports, err := tk.Coverage("./...") - if err != nil { - t.Fatalf("Coverage should not error on partial output: %v", err) - } - if len(reports) != 0 { - t.Errorf("expected 0 reports from failed build, got %d", len(reports)) - } -} - -func TestGitLog_Good(t *testing.T) { - now := time.Now().Truncate(time.Second) - nowStr := now.Format(time.RFC3339) - - output := fmt.Sprintf("abc123|Alice|%s|Fix the bug\ndef456|Bob|%s|Add feature", nowStr, nowStr) - setupMockCmd(t, "git", output) - - tk := New(t.TempDir()) - commits, err := tk.GitLog(2) - if err != nil { - t.Fatalf("GitLog failed: %v", err) - } - if len(commits) != 2 { - t.Fatalf("expected 2 commits, got %d", len(commits)) - } - if commits[0].Hash != "abc123" { - t.Errorf("hash: want abc123, got %s", commits[0].Hash) - } - if commits[0].Author != "Alice" { - t.Errorf("author: want Alice, got %s", commits[0].Author) - } - if commits[0].Message != "Fix the bug" { - t.Errorf("message: want 'Fix the bug', got %q", commits[0].Message) - } - if !commits[0].Date.Equal(now) { - t.Errorf("date: want %v, got %v", now, commits[0].Date) - } -} - -func TestGitLog_Bad(t *testing.T) { - // Malformed lines should be skipped - setupMockCmd(t, "git", "incomplete|line\nabc|Bob|2025-01-01T00:00:00Z|Good commit") - - tk := New(t.TempDir()) - commits, err := tk.GitLog(5) - if err != nil { - t.Fatalf("GitLog failed: %v", err) - } - if len(commits) != 1 { - t.Errorf("expected 1 valid commit (skip malformed), got %d", len(commits)) - } -} - -func TestComplexity_Good(t *testing.T) { - output := "15 main ComplexFunc file.go:10:1\n20 pkg VeryComplex other.go:50:1" - setupMockCmd(t, "gocyclo", output) - - tk := New(t.TempDir()) - funcs, err := tk.Complexity(10) - if err != nil { - t.Fatalf("Complexity failed: %v", err) - } - if len(funcs) != 2 { - t.Fatalf("expected 2 funcs, got %d", len(funcs)) - } - if funcs[0].Score != 15 || funcs[0].FuncName != "ComplexFunc" || funcs[0].File != "file.go" || funcs[0].Line != 10 { - t.Errorf("func 0: unexpected %+v", funcs[0]) - } - if funcs[1].Score != 20 || funcs[1].Package != "pkg" { - t.Errorf("func 1: unexpected %+v", funcs[1]) - } -} - -func TestComplexity_Bad(t *testing.T) { - // No functions above threshold = empty output - setupMockCmd(t, "gocyclo", "") - - tk := New(t.TempDir()) - funcs, err := tk.Complexity(50) - if err != nil { - t.Fatalf("Complexity should not error on empty output: %v", err) - } - if len(funcs) != 0 { - t.Errorf("expected 0 funcs, got %d", len(funcs)) - } -} - -func TestDepGraph_Good(t *testing.T) { - output := "modA@v1 modB@v2\nmodA@v1 modC@v3\nmodB@v2 modD@v1" - setupMockCmd(t, "go", output) - - tk := New(t.TempDir()) - graph, err := tk.DepGraph("./...") - if err != nil { - t.Fatalf("DepGraph failed: %v", err) - } - if len(graph.Nodes) != 4 { - t.Errorf("expected 4 nodes, got %d: %v", len(graph.Nodes), graph.Nodes) - } - edgesA := graph.Edges["modA@v1"] - if len(edgesA) != 2 { - t.Errorf("expected 2 edges from modA@v1, got %d", len(edgesA)) - } -} - -func TestRaceDetect_Good(t *testing.T) { - // No races = clean run - setupMockCmd(t, "go", "ok\texample.com/safe\t0.1s") - - tk := New(t.TempDir()) - races, err := tk.RaceDetect("./...") - if err != nil { - t.Fatalf("RaceDetect failed on clean run: %v", err) - } - if len(races) != 0 { - t.Errorf("expected 0 races, got %d", len(races)) - } -} - -func TestRaceDetect_Bad(t *testing.T) { - stderrOut := `WARNING: DATA RACE -Read at 0x00c000123456 by goroutine 7: - /home/user/project/main.go:42 -Previous write at 0x00c000123456 by goroutine 6: - /home/user/project/main.go:38` - - setupMockCmdExit(t, "go", "", stderrOut, 1) - - tk := New(t.TempDir()) - races, err := tk.RaceDetect("./...") - if err != nil { - t.Fatalf("RaceDetect should parse races, not error: %v", err) - } - if len(races) != 1 { - t.Fatalf("expected 1 race, got %d", len(races)) - } - if races[0].File != "/home/user/project/main.go" || races[0].Line != 42 { - t.Errorf("race: unexpected %+v", races[0]) - } -} - -func TestDiffStat_Good(t *testing.T) { - output := ` file1.go | 10 +++++++--- - file2.go | 5 +++++ - 2 files changed, 12 insertions(+), 3 deletions(-)` - setupMockCmd(t, "git", output) - - tk := New(t.TempDir()) - s, err := tk.DiffStat() - if err != nil { - t.Fatalf("DiffStat failed: %v", err) - } - if s.FilesChanged != 2 { - t.Errorf("files: want 2, got %d", s.FilesChanged) - } - if s.Insertions != 12 { - t.Errorf("insertions: want 12, got %d", s.Insertions) - } - if s.Deletions != 3 { - t.Errorf("deletions: want 3, got %d", s.Deletions) - } -} - -func TestCheckPerms_Good(t *testing.T) { - dir := t.TempDir() - - // Create a world-writable file - badFile := filepath.Join(dir, "bad.txt") - if err := os.WriteFile(badFile, []byte("test"), 0644); err != nil { - t.Fatal(err) - } - if err := os.Chmod(badFile, 0666); err != nil { - t.Fatal(err) - } - // Create a safe file - goodFile := filepath.Join(dir, "good.txt") - if err := os.WriteFile(goodFile, []byte("test"), 0644); err != nil { - t.Fatal(err) - } - - tk := New("/") - issues, err := tk.CheckPerms(dir) - if err != nil { - t.Fatalf("CheckPerms failed: %v", err) - } - if len(issues) != 1 { - t.Fatalf("expected 1 issue (world-writable), got %d", len(issues)) - } - if issues[0].Issue != "World-writable" { - t.Errorf("issue: want 'World-writable', got %q", issues[0].Issue) - } -} - -func TestNew(t *testing.T) { - tk := New("/tmp") - if tk.Dir != "/tmp" { - t.Errorf("Dir: want /tmp, got %s", tk.Dir) - } -} - -// LEK-1 | lthn.ai | EUPL-1.2 diff --git a/devkit/vulncheck.go b/devkit/vulncheck.go deleted file mode 100644 index 70fff8a..0000000 --- a/devkit/vulncheck.go +++ /dev/null @@ -1,181 +0,0 @@ -// Package devkit provides a developer toolkit for common automation commands. -// LEK-1 | lthn.ai | EUPL-1.2 -package devkit - -import ( - "encoding/json" - "fmt" - "strings" -) - -// VulnFinding represents a single vulnerability found by govulncheck. -type VulnFinding struct { - ID string // e.g. GO-2024-1234 - Aliases []string // CVE/GHSA aliases - Package string // Affected package path - CalledFunction string // Function in call stack (empty if not called) - Description string // Human-readable summary - Severity string // "HIGH", "MEDIUM", "LOW", or empty - FixedVersion string // Version that contains the fix - ModulePath string // Go module path -} - -// VulnResult holds the complete output of a vulnerability scan. -type VulnResult struct { - Findings []VulnFinding - Module string // Module path that was scanned -} - -// --- govulncheck JSON wire types --- - -// govulncheckMessage represents a single JSON line from govulncheck -json output. -type govulncheckMessage struct { - Config *govulncheckConfig `json:"config,omitempty"` - OSV *govulncheckOSV `json:"osv,omitempty"` - Finding *govulncheckFind `json:"finding,omitempty"` - Progress *json.RawMessage `json:"progress,omitempty"` -} - -type govulncheckConfig struct { - GoVersion string `json:"go_version"` - ModulePath string `json:"module_path"` -} - -type govulncheckOSV struct { - ID string `json:"id"` - Aliases []string `json:"aliases"` - Summary string `json:"summary"` - Affected []govulncheckAffect `json:"affected"` -} - -type govulncheckAffect struct { - Package *govulncheckPkg `json:"package,omitempty"` - Ranges []govulncheckRange `json:"ranges,omitempty"` - Severity []govulncheckSeverity `json:"database_specific,omitempty"` -} - -type govulncheckPkg struct { - Name string `json:"name"` - Ecosystem string `json:"ecosystem"` -} - -type govulncheckRange struct { - Events []govulncheckEvent `json:"events"` -} - -type govulncheckEvent struct { - Fixed string `json:"fixed,omitempty"` -} - -type govulncheckSeverity struct { - Severity string `json:"severity,omitempty"` -} - -type govulncheckFind struct { - OSV string `json:"osv"` - Trace []govulncheckTrace `json:"trace"` -} - -type govulncheckTrace struct { - Module string `json:"module,omitempty"` - Package string `json:"package,omitempty"` - Function string `json:"function,omitempty"` - Version string `json:"version,omitempty"` -} - -// VulnCheck runs govulncheck -json on the given module path and parses -// the output into structured VulnFindings. -func (t *Toolkit) VulnCheck(modulePath string) (*VulnResult, error) { - if modulePath == "" { - modulePath = "./..." - } - - stdout, stderr, exitCode, err := t.Run("govulncheck", "-json", modulePath) - if err != nil && exitCode == -1 { - return nil, fmt.Errorf("govulncheck not installed or not available: %w", err) - } - - return ParseVulnCheckJSON(stdout, stderr) -} - -// ParseVulnCheckJSON parses govulncheck -json output (newline-delimited JSON messages). -func ParseVulnCheckJSON(stdout, stderr string) (*VulnResult, error) { - result := &VulnResult{} - - // Collect OSV entries and findings separately, then correlate. - osvMap := make(map[string]*govulncheckOSV) - var findings []govulncheckFind - - // Parse line-by-line to gracefully skip malformed entries. - // json.Decoder.More() hangs on non-JSON input, so we split first. - for line := range strings.SplitSeq(stdout, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - var msg govulncheckMessage - if err := json.Unmarshal([]byte(line), &msg); err != nil { - // Skip malformed lines — govulncheck sometimes emits progress text - continue - } - - if msg.Config != nil { - result.Module = msg.Config.ModulePath - } - if msg.OSV != nil { - osvMap[msg.OSV.ID] = msg.OSV - } - if msg.Finding != nil { - findings = append(findings, *msg.Finding) - } - } - - // Build VulnFindings by correlating findings with OSV metadata. - for _, f := range findings { - finding := VulnFinding{ - ID: f.OSV, - } - - // Extract package, function, and module from trace. - if len(f.Trace) > 0 { - // The first trace entry is the called function in user code; - // the last is the vulnerable symbol. - last := f.Trace[len(f.Trace)-1] - finding.Package = last.Package - finding.CalledFunction = last.Function - finding.ModulePath = last.Module - - // If the trace has a version, capture it. - for _, tr := range f.Trace { - if tr.Version != "" { - finding.FixedVersion = tr.Version - break - } - } - } - - // Enrich from OSV entry. - if osv, ok := osvMap[f.OSV]; ok { - finding.Description = osv.Summary - finding.Aliases = osv.Aliases - - // Extract fixed version and severity from affected entries. - for _, aff := range osv.Affected { - for _, r := range aff.Ranges { - for _, ev := range r.Events { - if ev.Fixed != "" && finding.FixedVersion == "" { - finding.FixedVersion = ev.Fixed - } - } - } - } - } - - result.Findings = append(result.Findings, finding) - } - - return result, nil -} - -// LEK-1 | lthn.ai | EUPL-1.2 diff --git a/devkit/vulncheck_test.go b/devkit/vulncheck_test.go deleted file mode 100644 index 9bedd62..0000000 --- a/devkit/vulncheck_test.go +++ /dev/null @@ -1,189 +0,0 @@ -// LEK-1 | lthn.ai | EUPL-1.2 -package devkit - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const sampleVulnJSON = `{"config":{"module_path":"example.com/mymod","go_version":"go1.22.0"}} -{"progress":{"message":"Scanning your code..."}} -{"osv":{"id":"GO-2024-0001","aliases":["CVE-2024-1234","GHSA-abcd-1234"],"summary":"Buffer overflow in net/http","affected":[{"package":{"name":"stdlib","ecosystem":"Go"},"ranges":[{"events":[{"fixed":"1.22.1"}]}]}]}} -{"osv":{"id":"GO-2024-0002","aliases":["CVE-2024-5678"],"summary":"Path traversal in archive/zip","affected":[{"package":{"name":"stdlib","ecosystem":"Go"},"ranges":[{"events":[{"fixed":"1.21.9"}]}]}]}} -{"finding":{"osv":"GO-2024-0001","trace":[{"module":"example.com/mymod","package":"example.com/mymod/server","function":"HandleRequest"},{"module":"stdlib","package":"net/http","function":"ReadRequest","version":"go1.22.0"}]}} -{"finding":{"osv":"GO-2024-0002","trace":[{"module":"stdlib","package":"archive/zip","function":"OpenReader","version":"go1.22.0"}]}} -` - -func TestParseVulnCheckJSON_Good(t *testing.T) { - result, err := ParseVulnCheckJSON(sampleVulnJSON, "") - require.NoError(t, err) - - assert.Equal(t, "example.com/mymod", result.Module) - assert.Len(t, result.Findings, 2) - - // First finding: GO-2024-0001 - f0 := result.Findings[0] - assert.Equal(t, "GO-2024-0001", f0.ID) - assert.Equal(t, "net/http", f0.Package) - assert.Equal(t, "ReadRequest", f0.CalledFunction) - assert.Equal(t, "Buffer overflow in net/http", f0.Description) - assert.Contains(t, f0.Aliases, "CVE-2024-1234") - assert.Contains(t, f0.Aliases, "GHSA-abcd-1234") - assert.Equal(t, "go1.22.0", f0.FixedVersion) // from trace version - - // Second finding: GO-2024-0002 - f1 := result.Findings[1] - assert.Equal(t, "GO-2024-0002", f1.ID) - assert.Equal(t, "archive/zip", f1.Package) - assert.Equal(t, "OpenReader", f1.CalledFunction) - assert.Equal(t, "Path traversal in archive/zip", f1.Description) - assert.Contains(t, f1.Aliases, "CVE-2024-5678") -} - -func TestParseVulnCheckJSON_EmptyOutput_Good(t *testing.T) { - result, err := ParseVulnCheckJSON("", "") - require.NoError(t, err) - assert.Empty(t, result.Findings) - assert.Empty(t, result.Module) -} - -func TestParseVulnCheckJSON_ConfigOnly_Good(t *testing.T) { - input := `{"config":{"module_path":"example.com/clean","go_version":"go1.23.0"}} -` - result, err := ParseVulnCheckJSON(input, "") - require.NoError(t, err) - assert.Equal(t, "example.com/clean", result.Module) - assert.Empty(t, result.Findings) -} - -func TestParseVulnCheckJSON_MalformedLines_Bad(t *testing.T) { - input := `not valid json -{"config":{"module_path":"example.com/mod"}} -also broken {{{ -{"osv":{"id":"GO-2024-0099","summary":"Test vuln","aliases":[],"affected":[]}} -{"finding":{"osv":"GO-2024-0099","trace":[{"module":"stdlib","package":"crypto/tls","function":"Dial"}]}} -` - result, err := ParseVulnCheckJSON(input, "") - require.NoError(t, err) - assert.Equal(t, "example.com/mod", result.Module) - assert.Len(t, result.Findings, 1) - assert.Equal(t, "GO-2024-0099", result.Findings[0].ID) - assert.Equal(t, "Dial", result.Findings[0].CalledFunction) -} - -func TestParseVulnCheckJSON_FindingWithoutOSV_Bad(t *testing.T) { - // Finding references an OSV ID that was never emitted — should still parse. - input := `{"finding":{"osv":"GO-2024-UNKNOWN","trace":[{"module":"example.com/mod","package":"example.com/mod/pkg","function":"DoStuff"}]}} -` - result, err := ParseVulnCheckJSON(input, "") - require.NoError(t, err) - assert.Len(t, result.Findings, 1) - - f := result.Findings[0] - assert.Equal(t, "GO-2024-UNKNOWN", f.ID) - assert.Equal(t, "example.com/mod/pkg", f.Package) - assert.Equal(t, "DoStuff", f.CalledFunction) - assert.Empty(t, f.Description) // No OSV entry to enrich from - assert.Empty(t, f.Aliases) -} - -func TestParseVulnCheckJSON_NoTrace_Bad(t *testing.T) { - input := `{"osv":{"id":"GO-2024-0050","summary":"Empty trace test","aliases":["CVE-2024-0050"],"affected":[]}} -{"finding":{"osv":"GO-2024-0050","trace":[]}} -` - result, err := ParseVulnCheckJSON(input, "") - require.NoError(t, err) - assert.Len(t, result.Findings, 1) - - f := result.Findings[0] - assert.Equal(t, "GO-2024-0050", f.ID) - assert.Equal(t, "Empty trace test", f.Description) - assert.Empty(t, f.Package) - assert.Empty(t, f.CalledFunction) -} - -func TestParseVulnCheckJSON_MultipleFindings_Good(t *testing.T) { - input := `{"osv":{"id":"GO-2024-0010","summary":"Vuln A","aliases":["CVE-A"],"affected":[{"ranges":[{"events":[{"fixed":"1.20.5"}]}]}]}} -{"osv":{"id":"GO-2024-0011","summary":"Vuln B","aliases":["CVE-B"],"affected":[]}} -{"osv":{"id":"GO-2024-0012","summary":"Vuln C","aliases":["CVE-C"],"affected":[{"ranges":[{"events":[{"fixed":"1.21.0"}]}]}]}} -{"finding":{"osv":"GO-2024-0010","trace":[{"package":"net/http","function":"Serve"}]}} -{"finding":{"osv":"GO-2024-0011","trace":[{"package":"encoding/xml","function":"Unmarshal"}]}} -{"finding":{"osv":"GO-2024-0012","trace":[{"package":"os/exec","function":"Command"}]}} -` - result, err := ParseVulnCheckJSON(input, "") - require.NoError(t, err) - assert.Len(t, result.Findings, 3) - - assert.Equal(t, "Vuln A", result.Findings[0].Description) - assert.Equal(t, "1.20.5", result.Findings[0].FixedVersion) - assert.Equal(t, "Vuln B", result.Findings[1].Description) - assert.Equal(t, "Vuln C", result.Findings[2].Description) - assert.Equal(t, "1.21.0", result.Findings[2].FixedVersion) -} - -func TestParseVulnCheckJSON_FixedVersionFromOSV_Good(t *testing.T) { - // When trace has no version, fixed version should come from OSV affected ranges. - input := `{"osv":{"id":"GO-2024-0077","summary":"Test","aliases":[],"affected":[{"ranges":[{"events":[{"fixed":"0.9.1"}]}]}]}} -{"finding":{"osv":"GO-2024-0077","trace":[{"package":"example.com/lib","function":"Process"}]}} -` - result, err := ParseVulnCheckJSON(input, "") - require.NoError(t, err) - require.Len(t, result.Findings, 1) - assert.Equal(t, "0.9.1", result.Findings[0].FixedVersion) -} - -func TestVulnCheck_NotInstalled_Ugly(t *testing.T) { - setupMockCmdExit(t, "govulncheck-nonexistent", "", "", 1) - // Don't mock govulncheck — ensure it handles missing binary gracefully - // We'll rely on the binary not being in the test temp PATH. - - tk := New(t.TempDir()) - // Remove PATH to simulate govulncheck not found - t.Setenv("PATH", t.TempDir()) - _, err := tk.VulnCheck("./...") - assert.Error(t, err) - assert.Contains(t, err.Error(), "not installed or not available") -} - -func TestVulnCheck_WithMock_Good(t *testing.T) { - // Mock govulncheck to return our sample JSON - setupMockCmd(t, "govulncheck", sampleVulnJSON) - - tk := New(t.TempDir()) - result, err := tk.VulnCheck("./...") - require.NoError(t, err) - assert.Equal(t, "example.com/mymod", result.Module) - assert.Len(t, result.Findings, 2) -} - -func TestVulnCheck_DefaultModulePath_Good(t *testing.T) { - setupMockCmd(t, "govulncheck", `{"config":{"module_path":"default/mod"}}`) - - tk := New(t.TempDir()) - result, err := tk.VulnCheck("") - require.NoError(t, err) - assert.Equal(t, "default/mod", result.Module) -} - -func TestParseVulnCheckJSON_ProgressOnly_Good(t *testing.T) { - input := `{"progress":{"message":"Scanning..."}} -{"progress":{"message":"Done"}} -` - result, err := ParseVulnCheckJSON(input, "") - require.NoError(t, err) - assert.Empty(t, result.Findings) -} - -func TestParseVulnCheckJSON_ModulePathFromTrace_Good(t *testing.T) { - input := `{"finding":{"osv":"GO-2024-0099","trace":[{"module":"example.com/vulnerable","package":"example.com/vulnerable/pkg","function":"Bad","version":"v1.2.3"}]}} -` - result, err := ParseVulnCheckJSON(input, "") - require.NoError(t, err) - require.Len(t, result.Findings, 1) - assert.Equal(t, "example.com/vulnerable", result.Findings[0].ModulePath) - assert.Equal(t, "v1.2.3", result.Findings[0].FixedVersion) -} - -// LEK-1 | lthn.ai | EUPL-1.2 diff --git a/devops/claude.go b/devops/claude.go deleted file mode 100644 index 17cfc67..0000000 --- a/devops/claude.go +++ /dev/null @@ -1,143 +0,0 @@ -package devops - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "forge.lthn.ai/core/go-io" -) - -// ClaudeOptions configures the Claude sandbox session. -type ClaudeOptions struct { - NoAuth bool // Don't forward any auth - Auth []string // Selective auth: "gh", "anthropic", "ssh", "git" - Model string // Model to use: opus, sonnet -} - -// Claude starts a sandboxed Claude session in the dev environment. -func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptions) error { - // Auto-boot if not running - running, err := d.IsRunning(ctx) - if err != nil { - return err - } - if !running { - fmt.Println("Dev environment not running, booting...") - if err := d.Boot(ctx, DefaultBootOptions()); err != nil { - return fmt.Errorf("failed to boot: %w", err) - } - } - - // Mount project - if err := d.mountProject(ctx, projectDir); err != nil { - return fmt.Errorf("failed to mount project: %w", err) - } - - // Prepare environment variables to forward - envVars := []string{} - - if !opts.NoAuth { - authTypes := opts.Auth - if len(authTypes) == 0 { - authTypes = []string{"gh", "anthropic", "ssh", "git"} - } - - for _, auth := range authTypes { - switch auth { - case "anthropic": - if key := os.Getenv("ANTHROPIC_API_KEY"); key != "" { - envVars = append(envVars, "ANTHROPIC_API_KEY="+key) - } - case "git": - // Forward git config - name, _ := exec.Command("git", "config", "user.name").Output() - email, _ := exec.Command("git", "config", "user.email").Output() - if len(name) > 0 { - envVars = append(envVars, "GIT_AUTHOR_NAME="+strings.TrimSpace(string(name))) - envVars = append(envVars, "GIT_COMMITTER_NAME="+strings.TrimSpace(string(name))) - } - if len(email) > 0 { - envVars = append(envVars, "GIT_AUTHOR_EMAIL="+strings.TrimSpace(string(email))) - envVars = append(envVars, "GIT_COMMITTER_EMAIL="+strings.TrimSpace(string(email))) - } - } - } - } - - // Build SSH command with agent forwarding - args := []string{ - "-o", "StrictHostKeyChecking=yes", - "-o", "UserKnownHostsFile=~/.core/known_hosts", - "-o", "LogLevel=ERROR", - "-A", // SSH agent forwarding - "-p", fmt.Sprintf("%d", DefaultSSHPort), - } - - args = append(args, "root@localhost") - - // Build command to run inside - claudeCmd := "cd /app && claude" - if opts.Model != "" { - claudeCmd += " --model " + opts.Model - } - args = append(args, claudeCmd) - - // Set environment for SSH - cmd := exec.CommandContext(ctx, "ssh", args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - // Pass environment variables through SSH - for _, env := range envVars { - parts := strings.SplitN(env, "=", 2) - if len(parts) == 2 { - cmd.Env = append(os.Environ(), env) - } - } - - fmt.Println("Starting Claude in sandboxed environment...") - fmt.Println("Project mounted at /app") - fmt.Println("Auth forwarded: SSH agent" + formatAuthList(opts)) - fmt.Println() - - return cmd.Run() -} - -func formatAuthList(opts ClaudeOptions) string { - if opts.NoAuth { - return " (none)" - } - if len(opts.Auth) == 0 { - return ", gh, anthropic, git" - } - return ", " + strings.Join(opts.Auth, ", ") -} - -// CopyGHAuth copies GitHub CLI auth to the VM. -func (d *DevOps) CopyGHAuth(ctx context.Context) error { - home, err := os.UserHomeDir() - if err != nil { - return err - } - - ghConfigDir := filepath.Join(home, ".config", "gh") - if !io.Local.IsDir(ghConfigDir) { - return nil // No gh config to copy - } - - // Use scp to copy gh config - cmd := exec.CommandContext(ctx, "scp", - "-o", "StrictHostKeyChecking=yes", - "-o", "UserKnownHostsFile=~/.core/known_hosts", - "-o", "LogLevel=ERROR", - "-P", fmt.Sprintf("%d", DefaultSSHPort), - "-r", ghConfigDir, - "root@localhost:/root/.config/", - ) - return cmd.Run() -} diff --git a/devops/claude_test.go b/devops/claude_test.go deleted file mode 100644 index 6c96b9b..0000000 --- a/devops/claude_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package devops - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestClaudeOptions_Default(t *testing.T) { - opts := ClaudeOptions{} - assert.False(t, opts.NoAuth) - assert.Nil(t, opts.Auth) - assert.Empty(t, opts.Model) -} - -func TestClaudeOptions_Custom(t *testing.T) { - opts := ClaudeOptions{ - NoAuth: true, - Auth: []string{"gh", "anthropic"}, - Model: "opus", - } - assert.True(t, opts.NoAuth) - assert.Equal(t, []string{"gh", "anthropic"}, opts.Auth) - assert.Equal(t, "opus", opts.Model) -} - -func TestFormatAuthList_Good_NoAuth(t *testing.T) { - opts := ClaudeOptions{NoAuth: true} - result := formatAuthList(opts) - assert.Equal(t, " (none)", result) -} - -func TestFormatAuthList_Good_Default(t *testing.T) { - opts := ClaudeOptions{} - result := formatAuthList(opts) - assert.Equal(t, ", gh, anthropic, git", result) -} - -func TestFormatAuthList_Good_CustomAuth(t *testing.T) { - opts := ClaudeOptions{ - Auth: []string{"gh"}, - } - result := formatAuthList(opts) - assert.Equal(t, ", gh", result) -} - -func TestFormatAuthList_Good_MultipleAuth(t *testing.T) { - opts := ClaudeOptions{ - Auth: []string{"gh", "ssh", "git"}, - } - result := formatAuthList(opts) - assert.Equal(t, ", gh, ssh, git", result) -} - -func TestFormatAuthList_Good_EmptyAuth(t *testing.T) { - opts := ClaudeOptions{ - Auth: []string{}, - } - result := formatAuthList(opts) - assert.Equal(t, ", gh, anthropic, git", result) -} diff --git a/devops/config.go b/devops/config.go deleted file mode 100644 index 07b301d..0000000 --- a/devops/config.go +++ /dev/null @@ -1,90 +0,0 @@ -package devops - -import ( - "os" - "path/filepath" - - "forge.lthn.ai/core/go-config" - "forge.lthn.ai/core/go-io" -) - -// Config holds global devops configuration from ~/.core/config.yaml. -type Config struct { - Version int `yaml:"version" mapstructure:"version"` - Images ImagesConfig `yaml:"images" mapstructure:"images"` -} - -// ImagesConfig holds image source configuration. -type ImagesConfig struct { - Source string `yaml:"source" mapstructure:"source"` // auto, github, registry, cdn - GitHub GitHubConfig `yaml:"github,omitempty" mapstructure:"github,omitempty"` - Registry RegistryConfig `yaml:"registry,omitempty" mapstructure:"registry,omitempty"` - CDN CDNConfig `yaml:"cdn,omitempty" mapstructure:"cdn,omitempty"` -} - -// GitHubConfig holds GitHub Releases configuration. -type GitHubConfig struct { - Repo string `yaml:"repo" mapstructure:"repo"` // owner/repo format -} - -// RegistryConfig holds container registry configuration. -type RegistryConfig struct { - Image string `yaml:"image" mapstructure:"image"` // e.g., ghcr.io/host-uk/core-devops -} - -// CDNConfig holds CDN/S3 configuration. -type CDNConfig struct { - URL string `yaml:"url" mapstructure:"url"` // base URL for downloads -} - -// DefaultConfig returns sensible defaults. -func DefaultConfig() *Config { - return &Config{ - Version: 1, - Images: ImagesConfig{ - Source: "auto", - GitHub: GitHubConfig{ - Repo: "host-uk/core-images", - }, - Registry: RegistryConfig{ - Image: "ghcr.io/host-uk/core-devops", - }, - }, - } -} - -// ConfigPath returns the path to the config file. -func ConfigPath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".core", "config.yaml"), nil -} - -// LoadConfig loads configuration from ~/.core/config.yaml using the provided medium. -// Returns default config if file doesn't exist. -func LoadConfig(m io.Medium) (*Config, error) { - configPath, err := ConfigPath() - if err != nil { - return DefaultConfig(), nil - } - - cfg := DefaultConfig() - - if !m.IsFile(configPath) { - return cfg, nil - } - - // Use centralized config service - c, err := config.New(config.WithMedium(m), config.WithPath(configPath)) - if err != nil { - return nil, err - } - - if err := c.Get("", cfg); err != nil { - return nil, err - } - - return cfg, nil -} diff --git a/devops/config_test.go b/devops/config_test.go deleted file mode 100644 index d912b10..0000000 --- a/devops/config_test.go +++ /dev/null @@ -1,255 +0,0 @@ -package devops - -import ( - "os" - "path/filepath" - "testing" - - "forge.lthn.ai/core/go-io" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestDefaultConfig(t *testing.T) { - cfg := DefaultConfig() - assert.Equal(t, 1, cfg.Version) - assert.Equal(t, "auto", cfg.Images.Source) - assert.Equal(t, "host-uk/core-images", cfg.Images.GitHub.Repo) -} - -func TestConfigPath(t *testing.T) { - path, err := ConfigPath() - assert.NoError(t, err) - assert.Contains(t, path, ".core/config.yaml") -} - -func TestLoadConfig_Good(t *testing.T) { - t.Run("returns default if not exists", func(t *testing.T) { - // Mock HOME to a temp dir - tempHome := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tempHome) - defer func() { _ = os.Setenv("HOME", origHome) }() - - cfg, err := LoadConfig(io.Local) - assert.NoError(t, err) - assert.Equal(t, DefaultConfig(), cfg) - }) - - t.Run("loads existing config", func(t *testing.T) { - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - coreDir := filepath.Join(tempHome, ".core") - err := os.MkdirAll(coreDir, 0755) - require.NoError(t, err) - - configData := ` -version: 2 -images: - source: cdn - cdn: - url: https://cdn.example.com -` - err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(configData), 0644) - require.NoError(t, err) - - cfg, err := LoadConfig(io.Local) - assert.NoError(t, err) - assert.Equal(t, 2, cfg.Version) - assert.Equal(t, "cdn", cfg.Images.Source) - assert.Equal(t, "https://cdn.example.com", cfg.Images.CDN.URL) - }) -} - -func TestLoadConfig_Bad(t *testing.T) { - t.Run("invalid yaml", func(t *testing.T) { - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - coreDir := filepath.Join(tempHome, ".core") - err := os.MkdirAll(coreDir, 0755) - require.NoError(t, err) - - err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte("invalid: yaml: :"), 0644) - require.NoError(t, err) - - _, err = LoadConfig(io.Local) - assert.Error(t, err) - }) -} - -func TestConfig_Struct(t *testing.T) { - cfg := &Config{ - Version: 2, - Images: ImagesConfig{ - Source: "github", - GitHub: GitHubConfig{ - Repo: "owner/repo", - }, - Registry: RegistryConfig{ - Image: "ghcr.io/owner/image", - }, - CDN: CDNConfig{ - URL: "https://cdn.example.com", - }, - }, - } - assert.Equal(t, 2, cfg.Version) - assert.Equal(t, "github", cfg.Images.Source) - assert.Equal(t, "owner/repo", cfg.Images.GitHub.Repo) - assert.Equal(t, "ghcr.io/owner/image", cfg.Images.Registry.Image) - assert.Equal(t, "https://cdn.example.com", cfg.Images.CDN.URL) -} - -func TestDefaultConfig_Complete(t *testing.T) { - cfg := DefaultConfig() - assert.Equal(t, 1, cfg.Version) - assert.Equal(t, "auto", cfg.Images.Source) - assert.Equal(t, "host-uk/core-images", cfg.Images.GitHub.Repo) - assert.Equal(t, "ghcr.io/host-uk/core-devops", cfg.Images.Registry.Image) - assert.Empty(t, cfg.Images.CDN.URL) -} - -func TestLoadConfig_Good_PartialConfig(t *testing.T) { - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - coreDir := filepath.Join(tempHome, ".core") - err := os.MkdirAll(coreDir, 0755) - require.NoError(t, err) - - // Config only specifies source, should merge with defaults - configData := ` -version: 1 -images: - source: github -` - err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(configData), 0644) - require.NoError(t, err) - - cfg, err := LoadConfig(io.Local) - assert.NoError(t, err) - assert.Equal(t, 1, cfg.Version) - assert.Equal(t, "github", cfg.Images.Source) - // Default values should be preserved - assert.Equal(t, "host-uk/core-images", cfg.Images.GitHub.Repo) -} - -func TestLoadConfig_Good_AllSourceTypes(t *testing.T) { - tests := []struct { - name string - config string - check func(*testing.T, *Config) - }{ - { - name: "github source", - config: ` -version: 1 -images: - source: github - github: - repo: custom/repo -`, - check: func(t *testing.T, cfg *Config) { - assert.Equal(t, "github", cfg.Images.Source) - assert.Equal(t, "custom/repo", cfg.Images.GitHub.Repo) - }, - }, - { - name: "cdn source", - config: ` -version: 1 -images: - source: cdn - cdn: - url: https://custom-cdn.com -`, - check: func(t *testing.T, cfg *Config) { - assert.Equal(t, "cdn", cfg.Images.Source) - assert.Equal(t, "https://custom-cdn.com", cfg.Images.CDN.URL) - }, - }, - { - name: "registry source", - config: ` -version: 1 -images: - source: registry - registry: - image: docker.io/custom/image -`, - check: func(t *testing.T, cfg *Config) { - assert.Equal(t, "registry", cfg.Images.Source) - assert.Equal(t, "docker.io/custom/image", cfg.Images.Registry.Image) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - coreDir := filepath.Join(tempHome, ".core") - err := os.MkdirAll(coreDir, 0755) - require.NoError(t, err) - - err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(tt.config), 0644) - require.NoError(t, err) - - cfg, err := LoadConfig(io.Local) - assert.NoError(t, err) - tt.check(t, cfg) - }) - } -} - -func TestImagesConfig_Struct(t *testing.T) { - ic := ImagesConfig{ - Source: "auto", - GitHub: GitHubConfig{Repo: "test/repo"}, - } - assert.Equal(t, "auto", ic.Source) - assert.Equal(t, "test/repo", ic.GitHub.Repo) -} - -func TestGitHubConfig_Struct(t *testing.T) { - gc := GitHubConfig{Repo: "owner/repo"} - assert.Equal(t, "owner/repo", gc.Repo) -} - -func TestRegistryConfig_Struct(t *testing.T) { - rc := RegistryConfig{Image: "ghcr.io/owner/image:latest"} - assert.Equal(t, "ghcr.io/owner/image:latest", rc.Image) -} - -func TestCDNConfig_Struct(t *testing.T) { - cc := CDNConfig{URL: "https://cdn.example.com/images"} - assert.Equal(t, "https://cdn.example.com/images", cc.URL) -} - -func TestLoadConfig_Bad_UnreadableFile(t *testing.T) { - // This test is platform-specific and may not work on all systems - // Skip if we can't test file permissions properly - if os.Getuid() == 0 { - t.Skip("Skipping permission test when running as root") - } - - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - coreDir := filepath.Join(tempHome, ".core") - err := os.MkdirAll(coreDir, 0755) - require.NoError(t, err) - - configPath := filepath.Join(coreDir, "config.yaml") - err = os.WriteFile(configPath, []byte("version: 1"), 0000) - require.NoError(t, err) - - _, err = LoadConfig(io.Local) - assert.Error(t, err) - - // Restore permissions so cleanup works - _ = os.Chmod(configPath, 0644) -} diff --git a/devops/devops.go b/devops/devops.go deleted file mode 100644 index c40b3c0..0000000 --- a/devops/devops.go +++ /dev/null @@ -1,244 +0,0 @@ -// Package devops provides a portable development environment using LinuxKit images. -package devops - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "runtime" - "time" - - "forge.lthn.ai/core/go-devops/container" - "forge.lthn.ai/core/go-io" -) - -const ( - // DefaultSSHPort is the default port for SSH connections to the dev environment. - DefaultSSHPort = 2222 -) - -// DevOps manages the portable development environment. -type DevOps struct { - medium io.Medium - config *Config - images *ImageManager - container *container.LinuxKitManager -} - -// New creates a new DevOps instance using the provided medium. -func New(m io.Medium) (*DevOps, error) { - cfg, err := LoadConfig(m) - if err != nil { - return nil, fmt.Errorf("devops.New: failed to load config: %w", err) - } - - images, err := NewImageManager(m, cfg) - if err != nil { - return nil, fmt.Errorf("devops.New: failed to create image manager: %w", err) - } - - mgr, err := container.NewLinuxKitManager(io.Local) - if err != nil { - return nil, fmt.Errorf("devops.New: failed to create container manager: %w", err) - } - - return &DevOps{ - medium: m, - config: cfg, - images: images, - container: mgr, - }, nil -} - -// ImageName returns the platform-specific image name. -func ImageName() string { - return fmt.Sprintf("core-devops-%s-%s.qcow2", runtime.GOOS, runtime.GOARCH) -} - -// ImagesDir returns the path to the images directory. -func ImagesDir() (string, error) { - if dir := os.Getenv("CORE_IMAGES_DIR"); dir != "" { - return dir, nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".core", "images"), nil -} - -// ImagePath returns the full path to the platform-specific image. -func ImagePath() (string, error) { - dir, err := ImagesDir() - if err != nil { - return "", err - } - return filepath.Join(dir, ImageName()), nil -} - -// IsInstalled checks if the dev image is installed. -func (d *DevOps) IsInstalled() bool { - path, err := ImagePath() - if err != nil { - return false - } - return d.medium.IsFile(path) -} - -// Install downloads and installs the dev image. -func (d *DevOps) Install(ctx context.Context, progress func(downloaded, total int64)) error { - return d.images.Install(ctx, progress) -} - -// CheckUpdate checks if an update is available. -func (d *DevOps) CheckUpdate(ctx context.Context) (current, latest string, hasUpdate bool, err error) { - return d.images.CheckUpdate(ctx) -} - -// BootOptions configures how to boot the dev environment. -type BootOptions struct { - Memory int // MB, default 4096 - CPUs int // default 2 - Name string // container name - Fresh bool // destroy existing and start fresh -} - -// DefaultBootOptions returns sensible defaults. -func DefaultBootOptions() BootOptions { - return BootOptions{ - Memory: 4096, - CPUs: 2, - Name: "core-dev", - } -} - -// Boot starts the dev environment. -func (d *DevOps) Boot(ctx context.Context, opts BootOptions) error { - if !d.images.IsInstalled() { - return errors.New("dev image not installed (run 'core dev install' first)") - } - - // Check if already running - if !opts.Fresh { - running, err := d.IsRunning(ctx) - if err == nil && running { - return errors.New("dev environment already running (use 'core dev stop' first or --fresh)") - } - } - - // Stop existing if fresh - if opts.Fresh { - _ = d.Stop(ctx) - } - - imagePath, err := ImagePath() - if err != nil { - return err - } - - // Build run options for LinuxKitManager - runOpts := container.RunOptions{ - Name: opts.Name, - Memory: opts.Memory, - CPUs: opts.CPUs, - SSHPort: DefaultSSHPort, - Detach: true, - } - - _, err = d.container.Run(ctx, imagePath, runOpts) - if err != nil { - return err - } - - // Wait for SSH to be ready and scan host key - // We try for up to 60 seconds as the VM takes a moment to boot - var lastErr error - for range 30 { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(2 * time.Second): - if err := ensureHostKey(ctx, runOpts.SSHPort); err == nil { - return nil - } else { - lastErr = err - } - } - } - - return fmt.Errorf("failed to verify host key after boot: %w", lastErr) -} - -// Stop stops the dev environment. -func (d *DevOps) Stop(ctx context.Context) error { - c, err := d.findContainer(ctx, "core-dev") - if err != nil { - return err - } - if c == nil { - return errors.New("dev environment not found") - } - return d.container.Stop(ctx, c.ID) -} - -// IsRunning checks if the dev environment is running. -func (d *DevOps) IsRunning(ctx context.Context) (bool, error) { - c, err := d.findContainer(ctx, "core-dev") - if err != nil { - return false, err - } - return c != nil && c.Status == container.StatusRunning, nil -} - -// findContainer finds a container by name. -func (d *DevOps) findContainer(ctx context.Context, name string) (*container.Container, error) { - containers, err := d.container.List(ctx) - if err != nil { - return nil, err - } - for _, c := range containers { - if c.Name == name { - return c, nil - } - } - return nil, nil -} - -// DevStatus returns information about the dev environment. -type DevStatus struct { - Installed bool - Running bool - ImageVersion string - ContainerID string - Memory int - CPUs int - SSHPort int - Uptime time.Duration -} - -// Status returns the current dev environment status. -func (d *DevOps) Status(ctx context.Context) (*DevStatus, error) { - status := &DevStatus{ - Installed: d.images.IsInstalled(), - SSHPort: DefaultSSHPort, - } - - if info, ok := d.images.manifest.Images[ImageName()]; ok { - status.ImageVersion = info.Version - } - - c, _ := d.findContainer(ctx, "core-dev") - if c != nil { - status.Running = c.Status == container.StatusRunning - status.ContainerID = c.ID - status.Memory = c.Memory - status.CPUs = c.CPUs - if status.Running { - status.Uptime = time.Since(c.StartedAt) - } - } - - return status, nil -} diff --git a/devops/devops_test.go b/devops/devops_test.go deleted file mode 100644 index 7059472..0000000 --- a/devops/devops_test.go +++ /dev/null @@ -1,833 +0,0 @@ -package devops - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "runtime" - "testing" - "time" - - "forge.lthn.ai/core/go-devops/container" - "forge.lthn.ai/core/go-io" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestImageName(t *testing.T) { - name := ImageName() - assert.Contains(t, name, "core-devops-") - assert.Contains(t, name, runtime.GOOS) - assert.Contains(t, name, runtime.GOARCH) - assert.True(t, (name[len(name)-6:] == ".qcow2")) -} - -func TestImagesDir(t *testing.T) { - t.Run("default directory", func(t *testing.T) { - // Unset env if it exists - orig := os.Getenv("CORE_IMAGES_DIR") - _ = os.Unsetenv("CORE_IMAGES_DIR") - defer func() { _ = os.Setenv("CORE_IMAGES_DIR", orig) }() - - dir, err := ImagesDir() - assert.NoError(t, err) - assert.Contains(t, dir, ".core/images") - }) - - t.Run("environment override", func(t *testing.T) { - customDir := "/tmp/custom-images" - t.Setenv("CORE_IMAGES_DIR", customDir) - - dir, err := ImagesDir() - assert.NoError(t, err) - assert.Equal(t, customDir, dir) - }) -} - -func TestImagePath(t *testing.T) { - customDir := "/tmp/images" - t.Setenv("CORE_IMAGES_DIR", customDir) - - path, err := ImagePath() - assert.NoError(t, err) - expected := filepath.Join(customDir, ImageName()) - assert.Equal(t, expected, path) -} - -func TestDefaultBootOptions(t *testing.T) { - opts := DefaultBootOptions() - assert.Equal(t, 4096, opts.Memory) - assert.Equal(t, 2, opts.CPUs) - assert.Equal(t, "core-dev", opts.Name) - assert.False(t, opts.Fresh) -} - -func TestIsInstalled_Bad(t *testing.T) { - t.Run("returns false for non-existent image", func(t *testing.T) { - // Point to a temp directory that is empty - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - // Create devops instance manually to avoid loading real config/images - d := &DevOps{medium: io.Local} - assert.False(t, d.IsInstalled()) - }) -} - -func TestIsInstalled_Good(t *testing.T) { - t.Run("returns true when image exists", func(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - // Create the image file - imagePath := filepath.Join(tempDir, ImageName()) - err := os.WriteFile(imagePath, []byte("fake image data"), 0644) - require.NoError(t, err) - - d := &DevOps{medium: io.Local} - assert.True(t, d.IsInstalled()) - }) -} - -type mockHypervisor struct{} - -func (m *mockHypervisor) Name() string { return "mock" } -func (m *mockHypervisor) Available() bool { return true } -func (m *mockHypervisor) BuildCommand(ctx context.Context, image string, opts *container.HypervisorOptions) (*exec.Cmd, error) { - return exec.Command("true"), nil -} - -func TestDevOps_Status_Good(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - // Setup mock container manager - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - images: mgr, - container: cm, - } - - // Add a fake running container - c := &container.Container{ - ID: "test-id", - Name: "core-dev", - Status: container.StatusRunning, - PID: os.Getpid(), // Use our own PID so isProcessRunning returns true - StartedAt: time.Now().Add(-time.Hour), - Memory: 2048, - CPUs: 4, - } - err = state.Add(c) - require.NoError(t, err) - - status, err := d.Status(context.Background()) - assert.NoError(t, err) - assert.NotNil(t, status) - assert.True(t, status.Running) - assert.Equal(t, "test-id", status.ContainerID) - assert.Equal(t, 2048, status.Memory) - assert.Equal(t, 4, status.CPUs) -} - -func TestDevOps_Status_Good_NotInstalled(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - images: mgr, - container: cm, - } - - status, err := d.Status(context.Background()) - assert.NoError(t, err) - assert.NotNil(t, status) - assert.False(t, status.Installed) - assert.False(t, status.Running) - assert.Equal(t, 2222, status.SSHPort) -} - -func TestDevOps_Status_Good_NoContainer(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - // Create fake image to mark as installed - imagePath := filepath.Join(tempDir, ImageName()) - err := os.WriteFile(imagePath, []byte("fake"), 0644) - require.NoError(t, err) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - images: mgr, - container: cm, - } - - status, err := d.Status(context.Background()) - assert.NoError(t, err) - assert.NotNil(t, status) - assert.True(t, status.Installed) - assert.False(t, status.Running) - assert.Empty(t, status.ContainerID) -} - -func TestDevOps_IsRunning_Good(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - images: mgr, - container: cm, - } - - c := &container.Container{ - ID: "test-id", - Name: "core-dev", - Status: container.StatusRunning, - PID: os.Getpid(), - StartedAt: time.Now(), - } - err = state.Add(c) - require.NoError(t, err) - - running, err := d.IsRunning(context.Background()) - assert.NoError(t, err) - assert.True(t, running) -} - -func TestDevOps_IsRunning_Bad_NotRunning(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - images: mgr, - container: cm, - } - - running, err := d.IsRunning(context.Background()) - assert.NoError(t, err) - assert.False(t, running) -} - -func TestDevOps_IsRunning_Bad_ContainerStopped(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - images: mgr, - container: cm, - } - - c := &container.Container{ - ID: "test-id", - Name: "core-dev", - Status: container.StatusStopped, - PID: 12345, - StartedAt: time.Now(), - } - err = state.Add(c) - require.NoError(t, err) - - running, err := d.IsRunning(context.Background()) - assert.NoError(t, err) - assert.False(t, running) -} - -func TestDevOps_findContainer_Good(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - images: mgr, - container: cm, - } - - c := &container.Container{ - ID: "test-id", - Name: "my-container", - Status: container.StatusRunning, - PID: os.Getpid(), - StartedAt: time.Now(), - } - err = state.Add(c) - require.NoError(t, err) - - found, err := d.findContainer(context.Background(), "my-container") - assert.NoError(t, err) - assert.NotNil(t, found) - assert.Equal(t, "test-id", found.ID) - assert.Equal(t, "my-container", found.Name) -} - -func TestDevOps_findContainer_Bad_NotFound(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - images: mgr, - container: cm, - } - - found, err := d.findContainer(context.Background(), "nonexistent") - assert.NoError(t, err) - assert.Nil(t, found) -} - -func TestDevOps_Stop_Bad_NotFound(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - images: mgr, - container: cm, - } - - err = d.Stop(context.Background()) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not found") -} - -func TestBootOptions_Custom(t *testing.T) { - opts := BootOptions{ - Memory: 8192, - CPUs: 4, - Name: "custom-dev", - Fresh: true, - } - assert.Equal(t, 8192, opts.Memory) - assert.Equal(t, 4, opts.CPUs) - assert.Equal(t, "custom-dev", opts.Name) - assert.True(t, opts.Fresh) -} - -func TestDevStatus_Struct(t *testing.T) { - status := DevStatus{ - Installed: true, - Running: true, - ImageVersion: "v1.2.3", - ContainerID: "abc123", - Memory: 4096, - CPUs: 2, - SSHPort: 2222, - Uptime: time.Hour, - } - assert.True(t, status.Installed) - assert.True(t, status.Running) - assert.Equal(t, "v1.2.3", status.ImageVersion) - assert.Equal(t, "abc123", status.ContainerID) - assert.Equal(t, 4096, status.Memory) - assert.Equal(t, 2, status.CPUs) - assert.Equal(t, 2222, status.SSHPort) - assert.Equal(t, time.Hour, status.Uptime) -} - -func TestDevOps_Boot_Bad_NotInstalled(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - images: mgr, - container: cm, - } - - err = d.Boot(context.Background(), DefaultBootOptions()) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not installed") -} - -func TestDevOps_Boot_Bad_AlreadyRunning(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - // Create fake image - imagePath := filepath.Join(tempDir, ImageName()) - err := os.WriteFile(imagePath, []byte("fake"), 0644) - require.NoError(t, err) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - images: mgr, - container: cm, - } - - // Add a running container - c := &container.Container{ - ID: "test-id", - Name: "core-dev", - Status: container.StatusRunning, - PID: os.Getpid(), - StartedAt: time.Now(), - } - err = state.Add(c) - require.NoError(t, err) - - err = d.Boot(context.Background(), DefaultBootOptions()) - assert.Error(t, err) - assert.Contains(t, err.Error(), "already running") -} - -func TestDevOps_Status_Good_WithImageVersion(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - // Create fake image - imagePath := filepath.Join(tempDir, ImageName()) - err := os.WriteFile(imagePath, []byte("fake"), 0644) - require.NoError(t, err) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - // Manually set manifest with version info - mgr.manifest.Images[ImageName()] = ImageInfo{ - Version: "v1.2.3", - Source: "test", - } - - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - config: cfg, - images: mgr, - container: cm, - } - - status, err := d.Status(context.Background()) - assert.NoError(t, err) - assert.True(t, status.Installed) - assert.Equal(t, "v1.2.3", status.ImageVersion) -} - -func TestDevOps_findContainer_Good_MultipleContainers(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - images: mgr, - container: cm, - } - - // Add multiple containers - c1 := &container.Container{ - ID: "id-1", - Name: "container-1", - Status: container.StatusRunning, - PID: os.Getpid(), - StartedAt: time.Now(), - } - c2 := &container.Container{ - ID: "id-2", - Name: "container-2", - Status: container.StatusRunning, - PID: os.Getpid(), - StartedAt: time.Now(), - } - err = state.Add(c1) - require.NoError(t, err) - err = state.Add(c2) - require.NoError(t, err) - - // Find specific container - found, err := d.findContainer(context.Background(), "container-2") - assert.NoError(t, err) - assert.NotNil(t, found) - assert.Equal(t, "id-2", found.ID) -} - -func TestDevOps_Status_Good_ContainerWithUptime(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - images: mgr, - container: cm, - } - - startTime := time.Now().Add(-2 * time.Hour) - c := &container.Container{ - ID: "test-id", - Name: "core-dev", - Status: container.StatusRunning, - PID: os.Getpid(), - StartedAt: startTime, - Memory: 4096, - CPUs: 2, - } - err = state.Add(c) - require.NoError(t, err) - - status, err := d.Status(context.Background()) - assert.NoError(t, err) - assert.True(t, status.Running) - assert.GreaterOrEqual(t, status.Uptime.Hours(), float64(1)) -} - -func TestDevOps_IsRunning_Bad_DifferentContainerName(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - images: mgr, - container: cm, - } - - // Add a container with different name - c := &container.Container{ - ID: "test-id", - Name: "other-container", - Status: container.StatusRunning, - PID: os.Getpid(), - StartedAt: time.Now(), - } - err = state.Add(c) - require.NoError(t, err) - - // IsRunning looks for "core-dev", not "other-container" - running, err := d.IsRunning(context.Background()) - assert.NoError(t, err) - assert.False(t, running) -} - -func TestDevOps_Boot_Good_FreshFlag(t *testing.T) { - t.Setenv("CORE_SKIP_SSH_SCAN", "true") - tempDir, err := os.MkdirTemp("", "devops-test-*") - require.NoError(t, err) - t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) - t.Setenv("CORE_IMAGES_DIR", tempDir) - - // Create fake image - imagePath := filepath.Join(tempDir, ImageName()) - err = os.WriteFile(imagePath, []byte("fake"), 0644) - require.NoError(t, err) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - images: mgr, - container: cm, - } - - // Add an existing container with non-existent PID (will be seen as stopped) - c := &container.Container{ - ID: "old-id", - Name: "core-dev", - Status: container.StatusRunning, - PID: 99999999, // Non-existent PID - List() will mark it as stopped - StartedAt: time.Now(), - } - err = state.Add(c) - require.NoError(t, err) - - // Boot with Fresh=true should try to stop the existing container - // then run a new one. The mock hypervisor "succeeds" so this won't error - opts := BootOptions{ - Memory: 4096, - CPUs: 2, - Name: "core-dev", - Fresh: true, - } - err = d.Boot(context.Background(), opts) - // The mock hypervisor's Run succeeds - assert.NoError(t, err) -} - -func TestDevOps_Stop_Bad_ContainerNotRunning(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - images: mgr, - container: cm, - } - - // Add a container that's already stopped - c := &container.Container{ - ID: "test-id", - Name: "core-dev", - Status: container.StatusStopped, - PID: 99999999, - StartedAt: time.Now(), - } - err = state.Add(c) - require.NoError(t, err) - - // Stop should fail because container is not running - err = d.Stop(context.Background()) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not running") -} - -func TestDevOps_Boot_Good_FreshWithNoExisting(t *testing.T) { - t.Setenv("CORE_SKIP_SSH_SCAN", "true") - tempDir, err := os.MkdirTemp("", "devops-boot-fresh-*") - require.NoError(t, err) - t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) - t.Setenv("CORE_IMAGES_DIR", tempDir) - - // Create fake image - imagePath := filepath.Join(tempDir, ImageName()) - err = os.WriteFile(imagePath, []byte("fake"), 0644) - require.NoError(t, err) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - images: mgr, - container: cm, - } - - // Boot with Fresh=true but no existing container - opts := BootOptions{ - Memory: 4096, - CPUs: 2, - Name: "core-dev", - Fresh: true, - } - err = d.Boot(context.Background(), opts) - // The mock hypervisor succeeds - assert.NoError(t, err) -} - -func TestImageName_Format(t *testing.T) { - name := ImageName() - // Check format: core-devops-{os}-{arch}.qcow2 - assert.Contains(t, name, "core-devops-") - assert.Contains(t, name, runtime.GOOS) - assert.Contains(t, name, runtime.GOARCH) - assert.True(t, filepath.Ext(name) == ".qcow2") -} - -func TestDevOps_Install_Delegates(t *testing.T) { - // This test verifies the Install method delegates to ImageManager - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - d := &DevOps{medium: io.Local, - images: mgr, - } - - // This will fail because no source is available, but it tests delegation - err = d.Install(context.Background(), nil) - assert.Error(t, err) -} - -func TestDevOps_CheckUpdate_Delegates(t *testing.T) { - // This test verifies the CheckUpdate method delegates to ImageManager - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - d := &DevOps{medium: io.Local, - images: mgr, - } - - // This will fail because image not installed, but it tests delegation - _, _, _, err = d.CheckUpdate(context.Background()) - assert.Error(t, err) -} - -func TestDevOps_Boot_Good_Success(t *testing.T) { - t.Setenv("CORE_SKIP_SSH_SCAN", "true") - tempDir, err := os.MkdirTemp("", "devops-boot-success-*") - require.NoError(t, err) - t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) - t.Setenv("CORE_IMAGES_DIR", tempDir) - - // Create fake image - imagePath := filepath.Join(tempDir, ImageName()) - err = os.WriteFile(imagePath, []byte("fake"), 0644) - require.NoError(t, err) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - statePath := filepath.Join(tempDir, "containers.json") - state := container.NewState(statePath) - h := &mockHypervisor{} - cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) - - d := &DevOps{medium: io.Local, - images: mgr, - container: cm, - } - - // Boot without Fresh flag and no existing container - opts := DefaultBootOptions() - err = d.Boot(context.Background(), opts) - assert.NoError(t, err) // Mock hypervisor succeeds -} - -func TestDevOps_Config(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tempDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - d := &DevOps{medium: io.Local, - config: cfg, - images: mgr, - } - - assert.NotNil(t, d.config) - assert.Equal(t, "auto", d.config.Images.Source) -} diff --git a/devops/images.go b/devops/images.go deleted file mode 100644 index 16b9736..0000000 --- a/devops/images.go +++ /dev/null @@ -1,199 +0,0 @@ -package devops - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "time" - - "forge.lthn.ai/core/go-devops/devops/sources" - "forge.lthn.ai/core/go-io" -) - -// ImageManager handles image downloads and updates. -type ImageManager struct { - medium io.Medium - config *Config - manifest *Manifest - sources []sources.ImageSource -} - -// Manifest tracks installed images. -type Manifest struct { - medium io.Medium - Images map[string]ImageInfo `json:"images"` - path string -} - -// ImageInfo holds metadata about an installed image. -type ImageInfo struct { - Version string `json:"version"` - SHA256 string `json:"sha256,omitempty"` - Downloaded time.Time `json:"downloaded"` - Source string `json:"source"` -} - -// NewImageManager creates a new image manager. -func NewImageManager(m io.Medium, cfg *Config) (*ImageManager, error) { - imagesDir, err := ImagesDir() - if err != nil { - return nil, err - } - - // Ensure images directory exists - if err := m.EnsureDir(imagesDir); err != nil { - return nil, err - } - - // Load or create manifest - manifestPath := filepath.Join(imagesDir, "manifest.json") - manifest, err := loadManifest(m, manifestPath) - if err != nil { - return nil, err - } - - // Build source list based on config - imageName := ImageName() - sourceCfg := sources.SourceConfig{ - GitHubRepo: cfg.Images.GitHub.Repo, - RegistryImage: cfg.Images.Registry.Image, - CDNURL: cfg.Images.CDN.URL, - ImageName: imageName, - } - - var srcs []sources.ImageSource - switch cfg.Images.Source { - case "github": - srcs = []sources.ImageSource{sources.NewGitHubSource(sourceCfg)} - case "cdn": - srcs = []sources.ImageSource{sources.NewCDNSource(sourceCfg)} - default: // "auto" - srcs = []sources.ImageSource{ - sources.NewGitHubSource(sourceCfg), - sources.NewCDNSource(sourceCfg), - } - } - - return &ImageManager{ - medium: m, - config: cfg, - manifest: manifest, - sources: srcs, - }, nil -} - -// IsInstalled checks if the dev image is installed. -func (m *ImageManager) IsInstalled() bool { - path, err := ImagePath() - if err != nil { - return false - } - return m.medium.IsFile(path) -} - -// Install downloads and installs the dev image. -func (m *ImageManager) Install(ctx context.Context, progress func(downloaded, total int64)) error { - imagesDir, err := ImagesDir() - if err != nil { - return err - } - - // Find first available source - var src sources.ImageSource - for _, s := range m.sources { - if s.Available() { - src = s - break - } - } - if src == nil { - return errors.New("no image source available") - } - - // Get version - version, err := src.LatestVersion(ctx) - if err != nil { - return fmt.Errorf("failed to get latest version: %w", err) - } - - fmt.Printf("Downloading %s from %s...\n", ImageName(), src.Name()) - - // Download - if err := src.Download(ctx, m.medium, imagesDir, progress); err != nil { - return err - } - - // Update manifest - m.manifest.Images[ImageName()] = ImageInfo{ - Version: version, - Downloaded: time.Now(), - Source: src.Name(), - } - - return m.manifest.Save() -} - -// CheckUpdate checks if an update is available. -func (m *ImageManager) CheckUpdate(ctx context.Context) (current, latest string, hasUpdate bool, err error) { - info, ok := m.manifest.Images[ImageName()] - if !ok { - return "", "", false, errors.New("image not installed") - } - current = info.Version - - // Find first available source - var src sources.ImageSource - for _, s := range m.sources { - if s.Available() { - src = s - break - } - } - if src == nil { - return current, "", false, errors.New("no image source available") - } - - latest, err = src.LatestVersion(ctx) - if err != nil { - return current, "", false, err - } - - hasUpdate = current != latest - return current, latest, hasUpdate, nil -} - -func loadManifest(m io.Medium, path string) (*Manifest, error) { - manifest := &Manifest{ - medium: m, - Images: make(map[string]ImageInfo), - path: path, - } - - content, err := m.Read(path) - if err != nil { - if os.IsNotExist(err) { - return manifest, nil - } - return nil, err - } - - if err := json.Unmarshal([]byte(content), manifest); err != nil { - return nil, err - } - manifest.medium = m - manifest.path = path - - return manifest, nil -} - -// Save writes the manifest to disk. -func (m *Manifest) Save() error { - data, err := json.MarshalIndent(m, "", " ") - if err != nil { - return err - } - return m.medium.Write(m.path, string(data)) -} diff --git a/devops/images_test.go b/devops/images_test.go deleted file mode 100644 index cfd5ef4..0000000 --- a/devops/images_test.go +++ /dev/null @@ -1,583 +0,0 @@ -package devops - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" - - "forge.lthn.ai/core/go-devops/devops/sources" - "forge.lthn.ai/core/go-io" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestImageManager_Good_IsInstalled(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tmpDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - // Not installed yet - assert.False(t, mgr.IsInstalled()) - - // Create fake image - imagePath := filepath.Join(tmpDir, ImageName()) - err = os.WriteFile(imagePath, []byte("fake"), 0644) - require.NoError(t, err) - - // Now installed - assert.True(t, mgr.IsInstalled()) -} - -func TestNewImageManager_Good(t *testing.T) { - t.Run("creates manager with cdn source", func(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tmpDir) - - cfg := DefaultConfig() - cfg.Images.Source = "cdn" - - mgr, err := NewImageManager(io.Local, cfg) - assert.NoError(t, err) - assert.NotNil(t, mgr) - assert.Len(t, mgr.sources, 1) - assert.Equal(t, "cdn", mgr.sources[0].Name()) - }) - - t.Run("creates manager with github source", func(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tmpDir) - - cfg := DefaultConfig() - cfg.Images.Source = "github" - - mgr, err := NewImageManager(io.Local, cfg) - assert.NoError(t, err) - assert.NotNil(t, mgr) - assert.Len(t, mgr.sources, 1) - assert.Equal(t, "github", mgr.sources[0].Name()) - }) -} - -func TestManifest_Save(t *testing.T) { - tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "manifest.json") - - m := &Manifest{ - medium: io.Local, - Images: make(map[string]ImageInfo), - path: path, - } - - m.Images["test.img"] = ImageInfo{ - Version: "1.0.0", - Source: "test", - } - - err := m.Save() - assert.NoError(t, err) - - // Verify file exists and has content - _, err = os.Stat(path) - assert.NoError(t, err) - - // Reload - m2, err := loadManifest(io.Local, path) - assert.NoError(t, err) - assert.Equal(t, "1.0.0", m2.Images["test.img"].Version) -} - -func TestLoadManifest_Bad(t *testing.T) { - t.Run("invalid json", func(t *testing.T) { - tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "manifest.json") - err := os.WriteFile(path, []byte("invalid json"), 0644) - require.NoError(t, err) - - _, err = loadManifest(io.Local, path) - assert.Error(t, err) - }) -} - -func TestCheckUpdate_Bad(t *testing.T) { - t.Run("image not installed", func(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tmpDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - require.NoError(t, err) - - _, _, _, err = mgr.CheckUpdate(context.Background()) - assert.Error(t, err) - assert.Contains(t, err.Error(), "image not installed") - }) -} - -func TestNewImageManager_Good_AutoSource(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tmpDir) - - cfg := DefaultConfig() - cfg.Images.Source = "auto" - - mgr, err := NewImageManager(io.Local, cfg) - assert.NoError(t, err) - assert.NotNil(t, mgr) - assert.Len(t, mgr.sources, 2) // github and cdn -} - -func TestNewImageManager_Good_UnknownSourceFallsToAuto(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tmpDir) - - cfg := DefaultConfig() - cfg.Images.Source = "unknown" - - mgr, err := NewImageManager(io.Local, cfg) - assert.NoError(t, err) - assert.NotNil(t, mgr) - assert.Len(t, mgr.sources, 2) // falls to default (auto) which is github + cdn -} - -func TestLoadManifest_Good_Empty(t *testing.T) { - tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "nonexistent.json") - - m, err := loadManifest(io.Local, path) - assert.NoError(t, err) - assert.NotNil(t, m) - assert.NotNil(t, m.Images) - assert.Empty(t, m.Images) - assert.Equal(t, path, m.path) -} - -func TestLoadManifest_Good_ExistingData(t *testing.T) { - tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "manifest.json") - - data := `{"images":{"test.img":{"version":"2.0.0","source":"cdn"}}}` - err := os.WriteFile(path, []byte(data), 0644) - require.NoError(t, err) - - m, err := loadManifest(io.Local, path) - assert.NoError(t, err) - assert.NotNil(t, m) - assert.Equal(t, "2.0.0", m.Images["test.img"].Version) - assert.Equal(t, "cdn", m.Images["test.img"].Source) -} - -func TestImageInfo_Struct(t *testing.T) { - info := ImageInfo{ - Version: "1.0.0", - SHA256: "abc123", - Downloaded: time.Now(), - Source: "github", - } - assert.Equal(t, "1.0.0", info.Version) - assert.Equal(t, "abc123", info.SHA256) - assert.False(t, info.Downloaded.IsZero()) - assert.Equal(t, "github", info.Source) -} - -func TestManifest_Save_Good_CreatesDirs(t *testing.T) { - tmpDir := t.TempDir() - nestedPath := filepath.Join(tmpDir, "nested", "dir", "manifest.json") - - m := &Manifest{ - medium: io.Local, - Images: make(map[string]ImageInfo), - path: nestedPath, - } - m.Images["test.img"] = ImageInfo{Version: "1.0.0"} - - // Save creates parent directories automatically via io.Local.Write - err := m.Save() - assert.NoError(t, err) - - // Verify file was created - _, err = os.Stat(nestedPath) - assert.NoError(t, err) -} - -func TestManifest_Save_Good_Overwrite(t *testing.T) { - tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "manifest.json") - - // First save - m1 := &Manifest{ - medium: io.Local, - Images: make(map[string]ImageInfo), - path: path, - } - m1.Images["test.img"] = ImageInfo{Version: "1.0.0"} - err := m1.Save() - require.NoError(t, err) - - // Second save with different data - m2 := &Manifest{ - medium: io.Local, - Images: make(map[string]ImageInfo), - path: path, - } - m2.Images["other.img"] = ImageInfo{Version: "2.0.0"} - err = m2.Save() - require.NoError(t, err) - - // Verify second data - loaded, err := loadManifest(io.Local, path) - assert.NoError(t, err) - assert.Equal(t, "2.0.0", loaded.Images["other.img"].Version) - _, exists := loaded.Images["test.img"] - assert.False(t, exists) -} - -func TestImageManager_Install_Bad_NoSourceAvailable(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tmpDir) - - // Create manager with empty sources - mgr := &ImageManager{ - medium: io.Local, - config: DefaultConfig(), - manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, - sources: nil, // no sources - } - - err := mgr.Install(context.Background(), nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), "no image source available") -} - -func TestNewImageManager_Good_CreatesDir(t *testing.T) { - tmpDir := t.TempDir() - imagesDir := filepath.Join(tmpDir, "images") - t.Setenv("CORE_IMAGES_DIR", imagesDir) - - cfg := DefaultConfig() - mgr, err := NewImageManager(io.Local, cfg) - assert.NoError(t, err) - assert.NotNil(t, mgr) - - // Verify directory was created - info, err := os.Stat(imagesDir) - assert.NoError(t, err) - assert.True(t, info.IsDir()) -} - -// mockImageSource is a test helper for simulating image sources -type mockImageSource struct { - name string - available bool - latestVersion string - latestErr error - downloadErr error -} - -func (m *mockImageSource) Name() string { return m.name } -func (m *mockImageSource) Available() bool { return m.available } -func (m *mockImageSource) LatestVersion(ctx context.Context) (string, error) { - return m.latestVersion, m.latestErr -} -func (m *mockImageSource) Download(ctx context.Context, medium io.Medium, dest string, progress func(downloaded, total int64)) error { - if m.downloadErr != nil { - return m.downloadErr - } - // Create a fake image file - imagePath := filepath.Join(dest, ImageName()) - return os.WriteFile(imagePath, []byte("mock image content"), 0644) -} - -func TestImageManager_Install_Good_WithMockSource(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tmpDir) - - mock := &mockImageSource{ - name: "mock", - available: true, - latestVersion: "v1.0.0", - } - - mgr := &ImageManager{ - medium: io.Local, - config: DefaultConfig(), - manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, - sources: []sources.ImageSource{mock}, - } - - err := mgr.Install(context.Background(), nil) - assert.NoError(t, err) - assert.True(t, mgr.IsInstalled()) - - // Verify manifest was updated - info, ok := mgr.manifest.Images[ImageName()] - assert.True(t, ok) - assert.Equal(t, "v1.0.0", info.Version) - assert.Equal(t, "mock", info.Source) -} - -func TestImageManager_Install_Bad_DownloadError(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tmpDir) - - mock := &mockImageSource{ - name: "mock", - available: true, - latestVersion: "v1.0.0", - downloadErr: assert.AnError, - } - - mgr := &ImageManager{ - medium: io.Local, - config: DefaultConfig(), - manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, - sources: []sources.ImageSource{mock}, - } - - err := mgr.Install(context.Background(), nil) - assert.Error(t, err) -} - -func TestImageManager_Install_Bad_VersionError(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tmpDir) - - mock := &mockImageSource{ - name: "mock", - available: true, - latestErr: assert.AnError, - } - - mgr := &ImageManager{ - medium: io.Local, - config: DefaultConfig(), - manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, - sources: []sources.ImageSource{mock}, - } - - err := mgr.Install(context.Background(), nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to get latest version") -} - -func TestImageManager_Install_Good_SkipsUnavailableSource(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tmpDir) - - unavailableMock := &mockImageSource{ - name: "unavailable", - available: false, - } - availableMock := &mockImageSource{ - name: "available", - available: true, - latestVersion: "v2.0.0", - } - - mgr := &ImageManager{ - medium: io.Local, - config: DefaultConfig(), - manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, - sources: []sources.ImageSource{unavailableMock, availableMock}, - } - - err := mgr.Install(context.Background(), nil) - assert.NoError(t, err) - - // Should have used the available source - info := mgr.manifest.Images[ImageName()] - assert.Equal(t, "available", info.Source) -} - -func TestImageManager_CheckUpdate_Good_WithMockSource(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tmpDir) - - mock := &mockImageSource{ - name: "mock", - available: true, - latestVersion: "v2.0.0", - } - - mgr := &ImageManager{ - medium: io.Local, - config: DefaultConfig(), - manifest: &Manifest{ - medium: io.Local, - Images: map[string]ImageInfo{ - ImageName(): {Version: "v1.0.0", Source: "mock"}, - }, - path: filepath.Join(tmpDir, "manifest.json"), - }, - sources: []sources.ImageSource{mock}, - } - - current, latest, hasUpdate, err := mgr.CheckUpdate(context.Background()) - assert.NoError(t, err) - assert.Equal(t, "v1.0.0", current) - assert.Equal(t, "v2.0.0", latest) - assert.True(t, hasUpdate) -} - -func TestImageManager_CheckUpdate_Good_NoUpdate(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tmpDir) - - mock := &mockImageSource{ - name: "mock", - available: true, - latestVersion: "v1.0.0", - } - - mgr := &ImageManager{ - medium: io.Local, - config: DefaultConfig(), - manifest: &Manifest{ - medium: io.Local, - Images: map[string]ImageInfo{ - ImageName(): {Version: "v1.0.0", Source: "mock"}, - }, - path: filepath.Join(tmpDir, "manifest.json"), - }, - sources: []sources.ImageSource{mock}, - } - - current, latest, hasUpdate, err := mgr.CheckUpdate(context.Background()) - assert.NoError(t, err) - assert.Equal(t, "v1.0.0", current) - assert.Equal(t, "v1.0.0", latest) - assert.False(t, hasUpdate) -} - -func TestImageManager_CheckUpdate_Bad_NoSource(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tmpDir) - - unavailableMock := &mockImageSource{ - name: "mock", - available: false, - } - - mgr := &ImageManager{ - medium: io.Local, - config: DefaultConfig(), - manifest: &Manifest{ - medium: io.Local, - Images: map[string]ImageInfo{ - ImageName(): {Version: "v1.0.0", Source: "mock"}, - }, - path: filepath.Join(tmpDir, "manifest.json"), - }, - sources: []sources.ImageSource{unavailableMock}, - } - - _, _, _, err := mgr.CheckUpdate(context.Background()) - assert.Error(t, err) - assert.Contains(t, err.Error(), "no image source available") -} - -func TestImageManager_CheckUpdate_Bad_VersionError(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tmpDir) - - mock := &mockImageSource{ - name: "mock", - available: true, - latestErr: assert.AnError, - } - - mgr := &ImageManager{ - medium: io.Local, - config: DefaultConfig(), - manifest: &Manifest{ - medium: io.Local, - Images: map[string]ImageInfo{ - ImageName(): {Version: "v1.0.0", Source: "mock"}, - }, - path: filepath.Join(tmpDir, "manifest.json"), - }, - sources: []sources.ImageSource{mock}, - } - - current, _, _, err := mgr.CheckUpdate(context.Background()) - assert.Error(t, err) - assert.Equal(t, "v1.0.0", current) // Current should still be returned -} - -func TestImageManager_Install_Bad_EmptySources(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tmpDir) - - mgr := &ImageManager{ - medium: io.Local, - config: DefaultConfig(), - manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, - sources: []sources.ImageSource{}, // Empty slice, not nil - } - - err := mgr.Install(context.Background(), nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), "no image source available") -} - -func TestImageManager_Install_Bad_AllUnavailable(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tmpDir) - - mock1 := &mockImageSource{name: "mock1", available: false} - mock2 := &mockImageSource{name: "mock2", available: false} - - mgr := &ImageManager{ - medium: io.Local, - config: DefaultConfig(), - manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, - sources: []sources.ImageSource{mock1, mock2}, - } - - err := mgr.Install(context.Background(), nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), "no image source available") -} - -func TestImageManager_CheckUpdate_Good_FirstSourceUnavailable(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CORE_IMAGES_DIR", tmpDir) - - unavailable := &mockImageSource{name: "unavailable", available: false} - available := &mockImageSource{name: "available", available: true, latestVersion: "v2.0.0"} - - mgr := &ImageManager{ - medium: io.Local, - config: DefaultConfig(), - manifest: &Manifest{ - medium: io.Local, - Images: map[string]ImageInfo{ - ImageName(): {Version: "v1.0.0", Source: "available"}, - }, - path: filepath.Join(tmpDir, "manifest.json"), - }, - sources: []sources.ImageSource{unavailable, available}, - } - - current, latest, hasUpdate, err := mgr.CheckUpdate(context.Background()) - assert.NoError(t, err) - assert.Equal(t, "v1.0.0", current) - assert.Equal(t, "v2.0.0", latest) - assert.True(t, hasUpdate) -} - -func TestManifest_Struct(t *testing.T) { - m := &Manifest{ - Images: map[string]ImageInfo{ - "test.img": {Version: "1.0.0"}, - }, - path: "/path/to/manifest.json", - } - assert.Equal(t, "/path/to/manifest.json", m.path) - assert.Len(t, m.Images, 1) - assert.Equal(t, "1.0.0", m.Images["test.img"].Version) -} diff --git a/devops/serve.go b/devops/serve.go deleted file mode 100644 index 849f845..0000000 --- a/devops/serve.go +++ /dev/null @@ -1,110 +0,0 @@ -package devops - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - - "forge.lthn.ai/core/go-io" -) - -// ServeOptions configures the dev server. -type ServeOptions struct { - Port int // Port to serve on (default 8000) - Path string // Subdirectory to serve (default: current dir) -} - -// Serve mounts the project and starts a dev server. -func (d *DevOps) Serve(ctx context.Context, projectDir string, opts ServeOptions) error { - running, err := d.IsRunning(ctx) - if err != nil { - return err - } - if !running { - return errors.New("dev environment not running (run 'core dev boot' first)") - } - - if opts.Port == 0 { - opts.Port = 8000 - } - - servePath := projectDir - if opts.Path != "" { - servePath = filepath.Join(projectDir, opts.Path) - } - - // Mount project directory via SSHFS - if err := d.mountProject(ctx, servePath); err != nil { - return fmt.Errorf("failed to mount project: %w", err) - } - - // Detect and run serve command - serveCmd := DetectServeCommand(d.medium, servePath) - fmt.Printf("Starting server: %s\n", serveCmd) - fmt.Printf("Listening on http://localhost:%d\n", opts.Port) - - // Run serve command via SSH - return d.sshShell(ctx, []string{"cd", "/app", "&&", serveCmd}) -} - -// mountProject mounts a directory into the VM via SSHFS. -func (d *DevOps) mountProject(ctx context.Context, path string) error { - absPath, err := filepath.Abs(path) - if err != nil { - return err - } - - // Use reverse SSHFS mount - // The VM connects back to host to mount the directory - cmd := exec.CommandContext(ctx, "ssh", - "-o", "StrictHostKeyChecking=yes", - "-o", "UserKnownHostsFile=~/.core/known_hosts", - "-o", "LogLevel=ERROR", - "-R", "10000:localhost:22", // Reverse tunnel for SSHFS - "-p", fmt.Sprintf("%d", DefaultSSHPort), - "root@localhost", - fmt.Sprintf("mkdir -p /app && sshfs -p 10000 %s@localhost:%s /app -o allow_other", os.Getenv("USER"), absPath), - ) - return cmd.Run() -} - -// DetectServeCommand auto-detects the serve command for a project. -func DetectServeCommand(m io.Medium, projectDir string) string { - // Laravel/Octane - if hasFile(m, projectDir, "artisan") { - return "php artisan octane:start --host=0.0.0.0 --port=8000" - } - - // Node.js with dev script - if hasFile(m, projectDir, "package.json") { - if hasPackageScript(m, projectDir, "dev") { - return "npm run dev -- --host 0.0.0.0" - } - if hasPackageScript(m, projectDir, "start") { - return "npm start" - } - } - - // PHP with composer - if hasFile(m, projectDir, "composer.json") { - return "frankenphp php-server -l :8000" - } - - // Go - if hasFile(m, projectDir, "go.mod") { - if hasFile(m, projectDir, "main.go") { - return "go run ." - } - } - - // Python Django - if hasFile(m, projectDir, "manage.py") { - return "python manage.py runserver 0.0.0.0:8000" - } - - // Fallback: simple HTTP server - return "python3 -m http.server 8000" -} diff --git a/devops/serve_test.go b/devops/serve_test.go deleted file mode 100644 index 1b25095..0000000 --- a/devops/serve_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package devops - -import ( - "os" - "path/filepath" - "testing" - - "forge.lthn.ai/core/go-io" - "github.com/stretchr/testify/assert" -) - -func TestDetectServeCommand_Good_Laravel(t *testing.T) { - tmpDir := t.TempDir() - err := os.WriteFile(filepath.Join(tmpDir, "artisan"), []byte("#!/usr/bin/env php"), 0644) - assert.NoError(t, err) - - cmd := DetectServeCommand(io.Local, tmpDir) - assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd) -} - -func TestDetectServeCommand_Good_NodeDev(t *testing.T) { - tmpDir := t.TempDir() - packageJSON := `{"scripts":{"dev":"vite","start":"node index.js"}}` - err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(packageJSON), 0644) - assert.NoError(t, err) - - cmd := DetectServeCommand(io.Local, tmpDir) - assert.Equal(t, "npm run dev -- --host 0.0.0.0", cmd) -} - -func TestDetectServeCommand_Good_NodeStart(t *testing.T) { - tmpDir := t.TempDir() - packageJSON := `{"scripts":{"start":"node server.js"}}` - err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(packageJSON), 0644) - assert.NoError(t, err) - - cmd := DetectServeCommand(io.Local, tmpDir) - assert.Equal(t, "npm start", cmd) -} - -func TestDetectServeCommand_Good_PHP(t *testing.T) { - tmpDir := t.TempDir() - err := os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"require":{}}`), 0644) - assert.NoError(t, err) - - cmd := DetectServeCommand(io.Local, tmpDir) - assert.Equal(t, "frankenphp php-server -l :8000", cmd) -} - -func TestDetectServeCommand_Good_GoMain(t *testing.T) { - tmpDir := t.TempDir() - err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) - assert.NoError(t, err) - err = os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644) - assert.NoError(t, err) - - cmd := DetectServeCommand(io.Local, tmpDir) - assert.Equal(t, "go run .", cmd) -} - -func TestDetectServeCommand_Good_GoWithoutMain(t *testing.T) { - tmpDir := t.TempDir() - err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) - assert.NoError(t, err) - - // No main.go, so falls through to fallback - cmd := DetectServeCommand(io.Local, tmpDir) - assert.Equal(t, "python3 -m http.server 8000", cmd) -} - -func TestDetectServeCommand_Good_Django(t *testing.T) { - tmpDir := t.TempDir() - err := os.WriteFile(filepath.Join(tmpDir, "manage.py"), []byte("#!/usr/bin/env python"), 0644) - assert.NoError(t, err) - - cmd := DetectServeCommand(io.Local, tmpDir) - assert.Equal(t, "python manage.py runserver 0.0.0.0:8000", cmd) -} - -func TestDetectServeCommand_Good_Fallback(t *testing.T) { - tmpDir := t.TempDir() - - cmd := DetectServeCommand(io.Local, tmpDir) - assert.Equal(t, "python3 -m http.server 8000", cmd) -} - -func TestDetectServeCommand_Good_Priority(t *testing.T) { - // Laravel (artisan) should take priority over PHP (composer.json) - tmpDir := t.TempDir() - err := os.WriteFile(filepath.Join(tmpDir, "artisan"), []byte("#!/usr/bin/env php"), 0644) - assert.NoError(t, err) - err = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"require":{}}`), 0644) - assert.NoError(t, err) - - cmd := DetectServeCommand(io.Local, tmpDir) - assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd) -} - -func TestServeOptions_Default(t *testing.T) { - opts := ServeOptions{} - assert.Equal(t, 0, opts.Port) - assert.Equal(t, "", opts.Path) -} - -func TestServeOptions_Custom(t *testing.T) { - opts := ServeOptions{ - Port: 3000, - Path: "public", - } - assert.Equal(t, 3000, opts.Port) - assert.Equal(t, "public", opts.Path) -} - -func TestHasFile_Good(t *testing.T) { - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test.txt") - err := os.WriteFile(testFile, []byte("content"), 0644) - assert.NoError(t, err) - - assert.True(t, hasFile(io.Local, tmpDir, "test.txt")) -} - -func TestHasFile_Bad(t *testing.T) { - tmpDir := t.TempDir() - - assert.False(t, hasFile(io.Local, tmpDir, "nonexistent.txt")) -} - -func TestHasFile_Bad_Directory(t *testing.T) { - tmpDir := t.TempDir() - subDir := filepath.Join(tmpDir, "subdir") - err := os.Mkdir(subDir, 0755) - assert.NoError(t, err) - - // hasFile correctly returns false for directories (only true for regular files) - assert.False(t, hasFile(io.Local, tmpDir, "subdir")) -} diff --git a/devops/shell.go b/devops/shell.go deleted file mode 100644 index 512226f..0000000 --- a/devops/shell.go +++ /dev/null @@ -1,75 +0,0 @@ -package devops - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" -) - -// ShellOptions configures the shell connection. -type ShellOptions struct { - Console bool // Use serial console instead of SSH - Command []string // Command to run (empty = interactive shell) -} - -// Shell connects to the dev environment. -func (d *DevOps) Shell(ctx context.Context, opts ShellOptions) error { - running, err := d.IsRunning(ctx) - if err != nil { - return err - } - if !running { - return errors.New("dev environment not running (run 'core dev boot' first)") - } - - if opts.Console { - return d.serialConsole(ctx) - } - - return d.sshShell(ctx, opts.Command) -} - -// sshShell connects via SSH. -func (d *DevOps) sshShell(ctx context.Context, command []string) error { - args := []string{ - "-o", "StrictHostKeyChecking=yes", - "-o", "UserKnownHostsFile=~/.core/known_hosts", - "-o", "LogLevel=ERROR", - "-A", // Agent forwarding - "-p", fmt.Sprintf("%d", DefaultSSHPort), - "root@localhost", - } - - if len(command) > 0 { - args = append(args, command...) - } - - cmd := exec.CommandContext(ctx, "ssh", args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - return cmd.Run() -} - -// serialConsole attaches to the QEMU serial console. -func (d *DevOps) serialConsole(ctx context.Context) error { - // Find the container to get its console socket - c, err := d.findContainer(ctx, "core-dev") - if err != nil { - return err - } - if c == nil { - return errors.New("console not available: container not found") - } - - // Use socat to connect to the console socket - socketPath := fmt.Sprintf("/tmp/core-%s-console.sock", c.ID) - cmd := exec.CommandContext(ctx, "socat", "-,raw,echo=0", "unix-connect:"+socketPath) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} diff --git a/devops/shell_test.go b/devops/shell_test.go deleted file mode 100644 index e065a78..0000000 --- a/devops/shell_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package devops - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestShellOptions_Default(t *testing.T) { - opts := ShellOptions{} - assert.False(t, opts.Console) - assert.Nil(t, opts.Command) -} - -func TestShellOptions_Console(t *testing.T) { - opts := ShellOptions{ - Console: true, - } - assert.True(t, opts.Console) - assert.Nil(t, opts.Command) -} - -func TestShellOptions_Command(t *testing.T) { - opts := ShellOptions{ - Command: []string{"ls", "-la"}, - } - assert.False(t, opts.Console) - assert.Equal(t, []string{"ls", "-la"}, opts.Command) -} - -func TestShellOptions_ConsoleWithCommand(t *testing.T) { - opts := ShellOptions{ - Console: true, - Command: []string{"echo", "hello"}, - } - assert.True(t, opts.Console) - assert.Equal(t, []string{"echo", "hello"}, opts.Command) -} - -func TestShellOptions_EmptyCommand(t *testing.T) { - opts := ShellOptions{ - Command: []string{}, - } - assert.False(t, opts.Console) - assert.Empty(t, opts.Command) - assert.Len(t, opts.Command, 0) -} diff --git a/devops/sources/cdn.go b/devops/sources/cdn.go deleted file mode 100644 index 21c5b66..0000000 --- a/devops/sources/cdn.go +++ /dev/null @@ -1,113 +0,0 @@ -package sources - -import ( - "context" - "fmt" - goio "io" - "net/http" - "os" - "path/filepath" - - "forge.lthn.ai/core/go-io" -) - -// CDNSource downloads images from a CDN or S3 bucket. -type CDNSource struct { - config SourceConfig -} - -// Compile-time interface check. -var _ ImageSource = (*CDNSource)(nil) - -// NewCDNSource creates a new CDN source. -func NewCDNSource(cfg SourceConfig) *CDNSource { - return &CDNSource{config: cfg} -} - -// Name returns "cdn". -func (s *CDNSource) Name() string { - return "cdn" -} - -// Available checks if CDN URL is configured. -func (s *CDNSource) Available() bool { - return s.config.CDNURL != "" -} - -// LatestVersion fetches version from manifest or returns "latest". -func (s *CDNSource) LatestVersion(ctx context.Context) (string, error) { - // Try to fetch manifest.json for version info - url := fmt.Sprintf("%s/manifest.json", s.config.CDNURL) - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return "latest", nil - } - - resp, err := http.DefaultClient.Do(req) - if err != nil || resp.StatusCode != 200 { - return "latest", nil - } - defer func() { _ = resp.Body.Close() }() - - // For now, just return latest - could parse manifest for version - return "latest", nil -} - -// Download downloads the image from CDN. -func (s *CDNSource) Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error { - url := fmt.Sprintf("%s/%s", s.config.CDNURL, s.config.ImageName) - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return fmt.Errorf("cdn.Download: %w", err) - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("cdn.Download: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != 200 { - return fmt.Errorf("cdn.Download: HTTP %d", resp.StatusCode) - } - - // Ensure dest directory exists - if err := m.EnsureDir(dest); err != nil { - return fmt.Errorf("cdn.Download: %w", err) - } - - // Create destination file - destPath := filepath.Join(dest, s.config.ImageName) - f, err := os.Create(destPath) - if err != nil { - return fmt.Errorf("cdn.Download: %w", err) - } - defer func() { _ = f.Close() }() - - // Copy with progress - total := resp.ContentLength - var downloaded int64 - - buf := make([]byte, 32*1024) - for { - n, err := resp.Body.Read(buf) - if n > 0 { - if _, werr := f.Write(buf[:n]); werr != nil { - return fmt.Errorf("cdn.Download: %w", werr) - } - downloaded += int64(n) - if progress != nil { - progress(downloaded, total) - } - } - if err == goio.EOF { - break - } - if err != nil { - return fmt.Errorf("cdn.Download: %w", err) - } - } - - return nil -} diff --git a/devops/sources/cdn_test.go b/devops/sources/cdn_test.go deleted file mode 100644 index 7473d45..0000000 --- a/devops/sources/cdn_test.go +++ /dev/null @@ -1,306 +0,0 @@ -package sources - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - - "forge.lthn.ai/core/go-io" - "github.com/stretchr/testify/assert" -) - -func TestCDNSource_Good_Available(t *testing.T) { - src := NewCDNSource(SourceConfig{ - CDNURL: "https://images.example.com", - ImageName: "core-devops-darwin-arm64.qcow2", - }) - - assert.Equal(t, "cdn", src.Name()) - assert.True(t, src.Available()) -} - -func TestCDNSource_Bad_NoURL(t *testing.T) { - src := NewCDNSource(SourceConfig{ - ImageName: "core-devops-darwin-arm64.qcow2", - }) - - assert.False(t, src.Available()) -} - -func TestCDNSource_LatestVersion_Good(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/manifest.json" { - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, `{"version": "1.2.3"}`) - } else { - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - - src := NewCDNSource(SourceConfig{ - CDNURL: server.URL, - ImageName: "test.img", - }) - - version, err := src.LatestVersion(context.Background()) - assert.NoError(t, err) - assert.Equal(t, "latest", version) // Current impl always returns "latest" -} - -func TestCDNSource_Download_Good(t *testing.T) { - content := "fake image data" - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/test.img" { - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, content) - } else { - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - - dest := t.TempDir() - imageName := "test.img" - src := NewCDNSource(SourceConfig{ - CDNURL: server.URL, - ImageName: imageName, - }) - - var progressCalled bool - err := src.Download(context.Background(), io.Local, dest, func(downloaded, total int64) { - progressCalled = true - }) - - assert.NoError(t, err) - assert.True(t, progressCalled) - - // Verify file content - data, err := os.ReadFile(filepath.Join(dest, imageName)) - assert.NoError(t, err) - assert.Equal(t, content, string(data)) -} - -func TestCDNSource_Download_Bad(t *testing.T) { - t.Run("HTTP error", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer server.Close() - - dest := t.TempDir() - src := NewCDNSource(SourceConfig{ - CDNURL: server.URL, - ImageName: "test.img", - }) - - err := src.Download(context.Background(), io.Local, dest, nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), "HTTP 500") - }) - - t.Run("Invalid URL", func(t *testing.T) { - dest := t.TempDir() - src := NewCDNSource(SourceConfig{ - CDNURL: "http://invalid-url-that-should-fail", - ImageName: "test.img", - }) - - err := src.Download(context.Background(), io.Local, dest, nil) - assert.Error(t, err) - }) -} - -func TestCDNSource_LatestVersion_Bad_NoManifest(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - })) - defer server.Close() - - src := NewCDNSource(SourceConfig{ - CDNURL: server.URL, - ImageName: "test.img", - }) - - version, err := src.LatestVersion(context.Background()) - assert.NoError(t, err) // Should not error, just return "latest" - assert.Equal(t, "latest", version) -} - -func TestCDNSource_LatestVersion_Bad_ServerError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer server.Close() - - src := NewCDNSource(SourceConfig{ - CDNURL: server.URL, - ImageName: "test.img", - }) - - version, err := src.LatestVersion(context.Background()) - assert.NoError(t, err) // Falls back to "latest" - assert.Equal(t, "latest", version) -} - -func TestCDNSource_Download_Good_NoProgress(t *testing.T) { - content := "test content" - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, content) - })) - defer server.Close() - - dest := t.TempDir() - src := NewCDNSource(SourceConfig{ - CDNURL: server.URL, - ImageName: "test.img", - }) - - // nil progress callback should be handled gracefully - err := src.Download(context.Background(), io.Local, dest, nil) - assert.NoError(t, err) - - data, err := os.ReadFile(filepath.Join(dest, "test.img")) - assert.NoError(t, err) - assert.Equal(t, content, string(data)) -} - -func TestCDNSource_Download_Good_LargeFile(t *testing.T) { - // Create content larger than buffer size (32KB) - content := make([]byte, 64*1024) // 64KB - for i := range content { - content[i] = byte(i % 256) - } - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(content) - })) - defer server.Close() - - dest := t.TempDir() - src := NewCDNSource(SourceConfig{ - CDNURL: server.URL, - ImageName: "large.img", - }) - - var progressCalls int - var lastDownloaded int64 - err := src.Download(context.Background(), io.Local, dest, func(downloaded, total int64) { - progressCalls++ - lastDownloaded = downloaded - }) - - assert.NoError(t, err) - assert.Greater(t, progressCalls, 1) // Should be called multiple times for large file - assert.Equal(t, int64(len(content)), lastDownloaded) -} - -func TestCDNSource_Download_Bad_HTTPErrorCodes(t *testing.T) { - testCases := []struct { - name string - statusCode int - }{ - {"Bad Request", http.StatusBadRequest}, - {"Unauthorized", http.StatusUnauthorized}, - {"Forbidden", http.StatusForbidden}, - {"Not Found", http.StatusNotFound}, - {"Service Unavailable", http.StatusServiceUnavailable}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(tc.statusCode) - })) - defer server.Close() - - dest := t.TempDir() - src := NewCDNSource(SourceConfig{ - CDNURL: server.URL, - ImageName: "test.img", - }) - - err := src.Download(context.Background(), io.Local, dest, nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), fmt.Sprintf("HTTP %d", tc.statusCode)) - }) - } -} - -func TestCDNSource_InterfaceCompliance(t *testing.T) { - // Verify CDNSource implements ImageSource - var _ ImageSource = (*CDNSource)(nil) -} - -func TestCDNSource_Config(t *testing.T) { - cfg := SourceConfig{ - CDNURL: "https://cdn.example.com", - ImageName: "my-image.qcow2", - } - src := NewCDNSource(cfg) - - assert.Equal(t, "https://cdn.example.com", src.config.CDNURL) - assert.Equal(t, "my-image.qcow2", src.config.ImageName) -} - -func TestNewCDNSource_Good(t *testing.T) { - cfg := SourceConfig{ - GitHubRepo: "host-uk/core-images", - RegistryImage: "ghcr.io/host-uk/core-devops", - CDNURL: "https://cdn.example.com", - ImageName: "core-devops-darwin-arm64.qcow2", - } - - src := NewCDNSource(cfg) - assert.NotNil(t, src) - assert.Equal(t, "cdn", src.Name()) - assert.Equal(t, cfg.CDNURL, src.config.CDNURL) -} - -func TestCDNSource_Download_Good_CreatesDestDir(t *testing.T) { - content := "test content" - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, content) - })) - defer server.Close() - - tmpDir := t.TempDir() - dest := filepath.Join(tmpDir, "nested", "dir") - // dest doesn't exist yet - - src := NewCDNSource(SourceConfig{ - CDNURL: server.URL, - ImageName: "test.img", - }) - - err := src.Download(context.Background(), io.Local, dest, nil) - assert.NoError(t, err) - - // Verify nested dir was created - info, err := os.Stat(dest) - assert.NoError(t, err) - assert.True(t, info.IsDir()) -} - -func TestSourceConfig_Struct(t *testing.T) { - cfg := SourceConfig{ - GitHubRepo: "owner/repo", - RegistryImage: "ghcr.io/owner/image", - CDNURL: "https://cdn.example.com", - ImageName: "image.qcow2", - } - - assert.Equal(t, "owner/repo", cfg.GitHubRepo) - assert.Equal(t, "ghcr.io/owner/image", cfg.RegistryImage) - assert.Equal(t, "https://cdn.example.com", cfg.CDNURL) - assert.Equal(t, "image.qcow2", cfg.ImageName) -} diff --git a/devops/sources/github.go b/devops/sources/github.go deleted file mode 100644 index 29650dd..0000000 --- a/devops/sources/github.go +++ /dev/null @@ -1,72 +0,0 @@ -package sources - -import ( - "context" - "fmt" - "os" - "os/exec" - "strings" - - "forge.lthn.ai/core/go-io" -) - -// GitHubSource downloads images from GitHub Releases. -type GitHubSource struct { - config SourceConfig -} - -// Compile-time interface check. -var _ ImageSource = (*GitHubSource)(nil) - -// NewGitHubSource creates a new GitHub source. -func NewGitHubSource(cfg SourceConfig) *GitHubSource { - return &GitHubSource{config: cfg} -} - -// Name returns "github". -func (s *GitHubSource) Name() string { - return "github" -} - -// Available checks if gh CLI is installed and authenticated. -func (s *GitHubSource) Available() bool { - _, err := exec.LookPath("gh") - if err != nil { - return false - } - // Check if authenticated - cmd := exec.Command("gh", "auth", "status") - return cmd.Run() == nil -} - -// LatestVersion returns the latest release tag. -func (s *GitHubSource) LatestVersion(ctx context.Context) (string, error) { - cmd := exec.CommandContext(ctx, "gh", "release", "view", - "-R", s.config.GitHubRepo, - "--json", "tagName", - "-q", ".tagName", - ) - out, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("github.LatestVersion: %w", err) - } - return strings.TrimSpace(string(out)), nil -} - -// Download downloads the image from the latest release. -func (s *GitHubSource) Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error { - // Get release assets to find our image - cmd := exec.CommandContext(ctx, "gh", "release", "download", - "-R", s.config.GitHubRepo, - "-p", s.config.ImageName, - "-D", dest, - "--clobber", - ) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { - return fmt.Errorf("github.Download: %w", err) - } - return nil -} diff --git a/devops/sources/github_test.go b/devops/sources/github_test.go deleted file mode 100644 index 7281129..0000000 --- a/devops/sources/github_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package sources - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestGitHubSource_Good_Available(t *testing.T) { - src := NewGitHubSource(SourceConfig{ - GitHubRepo: "host-uk/core-images", - ImageName: "core-devops-darwin-arm64.qcow2", - }) - - if src.Name() != "github" { - t.Errorf("expected name 'github', got %q", src.Name()) - } - - // Available depends on gh CLI being installed - _ = src.Available() -} - -func TestGitHubSource_Name(t *testing.T) { - src := NewGitHubSource(SourceConfig{}) - assert.Equal(t, "github", src.Name()) -} - -func TestGitHubSource_Config(t *testing.T) { - cfg := SourceConfig{ - GitHubRepo: "owner/repo", - ImageName: "test-image.qcow2", - } - src := NewGitHubSource(cfg) - - // Verify the config is stored - assert.Equal(t, "owner/repo", src.config.GitHubRepo) - assert.Equal(t, "test-image.qcow2", src.config.ImageName) -} - -func TestGitHubSource_Good_Multiple(t *testing.T) { - // Test creating multiple sources with different configs - src1 := NewGitHubSource(SourceConfig{GitHubRepo: "org1/repo1", ImageName: "img1.qcow2"}) - src2 := NewGitHubSource(SourceConfig{GitHubRepo: "org2/repo2", ImageName: "img2.qcow2"}) - - assert.Equal(t, "org1/repo1", src1.config.GitHubRepo) - assert.Equal(t, "org2/repo2", src2.config.GitHubRepo) - assert.Equal(t, "github", src1.Name()) - assert.Equal(t, "github", src2.Name()) -} - -func TestNewGitHubSource_Good(t *testing.T) { - cfg := SourceConfig{ - GitHubRepo: "host-uk/core-images", - RegistryImage: "ghcr.io/host-uk/core-devops", - CDNURL: "https://cdn.example.com", - ImageName: "core-devops-darwin-arm64.qcow2", - } - - src := NewGitHubSource(cfg) - assert.NotNil(t, src) - assert.Equal(t, "github", src.Name()) - assert.Equal(t, cfg.GitHubRepo, src.config.GitHubRepo) -} - -func TestGitHubSource_InterfaceCompliance(t *testing.T) { - // Verify GitHubSource implements ImageSource - var _ ImageSource = (*GitHubSource)(nil) -} diff --git a/devops/sources/source.go b/devops/sources/source.go deleted file mode 100644 index 64c802c..0000000 --- a/devops/sources/source.go +++ /dev/null @@ -1,33 +0,0 @@ -// Package sources provides image download sources for core-devops. -package sources - -import ( - "context" - - "forge.lthn.ai/core/go-io" -) - -// ImageSource defines the interface for downloading dev images. -type ImageSource interface { - // Name returns the source identifier. - Name() string - // Available checks if this source can be used. - Available() bool - // LatestVersion returns the latest available version. - LatestVersion(ctx context.Context) (string, error) - // Download downloads the image to the destination path. - // Reports progress via the callback if provided. - Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error -} - -// SourceConfig holds configuration for a source. -type SourceConfig struct { - // GitHub configuration - GitHubRepo string - // Registry configuration - RegistryImage string - // CDN configuration - CDNURL string - // Image name (e.g., core-devops-darwin-arm64.qcow2) - ImageName string -} diff --git a/devops/sources/source_test.go b/devops/sources/source_test.go deleted file mode 100644 index a63f09b..0000000 --- a/devops/sources/source_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package sources - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestSourceConfig_Empty(t *testing.T) { - cfg := SourceConfig{} - assert.Empty(t, cfg.GitHubRepo) - assert.Empty(t, cfg.RegistryImage) - assert.Empty(t, cfg.CDNURL) - assert.Empty(t, cfg.ImageName) -} - -func TestSourceConfig_Complete(t *testing.T) { - cfg := SourceConfig{ - GitHubRepo: "owner/repo", - RegistryImage: "ghcr.io/owner/image:v1", - CDNURL: "https://cdn.example.com/images", - ImageName: "my-image-darwin-arm64.qcow2", - } - - assert.Equal(t, "owner/repo", cfg.GitHubRepo) - assert.Equal(t, "ghcr.io/owner/image:v1", cfg.RegistryImage) - assert.Equal(t, "https://cdn.example.com/images", cfg.CDNURL) - assert.Equal(t, "my-image-darwin-arm64.qcow2", cfg.ImageName) -} - -func TestImageSource_Interface(t *testing.T) { - // Ensure both sources implement the interface - var _ ImageSource = (*GitHubSource)(nil) - var _ ImageSource = (*CDNSource)(nil) -} diff --git a/devops/ssh_utils.go b/devops/ssh_utils.go deleted file mode 100644 index 90a3399..0000000 --- a/devops/ssh_utils.go +++ /dev/null @@ -1,69 +0,0 @@ -package devops - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" -) - -// ensureHostKey ensures that the host key for the dev environment is in the known hosts file. -// This is used after boot to allow StrictHostKeyChecking=yes to work. -func ensureHostKey(ctx context.Context, port int) error { - // Skip if requested (used in tests) - if os.Getenv("CORE_SKIP_SSH_SCAN") == "true" { - return nil - } - - home, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("get home dir: %w", err) - } - - knownHostsPath := filepath.Join(home, ".core", "known_hosts") - - // Ensure directory exists - if err := os.MkdirAll(filepath.Dir(knownHostsPath), 0755); err != nil { - return fmt.Errorf("create known_hosts dir: %w", err) - } - - // Get host key using ssh-keyscan - cmd := exec.CommandContext(ctx, "ssh-keyscan", "-p", fmt.Sprintf("%d", port), "localhost") - out, err := cmd.Output() - if err != nil { - return fmt.Errorf("ssh-keyscan failed: %w", err) - } - - if len(out) == 0 { - return errors.New("ssh-keyscan returned no keys") - } - - // Read existing known_hosts to avoid duplicates - existing, _ := os.ReadFile(knownHostsPath) - existingStr := string(existing) - - // Append new keys that aren't already there - f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) - if err != nil { - return fmt.Errorf("open known_hosts: %w", err) - } - defer f.Close() - - lines := strings.Split(string(out), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - if !strings.Contains(existingStr, line) { - if _, err := f.WriteString(line + "\n"); err != nil { - return fmt.Errorf("write known_hosts: %w", err) - } - } - } - - return nil -} diff --git a/devops/test.go b/devops/test.go deleted file mode 100644 index d936bf1..0000000 --- a/devops/test.go +++ /dev/null @@ -1,189 +0,0 @@ -package devops - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "path/filepath" - "strings" - - "forge.lthn.ai/core/go-io" - "gopkg.in/yaml.v3" -) - -// TestConfig holds test configuration from .core/test.yaml. -type TestConfig struct { - Version int `yaml:"version"` - Command string `yaml:"command,omitempty"` - Commands []TestCommand `yaml:"commands,omitempty"` - Env map[string]string `yaml:"env,omitempty"` -} - -// TestCommand is a named test command. -type TestCommand struct { - Name string `yaml:"name"` - Run string `yaml:"run"` -} - -// TestOptions configures test execution. -type TestOptions struct { - Name string // Run specific named command from .core/test.yaml - Command []string // Override command (from -- args) -} - -// Test runs tests in the dev environment. -func (d *DevOps) Test(ctx context.Context, projectDir string, opts TestOptions) error { - running, err := d.IsRunning(ctx) - if err != nil { - return err - } - if !running { - return errors.New("dev environment not running (run 'core dev boot' first)") - } - - var cmd string - - // Priority: explicit command > named command > auto-detect - if len(opts.Command) > 0 { - cmd = strings.Join(opts.Command, " ") - } else if opts.Name != "" { - cfg, err := LoadTestConfig(d.medium, projectDir) - if err != nil { - return err - } - for _, c := range cfg.Commands { - if c.Name == opts.Name { - cmd = c.Run - break - } - } - if cmd == "" { - return fmt.Errorf("test command %q not found in .core/test.yaml", opts.Name) - } - } else { - cmd = DetectTestCommand(d.medium, projectDir) - if cmd == "" { - return errors.New("could not detect test command (create .core/test.yaml)") - } - } - - // Run via SSH - construct command as single string for shell execution - return d.sshShell(ctx, []string{"cd", "/app", "&&", cmd}) -} - -// DetectTestCommand auto-detects the test command for a project. -func DetectTestCommand(m io.Medium, projectDir string) string { - // 1. Check .core/test.yaml - cfg, err := LoadTestConfig(m, projectDir) - if err == nil && cfg.Command != "" { - return cfg.Command - } - - // 2. Check composer.json for test script - if hasFile(m, projectDir, "composer.json") { - if hasComposerScript(m, projectDir, "test") { - return "composer test" - } - } - - // 3. Check package.json for test script - if hasFile(m, projectDir, "package.json") { - if hasPackageScript(m, projectDir, "test") { - return "npm test" - } - } - - // 4. Check go.mod - if hasFile(m, projectDir, "go.mod") { - return "go test ./..." - } - - // 5. Check pytest - if hasFile(m, projectDir, "pytest.ini") || hasFile(m, projectDir, "pyproject.toml") { - return "pytest" - } - - // 6. Check Taskfile - if hasFile(m, projectDir, "Taskfile.yaml") || hasFile(m, projectDir, "Taskfile.yml") { - return "task test" - } - - return "" -} - -// LoadTestConfig loads .core/test.yaml. -func LoadTestConfig(m io.Medium, projectDir string) (*TestConfig, error) { - path := filepath.Join(projectDir, ".core", "test.yaml") - absPath, err := filepath.Abs(path) - if err != nil { - return nil, err - } - - content, err := m.Read(absPath) - if err != nil { - return nil, err - } - - var cfg TestConfig - if err := yaml.Unmarshal([]byte(content), &cfg); err != nil { - return nil, err - } - - return &cfg, nil -} - -func hasFile(m io.Medium, dir, name string) bool { - path := filepath.Join(dir, name) - absPath, err := filepath.Abs(path) - if err != nil { - return false - } - return m.IsFile(absPath) -} - -func hasPackageScript(m io.Medium, projectDir, script string) bool { - path := filepath.Join(projectDir, "package.json") - absPath, err := filepath.Abs(path) - if err != nil { - return false - } - - content, err := m.Read(absPath) - if err != nil { - return false - } - - var pkg struct { - Scripts map[string]string `json:"scripts"` - } - if err := json.Unmarshal([]byte(content), &pkg); err != nil { - return false - } - - _, ok := pkg.Scripts[script] - return ok -} - -func hasComposerScript(m io.Medium, projectDir, script string) bool { - path := filepath.Join(projectDir, "composer.json") - absPath, err := filepath.Abs(path) - if err != nil { - return false - } - - content, err := m.Read(absPath) - if err != nil { - return false - } - - var pkg struct { - Scripts map[string]any `json:"scripts"` - } - if err := json.Unmarshal([]byte(content), &pkg); err != nil { - return false - } - - _, ok := pkg.Scripts[script] - return ok -} diff --git a/devops/test_test.go b/devops/test_test.go deleted file mode 100644 index 1a2941c..0000000 --- a/devops/test_test.go +++ /dev/null @@ -1,354 +0,0 @@ -package devops - -import ( - "os" - "path/filepath" - "testing" - - "forge.lthn.ai/core/go-io" -) - -func TestDetectTestCommand_Good_ComposerJSON(t *testing.T) { - tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest"}}`), 0644) - - cmd := DetectTestCommand(io.Local, tmpDir) - if cmd != "composer test" { - t.Errorf("expected 'composer test', got %q", cmd) - } -} - -func TestDetectTestCommand_Good_PackageJSON(t *testing.T) { - tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"vitest"}}`), 0644) - - cmd := DetectTestCommand(io.Local, tmpDir) - if cmd != "npm test" { - t.Errorf("expected 'npm test', got %q", cmd) - } -} - -func TestDetectTestCommand_Good_GoMod(t *testing.T) { - tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) - - cmd := DetectTestCommand(io.Local, tmpDir) - if cmd != "go test ./..." { - t.Errorf("expected 'go test ./...', got %q", cmd) - } -} - -func TestDetectTestCommand_Good_CoreTestYaml(t *testing.T) { - tmpDir := t.TempDir() - coreDir := filepath.Join(tmpDir, ".core") - _ = os.MkdirAll(coreDir, 0755) - _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: custom-test"), 0644) - - cmd := DetectTestCommand(io.Local, tmpDir) - if cmd != "custom-test" { - t.Errorf("expected 'custom-test', got %q", cmd) - } -} - -func TestDetectTestCommand_Good_Pytest(t *testing.T) { - tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "pytest.ini"), []byte("[pytest]"), 0644) - - cmd := DetectTestCommand(io.Local, tmpDir) - if cmd != "pytest" { - t.Errorf("expected 'pytest', got %q", cmd) - } -} - -func TestDetectTestCommand_Good_Taskfile(t *testing.T) { - tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "Taskfile.yaml"), []byte("version: '3'"), 0644) - - cmd := DetectTestCommand(io.Local, tmpDir) - if cmd != "task test" { - t.Errorf("expected 'task test', got %q", cmd) - } -} - -func TestDetectTestCommand_Bad_NoFiles(t *testing.T) { - tmpDir := t.TempDir() - - cmd := DetectTestCommand(io.Local, tmpDir) - if cmd != "" { - t.Errorf("expected empty string, got %q", cmd) - } -} - -func TestDetectTestCommand_Good_Priority(t *testing.T) { - // .core/test.yaml should take priority over other detection methods - tmpDir := t.TempDir() - coreDir := filepath.Join(tmpDir, ".core") - _ = os.MkdirAll(coreDir, 0755) - _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: my-custom-test"), 0644) - _ = os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) - - cmd := DetectTestCommand(io.Local, tmpDir) - if cmd != "my-custom-test" { - t.Errorf("expected 'my-custom-test' (from .core/test.yaml), got %q", cmd) - } -} - -func TestLoadTestConfig_Good(t *testing.T) { - tmpDir := t.TempDir() - coreDir := filepath.Join(tmpDir, ".core") - _ = os.MkdirAll(coreDir, 0755) - - configYAML := `version: 1 -command: default-test -commands: - - name: unit - run: go test ./... - - name: integration - run: go test -tags=integration ./... -env: - CI: "true" -` - _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte(configYAML), 0644) - - cfg, err := LoadTestConfig(io.Local, tmpDir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if cfg.Version != 1 { - t.Errorf("expected version 1, got %d", cfg.Version) - } - if cfg.Command != "default-test" { - t.Errorf("expected command 'default-test', got %q", cfg.Command) - } - if len(cfg.Commands) != 2 { - t.Errorf("expected 2 commands, got %d", len(cfg.Commands)) - } - if cfg.Commands[0].Name != "unit" { - t.Errorf("expected first command name 'unit', got %q", cfg.Commands[0].Name) - } - if cfg.Env["CI"] != "true" { - t.Errorf("expected env CI='true', got %q", cfg.Env["CI"]) - } -} - -func TestLoadTestConfig_Bad_NotFound(t *testing.T) { - tmpDir := t.TempDir() - - _, err := LoadTestConfig(io.Local, tmpDir) - if err == nil { - t.Error("expected error for missing config, got nil") - } -} - -func TestHasPackageScript_Good(t *testing.T) { - tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"jest","build":"webpack"}}`), 0644) - - if !hasPackageScript(io.Local, tmpDir, "test") { - t.Error("expected to find 'test' script") - } - if !hasPackageScript(io.Local, tmpDir, "build") { - t.Error("expected to find 'build' script") - } -} - -func TestHasPackageScript_Bad_MissingScript(t *testing.T) { - tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"build":"webpack"}}`), 0644) - - if hasPackageScript(io.Local, tmpDir, "test") { - t.Error("expected not to find 'test' script") - } -} - -func TestHasComposerScript_Good(t *testing.T) { - tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest","post-install-cmd":"@php artisan migrate"}}`), 0644) - - if !hasComposerScript(io.Local, tmpDir, "test") { - t.Error("expected to find 'test' script") - } -} - -func TestHasComposerScript_Bad_MissingScript(t *testing.T) { - tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"build":"@php build.php"}}`), 0644) - - if hasComposerScript(io.Local, tmpDir, "test") { - t.Error("expected not to find 'test' script") - } -} - -func TestTestConfig_Struct(t *testing.T) { - cfg := &TestConfig{ - Version: 2, - Command: "my-test", - Commands: []TestCommand{{Name: "unit", Run: "go test ./..."}}, - Env: map[string]string{"CI": "true"}, - } - if cfg.Version != 2 { - t.Errorf("expected version 2, got %d", cfg.Version) - } - if cfg.Command != "my-test" { - t.Errorf("expected command 'my-test', got %q", cfg.Command) - } - if len(cfg.Commands) != 1 { - t.Errorf("expected 1 command, got %d", len(cfg.Commands)) - } - if cfg.Env["CI"] != "true" { - t.Errorf("expected CI=true, got %q", cfg.Env["CI"]) - } -} - -func TestTestCommand_Struct(t *testing.T) { - cmd := TestCommand{ - Name: "integration", - Run: "go test -tags=integration ./...", - } - if cmd.Name != "integration" { - t.Errorf("expected name 'integration', got %q", cmd.Name) - } - if cmd.Run != "go test -tags=integration ./..." { - t.Errorf("expected run command, got %q", cmd.Run) - } -} - -func TestTestOptions_Struct(t *testing.T) { - opts := TestOptions{ - Name: "unit", - Command: []string{"go", "test", "-v"}, - } - if opts.Name != "unit" { - t.Errorf("expected name 'unit', got %q", opts.Name) - } - if len(opts.Command) != 3 { - t.Errorf("expected 3 command parts, got %d", len(opts.Command)) - } -} - -func TestDetectTestCommand_Good_TaskfileYml(t *testing.T) { - tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "Taskfile.yml"), []byte("version: '3'"), 0644) - - cmd := DetectTestCommand(io.Local, tmpDir) - if cmd != "task test" { - t.Errorf("expected 'task test', got %q", cmd) - } -} - -func TestDetectTestCommand_Good_Pyproject(t *testing.T) { - tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "pyproject.toml"), []byte("[tool.pytest]"), 0644) - - cmd := DetectTestCommand(io.Local, tmpDir) - if cmd != "pytest" { - t.Errorf("expected 'pytest', got %q", cmd) - } -} - -func TestHasPackageScript_Bad_NoFile(t *testing.T) { - tmpDir := t.TempDir() - - if hasPackageScript(io.Local, tmpDir, "test") { - t.Error("expected false for missing package.json") - } -} - -func TestHasPackageScript_Bad_InvalidJSON(t *testing.T) { - tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`invalid json`), 0644) - - if hasPackageScript(io.Local, tmpDir, "test") { - t.Error("expected false for invalid JSON") - } -} - -func TestHasPackageScript_Bad_NoScripts(t *testing.T) { - tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0644) - - if hasPackageScript(io.Local, tmpDir, "test") { - t.Error("expected false for missing scripts section") - } -} - -func TestHasComposerScript_Bad_NoFile(t *testing.T) { - tmpDir := t.TempDir() - - if hasComposerScript(io.Local, tmpDir, "test") { - t.Error("expected false for missing composer.json") - } -} - -func TestHasComposerScript_Bad_InvalidJSON(t *testing.T) { - tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`invalid json`), 0644) - - if hasComposerScript(io.Local, tmpDir, "test") { - t.Error("expected false for invalid JSON") - } -} - -func TestHasComposerScript_Bad_NoScripts(t *testing.T) { - tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"name":"test/pkg"}`), 0644) - - if hasComposerScript(io.Local, tmpDir, "test") { - t.Error("expected false for missing scripts section") - } -} - -func TestLoadTestConfig_Bad_InvalidYAML(t *testing.T) { - tmpDir := t.TempDir() - coreDir := filepath.Join(tmpDir, ".core") - _ = os.MkdirAll(coreDir, 0755) - _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("invalid: yaml: :"), 0644) - - _, err := LoadTestConfig(io.Local, tmpDir) - if err == nil { - t.Error("expected error for invalid YAML") - } -} - -func TestLoadTestConfig_Good_MinimalConfig(t *testing.T) { - tmpDir := t.TempDir() - coreDir := filepath.Join(tmpDir, ".core") - _ = os.MkdirAll(coreDir, 0755) - _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("version: 1"), 0644) - - cfg, err := LoadTestConfig(io.Local, tmpDir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cfg.Version != 1 { - t.Errorf("expected version 1, got %d", cfg.Version) - } - if cfg.Command != "" { - t.Errorf("expected empty command, got %q", cfg.Command) - } -} - -func TestDetectTestCommand_Good_ComposerWithoutScript(t *testing.T) { - tmpDir := t.TempDir() - // composer.json without test script should not return composer test - _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"name":"test/pkg"}`), 0644) - - cmd := DetectTestCommand(io.Local, tmpDir) - // Falls through to empty (no match) - if cmd != "" { - t.Errorf("expected empty string, got %q", cmd) - } -} - -func TestDetectTestCommand_Good_PackageJSONWithoutScript(t *testing.T) { - tmpDir := t.TempDir() - // package.json without test or dev script - _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0644) - - cmd := DetectTestCommand(io.Local, tmpDir) - // Falls through to empty - if cmd != "" { - t.Errorf("expected empty string, got %q", cmd) - } -} diff --git a/infra/client.go b/infra/client.go deleted file mode 100644 index 2e0d5aa..0000000 --- a/infra/client.go +++ /dev/null @@ -1,274 +0,0 @@ -package infra - -import ( - "encoding/json" - "fmt" - "io" - "math" - "math/rand/v2" - "net/http" - "strconv" - "sync" - "time" -) - -// RetryConfig controls exponential backoff retry behaviour. -type RetryConfig struct { - // MaxRetries is the maximum number of retry attempts (0 = no retries). - MaxRetries int - // InitialBackoff is the delay before the first retry. - InitialBackoff time.Duration - // MaxBackoff is the upper bound on backoff duration. - MaxBackoff time.Duration -} - -// DefaultRetryConfig returns sensible defaults: 3 retries, 100ms initial, 5s max. -func DefaultRetryConfig() RetryConfig { - return RetryConfig{ - MaxRetries: 3, - InitialBackoff: 100 * time.Millisecond, - MaxBackoff: 5 * time.Second, - } -} - -// APIClient is a shared HTTP client with retry, rate-limit handling, -// and configurable authentication. Provider-specific clients embed or -// delegate to this struct. -type APIClient struct { - client *http.Client - retry RetryConfig - authFn func(req *http.Request) - prefix string // error prefix, e.g. "hcloud API" - mu sync.Mutex - blockedUntil time.Time // rate-limit window -} - -// APIClientOption configures an APIClient. -type APIClientOption func(*APIClient) - -// WithHTTPClient sets a custom http.Client. -func WithHTTPClient(c *http.Client) APIClientOption { - return func(a *APIClient) { a.client = c } -} - -// WithRetry sets the retry configuration. -func WithRetry(cfg RetryConfig) APIClientOption { - return func(a *APIClient) { a.retry = cfg } -} - -// WithAuth sets the authentication function applied to every request. -func WithAuth(fn func(req *http.Request)) APIClientOption { - return func(a *APIClient) { a.authFn = fn } -} - -// WithPrefix sets the error message prefix (e.g. "hcloud API"). -func WithPrefix(p string) APIClientOption { - return func(a *APIClient) { a.prefix = p } -} - -// NewAPIClient creates a new APIClient with the given options. -func NewAPIClient(opts ...APIClientOption) *APIClient { - a := &APIClient{ - client: &http.Client{Timeout: 30 * time.Second}, - retry: DefaultRetryConfig(), - prefix: "api", - } - for _, opt := range opts { - opt(a) - } - return a -} - -// Do executes an HTTP request with authentication, retry logic, and -// rate-limit handling. If result is non-nil, the response body is -// JSON-decoded into it. -func (a *APIClient) Do(req *http.Request, result any) error { - if a.authFn != nil { - a.authFn(req) - } - - var lastErr error - attempts := 1 + a.retry.MaxRetries - - for attempt := range attempts { - // Respect rate-limit backoff window. - a.mu.Lock() - wait := time.Until(a.blockedUntil) - a.mu.Unlock() - if wait > 0 { - select { - case <-req.Context().Done(): - return req.Context().Err() - case <-time.After(wait): - } - } - - resp, err := a.client.Do(req) - if err != nil { - lastErr = fmt.Errorf("%s: %w", a.prefix, err) - if attempt < attempts-1 { - a.backoff(attempt, req) - } - continue - } - - data, err := io.ReadAll(resp.Body) - _ = resp.Body.Close() - if err != nil { - lastErr = fmt.Errorf("read response: %w", err) - if attempt < attempts-1 { - a.backoff(attempt, req) - } - continue - } - - // Rate-limited: honour Retry-After and retry. - if resp.StatusCode == http.StatusTooManyRequests { - retryAfter := parseRetryAfter(resp.Header.Get("Retry-After")) - a.mu.Lock() - a.blockedUntil = time.Now().Add(retryAfter) - a.mu.Unlock() - - lastErr = fmt.Errorf("%s %d: rate limited", a.prefix, resp.StatusCode) - if attempt < attempts-1 { - select { - case <-req.Context().Done(): - return req.Context().Err() - case <-time.After(retryAfter): - } - } - continue - } - - // Server errors are retryable. - if resp.StatusCode >= 500 { - lastErr = fmt.Errorf("%s %d: %s", a.prefix, resp.StatusCode, string(data)) - if attempt < attempts-1 { - a.backoff(attempt, req) - } - continue - } - - // Client errors (4xx, except 429 handled above) are not retried. - if resp.StatusCode >= 400 { - return fmt.Errorf("%s %d: %s", a.prefix, resp.StatusCode, string(data)) - } - - // Success — decode if requested. - if result != nil { - if err := json.Unmarshal(data, result); err != nil { - return fmt.Errorf("decode response: %w", err) - } - } - return nil - } - - return lastErr -} - -// DoRaw executes a request and returns the raw response body. -// Same retry/rate-limit logic as Do but without JSON decoding. -func (a *APIClient) DoRaw(req *http.Request) ([]byte, error) { - if a.authFn != nil { - a.authFn(req) - } - - var lastErr error - attempts := 1 + a.retry.MaxRetries - - for attempt := range attempts { - // Respect rate-limit backoff window. - a.mu.Lock() - wait := time.Until(a.blockedUntil) - a.mu.Unlock() - if wait > 0 { - select { - case <-req.Context().Done(): - return nil, req.Context().Err() - case <-time.After(wait): - } - } - - resp, err := a.client.Do(req) - if err != nil { - lastErr = fmt.Errorf("%s: %w", a.prefix, err) - if attempt < attempts-1 { - a.backoff(attempt, req) - } - continue - } - - data, err := io.ReadAll(resp.Body) - _ = resp.Body.Close() - if err != nil { - lastErr = fmt.Errorf("read response: %w", err) - if attempt < attempts-1 { - a.backoff(attempt, req) - } - continue - } - - if resp.StatusCode == http.StatusTooManyRequests { - retryAfter := parseRetryAfter(resp.Header.Get("Retry-After")) - a.mu.Lock() - a.blockedUntil = time.Now().Add(retryAfter) - a.mu.Unlock() - - lastErr = fmt.Errorf("%s %d: rate limited", a.prefix, resp.StatusCode) - if attempt < attempts-1 { - select { - case <-req.Context().Done(): - return nil, req.Context().Err() - case <-time.After(retryAfter): - } - } - continue - } - - if resp.StatusCode >= 500 { - lastErr = fmt.Errorf("%s %d: %s", a.prefix, resp.StatusCode, string(data)) - if attempt < attempts-1 { - a.backoff(attempt, req) - } - continue - } - - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("%s %d: %s", a.prefix, resp.StatusCode, string(data)) - } - - return data, nil - } - - return nil, lastErr -} - -// backoff sleeps for exponential backoff with jitter, respecting context cancellation. -func (a *APIClient) backoff(attempt int, req *http.Request) { - base := float64(a.retry.InitialBackoff) * math.Pow(2, float64(attempt)) - if base > float64(a.retry.MaxBackoff) { - base = float64(a.retry.MaxBackoff) - } - // Add jitter: 50-100% of calculated backoff - jitter := base * (0.5 + rand.Float64()*0.5) - d := time.Duration(jitter) - - select { - case <-req.Context().Done(): - case <-time.After(d): - } -} - -// parseRetryAfter interprets the Retry-After header value. -// Supports seconds (integer) format. Falls back to 1 second. -func parseRetryAfter(val string) time.Duration { - if val == "" { - return 1 * time.Second - } - seconds, err := strconv.Atoi(val) - if err == nil && seconds > 0 { - return time.Duration(seconds) * time.Second - } - // Could also try HTTP-date format here, but seconds is typical for APIs. - return 1 * time.Second -} diff --git a/infra/client_test.go b/infra/client_test.go deleted file mode 100644 index 13db078..0000000 --- a/infra/client_test.go +++ /dev/null @@ -1,740 +0,0 @@ -package infra - -import ( - "context" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// --- Constructor --- - -func TestNewAPIClient_Good_Defaults(t *testing.T) { - c := NewAPIClient() - assert.NotNil(t, c.client) - assert.Equal(t, "api", c.prefix) - assert.Equal(t, 3, c.retry.MaxRetries) - assert.Equal(t, 100*time.Millisecond, c.retry.InitialBackoff) - assert.Equal(t, 5*time.Second, c.retry.MaxBackoff) - assert.Nil(t, c.authFn) -} - -func TestNewAPIClient_Good_WithOptions(t *testing.T) { - custom := &http.Client{Timeout: 10 * time.Second} - authCalled := false - - c := NewAPIClient( - WithHTTPClient(custom), - WithPrefix("test-api"), - WithRetry(RetryConfig{MaxRetries: 5, InitialBackoff: 200 * time.Millisecond, MaxBackoff: 10 * time.Second}), - WithAuth(func(req *http.Request) { authCalled = true }), - ) - - assert.Equal(t, custom, c.client) - assert.Equal(t, "test-api", c.prefix) - assert.Equal(t, 5, c.retry.MaxRetries) - assert.Equal(t, 200*time.Millisecond, c.retry.InitialBackoff) - assert.Equal(t, 10*time.Second, c.retry.MaxBackoff) - - // Trigger auth - c.authFn(&http.Request{Header: http.Header{}}) - assert.True(t, authCalled) -} - -func TestDefaultRetryConfig_Good(t *testing.T) { - cfg := DefaultRetryConfig() - assert.Equal(t, 3, cfg.MaxRetries) - assert.Equal(t, 100*time.Millisecond, cfg.InitialBackoff) - assert.Equal(t, 5*time.Second, cfg.MaxBackoff) -} - -// --- Do method --- - -func TestAPIClient_Do_Good_Success(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"name":"test"}`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithRetry(RetryConfig{}), - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil) - require.NoError(t, err) - - var result struct { - Name string `json:"name"` - } - err = c.Do(req, &result) - require.NoError(t, err) - assert.Equal(t, "test", result.Name) -} - -func TestAPIClient_Do_Good_NilResult(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNoContent) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithRetry(RetryConfig{}), - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodDelete, ts.URL+"/item", nil) - require.NoError(t, err) - - err = c.Do(req, nil) - assert.NoError(t, err) -} - -func TestAPIClient_Do_Good_AuthApplied(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "Bearer my-token", r.Header.Get("Authorization")) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{}`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithAuth(func(req *http.Request) { - req.Header.Set("Authorization", "Bearer my-token") - }), - WithRetry(RetryConfig{}), - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil) - require.NoError(t, err) - - err = c.Do(req, nil) - assert.NoError(t, err) -} - -func TestAPIClient_Do_Bad_ClientError(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`not found`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithPrefix("test-api"), - WithRetry(RetryConfig{}), - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/missing", nil) - require.NoError(t, err) - - err = c.Do(req, nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), "test-api 404") - assert.Contains(t, err.Error(), "not found") -} - -func TestAPIClient_Do_Bad_DecodeError(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`not json`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithRetry(RetryConfig{}), - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil) - require.NoError(t, err) - - var result struct{ Name string } - err = c.Do(req, &result) - assert.Error(t, err) - assert.Contains(t, err.Error(), "decode response") -} - -// --- Retry logic --- - -func TestAPIClient_Do_Good_RetriesServerError(t *testing.T) { - var attempts atomic.Int32 - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - n := attempts.Add(1) - if n < 3 { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`server error`)) - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithPrefix("retry-test"), - WithRetry(RetryConfig{ - MaxRetries: 3, - InitialBackoff: 1 * time.Millisecond, - MaxBackoff: 10 * time.Millisecond, - }), - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil) - require.NoError(t, err) - - var result struct { - OK bool `json:"ok"` - } - err = c.Do(req, &result) - require.NoError(t, err) - assert.True(t, result.OK) - assert.Equal(t, int32(3), attempts.Load()) -} - -func TestAPIClient_Do_Bad_ExhaustsRetries(t *testing.T) { - var attempts atomic.Int32 - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - attempts.Add(1) - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`always fails`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithPrefix("exhaust-test"), - WithRetry(RetryConfig{ - MaxRetries: 2, - InitialBackoff: 1 * time.Millisecond, - MaxBackoff: 5 * time.Millisecond, - }), - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil) - require.NoError(t, err) - - err = c.Do(req, nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), "exhaust-test 500") - // 1 initial + 2 retries = 3 attempts - assert.Equal(t, int32(3), attempts.Load()) -} - -func TestAPIClient_Do_Good_NoRetryOn4xx(t *testing.T) { - var attempts atomic.Int32 - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - attempts.Add(1) - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`bad request`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithRetry(RetryConfig{ - MaxRetries: 3, - InitialBackoff: 1 * time.Millisecond, - MaxBackoff: 5 * time.Millisecond, - }), - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil) - require.NoError(t, err) - - err = c.Do(req, nil) - assert.Error(t, err) - // 4xx errors are NOT retried - assert.Equal(t, int32(1), attempts.Load()) -} - -func TestAPIClient_Do_Good_ZeroRetries(t *testing.T) { - var attempts atomic.Int32 - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - attempts.Add(1) - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`fail`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithRetry(RetryConfig{}), // Zero retries - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil) - require.NoError(t, err) - - err = c.Do(req, nil) - assert.Error(t, err) - assert.Equal(t, int32(1), attempts.Load()) -} - -// --- Rate limiting --- - -func TestAPIClient_Do_Good_RateLimitRetry(t *testing.T) { - var attempts atomic.Int32 - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - n := attempts.Add(1) - if n == 1 { - w.Header().Set("Retry-After", "1") - w.WriteHeader(http.StatusTooManyRequests) - _, _ = w.Write([]byte(`rate limited`)) - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithRetry(RetryConfig{ - MaxRetries: 2, - InitialBackoff: 1 * time.Millisecond, - MaxBackoff: 5 * time.Millisecond, - }), - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil) - require.NoError(t, err) - - start := time.Now() - var result struct { - OK bool `json:"ok"` - } - err = c.Do(req, &result) - elapsed := time.Since(start) - - require.NoError(t, err) - assert.True(t, result.OK) - assert.Equal(t, int32(2), attempts.Load()) - // Should have waited at least 1 second for Retry-After - assert.GreaterOrEqual(t, elapsed.Milliseconds(), int64(900)) -} - -func TestAPIClient_Do_Bad_RateLimitExhausted(t *testing.T) { - var attempts atomic.Int32 - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - attempts.Add(1) - w.Header().Set("Retry-After", "1") - w.WriteHeader(http.StatusTooManyRequests) - _, _ = w.Write([]byte(`rate limited`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithRetry(RetryConfig{ - MaxRetries: 1, - InitialBackoff: 1 * time.Millisecond, - MaxBackoff: 5 * time.Millisecond, - }), - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil) - require.NoError(t, err) - - err = c.Do(req, nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), "rate limited") - assert.Equal(t, int32(2), attempts.Load()) // 1 initial + 1 retry -} - -func TestAPIClient_Do_Good_RateLimitNoRetryAfterHeader(t *testing.T) { - var attempts atomic.Int32 - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - n := attempts.Add(1) - if n == 1 { - // 429 without Retry-After header — falls back to 1s - w.WriteHeader(http.StatusTooManyRequests) - _, _ = w.Write([]byte(`rate limited`)) - return - } - _, _ = w.Write([]byte(`{}`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithRetry(RetryConfig{ - MaxRetries: 1, - InitialBackoff: 1 * time.Millisecond, - MaxBackoff: 5 * time.Millisecond, - }), - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil) - require.NoError(t, err) - - err = c.Do(req, nil) - require.NoError(t, err) - assert.Equal(t, int32(2), attempts.Load()) -} - -func TestAPIClient_Do_Ugly_ContextCancelled(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`fail`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithRetry(RetryConfig{ - MaxRetries: 5, - InitialBackoff: 5 * time.Second, // long backoff - MaxBackoff: 10 * time.Second, - }), - ) - - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/test", nil) - require.NoError(t, err) - - err = c.Do(req, nil) - assert.Error(t, err) -} - -// --- DoRaw method --- - -func TestAPIClient_DoRaw_Good_Success(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`raw data here`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithRetry(RetryConfig{}), - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/data", nil) - require.NoError(t, err) - - data, err := c.DoRaw(req) - require.NoError(t, err) - assert.Equal(t, "raw data here", string(data)) -} - -func TestAPIClient_DoRaw_Good_AuthApplied(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, pass, ok := r.BasicAuth() - assert.True(t, ok) - assert.Equal(t, "user", user) - assert.Equal(t, "pass", pass) - _, _ = w.Write([]byte(`ok`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithAuth(func(req *http.Request) { req.SetBasicAuth("user", "pass") }), - WithRetry(RetryConfig{}), - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil) - require.NoError(t, err) - - data, err := c.DoRaw(req) - require.NoError(t, err) - assert.Equal(t, "ok", string(data)) -} - -func TestAPIClient_DoRaw_Bad_ClientError(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`forbidden`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithPrefix("raw-test"), - WithRetry(RetryConfig{}), - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/secret", nil) - require.NoError(t, err) - - _, err = c.DoRaw(req) - assert.Error(t, err) - assert.Contains(t, err.Error(), "raw-test 403") -} - -func TestAPIClient_DoRaw_Good_RetriesServerError(t *testing.T) { - var attempts atomic.Int32 - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - n := attempts.Add(1) - if n < 2 { - w.WriteHeader(http.StatusBadGateway) - _, _ = w.Write([]byte(`bad gateway`)) - return - } - _, _ = w.Write([]byte(`ok`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithRetry(RetryConfig{ - MaxRetries: 2, - InitialBackoff: 1 * time.Millisecond, - MaxBackoff: 5 * time.Millisecond, - }), - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil) - require.NoError(t, err) - - data, err := c.DoRaw(req) - require.NoError(t, err) - assert.Equal(t, "ok", string(data)) - assert.Equal(t, int32(2), attempts.Load()) -} - -func TestAPIClient_DoRaw_Good_RateLimitRetry(t *testing.T) { - var attempts atomic.Int32 - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - n := attempts.Add(1) - if n == 1 { - w.Header().Set("Retry-After", "1") - w.WriteHeader(http.StatusTooManyRequests) - _, _ = w.Write([]byte(`rate limited`)) - return - } - _, _ = w.Write([]byte(`ok`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithRetry(RetryConfig{ - MaxRetries: 2, - InitialBackoff: 1 * time.Millisecond, - MaxBackoff: 5 * time.Millisecond, - }), - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil) - require.NoError(t, err) - - data, err := c.DoRaw(req) - require.NoError(t, err) - assert.Equal(t, "ok", string(data)) - assert.Equal(t, int32(2), attempts.Load()) -} - -func TestAPIClient_DoRaw_Bad_NoRetryOn4xx(t *testing.T) { - var attempts atomic.Int32 - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - attempts.Add(1) - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`validation error`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithRetry(RetryConfig{ - MaxRetries: 3, - InitialBackoff: 1 * time.Millisecond, - MaxBackoff: 5 * time.Millisecond, - }), - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/test", nil) - require.NoError(t, err) - - _, err = c.DoRaw(req) - assert.Error(t, err) - assert.Equal(t, int32(1), attempts.Load()) -} - -// --- parseRetryAfter --- - -func TestParseRetryAfter_Good_Seconds(t *testing.T) { - d := parseRetryAfter("5") - assert.Equal(t, 5*time.Second, d) -} - -func TestParseRetryAfter_Good_EmptyDefault(t *testing.T) { - d := parseRetryAfter("") - assert.Equal(t, 1*time.Second, d) -} - -func TestParseRetryAfter_Bad_InvalidFallback(t *testing.T) { - d := parseRetryAfter("not-a-number") - assert.Equal(t, 1*time.Second, d) -} - -func TestParseRetryAfter_Good_Zero(t *testing.T) { - d := parseRetryAfter("0") - // 0 is not > 0, falls back to 1s - assert.Equal(t, 1*time.Second, d) -} - -// --- Integration: HCloudClient uses APIClient retry --- - -func TestHCloudClient_Good_RetriesOnServerError(t *testing.T) { - var attempts atomic.Int32 - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - n := attempts.Add(1) - if n < 2 { - w.WriteHeader(http.StatusServiceUnavailable) - _, _ = w.Write([]byte(`unavailable`)) - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"servers":[]}`)) - })) - defer ts.Close() - - client := NewHCloudClient("test-token") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithAuth(func(req *http.Request) { - req.Header.Set("Authorization", "Bearer test-token") - }), - WithPrefix("hcloud API"), - WithRetry(RetryConfig{ - MaxRetries: 2, - InitialBackoff: 1 * time.Millisecond, - MaxBackoff: 5 * time.Millisecond, - }), - ) - - servers, err := client.ListServers(context.Background()) - require.NoError(t, err) - assert.Empty(t, servers) - assert.Equal(t, int32(2), attempts.Load()) -} - -func TestHCloudClient_Good_HandlesRateLimit(t *testing.T) { - var attempts atomic.Int32 - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - n := attempts.Add(1) - if n == 1 { - w.Header().Set("Retry-After", "1") - w.WriteHeader(http.StatusTooManyRequests) - _, _ = w.Write([]byte(`rate limited`)) - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"servers":[]}`)) - })) - defer ts.Close() - - client := NewHCloudClient("test-token") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithAuth(func(req *http.Request) { - req.Header.Set("Authorization", "Bearer test-token") - }), - WithPrefix("hcloud API"), - WithRetry(RetryConfig{ - MaxRetries: 2, - InitialBackoff: 1 * time.Millisecond, - MaxBackoff: 5 * time.Millisecond, - }), - ) - - servers, err := client.ListServers(context.Background()) - require.NoError(t, err) - assert.Empty(t, servers) - assert.Equal(t, int32(2), attempts.Load()) -} - -// --- Integration: CloudNS uses APIClient retry --- - -func TestCloudNSClient_Good_RetriesOnServerError(t *testing.T) { - var attempts atomic.Int32 - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - n := attempts.Add(1) - if n < 2 { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`internal error`)) - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[{"name":"example.com","type":"master","zone":"domain","status":"1"}]`)) - })) - defer ts.Close() - - client := NewCloudNSClient("12345", "secret") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithPrefix("cloudns API"), - WithRetry(RetryConfig{ - MaxRetries: 2, - InitialBackoff: 1 * time.Millisecond, - MaxBackoff: 5 * time.Millisecond, - }), - ) - - zones, err := client.ListZones(context.Background()) - require.NoError(t, err) - require.Len(t, zones, 1) - assert.Equal(t, "example.com", zones[0].Name) - assert.Equal(t, int32(2), attempts.Load()) -} - -// --- Rate limit shared state --- - -func TestAPIClient_Good_RateLimitSharedState(t *testing.T) { - // Verify that the blockedUntil state is respected across requests - var attempts atomic.Int32 - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - n := attempts.Add(1) - if n == 1 { - w.Header().Set("Retry-After", "1") - w.WriteHeader(http.StatusTooManyRequests) - return - } - _, _ = w.Write([]byte(`ok`)) - })) - defer ts.Close() - - c := NewAPIClient( - WithHTTPClient(ts.Client()), - WithRetry(RetryConfig{ - MaxRetries: 1, - InitialBackoff: 1 * time.Millisecond, - MaxBackoff: 5 * time.Millisecond, - }), - ) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/first", nil) - require.NoError(t, err) - - data, err := c.DoRaw(req) - require.NoError(t, err) - assert.Equal(t, "ok", string(data)) -} diff --git a/infra/cloudns.go b/infra/cloudns.go deleted file mode 100644 index bf7265d..0000000 --- a/infra/cloudns.go +++ /dev/null @@ -1,255 +0,0 @@ -package infra - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" -) - -const cloudnsBaseURL = "https://api.cloudns.net" - -// CloudNSClient is an HTTP client for the CloudNS DNS API. -type CloudNSClient struct { - authID string - password string - baseURL string - api *APIClient -} - -// NewCloudNSClient creates a new CloudNS API client. -// Uses sub-auth-user (auth-id) authentication. -func NewCloudNSClient(authID, password string) *CloudNSClient { - return &CloudNSClient{ - authID: authID, - password: password, - baseURL: cloudnsBaseURL, - api: NewAPIClient(WithPrefix("cloudns API")), - } -} - -// CloudNSZone represents a DNS zone. -type CloudNSZone struct { - Name string `json:"name"` - Type string `json:"type"` - Zone string `json:"zone"` - Status string `json:"status"` -} - -// CloudNSRecord represents a DNS record. -type CloudNSRecord struct { - ID string `json:"id"` - Type string `json:"type"` - Host string `json:"host"` - Record string `json:"record"` - TTL string `json:"ttl"` - Priority string `json:"priority,omitempty"` - Status int `json:"status"` -} - -// ListZones returns all DNS zones. -func (c *CloudNSClient) ListZones(ctx context.Context) ([]CloudNSZone, error) { - params := c.authParams() - params.Set("page", "1") - params.Set("rows-per-page", "100") - params.Set("search", "") - - data, err := c.get(ctx, "/dns/list-zones.json", params) - if err != nil { - return nil, err - } - - var zones []CloudNSZone - if err := json.Unmarshal(data, &zones); err != nil { - // CloudNS returns an empty object {} for no results instead of [] - return nil, nil - } - return zones, nil -} - -// ListRecords returns all DNS records for a zone. -func (c *CloudNSClient) ListRecords(ctx context.Context, domain string) (map[string]CloudNSRecord, error) { - params := c.authParams() - params.Set("domain-name", domain) - - data, err := c.get(ctx, "/dns/records.json", params) - if err != nil { - return nil, err - } - - var records map[string]CloudNSRecord - if err := json.Unmarshal(data, &records); err != nil { - return nil, fmt.Errorf("parse records: %w", err) - } - return records, nil -} - -// CreateRecord creates a DNS record. Returns the record ID. -func (c *CloudNSClient) CreateRecord(ctx context.Context, domain, host, recordType, value string, ttl int) (string, error) { - params := c.authParams() - params.Set("domain-name", domain) - params.Set("host", host) - params.Set("record-type", recordType) - params.Set("record", value) - params.Set("ttl", strconv.Itoa(ttl)) - - data, err := c.post(ctx, "/dns/add-record.json", params) - if err != nil { - return "", err - } - - var result struct { - Status string `json:"status"` - StatusDescription string `json:"statusDescription"` - Data struct { - ID int `json:"id"` - } `json:"data"` - } - if err := json.Unmarshal(data, &result); err != nil { - return "", fmt.Errorf("parse response: %w", err) - } - - if result.Status != "Success" { - return "", fmt.Errorf("cloudns: %s", result.StatusDescription) - } - - return strconv.Itoa(result.Data.ID), nil -} - -// UpdateRecord updates an existing DNS record. -func (c *CloudNSClient) UpdateRecord(ctx context.Context, domain, recordID, host, recordType, value string, ttl int) error { - params := c.authParams() - params.Set("domain-name", domain) - params.Set("record-id", recordID) - params.Set("host", host) - params.Set("record-type", recordType) - params.Set("record", value) - params.Set("ttl", strconv.Itoa(ttl)) - - data, err := c.post(ctx, "/dns/mod-record.json", params) - if err != nil { - return err - } - - var result struct { - Status string `json:"status"` - StatusDescription string `json:"statusDescription"` - } - if err := json.Unmarshal(data, &result); err != nil { - return fmt.Errorf("parse response: %w", err) - } - - if result.Status != "Success" { - return fmt.Errorf("cloudns: %s", result.StatusDescription) - } - - return nil -} - -// DeleteRecord deletes a DNS record by ID. -func (c *CloudNSClient) DeleteRecord(ctx context.Context, domain, recordID string) error { - params := c.authParams() - params.Set("domain-name", domain) - params.Set("record-id", recordID) - - data, err := c.post(ctx, "/dns/delete-record.json", params) - if err != nil { - return err - } - - var result struct { - Status string `json:"status"` - StatusDescription string `json:"statusDescription"` - } - if err := json.Unmarshal(data, &result); err != nil { - return fmt.Errorf("parse response: %w", err) - } - - if result.Status != "Success" { - return fmt.Errorf("cloudns: %s", result.StatusDescription) - } - - return nil -} - -// EnsureRecord creates or updates a DNS record to match the desired state. -// Returns true if a change was made. -func (c *CloudNSClient) EnsureRecord(ctx context.Context, domain, host, recordType, value string, ttl int) (bool, error) { - records, err := c.ListRecords(ctx, domain) - if err != nil { - return false, fmt.Errorf("list records: %w", err) - } - - // Check if record already exists - for id, r := range records { - if r.Host == host && r.Type == recordType { - if r.Record == value { - return false, nil // Already correct - } - // Update existing record - if err := c.UpdateRecord(ctx, domain, id, host, recordType, value, ttl); err != nil { - return false, fmt.Errorf("update record: %w", err) - } - return true, nil - } - } - - // Create new record - if _, err := c.CreateRecord(ctx, domain, host, recordType, value, ttl); err != nil { - return false, fmt.Errorf("create record: %w", err) - } - return true, nil -} - -// SetACMEChallenge creates a DNS-01 ACME challenge TXT record. -func (c *CloudNSClient) SetACMEChallenge(ctx context.Context, domain, value string) (string, error) { - return c.CreateRecord(ctx, domain, "_acme-challenge", "TXT", value, 60) -} - -// ClearACMEChallenge removes the DNS-01 ACME challenge TXT record. -func (c *CloudNSClient) ClearACMEChallenge(ctx context.Context, domain string) error { - records, err := c.ListRecords(ctx, domain) - if err != nil { - return err - } - - for id, r := range records { - if r.Host == "_acme-challenge" && r.Type == "TXT" { - if err := c.DeleteRecord(ctx, domain, id); err != nil { - return err - } - } - } - return nil -} - -func (c *CloudNSClient) authParams() url.Values { - params := url.Values{} - params.Set("auth-id", c.authID) - params.Set("auth-password", c.password) - return params -} - -func (c *CloudNSClient) get(ctx context.Context, path string, params url.Values) ([]byte, error) { - u := c.baseURL + path + "?" + params.Encode() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, err - } - return c.doRaw(req) -} - -func (c *CloudNSClient) post(ctx context.Context, path string, params url.Values) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, nil) - if err != nil { - return nil, err - } - req.URL.RawQuery = params.Encode() - return c.doRaw(req) -} - -func (c *CloudNSClient) doRaw(req *http.Request) ([]byte, error) { - return c.api.DoRaw(req) -} diff --git a/infra/cloudns_test.go b/infra/cloudns_test.go deleted file mode 100644 index 3436f95..0000000 --- a/infra/cloudns_test.go +++ /dev/null @@ -1,545 +0,0 @@ -package infra - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// --- Constructor --- - -func TestNewCloudNSClient_Good(t *testing.T) { - c := NewCloudNSClient("12345", "secret") - assert.NotNil(t, c) - assert.Equal(t, "12345", c.authID) - assert.Equal(t, "secret", c.password) - assert.NotNil(t, c.api) -} - -// --- authParams --- - -func TestCloudNSClient_AuthParams_Good(t *testing.T) { - c := NewCloudNSClient("49500", "hunter2") - params := c.authParams() - - assert.Equal(t, "49500", params.Get("auth-id")) - assert.Equal(t, "hunter2", params.Get("auth-password")) -} - -// --- doRaw --- - -func TestCloudNSClient_DoRaw_Good_ReturnsBody(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"status":"Success"}`)) - })) - defer ts.Close() - - client := NewCloudNSClient("test", "test") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithPrefix("cloudns API"), - WithRetry(RetryConfig{}), - ) - - ctx := context.Background() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/dns/test.json", nil) - require.NoError(t, err) - - data, err := client.doRaw(req) - require.NoError(t, err) - assert.Contains(t, string(data), "Success") -} - -func TestCloudNSClient_DoRaw_Bad_HTTPError(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"status":"Failed","statusDescription":"Invalid auth"}`)) - })) - defer ts.Close() - - client := NewCloudNSClient("bad", "creds") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithPrefix("cloudns API"), - WithRetry(RetryConfig{}), - ) - - ctx := context.Background() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/dns/test.json", nil) - require.NoError(t, err) - - _, err = client.doRaw(req) - assert.Error(t, err) - assert.Contains(t, err.Error(), "cloudns API 403") -} - -func TestCloudNSClient_DoRaw_Bad_ServerError(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`Internal Server Error`)) - })) - defer ts.Close() - - client := NewCloudNSClient("test", "test") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithPrefix("cloudns API"), - WithRetry(RetryConfig{}), - ) - - ctx := context.Background() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/test", nil) - require.NoError(t, err) - - _, err = client.doRaw(req) - assert.Error(t, err) - assert.Contains(t, err.Error(), "cloudns API 500") -} - -// --- Zone JSON parsing --- - -func TestCloudNSZone_JSON_Good(t *testing.T) { - data := `[ - {"name": "example.com", "type": "master", "zone": "domain", "status": "1"}, - {"name": "test.io", "type": "master", "zone": "domain", "status": "1"} - ]` - - var zones []CloudNSZone - err := json.Unmarshal([]byte(data), &zones) - - require.NoError(t, err) - require.Len(t, zones, 2) - assert.Equal(t, "example.com", zones[0].Name) - assert.Equal(t, "master", zones[0].Type) - assert.Equal(t, "test.io", zones[1].Name) -} - -func TestCloudNSZone_JSON_Good_EmptyResponse(t *testing.T) { - // CloudNS returns {} for no zones, not [] - data := `{}` - - var zones []CloudNSZone - err := json.Unmarshal([]byte(data), &zones) - - // Should fail to parse as slice — this is the edge case ListZones handles - assert.Error(t, err) -} - -// --- Record JSON parsing --- - -func TestCloudNSRecord_JSON_Good(t *testing.T) { - data := `{ - "12345": { - "id": "12345", - "type": "A", - "host": "www", - "record": "1.2.3.4", - "ttl": "3600", - "status": 1 - }, - "12346": { - "id": "12346", - "type": "MX", - "host": "", - "record": "mail.example.com", - "ttl": "3600", - "priority": "10", - "status": 1 - } - }` - - var records map[string]CloudNSRecord - err := json.Unmarshal([]byte(data), &records) - - require.NoError(t, err) - require.Len(t, records, 2) - - aRecord := records["12345"] - assert.Equal(t, "12345", aRecord.ID) - assert.Equal(t, "A", aRecord.Type) - assert.Equal(t, "www", aRecord.Host) - assert.Equal(t, "1.2.3.4", aRecord.Record) - assert.Equal(t, "3600", aRecord.TTL) - assert.Equal(t, 1, aRecord.Status) - - mxRecord := records["12346"] - assert.Equal(t, "MX", mxRecord.Type) - assert.Equal(t, "mail.example.com", mxRecord.Record) - assert.Equal(t, "10", mxRecord.Priority) -} - -func TestCloudNSRecord_JSON_Good_TXTRecord(t *testing.T) { - data := `{ - "99": { - "id": "99", - "type": "TXT", - "host": "_acme-challenge", - "record": "abc123def456", - "ttl": "60", - "status": 1 - } - }` - - var records map[string]CloudNSRecord - err := json.Unmarshal([]byte(data), &records) - - require.NoError(t, err) - require.Len(t, records, 1) - - txt := records["99"] - assert.Equal(t, "TXT", txt.Type) - assert.Equal(t, "_acme-challenge", txt.Host) - assert.Equal(t, "abc123def456", txt.Record) - assert.Equal(t, "60", txt.TTL) -} - -// --- CreateRecord response parsing --- - -func TestCloudNSClient_CreateRecord_Good_ResponseParsing(t *testing.T) { - data := `{"status":"Success","statusDescription":"The record was created successfully.","data":{"id":54321}}` - - var result struct { - Status string `json:"status"` - StatusDescription string `json:"statusDescription"` - Data struct { - ID int `json:"id"` - } `json:"data"` - } - - err := json.Unmarshal([]byte(data), &result) - require.NoError(t, err) - assert.Equal(t, "Success", result.Status) - assert.Equal(t, 54321, result.Data.ID) -} - -func TestCloudNSClient_CreateRecord_Bad_FailedStatus(t *testing.T) { - data := `{"status":"Failed","statusDescription":"Record already exists."}` - - var result struct { - Status string `json:"status"` - StatusDescription string `json:"statusDescription"` - } - - err := json.Unmarshal([]byte(data), &result) - require.NoError(t, err) - assert.Equal(t, "Failed", result.Status) - assert.Equal(t, "Record already exists.", result.StatusDescription) -} - -// --- UpdateRecord/DeleteRecord response parsing --- - -func TestCloudNSClient_UpdateDelete_Good_ResponseParsing(t *testing.T) { - data := `{"status":"Success","statusDescription":"The record was updated successfully."}` - - var result struct { - Status string `json:"status"` - StatusDescription string `json:"statusDescription"` - } - - err := json.Unmarshal([]byte(data), &result) - require.NoError(t, err) - assert.Equal(t, "Success", result.Status) -} - -// --- Full round-trip tests via doRaw --- - -func TestCloudNSClient_ListZones_Good_ViaDoRaw(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.NotEmpty(t, r.URL.Query().Get("auth-id")) - assert.NotEmpty(t, r.URL.Query().Get("auth-password")) - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[{"name":"example.com","type":"master","zone":"domain","status":"1"}]`)) - })) - defer ts.Close() - - client := NewCloudNSClient("12345", "secret") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithPrefix("cloudns API"), - WithRetry(RetryConfig{}), - ) - - zones, err := client.ListZones(context.Background()) - require.NoError(t, err) - require.Len(t, zones, 1) - assert.Equal(t, "example.com", zones[0].Name) -} - -func TestCloudNSClient_ListRecords_Good_ViaDoRaw(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "example.com", r.URL.Query().Get("domain-name")) - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "1": {"id":"1","type":"A","host":"www","record":"1.2.3.4","ttl":"3600","status":1}, - "2": {"id":"2","type":"CNAME","host":"blog","record":"www.example.com","ttl":"3600","status":1} - }`)) - })) - defer ts.Close() - - client := NewCloudNSClient("12345", "secret") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithPrefix("cloudns API"), - WithRetry(RetryConfig{}), - ) - - records, err := client.ListRecords(context.Background(), "example.com") - require.NoError(t, err) - require.Len(t, records, 2) - assert.Equal(t, "A", records["1"].Type) - assert.Equal(t, "CNAME", records["2"].Type) -} - -func TestCloudNSClient_CreateRecord_Good_ViaDoRaw(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "example.com", r.URL.Query().Get("domain-name")) - assert.Equal(t, "www", r.URL.Query().Get("host")) - assert.Equal(t, "A", r.URL.Query().Get("record-type")) - assert.Equal(t, "1.2.3.4", r.URL.Query().Get("record")) - assert.Equal(t, "3600", r.URL.Query().Get("ttl")) - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"status":"Success","statusDescription":"The record was created successfully.","data":{"id":99}}`)) - })) - defer ts.Close() - - client := NewCloudNSClient("12345", "secret") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithPrefix("cloudns API"), - WithRetry(RetryConfig{}), - ) - - id, err := client.CreateRecord(context.Background(), "example.com", "www", "A", "1.2.3.4", 3600) - require.NoError(t, err) - assert.Equal(t, "99", id) -} - -func TestCloudNSClient_DeleteRecord_Good_ViaDoRaw(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "example.com", r.URL.Query().Get("domain-name")) - assert.Equal(t, "42", r.URL.Query().Get("record-id")) - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"status":"Success","statusDescription":"The record was deleted successfully."}`)) - })) - defer ts.Close() - - client := NewCloudNSClient("12345", "secret") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithPrefix("cloudns API"), - WithRetry(RetryConfig{}), - ) - - err := client.DeleteRecord(context.Background(), "example.com", "42") - require.NoError(t, err) -} - -// --- ACME challenge helpers --- - -func TestCloudNSClient_SetACMEChallenge_Good_ParamVerification(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "example.com", r.URL.Query().Get("domain-name")) - assert.Equal(t, "_acme-challenge", r.URL.Query().Get("host")) - assert.Equal(t, "TXT", r.URL.Query().Get("record-type")) - assert.Equal(t, "60", r.URL.Query().Get("ttl")) - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"status":"Success","statusDescription":"OK","data":{"id":777}}`)) - })) - defer ts.Close() - - client := NewCloudNSClient("12345", "secret") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithPrefix("cloudns API"), - WithRetry(RetryConfig{}), - ) - - id, err := client.SetACMEChallenge(context.Background(), "example.com", "acme-token-value") - require.NoError(t, err) - assert.Equal(t, "777", id) -} - -func TestCloudNSClient_ClearACMEChallenge_Good_Logic(t *testing.T) { - records := map[string]CloudNSRecord{ - "1": {ID: "1", Type: "A", Host: "www", Record: "1.2.3.4"}, - "2": {ID: "2", Type: "TXT", Host: "_acme-challenge", Record: "token1"}, - "3": {ID: "3", Type: "TXT", Host: "_dmarc", Record: "v=DMARC1"}, - "4": {ID: "4", Type: "TXT", Host: "_acme-challenge", Record: "token2"}, - } - - var toDelete []string - for id, r := range records { - if r.Host == "_acme-challenge" && r.Type == "TXT" { - toDelete = append(toDelete, id) - } - } - - assert.Len(t, toDelete, 2) - assert.Contains(t, toDelete, "2") - assert.Contains(t, toDelete, "4") -} - -// --- EnsureRecord logic --- - -func TestEnsureRecord_Good_Logic_AlreadyCorrect(t *testing.T) { - records := map[string]CloudNSRecord{ - "10": {ID: "10", Type: "A", Host: "www", Record: "1.2.3.4"}, - } - - host := "www" - recordType := "A" - value := "1.2.3.4" - - var needsUpdate, needsCreate bool - for _, r := range records { - if r.Host == host && r.Type == recordType { - if r.Record == value { - needsUpdate = false - needsCreate = false - } else { - needsUpdate = true - } - break - } - } - - if !needsUpdate { - found := false - for _, r := range records { - if r.Host == host && r.Type == recordType { - found = true - break - } - } - if !found { - needsCreate = true - } - } - - assert.False(t, needsUpdate, "should not need update when value matches") - assert.False(t, needsCreate, "should not need create when record exists") -} - -func TestEnsureRecord_Good_Logic_NeedsUpdate(t *testing.T) { - records := map[string]CloudNSRecord{ - "10": {ID: "10", Type: "A", Host: "www", Record: "1.2.3.4"}, - } - - host := "www" - recordType := "A" - value := "5.6.7.8" - - var needsUpdate bool - for _, r := range records { - if r.Host == host && r.Type == recordType { - if r.Record != value { - needsUpdate = true - } - break - } - } - - assert.True(t, needsUpdate, "should need update when value differs") -} - -func TestEnsureRecord_Good_Logic_NeedsCreate(t *testing.T) { - records := map[string]CloudNSRecord{ - "10": {ID: "10", Type: "A", Host: "www", Record: "1.2.3.4"}, - } - - host := "api" - recordType := "A" - - found := false - for _, r := range records { - if r.Host == host && r.Type == recordType { - found = true - break - } - } - - assert.False(t, found, "should not find record for non-existent host") -} - -// --- Edge cases --- - -func TestCloudNSClient_DoRaw_Good_EmptyBody(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer ts.Close() - - client := NewCloudNSClient("test", "test") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithPrefix("cloudns API"), - WithRetry(RetryConfig{}), - ) - - ctx := context.Background() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/test", nil) - require.NoError(t, err) - - data, err := client.doRaw(req) - require.NoError(t, err) - assert.Empty(t, data) -} - -func TestCloudNSRecord_JSON_Good_EmptyMap(t *testing.T) { - data := `{}` - - var records map[string]CloudNSRecord - err := json.Unmarshal([]byte(data), &records) - - require.NoError(t, err) - assert.Empty(t, records) -} - -func TestCloudNSClient_DoRaw_Good_AuthQueryParams(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "49500", r.URL.Query().Get("auth-id")) - assert.Equal(t, "supersecret", r.URL.Query().Get("auth-password")) - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[]`)) - })) - defer ts.Close() - - client := NewCloudNSClient("49500", "supersecret") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithPrefix("cloudns API"), - WithRetry(RetryConfig{}), - ) - - ctx := context.Background() - params := client.authParams() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/dns/test.json?"+params.Encode(), nil) - require.NoError(t, err) - - _, err = client.doRaw(req) - require.NoError(t, err) -} diff --git a/infra/config.go b/infra/config.go deleted file mode 100644 index ec78108..0000000 --- a/infra/config.go +++ /dev/null @@ -1,300 +0,0 @@ -// Package infra provides infrastructure configuration and API clients -// for managing the Host UK production environment. -package infra - -import ( - "fmt" - "os" - "path/filepath" - - "gopkg.in/yaml.v3" -) - -// Config is the top-level infrastructure configuration parsed from infra.yaml. -type Config struct { - Hosts map[string]*Host `yaml:"hosts"` - LoadBalancer LoadBalancer `yaml:"load_balancer"` - Network Network `yaml:"network"` - DNS DNS `yaml:"dns"` - SSL SSL `yaml:"ssl"` - Database Database `yaml:"database"` - Cache Cache `yaml:"cache"` - Containers map[string]*Container `yaml:"containers"` - S3 S3Config `yaml:"s3"` - CDN CDN `yaml:"cdn"` - CICD CICD `yaml:"cicd"` - Monitoring Monitoring `yaml:"monitoring"` - Backups Backups `yaml:"backups"` -} - -// Host represents a server in the infrastructure. -type Host struct { - FQDN string `yaml:"fqdn"` - IP string `yaml:"ip"` - PrivateIP string `yaml:"private_ip,omitempty"` - Type string `yaml:"type"` // hcloud, hrobot - Role string `yaml:"role"` // bastion, app, builder - SSH SSHConf `yaml:"ssh"` - Services []string `yaml:"services"` -} - -// SSHConf holds SSH connection details for a host. -type SSHConf struct { - User string `yaml:"user"` - Key string `yaml:"key"` - Port int `yaml:"port"` -} - -// LoadBalancer represents a Hetzner managed load balancer. -type LoadBalancer struct { - Name string `yaml:"name"` - FQDN string `yaml:"fqdn"` - Provider string `yaml:"provider"` - Type string `yaml:"type"` - Location string `yaml:"location"` - Algorithm string `yaml:"algorithm"` - Backends []Backend `yaml:"backends"` - Health HealthCheck `yaml:"health_check"` - Listeners []Listener `yaml:"listeners"` - SSL LBCert `yaml:"ssl"` -} - -// Backend is a load balancer backend target. -type Backend struct { - Host string `yaml:"host"` - Port int `yaml:"port"` -} - -// HealthCheck configures load balancer health checking. -type HealthCheck struct { - Protocol string `yaml:"protocol"` - Path string `yaml:"path"` - Interval int `yaml:"interval"` -} - -// Listener maps a frontend port to a backend port. -type Listener struct { - Frontend int `yaml:"frontend"` - Backend int `yaml:"backend"` - Protocol string `yaml:"protocol"` - ProxyProtocol bool `yaml:"proxy_protocol"` -} - -// LBCert holds the SSL certificate configuration for the load balancer. -type LBCert struct { - Certificate string `yaml:"certificate"` - SAN []string `yaml:"san"` -} - -// Network describes the private network. -type Network struct { - CIDR string `yaml:"cidr"` - Name string `yaml:"name"` -} - -// DNS holds DNS provider configuration and zone records. -type DNS struct { - Provider string `yaml:"provider"` - Nameservers []string `yaml:"nameservers"` - Zones map[string]*Zone `yaml:"zones"` -} - -// Zone is a DNS zone with its records. -type Zone struct { - Records []DNSRecord `yaml:"records"` -} - -// DNSRecord is a single DNS record. -type DNSRecord struct { - Name string `yaml:"name"` - Type string `yaml:"type"` - Value string `yaml:"value"` - TTL int `yaml:"ttl"` -} - -// SSL holds SSL certificate configuration. -type SSL struct { - Wildcard WildcardCert `yaml:"wildcard"` -} - -// WildcardCert describes a wildcard SSL certificate. -type WildcardCert struct { - Domains []string `yaml:"domains"` - Method string `yaml:"method"` - DNSProvider string `yaml:"dns_provider"` - Termination string `yaml:"termination"` -} - -// Database describes the database cluster. -type Database struct { - Engine string `yaml:"engine"` - Version string `yaml:"version"` - Cluster string `yaml:"cluster"` - Nodes []DBNode `yaml:"nodes"` - SSTMethod string `yaml:"sst_method"` - Backup BackupConfig `yaml:"backup"` -} - -// DBNode is a database cluster node. -type DBNode struct { - Host string `yaml:"host"` - Port int `yaml:"port"` -} - -// BackupConfig describes automated backup settings. -type BackupConfig struct { - Schedule string `yaml:"schedule"` - Destination string `yaml:"destination"` - Bucket string `yaml:"bucket"` - Prefix string `yaml:"prefix"` -} - -// Cache describes the cache/session cluster. -type Cache struct { - Engine string `yaml:"engine"` - Version string `yaml:"version"` - Sentinel bool `yaml:"sentinel"` - Nodes []CacheNode `yaml:"nodes"` -} - -// CacheNode is a cache cluster node. -type CacheNode struct { - Host string `yaml:"host"` - Port int `yaml:"port"` -} - -// Container describes a container deployment. -type Container struct { - Image string `yaml:"image"` - Port int `yaml:"port,omitempty"` - Runtime string `yaml:"runtime,omitempty"` - Command string `yaml:"command,omitempty"` - Replicas int `yaml:"replicas,omitempty"` - DependsOn []string `yaml:"depends_on,omitempty"` -} - -// S3Config describes object storage. -type S3Config struct { - Endpoint string `yaml:"endpoint"` - Buckets map[string]*S3Bucket `yaml:"buckets"` -} - -// S3Bucket is an S3 bucket configuration. -type S3Bucket struct { - Purpose string `yaml:"purpose"` - Paths []string `yaml:"paths"` -} - -// CDN describes CDN configuration. -type CDN struct { - Provider string `yaml:"provider"` - Origin string `yaml:"origin"` - Zones []string `yaml:"zones"` -} - -// CICD describes CI/CD configuration. -type CICD struct { - Provider string `yaml:"provider"` - URL string `yaml:"url"` - Runner string `yaml:"runner"` - Registry string `yaml:"registry"` - DeployHook string `yaml:"deploy_hook"` -} - -// Monitoring describes monitoring configuration. -type Monitoring struct { - HealthEndpoints []HealthEndpoint `yaml:"health_endpoints"` - Alerts map[string]int `yaml:"alerts"` -} - -// HealthEndpoint is a URL to monitor. -type HealthEndpoint struct { - URL string `yaml:"url"` - Interval int `yaml:"interval"` -} - -// Backups describes backup schedules. -type Backups struct { - Daily []BackupJob `yaml:"daily"` - Weekly []BackupJob `yaml:"weekly"` -} - -// BackupJob is a scheduled backup task. -type BackupJob struct { - Name string `yaml:"name"` - Type string `yaml:"type"` - Destination string `yaml:"destination,omitempty"` - Hosts []string `yaml:"hosts,omitempty"` -} - -// Load reads and parses an infra.yaml file. -func Load(path string) (*Config, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read infra config: %w", err) - } - - var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("parse infra config: %w", err) - } - - // Expand SSH key paths - for _, h := range cfg.Hosts { - if h.SSH.Key != "" { - h.SSH.Key = expandPath(h.SSH.Key) - } - if h.SSH.Port == 0 { - h.SSH.Port = 22 - } - } - - return &cfg, nil -} - -// Discover searches for infra.yaml in the given directory and parent directories. -func Discover(startDir string) (*Config, string, error) { - dir := startDir - for { - path := filepath.Join(dir, "infra.yaml") - if _, err := os.Stat(path); err == nil { - cfg, err := Load(path) - return cfg, path, err - } - - parent := filepath.Dir(dir) - if parent == dir { - break - } - dir = parent - } - return nil, "", fmt.Errorf("infra.yaml not found (searched from %s)", startDir) -} - -// HostsByRole returns all hosts matching the given role. -func (c *Config) HostsByRole(role string) map[string]*Host { - result := make(map[string]*Host) - for name, h := range c.Hosts { - if h.Role == role { - result[name] = h - } - } - return result -} - -// AppServers returns hosts with role "app". -func (c *Config) AppServers() map[string]*Host { - return c.HostsByRole("app") -} - -// expandPath expands ~ to home directory. -func expandPath(path string) string { - if len(path) > 0 && path[0] == '~' { - home, err := os.UserHomeDir() - if err != nil { - return path - } - return filepath.Join(home, path[1:]) - } - return path -} diff --git a/infra/config_test.go b/infra/config_test.go deleted file mode 100644 index 1ec8b59..0000000 --- a/infra/config_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package infra - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoad_Good(t *testing.T) { - // Find infra.yaml relative to test - // Walk up from test dir to find it - dir, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - - cfg, path, err := Discover(dir) - if err != nil { - t.Skipf("infra.yaml not found from %s: %v", dir, err) - } - - t.Logf("Loaded %s", path) - - if len(cfg.Hosts) == 0 { - t.Error("expected at least one host") - } - - // Check required hosts exist - for _, name := range []string{"noc", "de", "de2", "build"} { - if _, ok := cfg.Hosts[name]; !ok { - t.Errorf("expected host %q in config", name) - } - } - - // Check de host details - de := cfg.Hosts["de"] - if de.IP != "116.202.82.115" { - t.Errorf("de IP = %q, want 116.202.82.115", de.IP) - } - if de.Role != "app" { - t.Errorf("de role = %q, want app", de.Role) - } - - // Check LB config - if cfg.LoadBalancer.Name != "hermes" { - t.Errorf("LB name = %q, want hermes", cfg.LoadBalancer.Name) - } - if cfg.LoadBalancer.Type != "lb11" { - t.Errorf("LB type = %q, want lb11", cfg.LoadBalancer.Type) - } - if len(cfg.LoadBalancer.Backends) != 2 { - t.Errorf("LB backends = %d, want 2", len(cfg.LoadBalancer.Backends)) - } - - // Check app servers helper - apps := cfg.AppServers() - if len(apps) != 2 { - t.Errorf("AppServers() = %d, want 2", len(apps)) - } -} - -func TestLoad_Bad(t *testing.T) { - _, err := Load("/nonexistent/infra.yaml") - if err == nil { - t.Error("expected error for nonexistent file") - } -} - -func TestLoad_Ugly(t *testing.T) { - // Invalid YAML - tmp := filepath.Join(t.TempDir(), "infra.yaml") - if err := os.WriteFile(tmp, []byte("{{invalid yaml"), 0644); err != nil { - t.Fatal(err) - } - - _, err := Load(tmp) - if err == nil { - t.Error("expected error for invalid YAML") - } -} - -func TestExpandPath(t *testing.T) { - home, _ := os.UserHomeDir() - - tests := []struct { - input string - want string - }{ - {"~/.ssh/id_rsa", filepath.Join(home, ".ssh/id_rsa")}, - {"/absolute/path", "/absolute/path"}, - {"relative/path", "relative/path"}, - } - - for _, tt := range tests { - got := expandPath(tt.input) - if got != tt.want { - t.Errorf("expandPath(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} diff --git a/infra/hetzner.go b/infra/hetzner.go deleted file mode 100644 index de0336c..0000000 --- a/infra/hetzner.go +++ /dev/null @@ -1,338 +0,0 @@ -package infra - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strings" -) - -const ( - hcloudBaseURL = "https://api.hetzner.cloud/v1" - hrobotBaseURL = "https://robot-ws.your-server.de" -) - -// HCloudClient is an HTTP client for the Hetzner Cloud API. -type HCloudClient struct { - token string - baseURL string - api *APIClient -} - -// NewHCloudClient creates a new Hetzner Cloud API client. -func NewHCloudClient(token string) *HCloudClient { - c := &HCloudClient{ - token: token, - baseURL: hcloudBaseURL, - } - c.api = NewAPIClient( - WithAuth(func(req *http.Request) { - req.Header.Set("Authorization", "Bearer "+c.token) - }), - WithPrefix("hcloud API"), - ) - return c -} - -// HCloudServer represents a Hetzner Cloud server. -type HCloudServer struct { - ID int `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - PublicNet HCloudPublicNet `json:"public_net"` - PrivateNet []HCloudPrivateNet `json:"private_net"` - ServerType HCloudServerType `json:"server_type"` - Datacenter HCloudDatacenter `json:"datacenter"` - Labels map[string]string `json:"labels"` -} - -// HCloudPublicNet holds public network info. -type HCloudPublicNet struct { - IPv4 HCloudIPv4 `json:"ipv4"` -} - -// HCloudIPv4 holds an IPv4 address. -type HCloudIPv4 struct { - IP string `json:"ip"` -} - -// HCloudPrivateNet holds private network info. -type HCloudPrivateNet struct { - IP string `json:"ip"` - Network int `json:"network"` -} - -// HCloudServerType holds server type info. -type HCloudServerType struct { - Name string `json:"name"` - Description string `json:"description"` - Cores int `json:"cores"` - Memory float64 `json:"memory"` - Disk int `json:"disk"` -} - -// HCloudDatacenter holds datacenter info. -type HCloudDatacenter struct { - Name string `json:"name"` - Description string `json:"description"` -} - -// HCloudLoadBalancer represents a Hetzner Cloud load balancer. -type HCloudLoadBalancer struct { - ID int `json:"id"` - Name string `json:"name"` - PublicNet HCloudLBPublicNet `json:"public_net"` - Algorithm HCloudLBAlgorithm `json:"algorithm"` - Services []HCloudLBService `json:"services"` - Targets []HCloudLBTarget `json:"targets"` - Location HCloudDatacenter `json:"location"` - Labels map[string]string `json:"labels"` -} - -// HCloudLBPublicNet holds LB public network info. -type HCloudLBPublicNet struct { - Enabled bool `json:"enabled"` - IPv4 HCloudIPv4 `json:"ipv4"` -} - -// HCloudLBAlgorithm holds the LB algorithm. -type HCloudLBAlgorithm struct { - Type string `json:"type"` -} - -// HCloudLBService describes an LB listener. -type HCloudLBService struct { - Protocol string `json:"protocol"` - ListenPort int `json:"listen_port"` - DestinationPort int `json:"destination_port"` - Proxyprotocol bool `json:"proxyprotocol"` - HTTP *HCloudLBHTTP `json:"http,omitempty"` - HealthCheck *HCloudLBHealthCheck `json:"health_check,omitempty"` -} - -// HCloudLBHTTP holds HTTP-specific LB options. -type HCloudLBHTTP struct { - RedirectHTTP bool `json:"redirect_http"` -} - -// HCloudLBHealthCheck holds LB health check config. -type HCloudLBHealthCheck struct { - Protocol string `json:"protocol"` - Port int `json:"port"` - Interval int `json:"interval"` - Timeout int `json:"timeout"` - Retries int `json:"retries"` - HTTP *HCloudLBHCHTTP `json:"http,omitempty"` -} - -// HCloudLBHCHTTP holds HTTP health check options. -type HCloudLBHCHTTP struct { - Path string `json:"path"` - StatusCode string `json:"status_codes"` -} - -// HCloudLBTarget is a load balancer backend target. -type HCloudLBTarget struct { - Type string `json:"type"` - IP *HCloudLBTargetIP `json:"ip,omitempty"` - Server *HCloudLBTargetServer `json:"server,omitempty"` - HealthStatus []HCloudLBHealthStatus `json:"health_status"` -} - -// HCloudLBTargetIP is an IP-based LB target. -type HCloudLBTargetIP struct { - IP string `json:"ip"` -} - -// HCloudLBTargetServer is a server-based LB target. -type HCloudLBTargetServer struct { - ID int `json:"id"` -} - -// HCloudLBHealthStatus holds target health info. -type HCloudLBHealthStatus struct { - ListenPort int `json:"listen_port"` - Status string `json:"status"` -} - -// HCloudLBCreateRequest holds load balancer creation params. -type HCloudLBCreateRequest struct { - Name string `json:"name"` - LoadBalancerType string `json:"load_balancer_type"` - Location string `json:"location"` - Algorithm HCloudLBAlgorithm `json:"algorithm"` - Services []HCloudLBService `json:"services"` - Targets []HCloudLBCreateTarget `json:"targets"` - Labels map[string]string `json:"labels"` -} - -// HCloudLBCreateTarget is a target for LB creation. -type HCloudLBCreateTarget struct { - Type string `json:"type"` - IP *HCloudLBTargetIP `json:"ip,omitempty"` -} - -// ListServers returns all Hetzner Cloud servers. -func (c *HCloudClient) ListServers(ctx context.Context) ([]HCloudServer, error) { - var result struct { - Servers []HCloudServer `json:"servers"` - } - if err := c.get(ctx, "/servers", &result); err != nil { - return nil, err - } - return result.Servers, nil -} - -// ListLoadBalancers returns all load balancers. -func (c *HCloudClient) ListLoadBalancers(ctx context.Context) ([]HCloudLoadBalancer, error) { - var result struct { - LoadBalancers []HCloudLoadBalancer `json:"load_balancers"` - } - if err := c.get(ctx, "/load_balancers", &result); err != nil { - return nil, err - } - return result.LoadBalancers, nil -} - -// GetLoadBalancer returns a load balancer by ID. -func (c *HCloudClient) GetLoadBalancer(ctx context.Context, id int) (*HCloudLoadBalancer, error) { - var result struct { - LoadBalancer HCloudLoadBalancer `json:"load_balancer"` - } - if err := c.get(ctx, fmt.Sprintf("/load_balancers/%d", id), &result); err != nil { - return nil, err - } - return &result.LoadBalancer, nil -} - -// CreateLoadBalancer creates a new load balancer. -func (c *HCloudClient) CreateLoadBalancer(ctx context.Context, req HCloudLBCreateRequest) (*HCloudLoadBalancer, error) { - body, err := json.Marshal(req) - if err != nil { - return nil, fmt.Errorf("marshal request: %w", err) - } - - var result struct { - LoadBalancer HCloudLoadBalancer `json:"load_balancer"` - } - if err := c.post(ctx, "/load_balancers", body, &result); err != nil { - return nil, err - } - return &result.LoadBalancer, nil -} - -// DeleteLoadBalancer deletes a load balancer by ID. -func (c *HCloudClient) DeleteLoadBalancer(ctx context.Context, id int) error { - return c.delete(ctx, fmt.Sprintf("/load_balancers/%d", id)) -} - -// CreateSnapshot creates a server snapshot. -func (c *HCloudClient) CreateSnapshot(ctx context.Context, serverID int, description string) error { - body, _ := json.Marshal(map[string]string{ - "description": description, - "type": "snapshot", - }) - return c.post(ctx, fmt.Sprintf("/servers/%d/actions/create_image", serverID), body, nil) -} - -func (c *HCloudClient) get(ctx context.Context, path string, result any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) - if err != nil { - return err - } - return c.do(req, result) -} - -func (c *HCloudClient) post(ctx context.Context, path string, body []byte, result any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, strings.NewReader(string(body))) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - return c.do(req, result) -} - -func (c *HCloudClient) delete(ctx context.Context, path string) error { - req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL+path, nil) - if err != nil { - return err - } - return c.do(req, nil) -} - -func (c *HCloudClient) do(req *http.Request, result any) error { - return c.api.Do(req, result) -} - -// --- Hetzner Robot API --- - -// HRobotClient is an HTTP client for the Hetzner Robot API. -type HRobotClient struct { - user string - password string - baseURL string - api *APIClient -} - -// NewHRobotClient creates a new Hetzner Robot API client. -func NewHRobotClient(user, password string) *HRobotClient { - c := &HRobotClient{ - user: user, - password: password, - baseURL: hrobotBaseURL, - } - c.api = NewAPIClient( - WithAuth(func(req *http.Request) { - req.SetBasicAuth(c.user, c.password) - }), - WithPrefix("hrobot API"), - ) - return c -} - -// HRobotServer represents a Hetzner Robot dedicated server. -type HRobotServer struct { - ServerIP string `json:"server_ip"` - ServerName string `json:"server_name"` - Product string `json:"product"` - Datacenter string `json:"dc"` - Status string `json:"status"` - Cancelled bool `json:"cancelled"` - PaidUntil string `json:"paid_until"` -} - -// ListServers returns all Robot dedicated servers. -func (c *HRobotClient) ListServers(ctx context.Context) ([]HRobotServer, error) { - var raw []struct { - Server HRobotServer `json:"server"` - } - if err := c.get(ctx, "/server", &raw); err != nil { - return nil, err - } - - servers := make([]HRobotServer, len(raw)) - for i, s := range raw { - servers[i] = s.Server - } - return servers, nil -} - -// GetServer returns a Robot server by IP. -func (c *HRobotClient) GetServer(ctx context.Context, ip string) (*HRobotServer, error) { - var raw struct { - Server HRobotServer `json:"server"` - } - if err := c.get(ctx, "/server/"+ip, &raw); err != nil { - return nil, err - } - return &raw.Server, nil -} - -func (c *HRobotClient) get(ctx context.Context, path string, result any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) - if err != nil { - return err - } - return c.api.Do(req, result) -} diff --git a/infra/hetzner_test.go b/infra/hetzner_test.go deleted file mode 100644 index 204c76e..0000000 --- a/infra/hetzner_test.go +++ /dev/null @@ -1,310 +0,0 @@ -package infra - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewHCloudClient_Good(t *testing.T) { - c := NewHCloudClient("my-token") - assert.NotNil(t, c) - assert.Equal(t, "my-token", c.token) - assert.NotNil(t, c.api) -} - -func TestHCloudClient_ListServers_Good(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) - assert.Equal(t, http.MethodGet, r.Method) - - resp := map[string]any{ - "servers": []map[string]any{ - { - "id": 1, "name": "de1", "status": "running", - "public_net": map[string]any{"ipv4": map[string]any{"ip": "1.2.3.4"}}, - "server_type": map[string]any{"name": "cx22", "cores": 2, "memory": 4.0, "disk": 40}, - "datacenter": map[string]any{"name": "fsn1-dc14"}, - }, - { - "id": 2, "name": "de2", "status": "running", - "public_net": map[string]any{"ipv4": map[string]any{"ip": "5.6.7.8"}}, - "server_type": map[string]any{"name": "cx32", "cores": 4, "memory": 8.0, "disk": 80}, - "datacenter": map[string]any{"name": "nbg1-dc3"}, - }, - }, - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) - }) - - ts := httptest.NewServer(mux) - defer ts.Close() - - client := NewHCloudClient("test-token") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithAuth(func(req *http.Request) { - req.Header.Set("Authorization", "Bearer "+client.token) - }), - WithPrefix("hcloud API"), - WithRetry(RetryConfig{}), // no retries in tests - ) - - servers, err := client.ListServers(context.Background()) - require.NoError(t, err) - require.Len(t, servers, 2) - assert.Equal(t, "de1", servers[0].Name) - assert.Equal(t, "de2", servers[1].Name) -} - -func TestHCloudClient_Do_Good_ParsesJSON(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"servers":[{"id":1,"name":"test","status":"running"}]}`)) - })) - defer ts.Close() - - client := NewHCloudClient("test-token") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithAuth(func(req *http.Request) { - req.Header.Set("Authorization", "Bearer test-token") - }), - WithPrefix("hcloud API"), - WithRetry(RetryConfig{}), - ) - - var result struct { - Servers []HCloudServer `json:"servers"` - } - err := client.get(context.Background(), "/servers", &result) - require.NoError(t, err) - require.Len(t, result.Servers, 1) - assert.Equal(t, 1, result.Servers[0].ID) - assert.Equal(t, "test", result.Servers[0].Name) - assert.Equal(t, "running", result.Servers[0].Status) -} - -func TestHCloudClient_Do_Bad_APIError(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"error":{"code":"forbidden","message":"insufficient permissions"}}`)) - })) - defer ts.Close() - - client := NewHCloudClient("bad-token") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithAuth(func(req *http.Request) { - req.Header.Set("Authorization", "Bearer bad-token") - }), - WithPrefix("hcloud API"), - WithRetry(RetryConfig{}), - ) - - var result struct{} - err := client.get(context.Background(), "/servers", &result) - assert.Error(t, err) - assert.Contains(t, err.Error(), "hcloud API 403") -} - -func TestHCloudClient_Do_Bad_APIErrorNoJSON(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`Internal Server Error`)) - })) - defer ts.Close() - - client := NewHCloudClient("test-token") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithPrefix("hcloud API"), - WithRetry(RetryConfig{}), - ) - - err := client.get(context.Background(), "/servers", nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), "hcloud API 500") -} - -func TestHCloudClient_Do_Good_NilResult(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNoContent) - })) - defer ts.Close() - - client := NewHCloudClient("test-token") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithPrefix("hcloud API"), - WithRetry(RetryConfig{}), - ) - - err := client.delete(context.Background(), "/servers/1") - assert.NoError(t, err) -} - -// --- Hetzner Robot API --- - -func TestNewHRobotClient_Good(t *testing.T) { - c := NewHRobotClient("user", "pass") - assert.NotNil(t, c) - assert.Equal(t, "user", c.user) - assert.Equal(t, "pass", c.password) - assert.NotNil(t, c.api) -} - -func TestHRobotClient_ListServers_Good(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, pass, ok := r.BasicAuth() - assert.True(t, ok) - assert.Equal(t, "testuser", user) - assert.Equal(t, "testpass", pass) - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[{"server":{"server_ip":"1.2.3.4","server_name":"test","product":"EX44","dc":"FSN1","status":"ready","cancelled":false}}]`)) - })) - defer ts.Close() - - client := NewHRobotClient("testuser", "testpass") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithAuth(func(req *http.Request) { - req.SetBasicAuth("testuser", "testpass") - }), - WithPrefix("hrobot API"), - WithRetry(RetryConfig{}), - ) - - servers, err := client.ListServers(context.Background()) - require.NoError(t, err) - require.Len(t, servers, 1) - assert.Equal(t, "1.2.3.4", servers[0].ServerIP) - assert.Equal(t, "test", servers[0].ServerName) - assert.Equal(t, "EX44", servers[0].Product) -} - -func TestHRobotClient_Get_Bad_HTTPError(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"error":{"status":401,"code":"UNAUTHORIZED","message":"Invalid credentials"}}`)) - })) - defer ts.Close() - - client := NewHRobotClient("bad", "creds") - client.baseURL = ts.URL - client.api = NewAPIClient( - WithHTTPClient(ts.Client()), - WithAuth(func(req *http.Request) { - req.SetBasicAuth("bad", "creds") - }), - WithPrefix("hrobot API"), - WithRetry(RetryConfig{}), - ) - - err := client.get(context.Background(), "/server", nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), "hrobot API 401") -} - -// --- Type serialisation --- - -func TestHCloudServer_JSON_Good(t *testing.T) { - data := `{ - "id": 123, - "name": "web-1", - "status": "running", - "public_net": {"ipv4": {"ip": "10.0.0.1"}}, - "private_net": [{"ip": "10.0.1.1", "network": 456}], - "server_type": {"name": "cx22", "cores": 2, "memory": 4.0, "disk": 40}, - "datacenter": {"name": "fsn1-dc14"}, - "labels": {"env": "prod"} - }` - - var server HCloudServer - err := json.Unmarshal([]byte(data), &server) - - require.NoError(t, err) - assert.Equal(t, 123, server.ID) - assert.Equal(t, "web-1", server.Name) - assert.Equal(t, "running", server.Status) - assert.Equal(t, "10.0.0.1", server.PublicNet.IPv4.IP) - assert.Len(t, server.PrivateNet, 1) - assert.Equal(t, "10.0.1.1", server.PrivateNet[0].IP) - assert.Equal(t, "cx22", server.ServerType.Name) - assert.Equal(t, 2, server.ServerType.Cores) - assert.Equal(t, 4.0, server.ServerType.Memory) - assert.Equal(t, "fsn1-dc14", server.Datacenter.Name) - assert.Equal(t, "prod", server.Labels["env"]) -} - -func TestHCloudLoadBalancer_JSON_Good(t *testing.T) { - data := `{ - "id": 789, - "name": "hermes", - "public_net": {"enabled": true, "ipv4": {"ip": "5.6.7.8"}}, - "algorithm": {"type": "round_robin"}, - "services": [ - {"protocol": "https", "listen_port": 443, "destination_port": 8080, "proxyprotocol": true} - ], - "targets": [ - {"type": "ip", "ip": {"ip": "10.0.0.1"}, "health_status": [{"listen_port": 443, "status": "healthy"}]} - ], - "labels": {"role": "lb"} - }` - - var lb HCloudLoadBalancer - err := json.Unmarshal([]byte(data), &lb) - - require.NoError(t, err) - assert.Equal(t, 789, lb.ID) - assert.Equal(t, "hermes", lb.Name) - assert.True(t, lb.PublicNet.Enabled) - assert.Equal(t, "5.6.7.8", lb.PublicNet.IPv4.IP) - assert.Equal(t, "round_robin", lb.Algorithm.Type) - require.Len(t, lb.Services, 1) - assert.Equal(t, 443, lb.Services[0].ListenPort) - assert.True(t, lb.Services[0].Proxyprotocol) - require.Len(t, lb.Targets, 1) - assert.Equal(t, "ip", lb.Targets[0].Type) - assert.Equal(t, "10.0.0.1", lb.Targets[0].IP.IP) - assert.Equal(t, "healthy", lb.Targets[0].HealthStatus[0].Status) -} - -func TestHRobotServer_JSON_Good(t *testing.T) { - data := `{ - "server_ip": "1.2.3.4", - "server_name": "noc", - "product": "EX44", - "dc": "FSN1-DC14", - "status": "ready", - "cancelled": false, - "paid_until": "2026-03-01" - }` - - var server HRobotServer - err := json.Unmarshal([]byte(data), &server) - - require.NoError(t, err) - assert.Equal(t, "1.2.3.4", server.ServerIP) - assert.Equal(t, "noc", server.ServerName) - assert.Equal(t, "EX44", server.Product) - assert.Equal(t, "FSN1-DC14", server.Datacenter) - assert.Equal(t, "ready", server.Status) - assert.False(t, server.Cancelled) - assert.Equal(t, "2026-03-01", server.PaidUntil) -}