Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
```

Expand Down Expand Up @@ -305,18 +305,24 @@ 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
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
Expand Down Expand Up @@ -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).
Expand Down
9 changes: 7 additions & 2 deletions internal/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand All @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions internal/capabilities/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
20 changes: 13 additions & 7 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
})
Expand All @@ -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
})
},
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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)
},
Expand Down
5 changes: 4 additions & 1 deletion internal/cli/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion internal/cli/harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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("")

Expand All @@ -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")
Expand Down
45 changes: 41 additions & 4 deletions internal/cluster/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
37 changes: 33 additions & 4 deletions internal/cluster/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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,
}))
}
Loading
Loading