From 2492c728f75ae8f7692a84872f0145fa9ece118a Mon Sep 17 00:00:00 2001 From: Adewoye Adegoke <69061153+Degoke@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:46:46 +0100 Subject: [PATCH] feat(cluster): add k3d as alternative cluster driver Add --driver k3d|kind, remote k3d bootstrap, and driver-aware lifecycle for list/delete/prune/kubectl. Tune dev capacity checks for k3d, isolate test config from ~/.outpost, and update docs. --- README.md | 20 +++- internal/bootstrap/bootstrap.go | 9 +- internal/capabilities/capabilities.go | 1 + internal/cli/app.go | 20 ++-- internal/cli/commands_test.go | 5 +- internal/cli/harness_test.go | 6 +- internal/cluster/config.go | 45 ++++++- internal/cluster/config_test.go | 37 +++++- internal/cluster/create_test.go | 87 +++++++++++++- internal/cluster/delete_test.go | 72 ++++++++++++ internal/cluster/driver.go | 163 ++++++++++++++++++++++++++ internal/cluster/driver_test.go | 25 ++++ internal/cluster/kubectl_test.go | 7 ++ internal/cluster/service.go | 106 ++++++----------- internal/config/config.go | 8 +- internal/config/config_test.go | 17 ++- internal/config/testenv.go | 48 ++++++++ internal/inspect/collect.go | 17 +++ internal/integration/cluster_test.go | 6 + internal/prune/clusters_test.go | 54 +++++++++ internal/prune/plan.go | 33 +++++- internal/status/status_test.go | 6 + internal/testenv/config.go | 21 ++++ internal/top/top.go | 2 + ui/public/llms.txt | 4 +- ui/src/main.jsx | 24 ++-- 26 files changed, 726 insertions(+), 117 deletions(-) create mode 100644 internal/cluster/delete_test.go create mode 100644 internal/cluster/driver.go create mode 100644 internal/cluster/driver_test.go create mode 100644 internal/config/testenv.go create mode 100644 internal/prune/clusters_test.go create mode 100644 internal/testenv/config.go diff --git a/README.md b/README.md index 6158d82..ff5049e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Use an existing Linux server or let Outpost provision one on AWS. Share the host ## What you get - **Remote Docker and Compose** — develop against containers on a shared host, not your laptop. -- **Kubernetes with kind** — create named clusters and run `kubectl` remotely. +- **Kubernetes with kind or k3d** — create named clusters and run `kubectl` remotely. - **Linux machines with Incus** — system containers by default; full VMs when the host supports KVM. - **Local port forwarding** — reach remote services at `http://127.0.0.1:8080` from your machine. - **Remote mirror** — sync your repo and run commands on the host; detached tmux sessions survive disconnects. @@ -27,7 +27,7 @@ You install the Outpost CLI locally. It connects to your host over SSH, installs Your machine Remote Linux host ───────────── ───────────────── outpost CLI SSH → Docker + Compose -~/.outpost/ (global) kind + kubectl +~/.outpost/ (global) kind, k3d + kubectl .outpost/ (per repo) Incus ``` @@ -305,11 +305,15 @@ outpost host destroy personal # terminate the EC2 instance ## Kubernetes -Create and use kind clusters on the host. No local `kubectl` required. +Create and use Kubernetes clusters on the host with **kind** (default) or **k3d**. No local `kubectl` required. + +On hosts bootstrapped before k3d support was added, Outpost installs `k3d` automatically the first time you run a cluster command (`cluster create --driver k3d`, `cluster list`, `kubectl`, etc.) — existing kind/kubectl installs are left in place. ```bash outpost cluster create dev outpost cluster create staging --workers 2 +outpost cluster create edge --driver k3d +outpost cluster create prod --driver k3d --workers 2 outpost cluster list outpost cluster status dev outpost kubectl --cluster dev get nodes @@ -317,6 +321,8 @@ outpost kubectl --cluster dev apply -f ./manifest.yaml outpost cluster delete dev ``` +Use `--driver kind` (default) or `--driver k3d` on `cluster create`. List, status, delete, and kubectl work the same for both drivers. + Local manifest files are uploaded automatically when you apply them. ## Linux machines @@ -445,6 +451,14 @@ These flags work on every command: | Start over locally | Run `outpost reset` to clear `~/.outpost` (hosts, keys, sessions). Remote servers and repo project files are kept. | +## Development + +```bash +make test # or: go test ./... +``` + +`go test` automatically redirects `~/.outpost` to a temporary directory so your real hosts, keys, and kubeconfigs are not touched. To opt out (e.g. integration testing against a real config), set `OUTPOST_ALLOW_REAL_CONFIG=1`. You can also point tests at a specific directory with `OUTPOST_CONFIG_DIR=/path/to/config`. + ## License Outpost is open source software licensed under the [MIT License](LICENSE). diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index fd406a9..16bbc54 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -161,7 +161,7 @@ fi const kubernetesToolsScript = ` set -e -if command -v kubectl >/dev/null 2>&1 && command -v kind >/dev/null 2>&1; then +if command -v kubectl >/dev/null 2>&1 && command -v kind >/dev/null 2>&1 && command -v k3d >/dev/null 2>&1; then exit 0 fi need_sudo="" @@ -176,10 +176,15 @@ if ! command -v kind >/dev/null 2>&1; then chmod +x /tmp/kind $need_sudo mv /tmp/kind /usr/local/bin/kind fi +if ! command -v k3d >/dev/null 2>&1; then + curl -fsSL https://github.com/k3d-io/k3d/releases/download/v5.8.3/k3d-linux-amd64 -o /tmp/k3d + chmod +x /tmp/k3d + $need_sudo mv /tmp/k3d /usr/local/bin/k3d +fi ` func EnsureKubernetesTools(ctx context.Context, exec transport.Executor) error { - code, err := exec.Run(ctx, "command -v kind >/dev/null 2>&1 && command -v kubectl >/dev/null 2>&1", transport.RunOpts{}) + code, err := exec.Run(ctx, "command -v kind >/dev/null 2>&1 && command -v kubectl >/dev/null 2>&1 && command -v k3d >/dev/null 2>&1", transport.RunOpts{}) if err != nil { return err } diff --git a/internal/capabilities/capabilities.go b/internal/capabilities/capabilities.go index b327075..676e286 100644 --- a/internal/capabilities/capabilities.go +++ b/internal/capabilities/capabilities.go @@ -32,6 +32,7 @@ func DetectWithProvider(ctx context.Context, exec transport.Executor, providerMe {"docker", "docker info >/dev/null 2>&1"}, {"compose", "docker compose version >/dev/null 2>&1"}, {"kind", "command -v kind >/dev/null 2>&1"}, + {"k3d", "command -v k3d >/dev/null 2>&1"}, {"kubectl", "command -v kubectl >/dev/null 2>&1"}, {"incus", "command -v incus >/dev/null 2>&1"}, {"cgroup_v2", "stat -fc %T /sys/fs/cgroup/ 2>/dev/null | grep -q cgroup2fs"}, diff --git a/internal/cli/app.go b/internal/cli/app.go index 10dd8e2..1462cb7 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -1803,18 +1803,24 @@ func (app *App) clusterCmd() *cobra.Command { func (app *App) clusterCreateCmd() *cobra.Command { var workers, controlPlanes int + var driver string cmd := &cobra.Command{ Use: "create NAME", - Short: "Create a named kind cluster", + Short: "Create a named Kubernetes cluster", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + drv, err := cluster.ParseDriver(driver) + if err != nil { + return err + } return app.withClusterExecutor(func(ctx context.Context, exec transport.Executor, h *config.Host, svc *cluster.Service) error { - return svc.Create(ctx, args[0], workers, controlPlanes) + return svc.Create(ctx, args[0], drv, workers, controlPlanes) }) }, } cmd.Flags().IntVar(&workers, "workers", 0, "number of worker nodes") cmd.Flags().IntVar(&controlPlanes, "control-plane", 1, "number of control-plane nodes") + cmd.Flags().StringVar(&driver, "driver", "kind", "cluster runtime driver (kind or k3d)") return cmd } @@ -1836,8 +1842,8 @@ func (app *App) clusterListCmd() *cobra.Command { return nil } for _, c := range clusters { - app.Out.Info("%s status=%s nodes=%d control=%d workers=%d", - c.Name, c.Status, c.NodeCount, c.ControlPlanes, c.Workers) + app.Out.Info("%s driver=%s status=%s nodes=%d control=%d workers=%d", + c.Name, c.Driver, c.Status, c.NodeCount, c.ControlPlanes, c.Workers) } return nil }) @@ -1859,7 +1865,7 @@ func (app *App) clusterStatusCmd() *cobra.Command { if app.Out.JSON { return app.Out.PrintJSON(c) } - app.Out.Info("Cluster %s: status=%s nodes=%d", c.Name, c.Status, c.NodeCount) + app.Out.Info("Cluster %s: driver=%s status=%s nodes=%d", c.Name, c.Driver, c.Status, c.NodeCount) return nil }) }, @@ -1881,7 +1887,7 @@ func (app *App) clusterDeleteCmd() *cobra.Command { return err } if !app.ForceYes { - if err := authz.ConfirmPrompt("This will delete the kind cluster and its node containers"); err != nil { + if err := authz.ConfirmPrompt("This will delete the Kubernetes cluster and its node containers"); err != nil { return err } } @@ -2002,7 +2008,7 @@ func (app *App) pruneCmd() *cobra.Command { cmd.AddCommand(volumesCmd) clustersCmd := &cobra.Command{ Use: "clusters", - Short: "Prune kind clusters (explicit, owner only)", + Short: "Prune Kubernetes clusters (kind and k3d, explicit, owner only)", RunE: func(cmd *cobra.Command, args []string) error { return app.runPruneClusters(dryRun, force) }, diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go index 68e85f2..4ce68d4 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/degoke/outpost/internal/cli" + "github.com/degoke/outpost/internal/testenv" "github.com/spf13/cobra" "github.com/stretchr/testify/require" ) @@ -29,6 +30,7 @@ func TestCLIInit(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + testenv.UseHomeConfigDir(t, home) writeTestGlobal(t, home) root, app := cli.NewWithApp() @@ -46,6 +48,7 @@ func TestCLIInit(t *testing.T) { func TestCLIReset(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + testenv.UseHomeConfigDir(t, home) writeTestGlobal(t, home) root, _ := cli.NewWithApp() @@ -158,7 +161,7 @@ var untestableCLICommands = map[string]string{ "compose pull": "upload + remote image pull", "compose logs": "may stream indefinitely", "compose exec": "interactive session", - "cluster create": "provisions kind cluster on remote host", + "cluster create": "provisions Kubernetes cluster on remote host (kind or k3d)", "cluster delete": "destructive cluster removal", "cluster status": "requires an existing cluster name", "machine create": "provisions Incus instance", diff --git a/internal/cli/harness_test.go b/internal/cli/harness_test.go index b2d7cc2..5408b8a 100644 --- a/internal/cli/harness_test.go +++ b/internal/cli/harness_test.go @@ -9,6 +9,7 @@ import ( "github.com/degoke/outpost/internal/cli" "github.com/degoke/outpost/internal/config" "github.com/degoke/outpost/internal/project" + "github.com/degoke/outpost/internal/testenv" "github.com/degoke/outpost/internal/transport" "github.com/degoke/outpost/internal/transport/mock" "github.com/spf13/cobra" @@ -44,6 +45,7 @@ func newCLIEnv(t *testing.T) *cliEnv { home := t.TempDir() cwd := t.TempDir() t.Setenv("HOME", home) + testenv.UseHomeConfigDir(t, home) writeTestGlobal(t, home) setupProject(t, cwd) @@ -106,7 +108,7 @@ func seedCLIMocks(exec *mock.Executor) { exec.Responses["command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1"] = mockOK("") exec.Responses["mkdir -p /var/lib/outpost/projects /var/lib/outpost/share /var/lib/outpost/clusters /var/lib/outpost/machines && (chown -R \"$USER:$USER\" /var/lib/outpost 2>/dev/null || sudo chown -R \"$USER:$USER\" /var/lib/outpost) && test -d /var/lib/outpost/projects"] = mockOK("") exec.Responses["command -v free >/dev/null && command -v df >/dev/null && command -v du >/dev/null"] = mockOK("") - exec.Responses["command -v kind >/dev/null 2>&1 && command -v kubectl >/dev/null 2>&1"] = mockOK("") + exec.Responses["command -v kind >/dev/null 2>&1 && command -v kubectl >/dev/null 2>&1 && command -v k3d >/dev/null 2>&1"] = mockOK("") exec.Responses["command -v incus >/dev/null 2>&1 && (incus list >/dev/null 2>&1 || sudo incus list >/dev/null 2>&1)"] = mockOK("") exec.Responses["command -v tmux >/dev/null 2>&1"] = mockOK("") @@ -128,10 +130,12 @@ func seedCLIMocks(exec *mock.Executor) { exec.Responses["docker compose ls --format json"] = mockOK("") exec.Responses["docker stats --no-stream --format '{{json .}}'"] = mockOK("") exec.Responses["docker stats --no-stream --filter label=io.x-k8s.kind.role --format '{{json .}}'"] = mockOK("") + exec.Responses["docker stats --no-stream --filter label=k3d.role --format '{{json .}}'"] = mockOK("") exec.Responses["docker network ls --filter dangling=true -q | wc -l"] = mockOK("0\n") // Clusters / machines metadata exec.Responses["kind get clusters 2>/dev/null || true"] = mockOK("") + exec.Responses["k3d cluster list 2>/dev/null | awk 'NR>1 && NF {print $1}' || true"] = mockOK("") exec.Responses["ls -1"] = mockOK("") exec.Responses["incus list --format json 2>/dev/null || true"] = mockOK("[]") exec.Responses["docker ps --filter label=io.x-k8s.kind.cluster="] = mockOK("0\n") diff --git a/internal/cluster/config.go b/internal/cluster/config.go index 2cae27a..4526615 100644 --- a/internal/cluster/config.go +++ b/internal/cluster/config.go @@ -5,6 +5,36 @@ import ( "strings" ) +const ( + mib = 1024 * 1024 + gib = 1024 * 1024 * 1024 +) + +// Dev cluster capacity reservations for the pre-create check. +// +// Values track upstream minimums for a small single-node dev cluster, plus ~25% +// headroom so checks are realistic without blocking typical dev hosts (e.g. +// 2 vCPU / 4 GiB). The host capacity layer also keeps a 10% safety margin. +// +// References: +// - k3s server: 512 MiB RAM minimum (docs.k3s.io) +// - kind: 2 GiB RAM minimum for a single-node cluster (kind.sigs.k8s.io) +const ( + k3dServerCPU = 0.75 // ~0.5 core k3s server + k3d loadbalancer + k3dServerMem = 768 * mib + k3dAgentCPU = 0.25 + k3dAgentMem = 384 * mib + k3dBaseDisk = uint64(768 * mib) // k3s image + etcd; tight dev allowance + k3dAgentDisk = 256 * mib + + kindControlCPU = 1.25 // full node container; busier than k3s but fine for dev + kindControlMem = 2 * gib + kindWorkerCPU = 0.75 + kindWorkerMem = 1 * gib + kindBaseDisk = uint64(1536 * mib) // kindest/node image + small layer buffer + kindWorkerDisk = 512 * mib +) + type KindConfig struct { Name string ControlPlanes int @@ -29,12 +59,19 @@ func RenderKindConfig(cfg KindConfig) string { return b.String() } -func EstimateResources(controlPlanes, workers int) (cpu float64, memBytes, diskBytes uint64) { +func EstimateResources(driver Driver, controlPlanes, workers int) (cpu float64, memBytes, diskBytes uint64) { if controlPlanes == 0 { controlPlanes = 1 } - cpu = float64(controlPlanes)*2 + float64(workers) - memBytes = uint64(controlPlanes)*2*1024*1024*1024 + uint64(workers)*1024*1024*1024 - diskBytes = 5 * 1024 * 1024 * 1024 + switch driver { + case DriverK3d: + cpu = float64(controlPlanes)*k3dServerCPU + float64(workers)*k3dAgentCPU + memBytes = uint64(controlPlanes)*k3dServerMem + uint64(workers)*k3dAgentMem + diskBytes = k3dBaseDisk + uint64(workers)*k3dAgentDisk + default: + cpu = float64(controlPlanes)*kindControlCPU + float64(workers)*kindWorkerCPU + memBytes = uint64(controlPlanes)*kindControlMem + uint64(workers)*kindWorkerMem + diskBytes = kindBaseDisk + uint64(workers)*kindWorkerDisk + } return cpu, memBytes, diskBytes } diff --git a/internal/cluster/config_test.go b/internal/cluster/config_test.go index 6d50734..d54d2a1 100644 --- a/internal/cluster/config_test.go +++ b/internal/cluster/config_test.go @@ -4,8 +4,10 @@ import ( "strings" "testing" + "github.com/degoke/outpost/internal/capacity" "github.com/degoke/outpost/internal/cluster" "github.com/degoke/outpost/internal/config" + "github.com/degoke/outpost/internal/inspect" "github.com/stretchr/testify/require" ) @@ -31,9 +33,36 @@ func TestRenderKindConfigMultiNode(t *testing.T) { require.Equal(t, 2, strings.Count(cfg, "role: worker")) } -func TestEstimateResources(t *testing.T) { - cpu, mem, disk := cluster.EstimateResources(1, 2) - require.Equal(t, float64(4), cpu) +func TestEstimateResourcesKind(t *testing.T) { + cpu, mem, disk := cluster.EstimateResources(cluster.DriverKind, 1, 2) + require.Equal(t, 2.75, cpu) require.Equal(t, uint64(4*1024*1024*1024), mem) - require.Equal(t, uint64(5*1024*1024*1024), disk) + require.Equal(t, uint64(2560*1024*1024), disk) +} + +func TestEstimateResourcesK3dSingleNode(t *testing.T) { + cpu, mem, disk := cluster.EstimateResources(cluster.DriverK3d, 1, 0) + require.Equal(t, 0.75, cpu) + require.Equal(t, uint64(768*1024*1024), mem) + require.Equal(t, uint64(768*1024*1024), disk) +} + +func TestEstimateResourcesFitsTwoCoreDevHost(t *testing.T) { + const gib = 1024 * 1024 * 1024 + rep := &capacity.Report{ + Host: inspect.HostMetrics{CPUCores: 2, MemoryTotal: 4 * gib}, + AvailableCPU: 1.8, // 2 cores minus 10% capacity margin + AvailableMem: 3 * gib, + AvailableDisk: 20 * gib, + } + + k3dCPU, k3dMem, k3dDisk := cluster.EstimateResources(cluster.DriverK3d, 1, 0) + require.NoError(t, capacity.CheckWithReport(rep, capacity.Request{ + CPUCores: k3dCPU, MemoryBytes: k3dMem, DiskBytes: k3dDisk, + })) + + kindCPU, kindMem, kindDisk := cluster.EstimateResources(cluster.DriverKind, 1, 0) + require.NoError(t, capacity.CheckWithReport(rep, capacity.Request{ + CPUCores: kindCPU, MemoryBytes: kindMem, DiskBytes: kindDisk, + })) } diff --git a/internal/cluster/create_test.go b/internal/cluster/create_test.go index c07e166..0aad552 100644 --- a/internal/cluster/create_test.go +++ b/internal/cluster/create_test.go @@ -18,7 +18,7 @@ func TestCreateRejectsInsufficientCapacity(t *testing.T) { ExitCode int Err error }{ExitCode: 0} - exec.Responses["command -v kind >/dev/null 2>&1 && command -v kubectl >/dev/null 2>&1"] = struct { + exec.Responses["command -v kind >/dev/null 2>&1 && command -v kubectl >/dev/null 2>&1 && command -v k3d >/dev/null 2>&1"] = struct { Stdout string Stderr string ExitCode int @@ -60,6 +60,12 @@ func TestCreateRejectsInsufficientCapacity(t *testing.T) { ExitCode int Err error }{Stdout: "", ExitCode: 0} + exec.Responses["k3d cluster list 2>/dev/null | awk 'NR>1 && NF {print $1}' || true"] = struct { + Stdout string + Stderr string + ExitCode int + Err error + }{Stdout: "", ExitCode: 0} exec.Responses["ls -1 /var/lib/outpost/clusters 2>/dev/null || true"] = struct { Stdout string Stderr string @@ -68,7 +74,7 @@ func TestCreateRejectsInsufficientCapacity(t *testing.T) { }{Stdout: "", ExitCode: 0} svc := &cluster.Service{Exec: exec} - err := svc.Create(context.Background(), "dev", 4, 1) + err := svc.Create(context.Background(), "dev", cluster.DriverKind, 4, 1) require.Error(t, err) require.False(t, exec.HasCommand("kind create cluster")) } @@ -80,7 +86,7 @@ func TestCreateRollsBackOnKindFailure(t *testing.T) { ExitCode int Err error }{ExitCode: 0} - exec.Responses["command -v kind >/dev/null 2>&1 && command -v kubectl >/dev/null 2>&1"] = struct { + exec.Responses["command -v kind >/dev/null 2>&1 && command -v kubectl >/dev/null 2>&1 && command -v k3d >/dev/null 2>&1"] = struct { Stdout, Stderr string ExitCode int Err error @@ -115,6 +121,12 @@ func TestCreateRollsBackOnKindFailure(t *testing.T) { ExitCode int Err error }{Stdout: "", ExitCode: 0} + exec.Responses["k3d cluster list 2>/dev/null | awk 'NR>1 && NF {print $1}' || true"] = struct { + Stdout string + Stderr string + ExitCode int + Err error + }{Stdout: "", ExitCode: 0} exec.Responses["ls -1 /var/lib/outpost/clusters 2>/dev/null || true"] = struct { Stdout, Stderr string ExitCode int @@ -127,18 +139,83 @@ func TestCreateRollsBackOnKindFailure(t *testing.T) { }{ExitCode: 1, Stderr: "failed"} svc := &cluster.Service{Exec: exec} - err := svc.Create(context.Background(), "dev", 0, 1) + err := svc.Create(context.Background(), "dev", cluster.DriverKind, 0, 1) require.Error(t, err) require.True(t, exec.HasCommand("kind delete cluster")) } +func TestCreateRollsBackOnK3dFailure(t *testing.T) { + exec := mock.New() + exec.Responses["command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{ExitCode: 0} + exec.Responses["command -v kind >/dev/null 2>&1 && command -v kubectl >/dev/null 2>&1 && command -v k3d >/dev/null 2>&1"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{ExitCode: 0} + exec.Responses["nproc"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{Stdout: "8\n", ExitCode: 0} + exec.Responses["free -b | head -2"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{Stdout: " total used free shared buff/cache available\nMem: 17179869184 2147483648 1073741824 0 1073741824 15032385536\n", ExitCode: 0} + exec.Responses["df -B1 / | tail -1"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{Stdout: "/dev/root 100000000000 10000000000 90000000000 10% /\n", ExitCode: 0} + exec.Responses["head -1 /proc/stat"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{Stdout: "cpu 100 0 50 8500 0 0 0 0 0 0\n", ExitCode: 0} + exec.Responses["docker stats --no-stream --format '{{json .}}'"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{Stdout: "", ExitCode: 0} + exec.Responses["kind get clusters 2>/dev/null || true"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{Stdout: "", ExitCode: 0} + exec.Responses["k3d cluster list 2>/dev/null | awk 'NR>1 && NF {print $1}' || true"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{Stdout: "", ExitCode: 0} + exec.Responses["ls -1 /var/lib/outpost/clusters 2>/dev/null || true"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{Stdout: "", ExitCode: 0} + exec.Responses["k3d cluster create"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{ExitCode: 1, Stderr: "failed"} + + svc := &cluster.Service{Exec: exec} + err := svc.Create(context.Background(), "dev", cluster.DriverK3d, 0, 1) + require.Error(t, err) + require.True(t, exec.HasCommand("k3d cluster delete")) + require.False(t, exec.HasCommand("kind create cluster")) +} + func TestCapacityCheckRejectsLargeRequest(t *testing.T) { rep := &capacity.Report{ AvailableCPU: 1, AvailableMem: 512 * 1024 * 1024, AvailableDisk: 1 * 1024 * 1024 * 1024, } - cpu, mem, disk := cluster.EstimateResources(1, 4) + cpu, mem, disk := cluster.EstimateResources(cluster.DriverKind, 1, 4) err := capacity.CheckWithReport(rep, capacity.Request{CPUCores: cpu, MemoryBytes: mem, DiskBytes: disk}) require.Error(t, err) } diff --git a/internal/cluster/delete_test.go b/internal/cluster/delete_test.go new file mode 100644 index 0000000..74cfb8b --- /dev/null +++ b/internal/cluster/delete_test.go @@ -0,0 +1,72 @@ +package cluster_test + +import ( + "context" + "testing" + + "github.com/degoke/outpost/internal/cluster" + "github.com/degoke/outpost/internal/transport/mock" + "github.com/stretchr/testify/require" +) + +func TestDeleteWithoutMetaUsesK3dRuntime(t *testing.T) { + exec := mock.New() + exec.Responses["kind get clusters 2>/dev/null || true"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{Stdout: "", ExitCode: 0} + exec.Responses["k3d cluster list 2>/dev/null | awk 'NR>1 && NF {print $1}' || true"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{Stdout: "outpost-dev\n", ExitCode: 0} + exec.Responses["k3d cluster delete"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{ExitCode: 0} + exec.Responses["rm -rf"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{ExitCode: 0} + + svc := &cluster.Service{Exec: exec} + err := svc.Delete(context.Background(), "dev") + require.NoError(t, err) + require.True(t, exec.HasCommand("k3d cluster delete")) + require.False(t, exec.HasCommand("kind delete cluster")) +} + +func TestListIncludesK3dOrphanCluster(t *testing.T) { + exec := mock.New() + exec.Responses["kind get clusters 2>/dev/null || true"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{Stdout: "", ExitCode: 0} + exec.Responses["k3d cluster list 2>/dev/null | awk 'NR>1 && NF {print $1}' || true"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{Stdout: "outpost-staging\n", ExitCode: 0} + exec.Responses["ls -1 /var/lib/outpost/clusters 2>/dev/null || true"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{Stdout: "", ExitCode: 0} + exec.Responses["docker ps --filter label=k3d.cluster="] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{Stdout: "1\n", ExitCode: 0} + + svc := &cluster.Service{Exec: exec} + list, err := svc.List(context.Background()) + require.NoError(t, err) + require.Len(t, list, 1) + require.Equal(t, "staging", list[0].Name) + require.Equal(t, "k3d", list[0].Driver) + require.Equal(t, "ready", list[0].Status) +} diff --git a/internal/cluster/driver.go b/internal/cluster/driver.go new file mode 100644 index 0000000..57f48b9 --- /dev/null +++ b/internal/cluster/driver.go @@ -0,0 +1,163 @@ +package cluster + +import ( + "context" + "fmt" + "strings" + + "github.com/degoke/outpost/internal/config" + "github.com/degoke/outpost/internal/inspect" + "github.com/degoke/outpost/internal/transport" + "gopkg.in/yaml.v3" +) + +type Driver string + +const ( + DriverKind Driver = "kind" + DriverK3d Driver = "k3d" +) + +func ParseDriver(s string) (Driver, error) { + switch Driver(strings.ToLower(strings.TrimSpace(s))) { + case "", "kind": + return DriverKind, nil + case "k3d": + return DriverK3d, nil + default: + return "", fmt.Errorf("unsupported cluster driver %q (use kind or k3d)", s) + } +} + +func (d Driver) String() string { + if d == "" { + return string(DriverKind) + } + return string(d) +} + +func metaDriver(m Meta) Driver { + switch Driver(m.Driver) { + case DriverK3d: + return DriverK3d + default: + return DriverKind + } +} + +func listRuntimeClusters(ctx context.Context, exec transport.Executor) (map[string]Driver, error) { + result := map[string]Driver{} + + kindOut, err := inspect.RunOutput(ctx, exec, "kind get clusters 2>/dev/null || true") + if err != nil { + return nil, err + } + for _, line := range strings.Split(strings.TrimSpace(kindOut), "\n") { + line = strings.TrimSpace(line) + if line != "" { + result[line] = DriverKind + } + } + + k3dOut, err := inspect.RunOutput(ctx, exec, "k3d cluster list 2>/dev/null | awk 'NR>1 && NF {print $1}' || true") + if err != nil { + return nil, err + } + for _, line := range strings.Split(strings.TrimSpace(k3dOut), "\n") { + line = strings.TrimSpace(line) + if line != "" { + result[line] = DriverK3d + } + } + return result, nil +} + +func createCluster(ctx context.Context, exec transport.Executor, driver Driver, runtimeName string, workers, controlPlanes int, cfgPath string) (int, error) { + if controlPlanes == 0 { + controlPlanes = 1 + } + var cmd string + switch driver { + case DriverK3d: + cmd = fmt.Sprintf( + "k3d cluster create %s --servers %d --agents %d --kubeconfig-update-default=false --wait", + shellQuote(runtimeName), controlPlanes, workers, + ) + default: + cmd = fmt.Sprintf( + "kind create cluster --name %s --config %s", + shellQuote(runtimeName), shellQuote(cfgPath), + ) + } + return exec.Run(ctx, cmd, transport.RunOpts{}) +} + +func deleteCluster(ctx context.Context, exec transport.Executor, driver Driver, runtimeName string) error { + var cmd string + switch driver { + case DriverK3d: + cmd = fmt.Sprintf("k3d cluster delete %s 2>/dev/null || true", shellQuote(runtimeName)) + default: + cmd = fmt.Sprintf("kind delete cluster --name %s 2>/dev/null || true", shellQuote(runtimeName)) + } + _, err := exec.Run(ctx, cmd, transport.RunOpts{}) + return err +} + +func fetchKubeconfig(ctx context.Context, exec transport.Executor, driver Driver, runtimeName string) (string, error) { + var cmd string + switch driver { + case DriverK3d: + cmd = fmt.Sprintf("k3d kubeconfig get %s", shellQuote(runtimeName)) + default: + cmd = fmt.Sprintf("kind get kubeconfig --name %s", shellQuote(runtimeName)) + } + return inspect.RunOutput(ctx, exec, cmd) +} + +func nodeCountFor(ctx context.Context, exec transport.Executor, driver Driver, runtimeName string) (int, error) { + var cmd string + switch driver { + case DriverK3d: + cmd = fmt.Sprintf( + "docker ps --filter label=k3d.cluster=%s --format '{{.Label \"k3d.role\"}}' | grep -E '^(server|agent)$' | wc -l", + shellQuote(runtimeName), + ) + default: + cmd = fmt.Sprintf("docker ps --filter label=io.x-k8s.kind.cluster=%s --format '{{.ID}}' | wc -l", shellQuote(runtimeName)) + } + out, err := inspect.RunOutput(ctx, exec, cmd) + if err != nil { + return 0, err + } + var n int + fmt.Sscanf(strings.TrimSpace(out), "%d", &n) + return n, nil +} + +func resolveClusterTarget(ctx context.Context, exec transport.Executor, name string) (runtimeName string, driver Driver) { + safe := config.SanitizeClusterName(name) + runtimeName = KindName(name) + driver = DriverKind + if meta, err := loadMetaFromExec(exec, safe); err == nil { + return meta.KindName, metaDriver(*meta) + } + if runtimeClusters, err := listRuntimeClusters(ctx, exec); err == nil { + if drv, ok := runtimeClusters[runtimeName]; ok { + return runtimeName, drv + } + } + return runtimeName, driver +} + +func loadMetaFromExec(exec transport.Executor, safeName string) (*Meta, error) { + data, err := exec.Download(remoteBase + "/" + safeName + "/meta.yaml") + if err != nil { + return nil, err + } + var m Meta + if err := yaml.Unmarshal(data, &m); err != nil { + return nil, err + } + return &m, nil +} diff --git a/internal/cluster/driver_test.go b/internal/cluster/driver_test.go new file mode 100644 index 0000000..100ec85 --- /dev/null +++ b/internal/cluster/driver_test.go @@ -0,0 +1,25 @@ +package cluster_test + +import ( + "testing" + + "github.com/degoke/outpost/internal/cluster" + "github.com/stretchr/testify/require" +) + +func TestParseDriver(t *testing.T) { + d, err := cluster.ParseDriver("kind") + require.NoError(t, err) + require.Equal(t, cluster.DriverKind, d) + + d, err = cluster.ParseDriver("k3d") + require.NoError(t, err) + require.Equal(t, cluster.DriverK3d, d) + + d, err = cluster.ParseDriver("") + require.NoError(t, err) + require.Equal(t, cluster.DriverKind, d) + + _, err = cluster.ParseDriver("minikube") + require.Error(t, err) +} diff --git a/internal/cluster/kubectl_test.go b/internal/cluster/kubectl_test.go index cd131eb..2edd3bd 100644 --- a/internal/cluster/kubectl_test.go +++ b/internal/cluster/kubectl_test.go @@ -34,6 +34,12 @@ func TestListClustersFromMock(t *testing.T) { ExitCode int Err error }{Stdout: "outpost-demo\n", ExitCode: 0} + exec.Responses["k3d cluster list 2>/dev/null | awk 'NR>1 && NF {print $1}' || true"] = struct { + Stdout string + Stderr string + ExitCode int + Err error + }{Stdout: "", ExitCode: 0} exec.Responses["ls -1 /var/lib/outpost/clusters 2>/dev/null || true"] = struct { Stdout string Stderr string @@ -52,5 +58,6 @@ func TestListClustersFromMock(t *testing.T) { require.NoError(t, err) require.Len(t, list, 1) require.Equal(t, "demo", list[0].Name) + require.Equal(t, "kind", list[0].Driver) require.Equal(t, "ready", list[0].Status) } diff --git a/internal/cluster/service.go b/internal/cluster/service.go index ced82b0..879e8a1 100644 --- a/internal/cluster/service.go +++ b/internal/cluster/service.go @@ -19,6 +19,7 @@ import ( type Cluster struct { Name string `json:"name"` + Driver string `json:"driver"` KindName string `json:"kind_name"` Workers int `json:"workers"` ControlPlanes int `json:"control_planes"` @@ -28,6 +29,7 @@ type Cluster struct { type Meta struct { Name string `yaml:"name"` + Driver string `yaml:"driver,omitempty"` KindName string `yaml:"kind_name"` Workers int `yaml:"workers"` ControlPlanes int `yaml:"control_planes"` @@ -40,12 +42,12 @@ type Service struct { HostName string } -func (s *Service) Create(ctx context.Context, name string, workers, controlPlanes int) error { +func (s *Service) Create(ctx context.Context, name string, driver Driver, workers, controlPlanes int) error { safe := config.SanitizeClusterName(name) if safe == "" { return fmt.Errorf("cluster name is required") } - kindName := KindName(name) + runtimeName := KindName(name) if controlPlanes == 0 { controlPlanes = 1 } @@ -55,7 +57,7 @@ func (s *Service) Create(ctx context.Context, name string, workers, controlPlane if err := bootstrap.EnsureKubernetesTools(ctx, s.Exec); err != nil { return err } - cpu, mem, disk := EstimateResources(controlPlanes, workers) + cpu, mem, disk := EstimateResources(driver, controlPlanes, workers) if err := capacity.Check(ctx, s.Exec, capacity.Request{CPUCores: cpu, MemoryBytes: mem, DiskBytes: disk}); err != nil { return err } @@ -70,97 +72,93 @@ func (s *Service) Create(ctx context.Context, name string, workers, controlPlane if err := transport.EnsureRemoteDir(s.Exec, remoteDir); err != nil { return err } - cfg := RenderKindConfig(KindConfig{Name: kindName, ControlPlanes: controlPlanes, Workers: workers}) cfgPath := remoteDir + "/kind-config.yaml" - if err := s.Exec.UploadBytes([]byte(cfg), cfgPath); err != nil { - return err + if driver == DriverKind { + cfg := RenderKindConfig(KindConfig{Name: runtimeName, ControlPlanes: controlPlanes, Workers: workers}) + if err := s.Exec.UploadBytes([]byte(cfg), cfgPath); err != nil { + return err + } } + driverLabel := driver.String() if s.Out != nil { - s.Out.Step("Creating Kubernetes cluster %q with kind...", name) + s.Out.Step("Creating Kubernetes cluster %q with %s...", name, driverLabel) } - createCmd := fmt.Sprintf("kind create cluster --name %s --config %s", shellQuote(kindName), shellQuote(cfgPath)) - code, err := s.Exec.Run(ctx, createCmd, transport.RunOpts{}) + code, err := createCluster(ctx, s.Exec, driver, runtimeName, workers, controlPlanes, cfgPath) if err != nil { - _ = s.deleteKindCluster(ctx, kindName) + _ = deleteCluster(ctx, s.Exec, driver, runtimeName) return err } if code != 0 { - _ = s.deleteKindCluster(ctx, kindName) - return fmt.Errorf("kind create cluster failed (exit %d)", code) + _ = deleteCluster(ctx, s.Exec, driver, runtimeName) + return fmt.Errorf("%s cluster create failed (exit %d)", driverLabel, code) } if s.Out != nil { s.Out.Step("Saving kubeconfig...") } - kubeCmd := fmt.Sprintf("kind get kubeconfig --name %s", shellQuote(kindName)) - kubeOut, err := inspect.RunOutput(ctx, s.Exec, kubeCmd) + kubeOut, err := fetchKubeconfig(ctx, s.Exec, driver, runtimeName) if err != nil { - _ = s.deleteKindCluster(ctx, kindName) + _ = deleteCluster(ctx, s.Exec, driver, runtimeName) return fmt.Errorf("fetch kubeconfig: %w", err) } kubePath := RemoteKubeconfig(name) if err := s.Exec.UploadBytes([]byte(kubeOut), kubePath); err != nil { - _ = s.deleteKindCluster(ctx, kindName) + _ = deleteCluster(ctx, s.Exec, driver, runtimeName) return err } _, _ = s.Exec.Run(ctx, fmt.Sprintf("chmod 600 %s", shellQuote(kubePath)), transport.RunOpts{}) meta := Meta{ - Name: safe, KindName: kindName, Workers: workers, ControlPlanes: controlPlanes, CreatedAt: time.Now().UTC(), + Name: safe, Driver: driver.String(), KindName: runtimeName, + Workers: workers, ControlPlanes: controlPlanes, CreatedAt: time.Now().UTC(), } metaBytes, _ := yaml.Marshal(meta) if err := s.Exec.UploadBytes(metaBytes, remoteDir+"/meta.yaml"); err != nil { - _ = s.deleteKindCluster(ctx, kindName) + _ = deleteCluster(ctx, s.Exec, driver, runtimeName) return err } if err := s.syncLocalKubeconfig(name, kubeOut); err != nil { return err } if s.Out != nil && !s.Out.JSON { - s.Out.Success("Cluster %q is ready (%d control-plane, %d workers)", name, controlPlanes, workers) + s.Out.Success("Cluster %q is ready (%s, %d control-plane, %d workers)", name, driverLabel, controlPlanes, workers) } return nil } func (s *Service) List(ctx context.Context) ([]Cluster, error) { - out, err := inspect.RunOutput(ctx, s.Exec, "kind get clusters 2>/dev/null || true") + runtimeClusters, err := listRuntimeClusters(ctx, s.Exec) if err != nil { return nil, err } - kindNames := map[string]bool{} - for _, line := range strings.Split(strings.TrimSpace(out), "\n") { - line = strings.TrimSpace(line) - if line != "" { - kindNames[line] = true - } - } metaClusters, _ := s.listMeta(ctx) var result []Cluster seen := map[string]bool{} for _, m := range metaClusters { + drv := metaDriver(m) status := "unknown" - if kindNames[m.KindName] { + if _, ok := runtimeClusters[m.KindName]; ok { status = "ready" } - nodes, _ := s.nodeCount(ctx, m.KindName) + nodes, _ := nodeCountFor(ctx, s.Exec, drv, m.KindName) result = append(result, Cluster{ - Name: m.Name, KindName: m.KindName, Workers: m.Workers, + Name: m.Name, Driver: drv.String(), KindName: m.KindName, Workers: m.Workers, ControlPlanes: m.ControlPlanes, Status: status, NodeCount: nodes, }) seen[m.Name] = true } - for kn := range kindNames { - if !strings.HasPrefix(kn, "outpost-") { + for rn, drv := range runtimeClusters { + if !strings.HasPrefix(rn, "outpost-") { continue } - display := strings.TrimPrefix(kn, "outpost-") + display := strings.TrimPrefix(rn, "outpost-") if seen[display] { continue } - nodes, _ := s.nodeCount(ctx, kn) + nodes, _ := nodeCountFor(ctx, s.Exec, drv, rn) result = append(result, Cluster{ - Name: display, KindName: kn, Status: "ready", NodeCount: nodes, + Name: display, Driver: drv.String(), KindName: rn, Status: "ready", NodeCount: nodes, }) } return result, nil @@ -181,13 +179,8 @@ func (s *Service) Status(ctx context.Context, name string) (*Cluster, error) { } func (s *Service) Delete(ctx context.Context, name string) error { - safe := config.SanitizeClusterName(name) - meta, err := s.loadMeta(ctx, safe) - kindName := KindName(name) - if err == nil { - kindName = meta.KindName - } - if err := s.deleteKindCluster(ctx, kindName); err != nil { + runtimeName, driver := resolveClusterTarget(ctx, s.Exec, name) + if err := deleteCluster(ctx, s.Exec, driver, runtimeName); err != nil { return err } remoteDir := RemoteDir(name) @@ -200,12 +193,6 @@ func (s *Service) Delete(ctx context.Context, name string) error { return nil } -func (s *Service) deleteKindCluster(ctx context.Context, kindName string) error { - cmd := fmt.Sprintf("kind delete cluster --name %s 2>/dev/null || true", shellQuote(kindName)) - _, err := s.Exec.Run(ctx, cmd, transport.RunOpts{}) - return err -} - func (s *Service) listMeta(ctx context.Context) ([]Meta, error) { out, err := inspect.RunOutput(ctx, s.Exec, fmt.Sprintf("ls -1 %s 2>/dev/null || true", shellQuote(remoteBase))) if err != nil { @@ -229,29 +216,6 @@ func (s *Service) listMeta(ctx context.Context) ([]Meta, error) { return metas, nil } -func (s *Service) loadMeta(ctx context.Context, safeName string) (*Meta, error) { - data, err := s.Exec.Download(remoteBase + "/" + safeName + "/meta.yaml") - if err != nil { - return nil, err - } - var m Meta - if err := yaml.Unmarshal(data, &m); err != nil { - return nil, err - } - return &m, nil -} - -func (s *Service) nodeCount(ctx context.Context, kindName string) (int, error) { - cmd := fmt.Sprintf("docker ps --filter label=io.x-k8s.kind.cluster=%s --format '{{.ID}}' | wc -l", shellQuote(kindName)) - out, err := inspect.RunOutput(ctx, s.Exec, cmd) - if err != nil { - return 0, err - } - var n int - fmt.Sscanf(strings.TrimSpace(out), "%d", &n) - return n, nil -} - func (s *Service) syncLocalKubeconfig(name, content string) error { if s.HostName == "" { return nil diff --git a/internal/config/config.go b/internal/config/config.go index 38f5830..6c797d1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -94,6 +94,9 @@ type ProjectVolumeState struct { } func ConfigDir() (string, error) { + if dir := strings.TrimSpace(os.Getenv(ConfigDirEnv)); dir != "" { + return dir, nil + } home, err := os.UserHomeDir() if err != nil { return "", err @@ -302,8 +305,9 @@ func KubeconfigsDir() (string, error) { return filepath.Join(dir, "kubeconfigs"), nil } -// ResetLocal removes the entire local Outpost configuration directory (~/.outpost). -// Remote servers and per-repository .outpost/project.yaml files are not affected. +// ResetLocal removes the entire local Outpost configuration directory (~/.outpost, +// or OUTPOST_CONFIG_DIR when set). Remote servers and per-repository +// .outpost/project.yaml files are not affected. func ResetLocal() error { dir, err := ConfigDir() if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 797e342..e590627 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/degoke/outpost/internal/config" + "github.com/degoke/outpost/internal/testenv" "github.com/stretchr/testify/require" ) @@ -39,17 +40,25 @@ func TestGlobalResolveHost(t *testing.T) { } func TestResetLocal(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + dir := filepath.Join(t.TempDir(), ".outpost") + testenv.UseConfigDir(t, dir) - dir, err := config.ConfigDir() - require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Join(dir, "identities", "host-1"), 0700)) require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("version: 1\n"), 0600)) require.NoError(t, config.ResetLocal()) - _, err = os.Stat(dir) + _, err := os.Stat(dir) require.True(t, os.IsNotExist(err)) require.NoError(t, config.ResetLocal()) } + +func TestConfigDirRespectsEnvOverride(t *testing.T) { + dir := filepath.Join(t.TempDir(), "custom-outpost") + testenv.UseConfigDir(t, dir) + + got, err := config.ConfigDir() + require.NoError(t, err) + require.Equal(t, dir, got) +} diff --git a/internal/config/testenv.go b/internal/config/testenv.go new file mode 100644 index 0000000..224d43c --- /dev/null +++ b/internal/config/testenv.go @@ -0,0 +1,48 @@ +package config + +import ( + "os" + "path/filepath" + "strings" +) + +// ConfigDirEnv overrides the global Outpost config directory (~/.outpost) when set. +// Tests set this automatically; set OUTPOST_ALLOW_REAL_CONFIG=1 to opt out. +const ConfigDirEnv = "OUTPOST_CONFIG_DIR" + +const allowRealConfigEnv = "OUTPOST_ALLOW_REAL_CONFIG" + +func init() { + isolateConfigForTests() +} + +func isolateConfigForTests() { + if os.Getenv(allowRealConfigEnv) == "1" { + return + } + if strings.TrimSpace(os.Getenv(ConfigDirEnv)) != "" { + return + } + if !runningGoTestBinary() { + return + } + dir, err := os.MkdirTemp("", "outpost-test-config-*") + if err != nil { + panic("outpost: failed to isolate test config dir: " + err.Error()) + } + _ = os.Setenv(ConfigDirEnv, dir) +} + +func runningGoTestBinary() bool { + return strings.Contains(filepath.Base(os.Args[0]), ".test") +} + +// SetConfigDir overrides ~/.outpost for the current process. +func SetConfigDir(dir string) { + _ = os.Setenv(ConfigDirEnv, dir) +} + +// ConfigDirUnderHome returns the Outpost config path for a given home directory. +func ConfigDirUnderHome(home string) string { + return filepath.Join(home, ".outpost") +} diff --git a/internal/inspect/collect.go b/internal/inspect/collect.go index dcdf83b..045d01b 100644 --- a/internal/inspect/collect.go +++ b/internal/inspect/collect.go @@ -165,6 +165,23 @@ func ListKindNodeStats(ctx context.Context, exec transport.Executor) ([]Containe return stats, nil } +func ListK3dNodeStats(ctx context.Context, exec transport.Executor) ([]ContainerStats, error) { + var out bytes.Buffer + cmd := "docker stats --no-stream --filter label=k3d.role --format '{{json .}}'" + code, err := exec.Run(ctx, cmd, transport.RunOpts{Stdout: &out}) + if err != nil || code != 0 { + return nil, nil + } + stats, err := ParseDockerStatsLines(out.String()) + if err != nil { + return nil, err + } + for i := range stats { + stats[i].Project = "k3d:" + stats[i].Name + } + return stats, nil +} + func ListComposeProjects(ctx context.Context, exec transport.Executor) ([]ComposeProject, error) { var out bytes.Buffer code, err := exec.Run(ctx, "docker compose ls --format json", transport.RunOpts{Stdout: &out}) diff --git a/internal/integration/cluster_test.go b/internal/integration/cluster_test.go index 5196882..54ca3e6 100644 --- a/internal/integration/cluster_test.go +++ b/internal/integration/cluster_test.go @@ -17,6 +17,12 @@ func TestClusterListIntegration(t *testing.T) { ExitCode int Err error }{Stdout: "outpost-dev\noutpost-staging\n", ExitCode: 0} + exec.Responses["k3d cluster list 2>/dev/null | awk 'NR>1 && NF {print $1}' || true"] = struct { + Stdout string + Stderr string + ExitCode int + Err error + }{Stdout: "", ExitCode: 0} exec.Responses["ls -1 /var/lib/outpost/clusters 2>/dev/null || true"] = struct { Stdout string Stderr string diff --git a/internal/prune/clusters_test.go b/internal/prune/clusters_test.go new file mode 100644 index 0000000..8e2ed16 --- /dev/null +++ b/internal/prune/clusters_test.go @@ -0,0 +1,54 @@ +package prune_test + +import ( + "context" + "testing" + + "github.com/degoke/outpost/internal/prune" + "github.com/degoke/outpost/internal/transport/mock" + "github.com/stretchr/testify/require" +) + +func TestBuildPlanClustersIncludesK3d(t *testing.T) { + exec := mock.New() + exec.Responses["kind get clusters 2>/dev/null || true"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{Stdout: "outpost-dev\n", ExitCode: 0} + exec.Responses["k3d cluster list 2>/dev/null | awk 'NR>1 && NF {print $1}' || true"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{Stdout: "outpost-staging\n", ExitCode: 0} + + plan, err := prune.BuildPlan(context.Background(), exec, prune.Options{Clusters: true}) + require.NoError(t, err) + require.Len(t, plan.Candidates, 2) + + drivers := map[string]string{} + for _, c := range plan.Candidates { + drivers[c.ID] = c.Driver + } + require.Equal(t, "kind", drivers["outpost-dev"]) + require.Equal(t, "k3d", drivers["outpost-staging"]) +} + +func TestExecuteClustersDeletesK3d(t *testing.T) { + exec := mock.New() + exec.Responses["k3d cluster delete"] = struct { + Stdout, Stderr string + ExitCode int + Err error + }{ExitCode: 0} + + plan := &prune.PrunePlan{ + Candidates: []prune.Candidate{ + {Kind: "cluster", ID: "outpost-staging", Driver: "k3d"}, + }, + } + result, err := prune.Execute(context.Background(), exec, plan, prune.Options{Clusters: true}) + require.NoError(t, err) + require.Len(t, result.Removed, 1) + require.True(t, exec.HasCommand("k3d cluster delete")) +} diff --git a/internal/prune/plan.go b/internal/prune/plan.go index 3319089..f5c5625 100644 --- a/internal/prune/plan.go +++ b/internal/prune/plan.go @@ -15,6 +15,7 @@ type Candidate struct { Kind string `json:"kind"` ID string `json:"id"` Name string `json:"name,omitempty"` + Driver string `json:"driver,omitempty"` EstimatedBytes int64 `json:"estimated_bytes,omitempty"` Reason string `json:"reason,omitempty"` } @@ -63,18 +64,36 @@ func BuildPlan(ctx context.Context, exec transport.Executor, opts Options) (*Pru } if opts.Clusters { - clusters, err := inspect.RunOutput(ctx, exec, "kind get clusters 2>/dev/null || true") + kindClusters, err := inspect.RunOutput(ctx, exec, "kind get clusters 2>/dev/null || true") if err != nil { return nil, err } - for _, line := range strings.Split(strings.TrimSpace(clusters), "\n") { + for _, line := range strings.Split(strings.TrimSpace(kindClusters), "\n") { line = strings.TrimSpace(line) if line == "" { continue } plan.Candidates = append(plan.Candidates, Candidate{ Kind: "cluster", ID: line, Name: strings.TrimPrefix(line, "outpost-"), - Reason: "kind cluster", + Driver: "kind", Reason: "kind cluster", + }) + } + k3dClusters, err := inspect.RunOutput(ctx, exec, "k3d cluster list 2>/dev/null | awk 'NR>1 && NF {print $1}' || true") + if err != nil { + return nil, err + } + seen := map[string]bool{} + for _, c := range plan.Candidates { + seen[c.ID] = true + } + for _, line := range strings.Split(strings.TrimSpace(k3dClusters), "\n") { + line = strings.TrimSpace(line) + if line == "" || seen[line] { + continue + } + plan.Candidates = append(plan.Candidates, Candidate{ + Kind: "cluster", ID: line, Name: strings.TrimPrefix(line, "outpost-"), + Driver: "k3d", Reason: "k3d cluster", }) } return plan, nil @@ -231,7 +250,13 @@ func executeClusters(ctx context.Context, exec transport.Executor, plan *PrunePl if c.Kind != "cluster" { continue } - cmd := fmt.Sprintf("kind delete cluster --name %s", shellQuote(c.ID)) + var cmd string + switch c.Driver { + case "k3d": + cmd = fmt.Sprintf("k3d cluster delete %s", shellQuote(c.ID)) + default: + cmd = fmt.Sprintf("kind delete cluster --name %s", shellQuote(c.ID)) + } code, err := exec.Run(ctx, cmd, transport.RunOpts{}) if err != nil { return result, err diff --git a/internal/status/status_test.go b/internal/status/status_test.go index e6d2b63..7db363d 100644 --- a/internal/status/status_test.go +++ b/internal/status/status_test.go @@ -88,6 +88,12 @@ func TestCollectStatusJSONShape(t *testing.T) { ExitCode int Err error }{Stdout: "", ExitCode: 0} + exec.Responses["k3d cluster list 2>/dev/null | awk 'NR>1 && NF {print $1}' || true"] = struct { + Stdout string + Stderr string + ExitCode int + Err error + }{Stdout: "", ExitCode: 0} exec.Responses["ls -1"] = struct { Stdout string Stderr string diff --git a/internal/testenv/config.go b/internal/testenv/config.go new file mode 100644 index 0000000..670812c --- /dev/null +++ b/internal/testenv/config.go @@ -0,0 +1,21 @@ +package testenv + +import ( + "testing" + + "github.com/degoke/outpost/internal/config" +) + +// UseConfigDir redirects ~/.outpost to dir for the duration of a test. +func UseConfigDir(t *testing.T, dir string) { + t.Helper() + t.Setenv(config.ConfigDirEnv, dir) +} + +// UseHomeConfigDir redirects ~/.outpost to $HOME/.outpost for the test. +func UseHomeConfigDir(t *testing.T, home string) string { + t.Helper() + dir := config.ConfigDirUnderHome(home) + UseConfigDir(t, dir) + return dir +} diff --git a/internal/top/top.go b/internal/top/top.go index 352ff7b..3f2a8af 100644 --- a/internal/top/top.go +++ b/internal/top/top.go @@ -24,6 +24,8 @@ func RunOnce(ctx context.Context, exec transport.Executor, out io.Writer) error } kindStats, _ := inspect.ListKindNodeStats(ctx, exec) stats = append(stats, kindStats...) + k3dStats, _ := inspect.ListK3dNodeStats(ctx, exec) + stats = append(stats, k3dStats...) incusStats, _ := inspect.ListIncusInstanceStats(ctx, exec) stats = append(stats, incusStats...) if len(stats) == 0 { diff --git a/ui/public/llms.txt b/ui/public/llms.txt index 539a6a6..5591115 100644 --- a/ui/public/llms.txt +++ b/ui/public/llms.txt @@ -2,7 +2,7 @@ > Terminal-first remote development. Local workflow. Remote runtime. -Outpost turns a remote Linux host into a shared development environment you control from your local terminal. Run Docker Compose stacks, Kubernetes (kind) clusters, and lightweight Linux machines on infrastructure you own — without installing Docker, kubectl, or a local VM stack on your laptop. +Outpost turns a remote Linux host into a shared development environment you control from your local terminal. Run Docker Compose stacks, Kubernetes (kind or k3d) clusters, and lightweight Linux machines on infrastructure you own — without installing Docker, kubectl, or a local VM stack on your laptop. ## Primary pages @@ -23,7 +23,7 @@ curl -fsSL https://raw.githubusercontent.com/degoke/outpost/main/scripts/install - `outpost init` — initialize a project on the active host - `outpost compose up` — run Docker Compose remotely - `outpost connect` — forward remote services to localhost -- `outpost cluster create` — create a kind Kubernetes cluster on the host +- `outpost cluster create` — create a Kubernetes cluster on the host (`--driver kind` or `k3d`, default kind) - `outpost machine create` — launch Incus containers or VMs - `outpost mirror sync` — sync a repository to the host (SFTP with parallel uploads, or `--rsync`) - `outpost mirror watch` — continuously sync file changes while you edit locally diff --git a/ui/src/main.jsx b/ui/src/main.jsx index cda48dc..0c2c99d 100644 --- a/ui/src/main.jsx +++ b/ui/src/main.jsx @@ -418,15 +418,22 @@ const groups = [ [ "clusters", "Kubernetes", - "Create and use kind clusters on the remote host without a local kubectl runtime.", + "Create and use Kubernetes clusters (kind or k3d) on the remote host without a local kubectl runtime.", [ [ "Create and inspect", [ - ["outpost cluster create dev", "Create a named kind cluster"], + [ + "outpost cluster create dev", + "Create a named cluster (kind, default)", + ], [ "outpost cluster create staging --workers 2", - "Create a cluster with worker nodes", + "Create a kind cluster with worker nodes", + ], + [ + "outpost cluster create edge --driver k3d", + "Create a lighter k3s cluster via k3d", ], ["outpost cluster list", "List clusters on the host"], ["outpost cluster status dev", "Inspect cluster state"], @@ -619,7 +626,10 @@ const groups = [ [ "Clusters and machines", [ - ["outpost cluster create|list|status|delete", "Manage kind clusters"], + [ + "outpost cluster create|list|status|delete", + "Manage Kubernetes clusters (kind or k3d)", + ], [ "outpost machine create|list|status", "Create and inspect Incus machines", @@ -1114,8 +1124,8 @@ function Capabilities() { ], [ "◈", - "Kubernetes with kind", - "Create named clusters and run kubectl remotely. No local runtime stack required.", + "Kubernetes with kind or k3d", + "Create named clusters with kind (default) or k3d and run kubectl remotely. No local runtime stack required.", ], [ "□", @@ -1217,7 +1227,7 @@ function Home() {