refactor: remove extracted packages
- container/ → core/go-container v0.1.0 - devops/ → core/go-container/devenv v0.1.0 - infra/ → core/go-infra v0.1.0 - devkit/ → core/lint v0.2.0 All cmd/ consumers already import from standalone repos. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
14cfa408e7
commit
3a995db1db
48 changed files with 0 additions and 12779 deletions
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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: |
|
||||
<?php
|
||||
echo "Welcome to ${APP_NAME:-app}";
|
||||
mode: "0644"
|
||||
- path: /app/public/health.php
|
||||
contents: |
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'status' => '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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
560
devkit/devkit.go
560
devkit/devkit.go
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
143
devops/claude.go
143
devops/claude.go
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
244
devops/devops.go
244
devops/devops.go
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
199
devops/images.go
199
devops/images.go
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
110
devops/serve.go
110
devops/serve.go
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
189
devops/test.go
189
devops/test.go
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
274
infra/client.go
274
infra/client.go
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
255
infra/cloudns.go
255
infra/cloudns.go
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
300
infra/config.go
300
infra/config.go
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
338
infra/hetzner.go
338
infra/hetzner.go
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue